Skip to main content

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    /// uvpacket — 4-GFSK packet protocol for narrow-FM voice channels
42    /// at U/VHF (Rayleigh-fading-tolerant). 4 sub-modes share this
43    /// family ID; the protocol-layer ZST disambiguates.
44    UvPacket = 8,
45}
46
47/// Baseband modulation parameters (tones, symbol rate, Gray mapping, Gaussian
48/// shaping and the tunable DSP ratios the pipeline reads per protocol).
49///
50/// All constants are evaluated at compile time; the trait carries no data so
51/// implementors are typically zero-sized types.
52pub trait ModulationParams: Copy + Default + 'static {
53    /// Number of FSK tones (M in M-ary FSK).
54    const NTONES: u32;
55
56    /// Information bits carried per modulated symbol (= log2(NTONES)).
57    const BITS_PER_SYMBOL: u32;
58
59    /// Samples per symbol at the 12 kHz pipeline sample rate.
60    const NSPS: u32;
61
62    /// Symbol duration in seconds (= NSPS / 12000).
63    const SYMBOL_DT: f32;
64
65    /// Spacing between adjacent tones, in Hz.
66    const TONE_SPACING_HZ: f32;
67
68    /// Gray-code map: `GRAY_MAP[tone_index]` returns the NATURAL-bit pattern
69    /// for that tone. The map covers at least the data alphabet
70    /// (`2^BITS_PER_SYMBOL` entries) and at most the full tone set
71    /// (`NTONES` entries). Protocols whose sync tones are part of
72    /// the data alphabet (FT8 / FT4 / FST4 / WSPR) have
73    /// `len() == NTONES == 2^BITS_PER_SYMBOL`; protocols that
74    /// reserve additional sync-only tones (JT9, JT65, Q65) either
75    /// trim the map to the data alphabet (JT9: 8 entries for 9
76    /// tones) or extend it with identity over the sync slots
77    /// (JT65 / Q65). Pinned by `tests/protocol_invariants.rs`.
78    const GRAY_MAP: &'static [u8];
79
80    // ── GFSK shaping ────────────────────────────────────────────────────
81    /// Gaussian bandwidth-time product. FT8 = 2.0, FT4 = 1.0, FST4 ≈ 1.0.
82    const GFSK_BT: f32;
83    /// Modulation index h — the phase increment per symbol is `2π · h`.
84    /// FT8 and FT4 both use 1.0 (orthogonal tones at `1/T` spacing).
85    const GFSK_HMOD: f32;
86
87    // ── Per-protocol DSP ratios ─────────────────────────────────────────
88    /// Per-symbol FFT size = `NSPS * NFFT_PER_SYMBOL_FACTOR`.
89    /// FT8 = 2 (window is 2·NSPS), FT4 = 4 (window is 4·NSPS) — trade-off
90    /// between frequency resolution and time localisation.
91    const NFFT_PER_SYMBOL_FACTOR: u32;
92    /// Coarse-sync time-step = `NSPS / NSTEP_PER_SYMBOL`.
93    /// FT8 = 4 (quarter-symbol resolution), FT4 = 1 (symbol-granular).
94    const NSTEP_PER_SYMBOL: u32;
95    /// Downsample decimation factor: baseband rate = `12 000 / NDOWN` Hz.
96    /// FT8 = 60 (→200 Hz), FT4 = 18 (→667 Hz). Proportional to tone spacing.
97    const NDOWN: u32;
98
99    /// LLR scale factor applied after standard-deviation normalisation.
100    /// FT8 uses 2.83 (empirical, from WSJT-X ft8b.f90). Different
101    /// bits-per-symbol counts may shift the optimum — FT4's 2-bit LLR
102    /// dynamics are not identical to FT8's 3-bit case.
103    const LLR_SCALE: f32 = 2.83;
104}
105
106/// One Costas / pilot block: a contiguous run of tones starting at a specific
107/// symbol index within the frame.
108///
109/// FT8 has three identical blocks (positions 0/36/72, same Costas-7 pattern);
110/// FT4 has four *different* blocks (positions 0/33/66/99, each a permutation
111/// of `[0,1,2,3]`). The trait is shaped to accommodate both.
112#[derive(Copy, Clone, Debug)]
113pub struct SyncBlock {
114    /// Symbol index (0-based) where this block starts.
115    pub start_symbol: u32,
116    /// Tone sequence for this block. `pattern.len()` is the block length.
117    pub pattern: &'static [u8],
118}
119
120/// How sync information is carried in the channel symbol stream.
121///
122/// * `Block` — dedicated contiguous sync blocks (Costas arrays) occupy
123///   specific symbol positions, with data symbols filling the rest. Used by
124///   FT8, FT4, FST4.
125/// * `Interleaved` — every channel symbol carries one sync bit (fixed
126///   position within the tone index) AND payload bits. The sync bits
127///   concatenated across the frame form a known pseudorandom vector.
128///   Used by WSPR: `tone = 2·data_bit + sync_bit`, so LSB of each
129///   4-FSK symbol reproduces the 162-bit `npr3` sync vector.
130#[derive(Copy, Clone, Debug)]
131pub enum SyncMode {
132    Block(&'static [SyncBlock]),
133    Interleaved {
134        /// Position of the sync bit within the tone index, LSB-first.
135        /// WSPR = 0 (LSB).
136        sync_bit_pos: u8,
137        /// Sync vector, one bit per frame symbol. Length == `N_SYMBOLS`.
138        vector: &'static [u8],
139    },
140}
141
142impl SyncMode {
143    /// Block list for `Block` mode; empty slice for `Interleaved`.
144    /// Sync/LLR/TX helpers that only handle block-structured sync can iterate
145    /// this unconditionally — they will no-op on WSPR-style protocols, which
146    /// then need their own interleaved-sync pipeline entry point.
147    pub const fn blocks(&self) -> &'static [SyncBlock] {
148        match self {
149            SyncMode::Block(b) => b,
150            SyncMode::Interleaved { .. } => &[],
151        }
152    }
153}
154
155/// Frame structure: data / sync symbol counts, the ordered list of sync
156/// blocks, and the TX-side nominal start offset.
157pub trait FrameLayout: Copy + Default + 'static {
158    /// Data symbols carrying FEC-coded payload.
159    const N_DATA: u32;
160
161    /// Sync symbols (sum of `pattern.len()` across `SYNC_BLOCKS`).
162    const N_SYNC: u32;
163
164    /// Total channel symbols per frame (= N_DATA + N_SYNC). Excludes any
165    /// GFSK ramp-up / ramp-down symbols that are a shaping artifact.
166    const N_SYMBOLS: u32;
167
168    /// Extra symbol slots on each side of the frame reserved for amplitude
169    /// ramp (FT4 has 1 each side = 2; FT8 has 0 — ramp absorbed into the
170    /// first/last data symbol envelope). Applied at the transmitter.
171    const N_RAMP: u32;
172
173    /// Sync-symbol layout. Most WSJT protocols use `SyncMode::Block` with
174    /// dedicated Costas blocks (FT8/FT4/FST4); WSPR uses `SyncMode::Interleaved`
175    /// with a per-symbol sync bit. Callers that only support block sync should
176    /// read `SYNC_MODE.blocks()` and treat an empty slice as "unsupported".
177    const SYNC_MODE: SyncMode;
178
179    /// Nominal TX/RX slot length in seconds (informational — used by
180    /// schedulers and UI, not by the DSP pipeline). FT8 = 15 s, FT4 = 7.5 s.
181    const T_SLOT_S: f32;
182
183    /// Time (seconds) from the start of the slot-audio buffer to the start
184    /// of the first frame symbol — the "dt = 0" reference point used by
185    /// sync, signal subtraction, and DT reporting. FT8 = 0.5, FT4 = 0.5.
186    const TX_START_OFFSET_S: f32;
187
188    /// Optional bit interleaver: permutation table such that
189    /// `cw[CODEWORD_INTERLEAVE[j]]` is the codeword bit transmitted at
190    /// **channel-bit position** `j`. Length must equal
191    /// `<Self as Protocol>::Fec::N` when `Some`.
192    ///
193    /// `None` (default) means the codeword bits flow into the channel in
194    /// natural order — what FT8 / FT4 / FST4 / WSPR / JT9 / JT65 / Q65
195    /// all do, since their existing FECs and operating channels make
196    /// burst-error tolerance a non-issue (or it's handled inside the FEC,
197    /// as Q65's QRA does symbol-level dispersion).
198    ///
199    /// `Some(table)` is for codecs targeting **time-selective fading**
200    /// channels where a deep fade null can wipe out consecutive channel
201    /// bits. The interleaver spreads consecutive codeword bits across the
202    /// frame so the same fade null hits scattered codeword bits, which
203    /// soft-decision LDPC handles well. The table is a permutation of
204    /// `0..codeword_bits`; a polynomial form `INTERLEAVE[j] = (s * j)
205    /// mod n` with `gcd(s, n) = 1` gives uniform stride spacing.
206    ///
207    /// Both [`crate::core::tx::codeword_to_itone`] and the pipeline's
208    /// LLR-deinterleave step honour this constant; protocols that
209    /// override get TX/RX symmetry for free.
210    const CODEWORD_INTERLEAVE: Option<&'static [u16]> = None;
211}
212
213// ──────────────────────────────────────────────────────────────────────────
214// FEC
215// ──────────────────────────────────────────────────────────────────────────
216
217/// Options controlling FEC decoding depth / fall-backs.
218///
219/// This is deliberately a plain data struct rather than a trait — it describes
220/// *how* to decode, not *what* code to use. Codecs ignore fields that don't
221/// apply (e.g. convolutional decoders ignore `osd_depth`).
222#[derive(Copy, Clone, Debug)]
223pub struct FecOpts<'a> {
224    /// Maximum belief-propagation iterations (LDPC).
225    pub bp_max_iter: u32,
226    /// Ordered-statistics-decoding search depth (0 disables OSD fallback).
227    pub osd_depth: u32,
228    /// Optional a-priori hint: bits whose LLR should be clamped to a strong
229    /// known value before decoding. `Some((mask, values))` where `mask[i] == 1`
230    /// means `values[i]` is locked to `values[i]`.
231    ///
232    /// Lifetime is per-call: the caller allocates the AP vectors for the
233    /// duration of this decode — typical usage builds a `Vec<u8>` from an
234    /// `ApHint` and borrows into `FecOpts` for a single `decode_soft` call.
235    pub ap_mask: Option<(&'a [u8], &'a [u8])>,
236    /// Optional integrity verifier called when the FEC reaches a
237    /// parity-converged candidate. Returning `false` rejects the
238    /// candidate and BP keeps iterating; returning `true` accepts.
239    /// `None` accepts unconditionally — appropriate for FEC users
240    /// whose message codec carries no inline integrity field.
241    ///
242    /// Typical use: pipeline code threads `<P::Msg as
243    /// MessageCodec>::verify_info` here so that, e.g., FT8/FT4/FST4
244    /// reject parity-only candidates whose CRC-14 doesn't pass.
245    pub verify_info: Option<fn(&[u8]) -> bool>,
246}
247
248impl<'a> Default for FecOpts<'a> {
249    fn default() -> Self {
250        Self {
251            bp_max_iter: 30,
252            osd_depth: 0,
253            ap_mask: None,
254            verify_info: None,
255        }
256    }
257}
258
259/// Result of a successful FEC decode.
260#[derive(Clone, Debug)]
261pub struct FecResult {
262    /// Hard-decision information bits (length = `FecCodec::K`).
263    pub info: Vec<u8>,
264    /// Number of hard-decision errors corrected (for quality metric).
265    pub hard_errors: u32,
266    /// Iterations consumed (0 if N/A).
267    pub iterations: u32,
268}
269
270/// Forward-error-correction codec: maps `K` information bits ↔ `N` codeword
271/// bits.
272///
273/// Implementors MUST be `Default`-constructible so generic pipeline code can
274/// obtain an instance via `P::Fec::default()` without plumbing state.
275/// Stateless codecs (matrices in `const` / `static`) are the common case.
276pub trait FecCodec: Default + 'static {
277    /// Codeword length.
278    const N: usize;
279
280    /// Information-bit length.
281    const K: usize;
282
283    /// Systematic encode: `info.len() == K`, `codeword.len() == N`. The first
284    /// `K` bits of `codeword` must equal `info` (systematic form).
285    fn encode(&self, info: &[u8], codeword: &mut [u8]);
286
287    /// Soft-decision decode from log-likelihood ratios.
288    ///
289    /// `llr.len() == N`. On success returns the `K` information bits plus
290    /// decoder statistics. On failure returns `None`.
291    fn decode_soft(&self, llr: &[f32], opts: &FecOpts) -> Option<FecResult>;
292}
293
294// ──────────────────────────────────────────────────────────────────────────
295// Message codec
296// ──────────────────────────────────────────────────────────────────────────
297
298/// Human-facing message payload codec (callsigns, grids, reports, free text).
299///
300/// Operates on the FEC-decoded information bits (`PAYLOAD_BITS` wide, NOT
301/// including any CRC protecting them — callers handle the CRC layer).
302///
303/// Unlike `FecCodec`, this trait is an acceptable place for `dyn` when the
304/// caller juggles heterogeneous protocols at runtime (FFI, CLI dump tools):
305/// message unpacking is a cold path relative to DSP/FEC inner loops.
306pub trait MessageCodec: Default + 'static {
307    /// Decoded high-level representation returned by `unpack`.
308    type Unpacked;
309
310    /// Number of information bits consumed by `pack` / produced by `unpack`.
311    const PAYLOAD_BITS: u32;
312
313    /// CRC width guarding the payload during transmission (0 if the FEC itself
314    /// provides all error detection, as with JT65 Reed–Solomon).
315    const CRC_BITS: u32;
316
317    /// Encode high-level fields to a bit vector of length `PAYLOAD_BITS`.
318    /// Returns `None` on encoding failure (invalid callsign format, overflow…).
319    fn pack(&self, fields: &MessageFields) -> Option<Vec<u8>>;
320
321    /// Decode a `PAYLOAD_BITS`-long bit vector to the protocol-specific
322    /// unpacked representation. `ctx` carries side information such as the
323    /// callsign-hash table.
324    fn unpack(&self, payload: &[u8], ctx: &DecodeContext) -> Option<Self::Unpacked>;
325
326    /// Verify the integrity of post-FEC info bits. The FEC layer
327    /// invokes this when a candidate codeword satisfies parity:
328    /// returning `true` accepts the codeword; returning `false`
329    /// causes the FEC to keep iterating.
330    ///
331    /// Default: accept unconditionally — appropriate for codecs whose
332    /// message format carries no inline integrity field (the FEC layer
333    /// has already enforced parity convergence by the time this is
334    /// called).
335    ///
336    /// CRC-bearing codecs override this. For example,
337    /// [`crate::msg::Wsjt77Message`] verifies the CRC-14 stored in
338    /// info bits 77..91. The associated-function (no `&self`) shape
339    /// keeps the verifier compatible with the function-pointer field
340    /// on [`FecOpts::verify_info`].
341    fn verify_info(info: &[u8]) -> bool {
342        let _ = info;
343        true
344    }
345}
346
347/// Generic input to `MessageCodec::pack` — protocol-specific codecs accept
348/// the subset of fields they understand and return `None` for unsupported
349/// combinations.
350#[derive(Clone, Debug, Default)]
351pub struct MessageFields {
352    pub call1: Option<String>,
353    pub call2: Option<String>,
354    pub grid: Option<String>,
355    pub report: Option<i32>,
356    pub free_text: Option<String>,
357}
358
359/// Side information passed to `MessageCodec::unpack`.
360///
361/// `callsign_hash_table` is an opaque pointer the protocol crate
362/// downcasts to its own table type — generic code does not need to know the
363/// shape. This keeps `mfsk-msg` optional at the `mfsk-core` level.
364#[derive(Clone, Debug, Default)]
365pub struct DecodeContext {
366    /// Optional hashed-callsign lookup owned by the caller. Concrete layout is
367    /// protocol-defined; interpret via `Any::downcast_ref` inside the codec.
368    pub callsign_hash_table: Option<std::sync::Arc<dyn std::any::Any + Send + Sync>>,
369}
370
371// ──────────────────────────────────────────────────────────────────────────
372// Protocol facade
373// ──────────────────────────────────────────────────────────────────────────
374
375/// The full protocol description: ties `ModulationParams`, `FrameLayout`, a
376/// FEC codec and a message codec together under one trait for ergonomic
377/// `<P: Protocol>` bounds.
378pub trait Protocol: ModulationParams + FrameLayout + 'static {
379    /// FEC codec carrying `N_DATA * BITS_PER_SYMBOL` coded bits.
380    type Fec: FecCodec;
381
382    /// Message codec consuming the FEC-decoded information bits.
383    type Msg: MessageCodec;
384
385    /// Runtime tag used at FFI / WASM boundaries.
386    const ID: ProtocolId;
387}