mfsk_core/core/protocol.rs
1//! Protocol trait hierarchy.
2//!
3//! A `Protocol` is a zero-sized type that ties together the four axes of
4//! variation across WSJT-family digital modes:
5//!
6//! | Axis | Trait | Examples |
7//! |--------------------|--------------------|-----------------------------------|
8//! | Tones / baseband | `ModulationParams` | 8-FSK @ 6.25 Hz (FT8) vs 4-FSK (FT4) |
9//! | Frame layout | `FrameLayout` | Costas pattern, sync positions |
10//! | FEC | `FecCodec` | LDPC(174,91) / Reed–Solomon / Fano |
11//! | Message payload | `MessageCodec` | WSJT 77-bit / JT 72-bit / WSPR 50 |
12//!
13//! Splitting the traits lets implementations share code: FT4 reuses FT8's
14//! `Ldpc174_91` and `Wsjt77Message` and differs only in `ModulationParams` +
15//! `FrameLayout`, so SIMD optimisations to the shared LDPC decoder
16//! automatically benefit every LDPC-based protocol.
17
18/// Runtime protocol tag — used at FFI boundaries where generics cannot cross
19/// the C ABI. Order is stable; append new variants at the end.
20#[repr(u8)]
21#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
22pub enum ProtocolId {
23 /// FT8 — 15 s slot, 8-FSK, LDPC(174,91), 77-bit message.
24 Ft8 = 0,
25 /// FT4 — 7.5 s slot, 4-FSK, LDPC(174,91), 77-bit message.
26 Ft4 = 1,
27 /// FT2 (experimental / contest variant).
28 Ft2 = 2,
29 /// FST4 — 60 s slot, 4-FSK, LDPC(240,101) + CRC-24, 77-bit message.
30 Fst4 = 3,
31 /// JT65 — 60 s slot, 65-tone FSK, Reed-Solomon(63,12), 72-bit message.
32 Jt65 = 4,
33 /// JT9 — 60 s slot, 9-FSK, convolutional r=½ K=32 + Fano, 72-bit message.
34 Jt9 = 5,
35 /// WSPR — 120 s slot, 4-FSK, convolutional r=½ K=32 + Fano, 50-bit message.
36 Wspr = 6,
37 /// Q65 — 65-tone FSK, QRA(15,65) over GF(64), 77-bit Wsjt77 message.
38 /// Multiple T/R-period × tone-spacing variants share this tag at the
39 /// FFI level; the protocol-layer ZST disambiguates.
40 Q65 = 7,
41}
42
43/// Baseband modulation parameters (tones, symbol rate, Gray mapping, Gaussian
44/// shaping and the tunable DSP ratios the pipeline reads per protocol).
45///
46/// All constants are evaluated at compile time; the trait carries no data so
47/// implementors are typically zero-sized types.
48pub trait ModulationParams: Copy + Default + 'static {
49 /// Number of FSK tones (M in M-ary FSK).
50 const NTONES: u32;
51
52 /// Information bits carried per modulated symbol (= log2(NTONES)).
53 const BITS_PER_SYMBOL: u32;
54
55 /// Samples per symbol at the 12 kHz pipeline sample rate.
56 const NSPS: u32;
57
58 /// Symbol duration in seconds (= NSPS / 12000).
59 const SYMBOL_DT: f32;
60
61 /// Spacing between adjacent tones, in Hz.
62 const TONE_SPACING_HZ: f32;
63
64 /// Gray-code map: `GRAY_MAP[tone_index]` returns the NATURAL-bit pattern
65 /// for that tone. The map covers at least the data alphabet
66 /// (`2^BITS_PER_SYMBOL` entries) and at most the full tone set
67 /// (`NTONES` entries). Protocols whose sync tones are part of
68 /// the data alphabet (FT8 / FT4 / FST4 / WSPR) have
69 /// `len() == NTONES == 2^BITS_PER_SYMBOL`; protocols that
70 /// reserve additional sync-only tones (JT9, JT65, Q65) either
71 /// trim the map to the data alphabet (JT9: 8 entries for 9
72 /// tones) or extend it with identity over the sync slots
73 /// (JT65 / Q65). Pinned by `tests/protocol_invariants.rs`.
74 const GRAY_MAP: &'static [u8];
75
76 // ── GFSK shaping ────────────────────────────────────────────────────
77 /// Gaussian bandwidth-time product. FT8 = 2.0, FT4 = 1.0, FST4 ≈ 1.0.
78 const GFSK_BT: f32;
79 /// Modulation index h — the phase increment per symbol is `2π · h`.
80 /// FT8 and FT4 both use 1.0 (orthogonal tones at `1/T` spacing).
81 const GFSK_HMOD: f32;
82
83 // ── Per-protocol DSP ratios ─────────────────────────────────────────
84 /// Per-symbol FFT size = `NSPS * NFFT_PER_SYMBOL_FACTOR`.
85 /// FT8 = 2 (window is 2·NSPS), FT4 = 4 (window is 4·NSPS) — trade-off
86 /// between frequency resolution and time localisation.
87 const NFFT_PER_SYMBOL_FACTOR: u32;
88 /// Coarse-sync time-step = `NSPS / NSTEP_PER_SYMBOL`.
89 /// FT8 = 4 (quarter-symbol resolution), FT4 = 1 (symbol-granular).
90 const NSTEP_PER_SYMBOL: u32;
91 /// Downsample decimation factor: baseband rate = `12 000 / NDOWN` Hz.
92 /// FT8 = 60 (→200 Hz), FT4 = 18 (→667 Hz). Proportional to tone spacing.
93 const NDOWN: u32;
94
95 /// LLR scale factor applied after standard-deviation normalisation.
96 /// FT8 uses 2.83 (empirical, from WSJT-X ft8b.f90). Different
97 /// bits-per-symbol counts may shift the optimum — FT4's 2-bit LLR
98 /// dynamics are not identical to FT8's 3-bit case.
99 const LLR_SCALE: f32 = 2.83;
100}
101
102/// One Costas / pilot block: a contiguous run of tones starting at a specific
103/// symbol index within the frame.
104///
105/// FT8 has three identical blocks (positions 0/36/72, same Costas-7 pattern);
106/// FT4 has four *different* blocks (positions 0/33/66/99, each a permutation
107/// of `[0,1,2,3]`). The trait is shaped to accommodate both.
108#[derive(Copy, Clone, Debug)]
109pub struct SyncBlock {
110 /// Symbol index (0-based) where this block starts.
111 pub start_symbol: u32,
112 /// Tone sequence for this block. `pattern.len()` is the block length.
113 pub pattern: &'static [u8],
114}
115
116/// How sync information is carried in the channel symbol stream.
117///
118/// * `Block` — dedicated contiguous sync blocks (Costas arrays) occupy
119/// specific symbol positions, with data symbols filling the rest. Used by
120/// FT8, FT4, FST4.
121/// * `Interleaved` — every channel symbol carries one sync bit (fixed
122/// position within the tone index) AND payload bits. The sync bits
123/// concatenated across the frame form a known pseudorandom vector.
124/// Used by WSPR: `tone = 2·data_bit + sync_bit`, so LSB of each
125/// 4-FSK symbol reproduces the 162-bit `npr3` sync vector.
126#[derive(Copy, Clone, Debug)]
127pub enum SyncMode {
128 Block(&'static [SyncBlock]),
129 Interleaved {
130 /// Position of the sync bit within the tone index, LSB-first.
131 /// WSPR = 0 (LSB).
132 sync_bit_pos: u8,
133 /// Sync vector, one bit per frame symbol. Length == `N_SYMBOLS`.
134 vector: &'static [u8],
135 },
136}
137
138impl SyncMode {
139 /// Block list for `Block` mode; empty slice for `Interleaved`.
140 /// Sync/LLR/TX helpers that only handle block-structured sync can iterate
141 /// this unconditionally — they will no-op on WSPR-style protocols, which
142 /// then need their own interleaved-sync pipeline entry point.
143 pub const fn blocks(&self) -> &'static [SyncBlock] {
144 match self {
145 SyncMode::Block(b) => b,
146 SyncMode::Interleaved { .. } => &[],
147 }
148 }
149}
150
151/// Frame structure: data / sync symbol counts, the ordered list of sync
152/// blocks, and the TX-side nominal start offset.
153pub trait FrameLayout: Copy + Default + 'static {
154 /// Data symbols carrying FEC-coded payload.
155 const N_DATA: u32;
156
157 /// Sync symbols (sum of `pattern.len()` across `SYNC_BLOCKS`).
158 const N_SYNC: u32;
159
160 /// Total channel symbols per frame (= N_DATA + N_SYNC). Excludes any
161 /// GFSK ramp-up / ramp-down symbols that are a shaping artifact.
162 const N_SYMBOLS: u32;
163
164 /// Extra symbol slots on each side of the frame reserved for amplitude
165 /// ramp (FT4 has 1 each side = 2; FT8 has 0 — ramp absorbed into the
166 /// first/last data symbol envelope). Applied at the transmitter.
167 const N_RAMP: u32;
168
169 /// Sync-symbol layout. Most WSJT protocols use `SyncMode::Block` with
170 /// dedicated Costas blocks (FT8/FT4/FST4); WSPR uses `SyncMode::Interleaved`
171 /// with a per-symbol sync bit. Callers that only support block sync should
172 /// read `SYNC_MODE.blocks()` and treat an empty slice as "unsupported".
173 const SYNC_MODE: SyncMode;
174
175 /// Nominal TX/RX slot length in seconds (informational — used by
176 /// schedulers and UI, not by the DSP pipeline). FT8 = 15 s, FT4 = 7.5 s.
177 const T_SLOT_S: f32;
178
179 /// Time (seconds) from the start of the slot-audio buffer to the start
180 /// of the first frame symbol — the "dt = 0" reference point used by
181 /// sync, signal subtraction, and DT reporting. FT8 = 0.5, FT4 = 0.5.
182 const TX_START_OFFSET_S: f32;
183}
184
185// ──────────────────────────────────────────────────────────────────────────
186// FEC
187// ──────────────────────────────────────────────────────────────────────────
188
189/// Options controlling FEC decoding depth / fall-backs.
190///
191/// This is deliberately a plain data struct rather than a trait — it describes
192/// *how* to decode, not *what* code to use. Codecs ignore fields that don't
193/// apply (e.g. convolutional decoders ignore `osd_depth`).
194#[derive(Copy, Clone, Debug)]
195pub struct FecOpts<'a> {
196 /// Maximum belief-propagation iterations (LDPC).
197 pub bp_max_iter: u32,
198 /// Ordered-statistics-decoding search depth (0 disables OSD fallback).
199 pub osd_depth: u32,
200 /// Optional a-priori hint: bits whose LLR should be clamped to a strong
201 /// known value before decoding. `Some((mask, values))` where `mask[i] == 1`
202 /// means `values[i]` is locked to `values[i]`.
203 ///
204 /// Lifetime is per-call: the caller allocates the AP vectors for the
205 /// duration of this decode — typical usage builds a `Vec<u8>` from an
206 /// `ApHint` and borrows into `FecOpts` for a single `decode_soft` call.
207 pub ap_mask: Option<(&'a [u8], &'a [u8])>,
208}
209
210impl<'a> Default for FecOpts<'a> {
211 fn default() -> Self {
212 Self {
213 bp_max_iter: 30,
214 osd_depth: 0,
215 ap_mask: None,
216 }
217 }
218}
219
220/// Result of a successful FEC decode.
221#[derive(Clone, Debug)]
222pub struct FecResult {
223 /// Hard-decision information bits (length = `FecCodec::K`).
224 pub info: Vec<u8>,
225 /// Number of hard-decision errors corrected (for quality metric).
226 pub hard_errors: u32,
227 /// Iterations consumed (0 if N/A).
228 pub iterations: u32,
229}
230
231/// Forward-error-correction codec: maps `K` information bits ↔ `N` codeword
232/// bits.
233///
234/// Implementors MUST be `Default`-constructible so generic pipeline code can
235/// obtain an instance via `P::Fec::default()` without plumbing state.
236/// Stateless codecs (matrices in `const` / `static`) are the common case.
237pub trait FecCodec: Default + 'static {
238 /// Codeword length.
239 const N: usize;
240
241 /// Information-bit length.
242 const K: usize;
243
244 /// Systematic encode: `info.len() == K`, `codeword.len() == N`. The first
245 /// `K` bits of `codeword` must equal `info` (systematic form).
246 fn encode(&self, info: &[u8], codeword: &mut [u8]);
247
248 /// Soft-decision decode from log-likelihood ratios.
249 ///
250 /// `llr.len() == N`. On success returns the `K` information bits plus
251 /// decoder statistics. On failure returns `None`.
252 fn decode_soft(&self, llr: &[f32], opts: &FecOpts) -> Option<FecResult>;
253}
254
255// ──────────────────────────────────────────────────────────────────────────
256// Message codec
257// ──────────────────────────────────────────────────────────────────────────
258
259/// Human-facing message payload codec (callsigns, grids, reports, free text).
260///
261/// Operates on the FEC-decoded information bits (`PAYLOAD_BITS` wide, NOT
262/// including any CRC protecting them — callers handle the CRC layer).
263///
264/// Unlike `FecCodec`, this trait is an acceptable place for `dyn` when the
265/// caller juggles heterogeneous protocols at runtime (FFI, CLI dump tools):
266/// message unpacking is a cold path relative to DSP/FEC inner loops.
267pub trait MessageCodec: Default + 'static {
268 /// Decoded high-level representation returned by `unpack`.
269 type Unpacked;
270
271 /// Number of information bits consumed by `pack` / produced by `unpack`.
272 const PAYLOAD_BITS: u32;
273
274 /// CRC width guarding the payload during transmission (0 if the FEC itself
275 /// provides all error detection, as with JT65 Reed–Solomon).
276 const CRC_BITS: u32;
277
278 /// Encode high-level fields to a bit vector of length `PAYLOAD_BITS`.
279 /// Returns `None` on encoding failure (invalid callsign format, overflow…).
280 fn pack(&self, fields: &MessageFields) -> Option<Vec<u8>>;
281
282 /// Decode a `PAYLOAD_BITS`-long bit vector to the protocol-specific
283 /// unpacked representation. `ctx` carries side information such as the
284 /// callsign-hash table.
285 fn unpack(&self, payload: &[u8], ctx: &DecodeContext) -> Option<Self::Unpacked>;
286}
287
288/// Generic input to `MessageCodec::pack` — protocol-specific codecs accept
289/// the subset of fields they understand and return `None` for unsupported
290/// combinations.
291#[derive(Clone, Debug, Default)]
292pub struct MessageFields {
293 pub call1: Option<String>,
294 pub call2: Option<String>,
295 pub grid: Option<String>,
296 pub report: Option<i32>,
297 pub free_text: Option<String>,
298}
299
300/// Side information passed to `MessageCodec::unpack`.
301///
302/// `callsign_hash_table` is an opaque pointer the protocol crate
303/// downcasts to its own table type — generic code does not need to know the
304/// shape. This keeps `mfsk-msg` optional at the `mfsk-core` level.
305#[derive(Clone, Debug, Default)]
306pub struct DecodeContext {
307 /// Optional hashed-callsign lookup owned by the caller. Concrete layout is
308 /// protocol-defined; interpret via `Any::downcast_ref` inside the codec.
309 pub callsign_hash_table: Option<std::sync::Arc<dyn std::any::Any + Send + Sync>>,
310}
311
312// ──────────────────────────────────────────────────────────────────────────
313// Protocol facade
314// ──────────────────────────────────────────────────────────────────────────
315
316/// The full protocol description: ties `ModulationParams`, `FrameLayout`, a
317/// FEC codec and a message codec together under one trait for ergonomic
318/// `<P: Protocol>` bounds.
319pub trait Protocol: ModulationParams + FrameLayout + 'static {
320 /// FEC codec carrying `N_DATA * BITS_PER_SYMBOL` coded bits.
321 type Fec: FecCodec;
322
323 /// Message codec consuming the FEC-decoded information bits.
324 type Msg: MessageCodec;
325
326 /// Runtime tag used at FFI / WASM boundaries.
327 const ID: ProtocolId;
328}