mfsk_core/ft8/mod.rs
1//! # `ft8` — FT8 decoder and synthesiser
2//!
3//! FT8 is the most widely-used WSJT-family mode: 15-second slots,
4//! 8-GFSK modulation at 6.25 baud (= 160 ms / symbol), LDPC(174, 91)
5//! with CRC-14 inside a 77-bit WSJT message, and three Costas-7 sync
6//! blocks at positions 0 / 36 / 72.
7//!
8//! ## Sample rate
9//!
10//! The internal decode pipeline assumes **12 000 Hz** PCM input.
11//! For other sample rates (e.g. 44 100, 48 000 Hz), use
12//! [`resample::resample_to_12k`] to convert before calling
13//! [`decode::decode_frame`] or [`decode::decode_sniper_ap`].
14//!
15//! The WASM wrapper (`ft8-web`) accepts a `sample_rate` parameter
16//! on each decode function and handles this conversion automatically.
17//!
18//! ## Protocol trait
19//!
20//! The zero-sized [`Ft8`] type implements the generic
21//! [`crate::core::Protocol`] trait so downstream pipeline code (shared with
22//! FT4, FT2, FST4) can dispatch on `P: Protocol` at compile time.
23//!
24//! ## Quick example
25//!
26//! Decode the top-scoring message in a 15-second slot:
27//!
28//! ```no_run
29//! use mfsk_core::ft8::decode::{decode_frame, DecodeDepth};
30//! use mfsk_core::msg::wsjt77::unpack77;
31//!
32//! # let audio: Vec<i16> = vec![];
33//! // `audio` is 180_000 i16 samples at 12 kHz (15 s, slot-aligned).
34//! let results = decode_frame(
35//! &audio,
36//! /* freq_min */ 100.0,
37//! /* freq_max */ 3_000.0,
38//! /* sync_min */ 1.0,
39//! /* freq_hint */ None,
40//! DecodeDepth::BpAllOsd,
41//! /* max_cand */ 200,
42//! );
43//! for r in &results {
44//! if let Some(text) = unpack77(&r.message77) {
45//! println!("{:7.1} Hz dt={:+.2} s SNR={:+.0} dB {}",
46//! r.freq_hz, r.dt_sec, r.snr_db, text);
47//! }
48//! }
49//! ```
50
51// Decode-side modules go through `core::fft` (FFT trait) and the
52// shared `core::pipeline`; gated on the FFT meta-feature so embedded
53// builds with `fft-microfft` or `fft-extern` get them. `wave_gen`,
54// `message`, `ldpc`, `params`, `hash_table` stay available for TX-only
55// / FEC-only use cases without any FFT backend.
56#[cfg(any(feature = "fft-rustfft", feature = "fft-extern"))]
57pub mod baseline;
58#[cfg(any(feature = "fft-rustfft", feature = "fft-extern"))]
59pub mod decode;
60#[cfg(any(feature = "fft-rustfft", feature = "fft-extern"))]
61pub mod decode_block;
62#[cfg(any(feature = "fft-rustfft", feature = "fft-extern"))]
63pub mod downsample;
64#[cfg(any(feature = "fft-rustfft", feature = "fft-extern"))]
65pub mod equalizer;
66pub mod hash_table;
67pub mod ldpc;
68#[cfg(any(feature = "fft-rustfft", feature = "fft-extern"))]
69pub mod llr;
70pub mod message;
71pub mod params;
72#[cfg(any(feature = "fft-rustfft", feature = "fft-extern"))]
73pub mod refine_fine;
74#[cfg(any(feature = "fft-rustfft", feature = "fft-extern"))]
75pub mod resample;
76// `subtract` consumes `super::decode::DecodeResult`, which is itself
77// FFT-gated — match the gate so `--features ft8` (TX-only) builds.
78#[cfg(any(feature = "fft-rustfft", feature = "fft-extern"))]
79pub mod subtract;
80#[cfg(any(feature = "fft-rustfft", feature = "fft-extern"))]
81pub mod sync;
82pub mod wave_gen;
83
84use crate::core::{FrameLayout, ModulationParams, Protocol, ProtocolId, SyncBlock, SyncMode};
85use crate::fec::Ldpc174_91;
86use crate::msg::Wsjt77Message;
87
88/// FT8 protocol marker: 8-GFSK, 79 symbols over a 15 s slot, 6.25 Hz tone
89/// spacing, three 7-symbol Costas arrays, LDPC(174,91) FEC, WSJT 77-bit
90/// message payload. Carries no data — used as a type-level switch.
91#[derive(Copy, Clone, Debug, Default)]
92pub struct Ft8;
93
94impl ModulationParams for Ft8 {
95 const NTONES: u32 = params::NTONES as u32;
96 const BITS_PER_SYMBOL: u32 = 3;
97 const NSPS: u32 = params::NSPS as u32;
98 const SYMBOL_DT: f32 = params::SYMBOL_DT;
99 const TONE_SPACING_HZ: f32 = 6.25;
100 const GRAY_MAP: &'static [u8] = &FT8_GRAY_MAP;
101 const GFSK_BT: f32 = 2.0;
102 const GFSK_HMOD: f32 = 1.0;
103 const NFFT_PER_SYMBOL_FACTOR: u32 = 2; // NFFT1 = 2 × NSPS = 3840
104 const NSTEP_PER_SYMBOL: u32 = 4; // quarter-symbol coarse-sync step
105 const NDOWN: u32 = 60; // 12 000 / 60 = 200 Hz baseband
106}
107
108impl FrameLayout for Ft8 {
109 const N_DATA: u32 = params::ND as u32;
110 const N_SYNC: u32 = params::NS as u32;
111 const N_SYMBOLS: u32 = params::NN as u32;
112 const N_RAMP: u32 = 0; // ramp is internal to gfsk::synth
113 const SYNC_MODE: SyncMode = SyncMode::Block(&FT8_SYNC_BLOCKS);
114 const T_SLOT_S: f32 = 15.0;
115 const TX_START_OFFSET_S: f32 = 0.5;
116}
117
118impl Protocol for Ft8 {
119 type Fec = Ldpc174_91;
120 type Msg = Wsjt77Message;
121 const ID: ProtocolId = ProtocolId::Ft8;
122}
123
124// `params::GRAYMAP` / `params::COSTAS` are `[usize; _]` for historical reasons,
125// but `ModulationParams::GRAY_MAP` etc. require `&'static [u8]`. Narrow them
126// here at compile time.
127const FT8_GRAY_MAP: [u8; 8] = {
128 let mut out = [0u8; 8];
129 let mut i = 0;
130 while i < 8 {
131 out[i] = params::GRAYMAP[i] as u8;
132 i += 1;
133 }
134 out
135};
136
137const FT8_COSTAS: [u8; 7] = {
138 let mut out = [0u8; 7];
139 let mut i = 0;
140 while i < 7 {
141 out[i] = params::COSTAS[i] as u8;
142 i += 1;
143 }
144 out
145};
146
147/// FT8 has three identical Costas arrays at symbols 0 / 36 / 72.
148const FT8_SYNC_BLOCKS: [SyncBlock; 3] = [
149 SyncBlock {
150 start_symbol: 0,
151 pattern: &FT8_COSTAS,
152 },
153 SyncBlock {
154 start_symbol: 36,
155 pattern: &FT8_COSTAS,
156 },
157 SyncBlock {
158 start_symbol: 72,
159 pattern: &FT8_COSTAS,
160 },
161];