Skip to main content

mfsk_core/ft4/
mod.rs

1//! # `ft4` — FT4 decoder and synthesiser
2//!
3//! FT4 shares the LDPC(174, 91) code and WSJT 77-bit message payload with FT8;
4//! only the modulation parameters (4-GFSK at 20.833 baud), frame layout (four
5//! 4-symbol Costas arrays at symbols 0 / 33 / 66 / 99) and DSP ratios differ.
6//! All heavy lifting is delegated to generic code in [`crate::core`]; this
7//! module mainly wires the trait impls and provides decode / synth entry
8//! points.
9//!
10//! ## Quick example
11//!
12//! Round-trip a synthesised FT4 frame through the decoder:
13//!
14//! ```
15//! # #[cfg(all(feature = "ft4", any(feature = "fft-rustfft", feature = "fft-extern")))] {
16//! use mfsk_core::ft4::{
17//!     decode::decode_frame,
18//!     encode::{message_to_tones, tones_to_i16},
19//! };
20//! use mfsk_core::msg::wsjt77::{pack77, unpack77};
21//!
22//! // 1. Pack a standard message and synthesise 12 kHz i16 PCM.
23//! //    The synth produces just the transmitted frame; pad to the full
24//! //    7.5 s slot with the signal starting at 0.5 s.
25//! let msg77 = pack77("CQ", "JA1ABC", "PM95").expect("pack");
26//! let tones = message_to_tones(&msg77);
27//! let frame = tones_to_i16(&tones, /* freq */ 1500.0, /* amp */ 20_000);
28//!
29//! let mut audio = vec![0i16; 90_000]; // 7.5 s @ 12 kHz
30//! let start = (0.5 * 12_000.0) as usize;
31//! for (i, &s) in frame.iter().enumerate() {
32//!     if start + i < audio.len() { audio[start + i] = s; }
33//! }
34//!
35//! // 2. Decode it back across the full FT4 band.
36//! let results = decode_frame(&audio, 100.0, 3_000.0, 1.0, /* max_cand */ 100);
37//! assert!(!results.is_empty(), "roundtrip must decode");
38//! let msg77: &[u8; 77] = results[0].message77().try_into().unwrap();
39//! let text = unpack77(msg77).expect("unpack");
40//! assert_eq!(text, "CQ JA1ABC PM95");
41//! # }
42//! ```
43
44use crate::core::{FrameLayout, ModulationParams, Protocol, ProtocolId, SyncBlock, SyncMode};
45use crate::fec::Ldpc174_91;
46use crate::msg::Wsjt77Message;
47
48// Decode pulls `core::pipeline` (FFT trait); gated on the FFT
49// meta-feature. `encode` is FFT-free and always available.
50#[cfg(any(feature = "fft-rustfft", feature = "fft-extern"))]
51pub mod decode;
52pub mod encode;
53#[cfg(any(feature = "fft-rustfft", feature = "fft-extern"))]
54#[cfg(any(feature = "fft-rustfft", feature = "fft-extern"))]
55pub mod subtract;
56
57/// FT4 protocol marker: 4-GFSK, 103 symbols over 7.5 s slot, 20.833 Hz tone
58/// spacing, four different Costas-4 arrays, LDPC(174,91) FEC, WSJT 77-bit
59/// message payload.
60#[derive(Copy, Clone, Debug, Default)]
61pub struct Ft4;
62
63impl ModulationParams for Ft4 {
64    const NTONES: u32 = 4;
65    const BITS_PER_SYMBOL: u32 = 2;
66    const NSPS: u32 = 576; // 48 ms @ 12 kHz
67    const SYMBOL_DT: f32 = 0.048;
68    const TONE_SPACING_HZ: f32 = 20.833;
69    const GRAY_MAP: &'static [u8] = &[0, 1, 3, 2];
70    const GFSK_BT: f32 = 1.0;
71    const GFSK_HMOD: f32 = 1.0;
72    const NFFT_PER_SYMBOL_FACTOR: u32 = 4; // NFFT1 = 4 × NSPS = 2304
73    const NSTEP_PER_SYMBOL: u32 = 2; // half-symbol coarse-sync step (24 ms)
74    const NDOWN: u32 = 18; // 12 000 / 18 ≈ 666.7 Hz baseband
75    // LLR_SCALE tuning (2.0 / 2.83 / 3.5) was measured to give identical
76    // threshold curves — BP already converges within that range. Keeping
77    // the WSJT-X default.
78
79    // Spectrum window: Rectangular preserves the synth-roundtrip
80    // tests (clean-signal case where Nuttall + global-pct baseline
81    // misranks the signal bin against sidelobes). Nuttall is correct
82    // per WSJT-X but needs a more robust noise-floor estimator first.
83    const SPECTRUM_WINDOW: crate::core::SpectrumWindow = crate::core::SpectrumWindow::Rectangular;
84
85    // 4-symbol coherent integration on the 3rd LLR variant (matches
86    // WSJT-X `get_ft4_bitmetrics.f90:71` `nsym=4`). Gives ~3 dB SNR
87    // boost on stable signals vs the default nsym=3 — exactly what
88    // weak FT4 goldens at 0.4–3× noise need to land on the true
89    // codeword instead of a CRC-14 false positive.
90    const LLR_NSYM_MAX: u32 = 4;
91
92    // 77-bit pre-LDPC scrambler (WSJT-X `genft4.f90:64`).
93    const INFO_SCRAMBLE_RVEC: Option<&'static [u8]> = Some(&FT4_RVEC);
94}
95
96impl FrameLayout for Ft4 {
97    const N_DATA: u32 = 87;
98    const N_SYNC: u32 = 16; // 4 × 4-symbol Costas
99    const N_SYMBOLS: u32 = 103; // active channel symbols (excludes 2 ramp symbols)
100    const N_RAMP: u32 = 2; // 1 each side, NN2 = 105
101    const SYNC_MODE: SyncMode = SyncMode::Block(&FT4_SYNC_BLOCKS);
102    const T_SLOT_S: f32 = 7.5;
103    const TX_START_OFFSET_S: f32 = 0.5;
104}
105
106impl Protocol for Ft4 {
107    type Fec = Ldpc174_91;
108    type Msg = Wsjt77Message;
109    const ID: ProtocolId = ProtocolId::Ft4;
110}
111
112/// FT4-specific 77-bit pre-LDPC scrambler. WSJT-X `genft4.f90:33-35,64`
113/// XORs the 77-bit message with this fixed sequence before appending
114/// CRC-14 + LDPC parity, and `ft4_decode.f90:430` removes the
115/// scrambling after LDPC decode + CRC verify. Without it, our
116/// decoder converges on a valid codeword whose payload is the
117/// message bits XORed with this vector — passes CRC (because the
118/// CRC was committed in the scrambled domain) but unpacks as
119/// nonsense.
120pub const FT4_RVEC: [u8; 77] = [
121    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,
122    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,
123    1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 1,
124];
125
126/// FT4's four Costas arrays — each a distinct permutation of `[0,1,2,3]`.
127const FT4_COSTAS_A: [u8; 4] = [0, 1, 3, 2];
128const FT4_COSTAS_B: [u8; 4] = [1, 0, 2, 3];
129const FT4_COSTAS_C: [u8; 4] = [2, 3, 1, 0];
130const FT4_COSTAS_D: [u8; 4] = [3, 2, 0, 1];
131
132const FT4_SYNC_BLOCKS: [SyncBlock; 4] = [
133    SyncBlock {
134        start_symbol: 0,
135        pattern: &FT4_COSTAS_A,
136    },
137    SyncBlock {
138        start_symbol: 33,
139        pattern: &FT4_COSTAS_B,
140    },
141    SyncBlock {
142        start_symbol: 66,
143        pattern: &FT4_COSTAS_C,
144    },
145    SyncBlock {
146        start_symbol: 99,
147        pattern: &FT4_COSTAS_D,
148    },
149];