Skip to main content

mfsk_core/fst4/
mod.rs

1//! # `fst4` — FST4 decoder and synthesiser
2//!
3//! FST4 is a weak-signal slow-speed mode used for EME / troposcatter /
4//! LF-MF propagation experiments. Trait surface, frame layout, Costas
5//! positions, DSP routing, and LDPC(240, 101) + CRC-24 codec
6//! ([`crate::fec::Ldpc240_101`]) are all wired. The 77-bit message
7//! layer is shared verbatim with FT8 / FT4.
8//!
9//! ## Covered sub-modes
10//!
11//! Five T/R-period sub-modes are wired: [`Fst4s15`], [`Fst4s30`],
12//! [`Fst4s60`], [`Fst4s120`], [`Fst4s300`]. They differ only in
13//! [`ModulationParams::NSPS`] / `NDOWN` / `SYMBOL_DT` /
14//! `TONE_SPACING_HZ` (and `Fst4s15` alone in
15//! [`FrameLayout::TX_START_OFFSET_S`] — see the `fst4_submode!`
16//! invocations below for the WSJT-X-sourced values) — frame layout,
17//! sync pattern, FEC, message codec, and GFSK shaping (BT=2.0) are
18//! identical across all of them, mirroring the
19//! [`crate::q65::Q65a30`]-family `q65_submode!` pattern. FST4-900 and
20//! FST4-1800 are not wired (no user demand as of writing). FST4W (the
21//! WSPR-style 50-bit one-way beacon variant, LDPC(240,74), periods
22//! 120/300/900/1800 s) is a separate message format entirely — not
23//! covered here; see issue #23 for status.
24//!
25//! ## References
26//!
27//! - K1JT et al., "The FST4 and FST4W Protocols", QEX 2021
28//! - WSJT-X `lib/fst4_decode.f90`, `lib/fst4/fst4sim.f90`,
29//!   `lib/fst4/fst4_params.f90`, `lib/fst4/genfst4.f90`,
30//!   `lib/fst4/gen_fst4wave.f90`
31//!
32//! ## Quick example
33//!
34//! ```no_run
35//! use mfsk_core::fst4::decode::decode_frame;
36//! use mfsk_core::msg::wsjt77::unpack77;
37//!
38//! # let audio: Vec<i16> = vec![];
39//! // `audio` is 720_000 i16 samples at 12 kHz (60 s FST4-60A slot).
40//! for r in decode_frame(&audio, 100.0, 3_000.0, 0.8, /* max_cand */ 30) {
41//!     let msg77: &[u8; 77] = r.message77().try_into().unwrap();
42//!     if let Some(text) = unpack77(msg77) {
43//!         println!("{:7.1} Hz  dt={:+.2} s  {}", r.freq_hz, r.dt_sec, text);
44//!     }
45//! }
46//! ```
47
48use crate::core::{FrameLayout, ModulationParams, Protocol, ProtocolId, SyncBlock, SyncMode};
49use crate::fec::Ldpc240_101;
50use crate::msg::Wsjt77Message;
51
52pub mod decode;
53pub mod encode;
54
55/// FST4's 77-bit pre-LDPC scrambler — identical to [`crate::ft4::FT4_RVEC`]
56/// (WSJT-X `genfst4.f90:29-31` uses the same literal array as
57/// `genft4.f90`), duplicated here rather than imported so `fst4` doesn't
58/// pull in the `ft4` feature. WSJT-X `genfst4.f90:63` XORs the 77-bit
59/// message with this sequence before CRC-24 + LDPC encode; the receiver
60/// must undo it after decode. Shared by every FST4 sub-mode (unconditional
61/// on T/R period).
62const FST4_RVEC: [u8; 77] = [
63    0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1, 0, 0,
64    1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1,
65    1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 1,
66];
67
68// Two alternating Costas patterns, each 8 symbols long, at symbols
69// 0 / 38 / 76 / 114 / 152 (0-indexed). Shared by every FST4 sub-mode —
70// `genfst4.f90` / `get_fst4_bitmetrics.f90` have no T/R-period branches.
71const FST4_SYNC_A: [u8; 8] = [0, 1, 3, 2, 1, 0, 2, 3];
72const FST4_SYNC_B: [u8; 8] = [2, 3, 1, 0, 3, 2, 0, 1];
73
74const FST4_SYNC_BLOCKS: [SyncBlock; 5] = [
75    SyncBlock {
76        start_symbol: 0,
77        pattern: &FST4_SYNC_A,
78    },
79    SyncBlock {
80        start_symbol: 38,
81        pattern: &FST4_SYNC_B,
82    },
83    SyncBlock {
84        start_symbol: 76,
85        pattern: &FST4_SYNC_A,
86    },
87    SyncBlock {
88        start_symbol: 114,
89        pattern: &FST4_SYNC_B,
90    },
91    SyncBlock {
92        start_symbol: 152,
93        pattern: &FST4_SYNC_A,
94    },
95];
96
97/// Define an FST4 sub-mode ZST with its `ModulationParams`,
98/// `FrameLayout` and `Protocol` impls. Every FST4 sub-mode shares
99/// NTONES, BITS_PER_SYMBOL, Gray map, GFSK BT/hmod, sync pattern, FEC,
100/// message codec, the 120-data/40-sync/160-total symbol layout, and the
101/// `rvec` message scramble (`fst4_params.f90` / `genfst4.f90` have no
102/// T/R-period branches) — only `NSPS` (⇒ `SYMBOL_DT` / `TONE_SPACING_HZ`),
103/// `NDOWN`, `T_SLOT_S`, and (for FST4-15 alone) `TX_START_OFFSET_S`
104/// differ, all taken directly from WSJT-X `fst4_decode.f90` /
105/// `fst4sim.f90`'s per-`ntrperiod` branches.
106macro_rules! fst4_submode {
107    (
108        $(#[$attr:meta])*
109        $name:ident,
110        nsps = $nsps:literal,
111        ndown = $ndown:literal,
112        tr_period_s = $period:literal,
113        tx_start_offset_s = $tx_start:literal,
114    ) => {
115        $(#[$attr])*
116        #[derive(Copy, Clone, Debug, Default)]
117        pub struct $name;
118
119        impl ModulationParams for $name {
120            const NTONES: u32 = 4;
121            const BITS_PER_SYMBOL: u32 = 2;
122            const NSPS: u32 = $nsps;
123            const SYMBOL_DT: f32 = ($nsps as f32) / 12_000.0;
124            const TONE_SPACING_HZ: f32 = 12_000.0 / ($nsps as f32);
125            const GRAY_MAP: &'static [u8] = &[0, 1, 3, 2];
126            /// BT=2.0 for every FST4 sub-mode (`gen_fst4wave.f90:37`
127            /// `gfsk_pulse(2.0,tt)`, unconditional on T/R period) —
128            /// matches FT8's `GFSK_BT`, not FT4's 1.0.
129            const GFSK_BT: f32 = 2.0;
130            const GFSK_HMOD: f32 = 1.0;
131            // NFFT window = 2 × NSPS (same convention as FT8) — longer
132            // windows don't help FST4 because the channel is assumed
133            // quasi-static across the slot.
134            const NFFT_PER_SYMBOL_FACTOR: u32 = 2;
135            // Half-symbol coarse grid (matches FT4 practice).
136            const NSTEP_PER_SYMBOL: u32 = 2;
137            const NDOWN: u32 = $ndown;
138            const INFO_SCRAMBLE_RVEC: Option<&'static [u8]> = Some(&FST4_RVEC);
139            /// Matches WSJT-X `get_fst4_bitmetrics.f90`'s 1/2/4/8-symbol
140            /// correlation ladder (issue #146) — was silently inheriting
141            /// the FT8-calibrated default of 3.
142            const LLR_NSYM_MAX: u32 = 8;
143            /// The nsym=4 rung of that same ladder — see
144            /// `ModulationParams::LLR_NSYM_MID` doc for the measured
145            /// recall effect.
146            const LLR_NSYM_MID: Option<u32> = Some(4);
147        }
148
149        impl FrameLayout for $name {
150            const N_DATA: u32 = 120;
151            const N_SYNC: u32 = 40; // 5 × 8
152            const N_SYMBOLS: u32 = 160;
153            const N_RAMP: u32 = 0; // GFSK synth handles ramp internally
154            const SYNC_MODE: SyncMode = SyncMode::Block(&FST4_SYNC_BLOCKS);
155            const T_SLOT_S: f32 = $period as f32;
156            const TX_START_OFFSET_S: f32 = $tx_start;
157        }
158
159        impl Protocol for $name {
160            /// LDPC(240, 101) + CRC-24 — see [`crate::fec::Ldpc240_101`].
161            type Fec = Ldpc240_101;
162            /// Same 77-bit WSJT message layout as FT8 / FT4 — fully reused.
163            type Msg = Wsjt77Message;
164            const ID: ProtocolId = ProtocolId::Fst4;
165        }
166    };
167}
168
169fst4_submode! {
170    /// FST4-15: 15 s T/R period, 16.667 Hz tone spacing (66.7 Hz
171    /// occupied). The fastest FST4 sub-mode; S/N threshold ≈ -20.7 dB.
172    /// WSJT-X `fst4_decode.f90` (`ntrperiod.eq.15`): `nsps=720`,
173    /// `ndown=18`. Uniquely among FST4 sub-modes, transmissions start
174    /// 0.5 s (not 1.0 s) after the slot boundary
175    /// (`fst4sim.f90`: `if(nsec.eq.15) k=nint((xdt+0.5)/dt)`;
176    /// `fst4_decode.f90`: `if(ntrperiod.eq.15) xdt=(isbest-real(nspsec)/2.0)/fs2`).
177    Fst4s15,
178    nsps = 720,
179    ndown = 18,
180    tr_period_s = 15,
181    tx_start_offset_s = 0.5,
182}
183
184fst4_submode! {
185    /// FST4-30: 30 s T/R period, 7.143 Hz tone spacing (28.6 Hz
186    /// occupied). S/N threshold ≈ -24.2 dB. WSJT-X `fst4_decode.f90`
187    /// (`ntrperiod.eq.30`): `nsps=1680`, `ndown=42`.
188    Fst4s30,
189    nsps = 1_680,
190    ndown = 42,
191    tr_period_s = 30,
192    tx_start_offset_s = 1.0,
193}
194
195fst4_submode! {
196    /// FST4-60A: 60 s T/R period, 3.0864 Hz tone spacing (12.35 Hz
197    /// occupied). The dominant terrestrial FST4 sub-mode; S/N
198    /// threshold ≈ -28.1 dB. WSJT-X `fst4_decode.f90`
199    /// (`ntrperiod.eq.60`): `nsps=3888`, `ndown=108`.
200    Fst4s60,
201    nsps = 3_888,
202    ndown = 108,
203    tr_period_s = 60,
204    tx_start_offset_s = 1.0,
205}
206
207fst4_submode! {
208    /// FST4-120: 120 s T/R period, 1.4634 Hz tone spacing (5.9 Hz
209    /// occupied). S/N threshold ≈ -31.3 dB. WSJT-X `fst4_decode.f90`
210    /// (`ntrperiod.eq.120`): `nsps=8200`, `ndown=205`.
211    Fst4s120,
212    nsps = 8_200,
213    ndown = 205,
214    tr_period_s = 120,
215    tx_start_offset_s = 1.0,
216}
217
218fst4_submode! {
219    /// FST4-300: 300 s (5 min) T/R period, 0.5580 Hz tone spacing
220    /// (2.2 Hz occupied). S/N threshold ≈ -35.3 dB. WSJT-X
221    /// `fst4_decode.f90` (`ntrperiod.eq.300`): `nsps=21504`,
222    /// `ndown=512`.
223    Fst4s300,
224    nsps = 21_504,
225    ndown = 512,
226    tr_period_s = 300,
227    tx_start_offset_s = 1.0,
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::core::FecCodec;
234
235    #[test]
236    fn fst4s60_trait_surface() {
237        assert_eq!(<Fst4s60 as ModulationParams>::NTONES, 4);
238        assert_eq!(<Fst4s60 as ModulationParams>::NSPS, 3_888);
239        assert!((<Fst4s60 as ModulationParams>::SYMBOL_DT - 0.324).abs() < 1e-6,);
240        assert_eq!(<Fst4s60 as FrameLayout>::N_SYMBOLS, 160);
241        assert_eq!(<Fst4s60 as FrameLayout>::N_DATA, 120);
242        assert_eq!(<Fst4s60 as FrameLayout>::N_SYNC, 40);
243        let blocks = <Fst4s60 as FrameLayout>::SYNC_MODE.blocks();
244        assert_eq!(blocks.len(), 5);
245        assert_eq!(
246            blocks.iter().map(|b| b.start_symbol).collect::<Vec<_>>(),
247            vec![0, 38, 76, 114, 152],
248        );
249        assert_eq!(blocks[0].pattern.len(), 8);
250
251        assert_eq!(<<Fst4s60 as Protocol>::Fec as FecCodec>::N, 240);
252        assert_eq!(<<Fst4s60 as Protocol>::Fec as FecCodec>::K, 101);
253    }
254
255    /// Every sub-mode's WSJT-X-sourced `NSPS`/`NDOWN` from the
256    /// `fst4_decode.f90` per-`ntrperiod` branches, spot-checked
257    /// against the derived `SYMBOL_DT`/`TONE_SPACING_HZ` and against
258    /// `NSPS % NDOWN == 0` (the generic sync pipeline assumes an exact
259    /// downsampled-samples-per-symbol ratio).
260    #[test]
261    fn all_submodes_match_wsjtx_fst4_decode_f90() {
262        fn check<P: ModulationParams + FrameLayout>(
263            name: &str,
264            nsps: u32,
265            ndown: u32,
266            t_slot_s: f32,
267            tx_start_offset_s: f32,
268        ) {
269            assert_eq!(P::NSPS, nsps, "{name} NSPS");
270            assert_eq!(P::NDOWN, ndown, "{name} NDOWN");
271            assert_eq!(
272                P::NSPS % P::NDOWN,
273                0,
274                "{name}: NSPS must be an exact multiple of NDOWN"
275            );
276            assert!(
277                (P::SYMBOL_DT - nsps as f32 / 12_000.0).abs() < 1e-6,
278                "{name} SYMBOL_DT"
279            );
280            assert!(
281                (P::TONE_SPACING_HZ - 12_000.0 / nsps as f32).abs() < 1e-3,
282                "{name} TONE_SPACING_HZ"
283            );
284            assert_eq!(P::T_SLOT_S, t_slot_s, "{name} T_SLOT_S");
285            assert_eq!(
286                P::TX_START_OFFSET_S,
287                tx_start_offset_s,
288                "{name} TX_START_OFFSET_S"
289            );
290        }
291        check::<Fst4s15>("Fst4s15", 720, 18, 15.0, 0.5);
292        check::<Fst4s30>("Fst4s30", 1_680, 42, 30.0, 1.0);
293        check::<Fst4s60>("Fst4s60", 3_888, 108, 60.0, 1.0);
294        check::<Fst4s120>("Fst4s120", 8_200, 205, 120.0, 1.0);
295        check::<Fst4s300>("Fst4s300", 21_504, 512, 300.0, 1.0);
296    }
297
298    /// Every FST4 sub-mode MUST share the same frame structure, sync
299    /// pattern, GFSK shaping, and message scramble so the generic
300    /// tx/rx code can switch between them on the type parameter alone.
301    #[test]
302    fn all_submodes_share_frame_layout_and_fec() {
303        fn check<P: ModulationParams + FrameLayout + Protocol>(name: &str) {
304            assert_eq!(P::N_DATA, 120, "{name} N_DATA");
305            assert_eq!(P::N_SYNC, 40, "{name} N_SYNC");
306            assert_eq!(P::N_SYMBOLS, 160, "{name} N_SYMBOLS");
307            assert_eq!(P::GFSK_BT, 2.0, "{name} GFSK_BT");
308            assert_eq!(P::GFSK_HMOD, 1.0, "{name} GFSK_HMOD");
309            assert_eq!(P::GRAY_MAP, &[0, 1, 3, 2], "{name} GRAY_MAP");
310            assert_eq!(<P::Fec as FecCodec>::N, 240, "{name} Fec::N");
311            assert_eq!(<P::Fec as FecCodec>::K, 101, "{name} Fec::K");
312            assert_eq!(P::ID, ProtocolId::Fst4, "{name} ID");
313            match P::SYNC_MODE {
314                SyncMode::Block(blocks) => assert_eq!(blocks.len(), 5, "{name} sync blocks"),
315                SyncMode::Interleaved { .. } => panic!("{name}: FST4 must use Block sync"),
316            }
317        }
318        check::<Fst4s15>("Fst4s15");
319        check::<Fst4s30>("Fst4s30");
320        check::<Fst4s60>("Fst4s60");
321        check::<Fst4s120>("Fst4s120");
322        check::<Fst4s300>("Fst4s300");
323    }
324
325    /// Cross-checks every sub-mode's `DownsampleCfg` / `GfskCfg`
326    /// (hand-picked `fft1_size`/`fft2_size`/`tone_spacing_hz` and
327    /// `samples_per_symbol`/`ramp_samples` respectively) against the
328    /// `ModulationParams`/`FrameLayout` trait constants they must
329    /// stay consistent with. Guards against exactly the class of
330    /// silent transcription bug that caused issue #23 in the first
331    /// place: a hand-typed constant in `decode.rs`/`encode.rs` that
332    /// quietly drifts from the ZST it's paired with.
333    #[test]
334    fn downsample_and_gfsk_configs_match_submode_constants() {
335        use crate::core::dsp::downsample::DownsampleCfg;
336        use crate::core::dsp::gfsk::GfskCfg;
337
338        fn check_downsample<P: ModulationParams + FrameLayout>(name: &str, cfg: &DownsampleCfg) {
339            let ndown = P::NDOWN as usize;
340            assert_eq!(
341                cfg.fft1_size % ndown,
342                0,
343                "{name}: fft1_size {} not divisible by NDOWN {ndown}",
344                cfg.fft1_size
345            );
346            assert_eq!(
347                cfg.fft1_size / ndown,
348                cfg.fft2_size,
349                "{name}: fft1_size/NDOWN != fft2_size"
350            );
351            let nmax = (P::T_SLOT_S * 12_000.0).round() as usize;
352            assert!(
353                cfg.fft1_size >= nmax,
354                "{name}: fft1_size {} < nmax {nmax} (slot won't fit)",
355                cfg.fft1_size
356            );
357            assert!(
358                (cfg.tone_spacing_hz - P::TONE_SPACING_HZ).abs() < 1e-3,
359                "{name}: cfg.tone_spacing_hz {} != ModulationParams::TONE_SPACING_HZ {}",
360                cfg.tone_spacing_hz,
361                P::TONE_SPACING_HZ
362            );
363        }
364
365        fn check_gfsk<P: ModulationParams>(name: &str, cfg: &GfskCfg) {
366            assert_eq!(
367                cfg.samples_per_symbol as u32,
368                P::NSPS,
369                "{name}: gfsk samples_per_symbol != NSPS"
370            );
371            assert_eq!(cfg.bt, P::GFSK_BT, "{name}: gfsk bt != GFSK_BT");
372            assert_eq!(cfg.hmod, P::GFSK_HMOD, "{name}: gfsk hmod != GFSK_HMOD");
373            assert_eq!(
374                cfg.ramp_samples as u32,
375                P::NSPS / 8,
376                "{name}: gfsk ramp_samples != NSPS/8"
377            );
378        }
379
380        check_downsample::<Fst4s15>("Fst4s15", &decode::FST4_15_DOWNSAMPLE);
381        check_downsample::<Fst4s30>("Fst4s30", &decode::FST4_30_DOWNSAMPLE);
382        check_downsample::<Fst4s60>("Fst4s60", &decode::FST4_60A_DOWNSAMPLE);
383        check_downsample::<Fst4s120>("Fst4s120", &decode::FST4_120_DOWNSAMPLE);
384        check_downsample::<Fst4s300>("Fst4s300", &decode::FST4_300_DOWNSAMPLE);
385
386        check_gfsk::<Fst4s15>("Fst4s15", &encode::FST4_15_GFSK);
387        check_gfsk::<Fst4s30>("Fst4s30", &encode::FST4_30_GFSK);
388        check_gfsk::<Fst4s60>("Fst4s60", &encode::FST4_60A_GFSK);
389        check_gfsk::<Fst4s120>("Fst4s120", &encode::FST4_120_GFSK);
390        check_gfsk::<Fst4s300>("Fst4s300", &encode::FST4_300_GFSK);
391    }
392}