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}