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//!     let msg77: &[u8; 77] = r.message77().try_into().unwrap();
32//!     if let Some(text) = unpack77(msg77) {
33//!         println!("{:7.1} Hz  dt={:+.2} s  {}", r.freq_hz, r.dt_sec, text);
34//!     }
35//! }
36//! ```
37
38use crate::core::{FrameLayout, ModulationParams, Protocol, ProtocolId, SyncBlock, SyncMode};
39use crate::fec::Ldpc240_101;
40use crate::msg::Wsjt77Message;
41
42pub mod decode;
43pub mod encode;
44
45/// FST4-60A: 4-GFSK, 60-second T/R period, 3.125 baud, minimum tone
46/// spacing (12.4 Hz occupied bandwidth). Uses LDPC(240, 101) + CRC-24
47/// over the same 77-bit WSJT message payload that FT8 / FT4 use.
48#[derive(Copy, Clone, Debug, Default)]
49pub struct Fst4s60;
50
51impl ModulationParams for Fst4s60 {
52    const NTONES: u32 = 4;
53    const BITS_PER_SYMBOL: u32 = 2;
54    // Symbol length 320 ms → 3.125 baud → 3.125 Hz tone spacing.
55    const NSPS: u32 = 3_840;
56    const SYMBOL_DT: f32 = 0.32;
57    const TONE_SPACING_HZ: f32 = 3.125;
58    const GRAY_MAP: &'static [u8] = &[0, 1, 3, 2];
59    // BT=1.0 matches the narrow GFSK shaping WSJT-X uses for the
60    // sensitive slow FST4 modes.
61    const GFSK_BT: f32 = 1.0;
62    const GFSK_HMOD: f32 = 1.0;
63    // NFFT window = 2 × NSPS (same convention as FT8) — longer windows
64    // don't help FST4-60 because the channel is assumed quasi-static
65    // across the 60 s slot.
66    const NFFT_PER_SYMBOL_FACTOR: u32 = 2;
67    // Half-symbol coarse grid (matches FT4 practice).
68    const NSTEP_PER_SYMBOL: u32 = 2;
69    // 12 000 / 192 = 62.5 Hz baseband — enough for 4-tone signal at
70    // 3.125 Hz spacing plus guard band. Production value may differ;
71    // revisit once decoder is wired.
72    const NDOWN: u32 = 192;
73}
74
75impl FrameLayout for Fst4s60 {
76    const N_DATA: u32 = 120;
77    const N_SYNC: u32 = 40; // 5 × 8
78    const N_SYMBOLS: u32 = 160;
79    const N_RAMP: u32 = 0; // GFSK synth handles ramp internally
80    const SYNC_MODE: SyncMode = SyncMode::Block(&FST4_SYNC_BLOCKS);
81    const T_SLOT_S: f32 = 60.0;
82    // FST4 transmissions start ~1 s after the slot boundary (per WSJT-X).
83    const TX_START_OFFSET_S: f32 = 1.0;
84}
85
86impl Protocol for Fst4s60 {
87    /// LDPC(240, 101) + CRC-24 — see [`crate::fec::Ldpc240_101`].
88    type Fec = Ldpc240_101;
89    /// Same 77-bit WSJT message layout as FT8 / FT4 — fully reused.
90    type Msg = Wsjt77Message;
91    const ID: ProtocolId = ProtocolId::Fst4;
92}
93
94// Two alternating Costas patterns, each 8 symbols long, at symbols
95// 0 / 38 / 76 / 114 / 152 (0-indexed).
96const FST4_SYNC_A: [u8; 8] = [0, 1, 3, 2, 1, 0, 2, 3];
97const FST4_SYNC_B: [u8; 8] = [2, 3, 1, 0, 3, 2, 0, 1];
98
99const FST4_SYNC_BLOCKS: [SyncBlock; 5] = [
100    SyncBlock {
101        start_symbol: 0,
102        pattern: &FST4_SYNC_A,
103    },
104    SyncBlock {
105        start_symbol: 38,
106        pattern: &FST4_SYNC_B,
107    },
108    SyncBlock {
109        start_symbol: 76,
110        pattern: &FST4_SYNC_A,
111    },
112    SyncBlock {
113        start_symbol: 114,
114        pattern: &FST4_SYNC_B,
115    },
116    SyncBlock {
117        start_symbol: 152,
118        pattern: &FST4_SYNC_A,
119    },
120];
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn fst4s60_trait_surface() {
128        assert_eq!(<Fst4s60 as ModulationParams>::NTONES, 4);
129        assert_eq!(<Fst4s60 as ModulationParams>::NSPS, 3_840);
130        assert!((<Fst4s60 as ModulationParams>::SYMBOL_DT - 0.32).abs() < 1e-6,);
131        assert_eq!(<Fst4s60 as FrameLayout>::N_SYMBOLS, 160);
132        assert_eq!(<Fst4s60 as FrameLayout>::N_DATA, 120);
133        assert_eq!(<Fst4s60 as FrameLayout>::N_SYNC, 40);
134        let blocks = <Fst4s60 as FrameLayout>::SYNC_MODE.blocks();
135        assert_eq!(blocks.len(), 5);
136        assert_eq!(
137            blocks.iter().map(|b| b.start_symbol).collect::<Vec<_>>(),
138            vec![0, 38, 76, 114, 152],
139        );
140        assert_eq!(blocks[0].pattern.len(), 8);
141
142        use crate::core::FecCodec;
143        assert_eq!(<<Fst4s60 as Protocol>::Fec as FecCodec>::N, 240);
144        assert_eq!(<<Fst4s60 as Protocol>::Fec as FecCodec>::K, 101);
145    }
146}