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];