mfsk-core 0.4.4

Pure-Rust WSJT-family decoders and synthesisers (FT8 FT4 FST4 WSPR JT9 JT65 Q65) behind a zero-cost Protocol trait. no_std + alloc capable with a pluggable FFT backend; ESP32-S3 PoC included.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! Protocol trait hierarchy.
//!
//! A `Protocol` is a zero-sized type that ties together the four axes of
//! variation across WSJT-family digital modes:
//!
//! | Axis               | Trait              | Examples                          |
//! |--------------------|--------------------|-----------------------------------|
//! | Tones / baseband   | `ModulationParams` | 8-FSK @ 6.25 Hz (FT8) vs 4-FSK (FT4) |
//! | Frame layout       | `FrameLayout`      | Costas pattern, sync positions    |
//! | FEC                | `FecCodec`         | LDPC(174,91) / Reed–Solomon / Fano |
//! | Message payload    | `MessageCodec`     | WSJT 77-bit / JT 72-bit / WSPR 50 |
//!
//! Splitting the traits lets implementations share code: FT4 reuses FT8's
//! `Ldpc174_91` and `Wsjt77Message` and differs only in `ModulationParams` +
//! `FrameLayout`, so SIMD optimisations to the shared LDPC decoder
//! automatically benefit every LDPC-based protocol.

use alloc::string::String;
use alloc::vec::Vec;

/// Runtime protocol tag — used at FFI boundaries where generics cannot cross
/// the C ABI. Order is stable; append new variants at the end.
#[repr(u8)]
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
pub enum ProtocolId {
    /// FT8 — 15 s slot, 8-FSK, LDPC(174,91), 77-bit message.
    Ft8 = 0,
    /// FT4 — 7.5 s slot, 4-FSK, LDPC(174,91), 77-bit message.
    Ft4 = 1,
    /// FT2 (experimental / contest variant).
    Ft2 = 2,
    /// FST4 — 60 s slot, 4-FSK, LDPC(240,101) + CRC-24, 77-bit message.
    Fst4 = 3,
    /// JT65 — 60 s slot, 65-tone FSK, Reed-Solomon(63,12), 72-bit message.
    Jt65 = 4,
    /// JT9 — 60 s slot, 9-FSK, convolutional r=½ K=32 + Fano, 72-bit message.
    Jt9 = 5,
    /// WSPR — 120 s slot, 4-FSK, convolutional r=½ K=32 + Fano, 50-bit message.
    Wspr = 6,
    /// Q65 — 65-tone FSK, QRA(15,65) over GF(64), 77-bit Wsjt77 message.
    /// Multiple T/R-period × tone-spacing variants share this tag at the
    /// FFI level; the protocol-layer ZST disambiguates.
    Q65 = 7,
    /// uvpacket — 4-GFSK packet protocol for narrow-FM voice channels
    /// at U/VHF (Rayleigh-fading-tolerant). 4 sub-modes share this
    /// family ID; the protocol-layer ZST disambiguates.
    UvPacket = 8,
}

/// Baseband modulation parameters (tones, symbol rate, Gray mapping, Gaussian
/// shaping and the tunable DSP ratios the pipeline reads per protocol).
///
/// All constants are evaluated at compile time; the trait carries no data so
/// implementors are typically zero-sized types.
pub trait ModulationParams: Copy + Default + 'static {
    /// Number of FSK tones (M in M-ary FSK).
    const NTONES: u32;

    /// Information bits carried per modulated symbol (= log2(NTONES)).
    const BITS_PER_SYMBOL: u32;

    /// Samples per symbol at the 12 kHz pipeline sample rate.
    const NSPS: u32;

    /// Symbol duration in seconds (= NSPS / 12000).
    const SYMBOL_DT: f32;

    /// Spacing between adjacent tones, in Hz.
    const TONE_SPACING_HZ: f32;

    /// Gray-code map: `GRAY_MAP[tone_index]` returns the NATURAL-bit pattern
    /// for that tone. The map covers at least the data alphabet
    /// (`2^BITS_PER_SYMBOL` entries) and at most the full tone set
    /// (`NTONES` entries). Protocols whose sync tones are part of
    /// the data alphabet (FT8 / FT4 / FST4 / WSPR) have
    /// `len() == NTONES == 2^BITS_PER_SYMBOL`; protocols that
    /// reserve additional sync-only tones (JT9, JT65, Q65) either
    /// trim the map to the data alphabet (JT9: 8 entries for 9
    /// tones) or extend it with identity over the sync slots
    /// (JT65 / Q65). Pinned by `tests/protocol_invariants.rs`.
    const GRAY_MAP: &'static [u8];

    // ── GFSK shaping ────────────────────────────────────────────────────
    /// Gaussian bandwidth-time product. FT8 = 2.0, FT4 = 1.0, FST4 ≈ 1.0.
    const GFSK_BT: f32;
    /// Modulation index h — the phase increment per symbol is `2π · h`.
    /// FT8 and FT4 both use 1.0 (orthogonal tones at `1/T` spacing).
    const GFSK_HMOD: f32;

    // ── Per-protocol DSP ratios ─────────────────────────────────────────
    /// Per-symbol FFT size = `NSPS * NFFT_PER_SYMBOL_FACTOR`.
    /// FT8 = 2 (window is 2·NSPS), FT4 = 4 (window is 4·NSPS) — trade-off
    /// between frequency resolution and time localisation.
    const NFFT_PER_SYMBOL_FACTOR: u32;
    /// Coarse-sync time-step = `NSPS / NSTEP_PER_SYMBOL`.
    /// FT8 = 4 (quarter-symbol resolution), FT4 = 1 (symbol-granular).
    const NSTEP_PER_SYMBOL: u32;
    /// Downsample decimation factor: baseband rate = `12 000 / NDOWN` Hz.
    /// FT8 = 60 (→200 Hz), FT4 = 18 (→667 Hz). Proportional to tone spacing.
    const NDOWN: u32;

    /// LLR scale factor applied after standard-deviation normalisation.
    /// FT8 uses 2.83 (empirical, from WSJT-X ft8b.f90). Different
    /// bits-per-symbol counts may shift the optimum — FT4's 2-bit LLR
    /// dynamics are not identical to FT8's 3-bit case.
    const LLR_SCALE: f32 = 2.83;
}

/// One Costas / pilot block: a contiguous run of tones starting at a specific
/// symbol index within the frame.
///
/// FT8 has three identical blocks (positions 0/36/72, same Costas-7 pattern);
/// FT4 has four *different* blocks (positions 0/33/66/99, each a permutation
/// of `[0,1,2,3]`). The trait is shaped to accommodate both.
#[derive(Copy, Clone, Debug)]
pub struct SyncBlock {
    /// Symbol index (0-based) where this block starts.
    pub start_symbol: u32,
    /// Tone sequence for this block. `pattern.len()` is the block length.
    pub pattern: &'static [u8],
}

/// How sync information is carried in the channel symbol stream.
///
/// * `Block` — dedicated contiguous sync blocks (Costas arrays) occupy
///   specific symbol positions, with data symbols filling the rest. Used by
///   FT8, FT4, FST4.
/// * `Interleaved` — every channel symbol carries one sync bit (fixed
///   position within the tone index) AND payload bits. The sync bits
///   concatenated across the frame form a known pseudorandom vector.
///   Used by WSPR: `tone = 2·data_bit + sync_bit`, so LSB of each
///   4-FSK symbol reproduces the 162-bit `npr3` sync vector.
#[derive(Copy, Clone, Debug)]
pub enum SyncMode {
    Block(&'static [SyncBlock]),
    Interleaved {
        /// Position of the sync bit within the tone index, LSB-first.
        /// WSPR = 0 (LSB).
        sync_bit_pos: u8,
        /// Sync vector, one bit per frame symbol. Length == `N_SYMBOLS`.
        vector: &'static [u8],
    },
}

impl SyncMode {
    /// Block list for `Block` mode; empty slice for `Interleaved`.
    /// Sync/LLR/TX helpers that only handle block-structured sync can iterate
    /// this unconditionally — they will no-op on WSPR-style protocols, which
    /// then need their own interleaved-sync pipeline entry point.
    pub const fn blocks(&self) -> &'static [SyncBlock] {
        match self {
            SyncMode::Block(b) => b,
            SyncMode::Interleaved { .. } => &[],
        }
    }
}

/// Frame structure: data / sync symbol counts, the ordered list of sync
/// blocks, and the TX-side nominal start offset.
pub trait FrameLayout: Copy + Default + 'static {
    /// Data symbols carrying FEC-coded payload.
    const N_DATA: u32;

    /// Sync symbols (sum of `pattern.len()` across `SYNC_BLOCKS`).
    const N_SYNC: u32;

    /// Total channel symbols per frame (= N_DATA + N_SYNC). Excludes any
    /// GFSK ramp-up / ramp-down symbols that are a shaping artifact.
    const N_SYMBOLS: u32;

    /// Extra symbol slots on each side of the frame reserved for amplitude
    /// ramp (FT4 has 1 each side = 2; FT8 has 0 — ramp absorbed into the
    /// first/last data symbol envelope). Applied at the transmitter.
    const N_RAMP: u32;

    /// Sync-symbol layout. Most WSJT protocols use `SyncMode::Block` with
    /// dedicated Costas blocks (FT8/FT4/FST4); WSPR uses `SyncMode::Interleaved`
    /// with a per-symbol sync bit. Callers that only support block sync should
    /// read `SYNC_MODE.blocks()` and treat an empty slice as "unsupported".
    const SYNC_MODE: SyncMode;

    /// Nominal TX/RX slot length in seconds (informational — used by
    /// schedulers and UI, not by the DSP pipeline). FT8 = 15 s, FT4 = 7.5 s.
    const T_SLOT_S: f32;

    /// Time (seconds) from the start of the slot-audio buffer to the start
    /// of the first frame symbol — the "dt = 0" reference point used by
    /// sync, signal subtraction, and DT reporting. FT8 = 0.5, FT4 = 0.5.
    const TX_START_OFFSET_S: f32;

    /// Optional bit interleaver: permutation table such that
    /// `cw[CODEWORD_INTERLEAVE[j]]` is the codeword bit transmitted at
    /// **channel-bit position** `j`. Length must equal
    /// `<Self as Protocol>::Fec::N` when `Some`.
    ///
    /// `None` (default) means the codeword bits flow into the channel in
    /// natural order — what FT8 / FT4 / FST4 / WSPR / JT9 / JT65 / Q65
    /// all do, since their existing FECs and operating channels make
    /// burst-error tolerance a non-issue (or it's handled inside the FEC,
    /// as Q65's QRA does symbol-level dispersion).
    ///
    /// `Some(table)` is for codecs targeting **time-selective fading**
    /// channels where a deep fade null can wipe out consecutive channel
    /// bits. The interleaver spreads consecutive codeword bits across the
    /// frame so the same fade null hits scattered codeword bits, which
    /// soft-decision LDPC handles well. The table is a permutation of
    /// `0..codeword_bits`; a polynomial form `INTERLEAVE[j] = (s * j)
    /// mod n` with `gcd(s, n) = 1` gives uniform stride spacing.
    ///
    /// Both [`crate::core::tx::codeword_to_itone`] and the pipeline's
    /// LLR-deinterleave step honour this constant; protocols that
    /// override get TX/RX symmetry for free.
    const CODEWORD_INTERLEAVE: Option<&'static [u16]> = None;
}

// ──────────────────────────────────────────────────────────────────────────
// FEC
// ──────────────────────────────────────────────────────────────────────────

/// LDPC belief-propagation check-node update kernel.
///
/// `SumProduct` is the WSJT-X-equivalent log-domain sum-product update
/// (the `2·atanh(∏ tanh(L/2))` formula). `NormalizedMinSum` and
/// `OffsetMinSum` are min-sum approximations that skip the
/// transcendental functions entirely — significantly faster on
/// FPU-poor embedded targets at a small (typically <0.2 dB on Q65 /
/// FT8 / FT4 thresholds with α=0.75 or β=0.5) SNR cost.
///
/// Both min-sum variants use the standard min1/min2 trick (track the
/// two smallest |L| at each check node) plus XOR-accumulated signs,
/// so the per-iteration cost is roughly O(check_degree) instead of
/// the sum-product's O(check_degree²) for the per-edge `tanh`-cache
/// lookups.
///
/// Use `SumProduct` on host targets (default), `NormalizedMinSum` or
/// `OffsetMinSum` on `no_std` / FPU-limited builds.
#[derive(Copy, Clone, Debug, Default)]
pub enum BpKind {
    /// WSJT-X-equivalent log-domain sum-product. Default — best
    /// accuracy, reference output.
    #[default]
    SumProduct,
    /// Normalised min-sum: `L_c→v ≈ α · sign(∏) · min|L|`. Typical
    /// `alpha ≈ 0.75`. Trades ~0.05–0.15 dB threshold for ~3-5×
    /// faster check-node update on f32, more on fixed-point.
    NormalizedMinSum {
        /// Magnitude scale factor, typically `0.7..=0.9`.
        alpha: f32,
    },
    /// Offset min-sum: `L_c→v ≈ sign(∏) · max(min|L| − β, 0)`.
    /// Typical `beta ≈ 0.5`. Performs slightly differently from NMS
    /// near low SNR; included for sweep comparison and parity with
    /// the LDPC literature.
    OffsetMinSum {
        /// Magnitude offset, typically `0.0..=1.0`.
        beta: f32,
    },
}

/// Options controlling FEC decoding depth / fall-backs.
///
/// This is deliberately a plain data struct rather than a trait — it describes
/// *how* to decode, not *what* code to use. Codecs ignore fields that don't
/// apply (e.g. convolutional decoders ignore `osd_depth`).
#[derive(Copy, Clone, Debug)]
pub struct FecOpts<'a> {
    /// Maximum belief-propagation iterations (LDPC).
    pub bp_max_iter: u32,
    /// Ordered-statistics-decoding search depth (0 disables OSD fallback).
    pub osd_depth: u32,
    /// Optional a-priori hint: bits whose LLR should be clamped to a strong
    /// known value before decoding. `Some((mask, values))` where `mask[i] == 1`
    /// means `values[i]` is locked to `values[i]`.
    ///
    /// Lifetime is per-call: the caller allocates the AP vectors for the
    /// duration of this decode — typical usage builds a `Vec<u8>` from an
    /// `ApHint` and borrows into `FecOpts` for a single `decode_soft` call.
    pub ap_mask: Option<(&'a [u8], &'a [u8])>,
    /// Optional integrity verifier called when the FEC reaches a
    /// parity-converged candidate. Returning `false` rejects the
    /// candidate and BP keeps iterating; returning `true` accepts.
    /// `None` accepts unconditionally — appropriate for FEC users
    /// whose message codec carries no inline integrity field.
    ///
    /// Typical use: pipeline code threads `<P::Msg as
    /// MessageCodec>::verify_info` here so that, e.g., FT8/FT4/FST4
    /// reject parity-only candidates whose CRC-14 doesn't pass.
    pub verify_info: Option<fn(&[u8]) -> bool>,
    /// LDPC BP check-node update kernel. Defaults to `SumProduct`
    /// (WSJT-X-equivalent). Embedded callers select
    /// `NormalizedMinSum { alpha: 0.75 }` to trade ~0.1 dB threshold
    /// for substantially faster decode on f32 / fixed-point math.
    pub bp_kind: BpKind,
}

impl<'a> Default for FecOpts<'a> {
    fn default() -> Self {
        Self {
            bp_max_iter: 30,
            osd_depth: 0,
            ap_mask: None,
            verify_info: None,
            bp_kind: BpKind::SumProduct,
        }
    }
}

/// Result of a successful FEC decode.
#[derive(Clone, Debug)]
pub struct FecResult {
    /// Hard-decision information bits (length = `FecCodec::K`).
    pub info: Vec<u8>,
    /// Number of hard-decision errors corrected (for quality metric).
    pub hard_errors: u32,
    /// Iterations consumed (0 if N/A).
    pub iterations: u32,
}

/// Forward-error-correction codec: maps `K` information bits ↔ `N` codeword
/// bits.
///
/// Implementors MUST be `Default`-constructible so generic pipeline code can
/// obtain an instance via `P::Fec::default()` without plumbing state.
/// Stateless codecs (matrices in `const` / `static`) are the common case.
///
/// # Symbol granularity
///
/// The trait surface speaks in **bits**: `&[u8]` info / codeword, `&[f32]`
/// bit-LLRs, `K` and `N` counted in bits. Non-binary codes (Q65's QRA over
/// GF(2⁶), JT65's RS over GF(2⁶)) implement this surface by packing /
/// unpacking bits ↔ symbols inside their own `encode`, and by using a
/// private symbol-level decode path that lives outside `decode_soft`. In
/// particular [`crate::q65::Q65Fec::decode_soft`] returns `None` by design —
/// the real Q65 decode runs over GF(64) probability vectors via
/// [`crate::fec::qra::Q65Codec`] and is invoked from
/// [`crate::q65::rx::decode_at_for`], not through this trait.
///
/// Counting `K` / `N` in bits keeps the cross-protocol invariant
/// `FecCodec::N ≤ N_DATA × BITS_PER_SYMBOL` (pinned in
/// `tests/protocol_invariants.rs::assert_codec_consistency`) meaningful for
/// both binary (LDPC, conv) and non-binary (RS, QRA) codes.
pub trait FecCodec: Default + 'static {
    /// Codeword length, in **bits** (regardless of the underlying symbol
    /// alphabet — see "Symbol granularity" above).
    const N: usize;

    /// Information-bit length.
    const K: usize;

    /// Systematic encode: `info.len() == K`, `codeword.len() == N`. The first
    /// `K` bits of `codeword` must equal `info` (systematic form).
    /// Non-binary codes pack bits into their native symbols internally.
    fn encode(&self, info: &[u8], codeword: &mut [u8]);

    /// Soft-decision decode from log-likelihood ratios.
    ///
    /// `llr.len() == N`. On success returns the `K` information bits plus
    /// decoder statistics. On failure returns `None`.
    ///
    /// Non-binary codes whose natural decode operates on symbol-level
    /// probability vectors (Q65) MAY return `None` unconditionally and
    /// expose their real decode through a protocol-specific entry point.
    fn decode_soft(&self, llr: &[f32], opts: &FecOpts) -> Option<FecResult>;
}

// ──────────────────────────────────────────────────────────────────────────
// Message codec
// ──────────────────────────────────────────────────────────────────────────

/// Human-facing message payload codec (callsigns, grids, reports, free text).
///
/// Operates on the FEC-decoded information bits (`PAYLOAD_BITS` wide, NOT
/// including any CRC protecting them — callers handle the CRC layer).
///
/// Unlike `FecCodec`, this trait is an acceptable place for `dyn` when the
/// caller juggles heterogeneous protocols at runtime (FFI, CLI dump tools):
/// message unpacking is a cold path relative to DSP/FEC inner loops.
pub trait MessageCodec: Default + 'static {
    /// Decoded high-level representation returned by `unpack`.
    type Unpacked;

    /// Number of information bits consumed by `pack` / produced by `unpack`.
    const PAYLOAD_BITS: u32;

    /// CRC width guarding the payload during transmission (0 if the FEC itself
    /// provides all error detection, as with JT65 Reed–Solomon).
    const CRC_BITS: u32;

    /// Encode high-level fields to a bit vector of length `PAYLOAD_BITS`.
    /// Returns `None` on encoding failure (invalid callsign format, overflow…).
    fn pack(&self, fields: &MessageFields) -> Option<Vec<u8>>;

    /// Decode a `PAYLOAD_BITS`-long bit vector to the protocol-specific
    /// unpacked representation. `ctx` carries side information such as the
    /// callsign-hash table.
    fn unpack(&self, payload: &[u8], ctx: &DecodeContext) -> Option<Self::Unpacked>;

    /// Verify the integrity of post-FEC info bits. The FEC layer
    /// invokes this when a candidate codeword satisfies parity:
    /// returning `true` accepts the codeword; returning `false`
    /// causes the FEC to keep iterating.
    ///
    /// Default: accept unconditionally — appropriate for codecs whose
    /// message format carries no inline integrity field (the FEC layer
    /// has already enforced parity convergence by the time this is
    /// called).
    ///
    /// CRC-bearing codecs override this. For example,
    /// [`crate::msg::Wsjt77Message`] verifies the CRC-14 stored in
    /// info bits 77..91. The associated-function (no `&self`) shape
    /// keeps the verifier compatible with the function-pointer field
    /// on [`FecOpts::verify_info`].
    fn verify_info(info: &[u8]) -> bool {
        let _ = info;
        true
    }
}

/// Generic input to `MessageCodec::pack` — protocol-specific codecs accept
/// the subset of fields they understand and return `None` for unsupported
/// combinations.
#[derive(Clone, Debug, Default)]
pub struct MessageFields {
    pub call1: Option<String>,
    pub call2: Option<String>,
    pub grid: Option<String>,
    pub report: Option<i32>,
    pub free_text: Option<String>,
}

/// Side information passed to `MessageCodec::unpack`.
///
/// `callsign_hash_table` is an opaque pointer the protocol crate
/// downcasts to its own table type — generic code does not need to know the
/// shape. This keeps `mfsk-msg` optional at the `mfsk-core` level.
#[derive(Clone, Debug, Default)]
pub struct DecodeContext {
    /// Optional hashed-callsign lookup owned by the caller. Concrete layout is
    /// protocol-defined; interpret via `Any::downcast_ref` inside the codec.
    pub callsign_hash_table: Option<alloc::sync::Arc<dyn core::any::Any + Send + Sync>>,
}

// ──────────────────────────────────────────────────────────────────────────
// Protocol facade
// ──────────────────────────────────────────────────────────────────────────

/// The full protocol description: ties `ModulationParams`, `FrameLayout`, a
/// FEC codec and a message codec together under one trait for ergonomic
/// `<P: Protocol>` bounds.
pub trait Protocol: ModulationParams + FrameLayout + 'static {
    /// FEC codec carrying `N_DATA * BITS_PER_SYMBOL` coded bits.
    type Fec: FecCodec;

    /// Message codec consuming the FEC-decoded information bits.
    type Msg: MessageCodec;

    /// Runtime tag used at FFI / WASM boundaries.
    const ID: ProtocolId;
}