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}