mfsk_core/lib.rs
1//! # mfsk-core
2//!
3//! Pure-Rust library for **WSJT-family digital amateur-radio modes**:
4//! FT8, FT4, FST4, WSPR, JT9, JT65 and Q65-30A. Decode, encode, and
5//! synthesis in a single crate.
6//!
7//! ## Why this exists
8//!
9//! [WSJT-X](https://sourceforge.net/projects/wsjt/) is the reference
10//! implementation of these modes and will stay that way — it is
11//! battle-tested on the desktop, heavily optimised, and the source of
12//! truth for every protocol constant you will find in this crate. But
13//! it is also a mixed Fortran / C / Qt application built around a
14//! specific desktop workflow. That makes it a poor fit whenever you
15//! want to run the decoders *somewhere else*:
16//!
17//! - in a **browser** as a WASM PWA (the original driver for this
18//! library — a waterfall + sniper-mode decoder that runs in Chrome
19//! / Safari without an install step),
20//! - on **Android or iOS** for portable operation, where linking a
21//! Fortran runtime is a non-starter,
22//! - in a **headless Rust application** (skimmer, monitoring station,
23//! remote SDR front end) that wants async I/O and safe memory
24//! handling,
25//! - or as the core of a **new protocol experiment** that reuses FT8's
26//! LDPC and sync machinery for a different modulation / FEC /
27//! message recipe.
28//!
29//! Each of the seven protocols here shares roughly 80 % of its signal
30//! path with at least one sibling: 8-GFSK / FSK demodulation, soft-
31//! decision LDPC / convolutional / Reed-Solomon / QRA decoding,
32//! 77- / 72- / 50-bit WSJT message packing, spectrogram-based sync
33//! search. In the Fortran codebase that commonality is expressed by
34//! copy-and-paste between per-mode source files; here it is expressed
35//! by a small set of traits.
36//!
37//! ## The abstraction
38//!
39//! A protocol in this crate is a **zero-sized type** (e.g. [`Ft8`])
40//! that implements four traits:
41//!
42//! - [`ModulationParams`] — tone count, symbol rate, Gray map, GFSK
43//! shaping constants.
44//! - [`FrameLayout`] — total symbols, sync / data symbol counts, slot
45//! length, sync-block layout.
46//! - [`Protocol`] — the top-level trait, tying the above together
47//! with two associated types: [`Protocol::Fec`] (implementing
48//! [`FecCodec`]) and [`Protocol::Msg`] (implementing
49//! [`MessageCodec`]).
50//!
51//! Because everything is expressed as `const` associated items + ZSTs,
52//! the generic pipeline code — `coarse_sync::<P>`, `decode_frame::<P>`,
53//! the LDPC inner loop — is **monomorphised per protocol**. LLVM sees
54//! a fully specialised function for each `P`, inlines the constants,
55//! and autovectorises the hot loops. The generated machine code is
56//! byte-identical to a hand-written per-protocol decoder; the only
57//! thing the abstraction costs is longer compile times.
58//!
59//! This pays off most clearly when you add a new protocol. FST4-60A
60//! joined the library post-hoc without touching any of the shared
61//! sync / DSP / FEC code — the entire implementation is the trait
62//! impl block on a single ZST plus a ~50-element Costas pattern
63//! table. Similarly, swapping an LDPC codec between two LDPC modes or
64//! exposing the same 77-bit message layer to FT8, FT4, and FST4 are
65//! one-line changes, not cross-cutting refactors.
66//!
67//! ## Why Rust
68//!
69//! - **Safety**: bit-level FEC routines (LDPC belief propagation,
70//! Karn's Berlekamp-Massey + Forney for RS, Fano sequential
71//! decoding) are textbook index-heavy code. Writing them in safe
72//! Rust eliminates an entire class of memory-corruption bugs that
73//! Fortran / C ports have historically hidden.
74//! - **Generics + trait bounds**: describing a protocol family as
75//! data + traits is natural. The equivalent in C++ would be template
76//! metaprogramming with subtler error messages; in Fortran, it
77//! simply isn't on offer.
78//! - **Targets**: the same code compiles to `wasm32-unknown-unknown`
79//! (WASM SIMD 128-bit via `rustfft`), to Android `arm64-v8a` via
80//! the NDK (NEON SIMD), and to any `x86_64-*-unknown` host for
81//! servers — from a single source tree.
82//! - **Ecosystem**: `rustfft`, `num-complex`, `crc`, `rayon` are
83//! plug-and-play, so the crate's dependency graph is small and
84//! reviewable.
85//!
86//! ## Relationship to WSJT-X
87//!
88//! Every algorithm in this crate is derived from WSJT-X (Joe Taylor
89//! K1JT et al.). Source files cite the corresponding upstream file
90//! they port (`lib/ft8/…`, `lib/ft4/…`, `lib/fst4/…`, `lib/wsprd/…`,
91//! `lib/jt65_*.f90`, `lib/jt9_*.f90`, `lib/packjt.f90`, etc.).
92//! Licensed GPL-3.0-or-later, matching upstream.
93//!
94//! `mfsk-core` is **not** a replacement for WSJT-X. The goal is to
95//! broaden the set of platforms and applications that can host WSJT
96//! decoding — WSJT-X on the desktop, `mfsk-core` everywhere else.
97//!
98//! ## Module layout
99//!
100//! - [`core`] — protocol traits, DSP (resample / downsample / GFSK /
101//! subtract), sync, LLR, equaliser, pipeline driver.
102//! - [`fec`] — LDPC(174, 91), LDPC(240, 101), convolutional r=½ K=32
103//! Fano, Reed-Solomon(63, 12) over GF(2⁶), and the QRA(15, 65)
104//! over GF(2⁶) Q-ary RA codec used by Q65 (belief-propagation
105//! decoder via Walsh-Hadamard messages).
106//! - [`msg`] — 77-bit WSJT, 72-bit JT, 50-bit WSPR and Q65 message
107//! codecs + callsign hash table.
108//! - [`ft8`] / [`ft4`] / [`fst4`] / [`wspr`] / [`jt9`] / [`jt65`] /
109//! [`q65`] — per-protocol ZSTs, decoders and synthesisers. Each is
110//! gated behind a feature of the same name.
111//!
112//! ## Feature flags
113//!
114//! | Feature | Default? | What it enables |
115//! |---------------|----------|----------------------------------------------|
116//! | `ft8` | yes | FT8 (15 s, 8-GFSK, LDPC(174,91)) |
117//! | `ft4` | yes | FT4 (7.5 s, 4-GFSK, LDPC(174,91)) |
118//! | `fst4` | | FST4-60A (60 s, 4-GFSK, LDPC(240,101)) |
119//! | `wspr` | | WSPR (120 s, 4-FSK, conv r=½ K=32 + Fano) |
120//! | `jt9` | | JT9 (60 s, 9-FSK, conv r=½ K=32 + Fano) |
121//! | `jt65` | | JT65 (60 s, 65-FSK, RS(63,12)) |
122//! | `q65` | | Q65-30A + Q65-60A‥E (65-FSK, QRA(15,65) GF(64)) |
123//! | `full` | | Aggregate of all seven protocols |
124//! | `parallel` | yes | Rayon-parallel candidate processing |
125//! | `osd-deep` | | OSD-3 fallback on AP decodes (extra CPU) |
126//! | `eq-fallback` | | Non-EQ fallback inside `EqMode::Adaptive` |
127//!
128//! ## Runtime registry
129//!
130//! [`PROTOCOLS`] is a `&'static [ProtocolMeta]` listing every
131//! `Protocol` impl wired into the current build. Each entry carries
132//! the protocol's id, display name, and every constant the trait
133//! surface exposes (modulation / frame / FEC / message). Use it
134//! when a UI layer or FFI bridge needs to enumerate "what does this
135//! build support?" without hardcoding its own list:
136//!
137//! ```
138//! # use mfsk_core::PROTOCOLS;
139//! for p in PROTOCOLS {
140//! println!("{}: {} tones × {} bits, {} s slot",
141//! p.name, p.ntones, p.bits_per_symbol, p.t_slot_s);
142//! }
143//! ```
144//!
145//! [`by_id`] / [`by_name`] / [`for_protocol_id`] cover the common
146//! lookup patterns. All six Q65 sub-modes (Q65-30A, Q65-60A‥E)
147//! appear as distinct registry entries because their NSPS and tone
148//! spacing differ; they share `ProtocolId::Q65` because the FFI
149//! protocol tag is family-level.
150//!
151//! ## Trait surface verification
152//!
153//! `tests/protocol_invariants.rs` runs a single generic
154//! `assert_protocol_invariants::<P: Protocol>` over every wired ZST
155//! (FT8 / FT4 / FST4 / WSPR / JT9 / JT65 plus all six Q65 sub-modes
156//! — 11 in total). It pins 17 trait-level invariants:
157//! `2^BITS_PER_SYMBOL ≤ NTONES`, `SYMBOL_DT × 12000 == NSPS`,
158//! `N_SYMBOLS == N_DATA + N_SYNC`, sync-mode self-consistency,
159//! `FecCodec::K ≥ MessageCodec::PAYLOAD_BITS`, and so on. Adding a
160//! new `Protocol` impl is a one-line registry edit + a one-line
161//! test invocation; the same generic body proves the new ZST's
162//! constants are internally consistent without any per-protocol
163//! glue. Drift between trait doc and implementation is caught
164//! mechanically — the work that landed Q65 surfaced one such
165//! discrepancy in `GRAY_MAP` and fixed it in the same pass.
166//!
167//! ## Library stack
168//!
169//! ```text
170//! ┌─────────────────────────────────────────────────────┐
171//! │ ft8 ft4 fst4 wspr jt9 jt65 … │ per-protocol ZSTs
172//! │ (each implements Protocol + FrameLayout) │ (feature-gated)
173//! └─────────────┬─────────────────┬─────────────────────┘
174//! │ │
175//! ┌────────▼────────┐ ┌─────▼──────┐
176//! │ msg │ │ fec │ shared codecs
177//! │ Wsjt77 · Jt72 │ │ LDPC · RS │ behind traits
178//! │ Wspr50 · Hash │ │ ConvFano │
179//! └────────┬────────┘ └─────┬──────┘
180//! │ │
181//! ┌───▼─────────────────▼───┐
182//! │ core │ Protocol trait, DSP
183//! │ sync · llr · equalize · │ (resample / GFSK /
184//! │ pipeline · tx · dsp │ downsample / subtract)
185//! └─────────────────────────┘
186//! ```
187//!
188//! Each protocol declares its slot length, tone count, Gray map,
189//! Costas / sync pattern, FEC codec and message codec at compile time
190//! via the [`Protocol`] trait. The generic code in [`core`] —
191//! coarse sync, fine sync, LLR computation, LDPC / RS / convolutional
192//! decode, GFSK synthesis — works for any type that satisfies the
193//! trait.
194//!
195//! ## Quick start
196//!
197//! ```toml
198//! # Cargo.toml
199//! [dependencies]
200//! mfsk-core = { version = "0.1", features = ["ft8", "ft4"] }
201//! ```
202//!
203//! Round-trip a synthesised FT8 frame through the decoder:
204//!
205//! ```
206//! # #[cfg(feature = "ft8")] {
207//! use mfsk_core::ft8::{
208//! decode::{decode_frame, DecodeDepth},
209//! wave_gen::{message_to_tones, tones_to_i16},
210//! };
211//! use mfsk_core::msg::wsjt77::{pack77, unpack77};
212//!
213//! // 1. Pack a standard FT8 message and synthesise 12 kHz i16 PCM.
214//! // The synth produces just the transmitted frame (~12.64 s);
215//! // pad to the full 15 s slot with the signal starting at 0.5 s.
216//! let msg77 = pack77("CQ", "JA1ABC", "PM95").expect("pack");
217//! let tones = message_to_tones(&msg77);
218//! let frame = tones_to_i16(&tones, /* freq */ 1500.0, /* amp */ 20_000);
219//!
220//! let mut audio = vec![0i16; 180_000]; // 15 s @ 12 kHz
221//! let start = (0.5 * 12_000.0) as usize;
222//! for (i, &s) in frame.iter().enumerate() {
223//! if start + i < audio.len() { audio[start + i] = s; }
224//! }
225//!
226//! // 2. Decode it back across the full FT8 band.
227//! let results = decode_frame(
228//! &audio,
229//! /* freq_min */ 100.0,
230//! /* freq_max */ 3_000.0,
231//! /* sync_min */ 1.0,
232//! /* freq_hint */ None,
233//! DecodeDepth::BpAllOsd,
234//! /* max_cand */ 50,
235//! );
236//! assert!(!results.is_empty(), "roundtrip must decode");
237//! let text = unpack77(&results[0].message77).expect("unpack");
238//! assert_eq!(text, "CQ JA1ABC PM95");
239//! # }
240//! ```
241
242// Several clippy lints fight with the style of this crate:
243//
244// - `too_many_arguments` triggers on inner FEC / DSP helpers that are
245// one-to-one ports of Fortran subroutines; splitting them into
246// "smaller" functions would just obscure the correspondence with
247// the upstream algorithm.
248// - `needless_range_loop` flags `for i in 0..N` loops that index into
249// fixed-size arrays. Algorithmic code ported from WSJT-X reads more
250// clearly with the index variable in scope (sync pattern iteration,
251// LDPC check-node passes, Reed-Solomon syndrome computation), so
252// the .iter().enumerate() form is not always an improvement.
253// - `unusual_byte_groupings` trips on magic constants where the digit
254// grouping encodes a bit-layout meaning (WSPR bit-reversal constants,
255// LDPC generator polynomial byte boundaries). Normalising the
256// grouping would obscure the intent.
257#![allow(
258 clippy::too_many_arguments,
259 clippy::needless_range_loop,
260 clippy::unusual_byte_groupings
261)]
262
263pub mod core;
264pub mod fec;
265pub mod msg;
266
267#[cfg(feature = "ft8")]
268pub mod ft8;
269
270#[cfg(feature = "ft4")]
271pub mod ft4;
272
273#[cfg(feature = "fst4")]
274pub mod fst4;
275
276#[cfg(feature = "wspr")]
277pub mod wspr;
278
279#[cfg(feature = "jt9")]
280pub mod jt9;
281
282#[cfg(feature = "jt65")]
283pub mod jt65;
284
285#[cfg(feature = "q65")]
286pub mod q65;
287
288pub mod registry;
289
290// Flatten commonly-used types to the crate root.
291pub use crate::core::{
292 DecodeContext, FecCodec, FecOpts, FecResult, FrameLayout, MessageCodec, MessageFields,
293 ModulationParams, Protocol, ProtocolId, SyncBlock, SyncMode,
294};
295pub use crate::registry::{PROTOCOLS, ProtocolMeta, by_id, by_name, for_protocol_id};
296
297#[cfg(feature = "fst4")]
298pub use crate::fst4::Fst4s60;
299#[cfg(feature = "ft4")]
300pub use crate::ft4::Ft4;
301#[cfg(feature = "ft8")]
302pub use crate::ft8::Ft8;
303#[cfg(feature = "jt9")]
304pub use crate::jt9::Jt9;
305#[cfg(feature = "jt65")]
306pub use crate::jt65::Jt65;
307#[cfg(feature = "q65")]
308pub use crate::q65::Q65a30;
309#[cfg(feature = "wspr")]
310pub use crate::wspr::Wspr;