Skip to main content

mfsk_core/fst4/
mod.rs

1//! # `fst4` — FST4-60A decoder and synthesiser
2//!
3//! FST4 is a weak-signal slow-speed mode used for EME / troposcatter /
4//! LF-MF propagation experiments. This module targets the **FST4-60A**
5//! sub-mode (60-second T/R period, minimum tone spacing). Trait surface,
6//! frame layout, Costas positions, DSP routing, and LDPC(240, 101) +
7//! CRC-24 codec ([`crate::fec::Ldpc240_101`]) are all wired. The 77-bit
8//! message layer is shared verbatim with FT8 / FT4.
9//!
10//! ## Covered sub-mode
11//!
12//! This module ships the **FST4-60A** sub-mode as [`Fst4s60`]. Other
13//! sub-modes (FST4-15, -30, -120, -300, -900, -1800) differ only in
14//! [`ModulationParams::NSPS`] / `SYMBOL_DT` / `TONE_SPACING_HZ` and
15//! can be added as additional ZSTs using the same trait impl pattern.
16//!
17//! ## References
18//!
19//! - K1JT et al., "The FST4 and FST4W Protocols", QEX 2021
20//! - WSJT-X `lib/fst4/` — `fst4_params.f90`, `genfst4.f90`
21//!
22//! ## Quick example
23//!
24//! ```no_run
25//! use mfsk_core::fst4::decode::decode_frame;
26//! use mfsk_core::msg::wsjt77::unpack77;
27//!
28//! # let audio: Vec<i16> = vec![];
29//! // `audio` is 720_000 i16 samples at 12 kHz (60 s FST4-60A slot).
30//! for r in decode_frame(&audio, 100.0, 3_000.0, 0.8, /* max_cand */ 30) {
31//!     if let Some(text) = unpack77(&r.message77) {
32//!         println!("{:7.1} Hz  dt={:+.2} s  {}", r.freq_hz, r.dt_sec, text);
33//!     }
34//! }
35//! ```
36
37use crate::core::{FrameLayout, ModulationParams, Protocol, ProtocolId, SyncBlock, SyncMode};
38use crate::fec::Ldpc240_101;
39use crate::msg::Wsjt77Message;
40
41pub mod decode;
42pub mod encode;
43
44/// FST4-60A: 4-GFSK, 60-second T/R period, 3.125 baud, minimum tone
45/// spacing (12.4 Hz occupied bandwidth). Uses LDPC(240, 101) + CRC-24
46/// over the same 77-bit WSJT message payload that FT8 / FT4 use.
47#[derive(Copy, Clone, Debug, Default)]
48pub struct Fst4s60;
49
50impl ModulationParams for Fst4s60 {
51    const NTONES: u32 = 4;
52    const BITS_PER_SYMBOL: u32 = 2;
53    // Symbol length 320 ms → 3.125 baud → 3.125 Hz tone spacing.
54    const NSPS: u32 = 3_840;
55    const SYMBOL_DT: f32 = 0.32;
56    const TONE_SPACING_HZ: f32 = 3.125;
57    const GRAY_MAP: &'static [u8] = &[0, 1, 3, 2];
58    // BT=1.0 matches the narrow GFSK shaping WSJT-X uses for the
59    // sensitive slow FST4 modes.
60    const GFSK_BT: f32 = 1.0;
61    const GFSK_HMOD: f32 = 1.0;
62    // NFFT window = 2 × NSPS (same convention as FT8) — longer windows
63    // don't help FST4-60 because the channel is assumed quasi-static
64    // across the 60 s slot.
65    const NFFT_PER_SYMBOL_FACTOR: u32 = 2;
66    // Half-symbol coarse grid (matches FT4 practice).
67    const NSTEP_PER_SYMBOL: u32 = 2;
68    // 12 000 / 192 = 62.5 Hz baseband — enough for 4-tone signal at
69    // 3.125 Hz spacing plus guard band. Production value may differ;
70    // revisit once decoder is wired.
71    const NDOWN: u32 = 192;
72}
73
74impl FrameLayout for Fst4s60 {
75    const N_DATA: u32 = 120;
76    const N_SYNC: u32 = 40; // 5 × 8
77    const N_SYMBOLS: u32 = 160;
78    const N_RAMP: u32 = 0; // GFSK synth handles ramp internally
79    const SYNC_MODE: SyncMode = SyncMode::Block(&FST4_SYNC_BLOCKS);
80    const T_SLOT_S: f32 = 60.0;
81    // FST4 transmissions start ~1 s after the slot boundary (per WSJT-X).
82    const TX_START_OFFSET_S: f32 = 1.0;
83}
84
85impl Protocol for Fst4s60 {
86    /// LDPC(240, 101) + CRC-24 — see [`crate::fec::Ldpc240_101`].
87    type Fec = Ldpc240_101;
88    /// Same 77-bit WSJT message layout as FT8 / FT4 — fully reused.
89    type Msg = Wsjt77Message;
90    const ID: ProtocolId = ProtocolId::Fst4;
91}
92
93// Two alternating Costas patterns, each 8 symbols long, at symbols
94// 0 / 38 / 76 / 114 / 152 (0-indexed).
95const FST4_SYNC_A: [u8; 8] = [0, 1, 3, 2, 1, 0, 2, 3];
96const FST4_SYNC_B: [u8; 8] = [2, 3, 1, 0, 3, 2, 0, 1];
97
98const FST4_SYNC_BLOCKS: [SyncBlock; 5] = [
99    SyncBlock {
100        start_symbol: 0,
101        pattern: &FST4_SYNC_A,
102    },
103    SyncBlock {
104        start_symbol: 38,
105        pattern: &FST4_SYNC_B,
106    },
107    SyncBlock {
108        start_symbol: 76,
109        pattern: &FST4_SYNC_A,
110    },
111    SyncBlock {
112        start_symbol: 114,
113        pattern: &FST4_SYNC_B,
114    },
115    SyncBlock {
116        start_symbol: 152,
117        pattern: &FST4_SYNC_A,
118    },
119];
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn fst4s60_trait_surface() {
127        assert_eq!(<Fst4s60 as ModulationParams>::NTONES, 4);
128        assert_eq!(<Fst4s60 as ModulationParams>::NSPS, 3_840);
129        assert!((<Fst4s60 as ModulationParams>::SYMBOL_DT - 0.32).abs() < 1e-6,);
130        assert_eq!(<Fst4s60 as FrameLayout>::N_SYMBOLS, 160);
131        assert_eq!(<Fst4s60 as FrameLayout>::N_DATA, 120);
132        assert_eq!(<Fst4s60 as FrameLayout>::N_SYNC, 40);
133        let blocks = <Fst4s60 as FrameLayout>::SYNC_MODE.blocks();
134        assert_eq!(blocks.len(), 5);
135        assert_eq!(
136            blocks.iter().map(|b| b.start_symbol).collect::<Vec<_>>(),
137            vec![0, 38, 76, 114, 152],
138        );
139        assert_eq!(blocks[0].pattern.len(), 8);
140
141        use crate::core::FecCodec;
142        assert_eq!(<<Fst4s60 as Protocol>::Fec as FecCodec>::N, 240);
143        assert_eq!(<<Fst4s60 as Protocol>::Fec as FecCodec>::K, 101);
144    }
145}