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//! - on a **handheld embedded controller** like
26//! [`embedded-poc/m5stack-s3-app`](https://github.com/jl1nie/mfsk-core/tree/main/embedded-poc/m5stack-s3-app)
27//! — a working M5StickS3 FT8 controller (LCD UI, BLE CI-V to
28//! IC-705, acoustic mic capture, QSO FSM) running this library on
29//! Xtensa LX7 + esp-dsp + the Goertzel per-symbol DFT, decoding
30//! real on-air signals in ~1.2 s post-SlotEnd; see
31//! [`docs/reference/MANUAL_M5STICKS3.md`](https://github.com/jl1nie/mfsk-core/blob/main/docs/reference/MANUAL_M5STICKS3.md)
32//! for build / flash / operation,
33//! - or as the core of a **new protocol experiment** that reuses FT8's
34//! LDPC and sync machinery for a different modulation / FEC /
35//! message recipe.
36//!
37//! Each of the seven protocols here shares roughly 80 % of its signal
38//! path with at least one sibling: 8-GFSK / FSK demodulation, soft-
39//! decision LDPC / convolutional / Reed-Solomon / QRA decoding,
40//! 77- / 72- / 50-bit WSJT message packing, spectrogram-based sync
41//! search. In the Fortran codebase that commonality is expressed by
42//! copy-and-paste between per-mode source files; here it is expressed
43//! by a small set of traits.
44//!
45//! ## The abstraction
46//!
47//! A protocol in this crate is a **zero-sized type** (e.g. [`Ft8`])
48//! that implements four traits:
49//!
50//! - [`ModulationParams`] — tone count, symbol rate, Gray map, GFSK
51//! shaping constants.
52//! - [`FrameLayout`] — total symbols, sync / data symbol counts, slot
53//! length, sync-block layout.
54//! - [`Protocol`] — the top-level trait, tying the above together
55//! with two associated types: [`Protocol::Fec`] (implementing
56//! [`FecCodec`]) and [`Protocol::Msg`] (implementing
57//! [`MessageCodec`]).
58//!
59//! Because everything is expressed as `const` associated items + ZSTs,
60//! the generic pipeline code — `coarse_sync::<P>`, `decode_frame::<P>`,
61//! the LDPC inner loop — is **monomorphised per protocol**. LLVM sees
62//! a fully specialised function for each `P`, inlines the constants,
63//! and autovectorises the hot loops. The generated machine code is
64//! byte-identical to a hand-written per-protocol decoder; the only
65//! thing the abstraction costs is longer compile times.
66//!
67//! This pays off most clearly when you add a new protocol. FST4-60A
68//! joined the library post-hoc without touching any of the shared
69//! sync / DSP / FEC code — the entire implementation is the trait
70//! impl block on a single ZST plus a ~50-element Costas pattern
71//! table. Similarly, swapping an LDPC codec between two LDPC modes or
72//! exposing the same 77-bit message layer to FT8, FT4, and FST4 are
73//! one-line changes, not cross-cutting refactors.
74//!
75//! ## Why Rust
76//!
77//! - **Safety**: bit-level FEC routines (LDPC belief propagation,
78//! Karn's Berlekamp-Massey + Forney for RS, Fano sequential
79//! decoding) are textbook index-heavy code. Writing them in safe
80//! Rust eliminates an entire class of memory-corruption bugs that
81//! Fortran / C ports have historically hidden.
82//! - **Generics + trait bounds**: describing a protocol family as
83//! data + traits is natural. The equivalent in C++ would be template
84//! metaprogramming with subtler error messages; in Fortran, it
85//! simply isn't on offer.
86//! - **Targets**: the same code compiles to `wasm32-unknown-unknown`
87//! (WASM SIMD 128-bit via `rustfft`), to Android `arm64-v8a` via
88//! the NDK (NEON SIMD), and to any `x86_64-*-unknown` host for
89//! servers — from a single source tree.
90//! - **Ecosystem**: `rustfft`, `num-complex`, `crc`, `rayon` are
91//! plug-and-play, so the crate's dependency graph is small and
92//! reviewable.
93//!
94//! ## Relationship to WSJT-X
95//!
96//! Every algorithm in this crate is derived from WSJT-X (Joe Taylor
97//! K1JT et al.). Source files cite the corresponding upstream file
98//! they port (`lib/ft8/…`, `lib/ft4/…`, `lib/fst4/…`, `lib/wsprd/…`,
99//! `lib/jt65_*.f90`, `lib/jt9_*.f90`, `lib/packjt.f90`, etc.).
100//! Licensed GPL-3.0-or-later, matching upstream.
101//!
102//! `mfsk-core` is **not** a replacement for WSJT-X. The goal is to
103//! broaden the set of platforms and applications that can host WSJT
104//! decoding — WSJT-X on the desktop, `mfsk-core` everywhere else.
105//!
106//! ## Module layout
107//!
108//! - [`core`] — protocol traits, DSP (resample / downsample / GFSK /
109//! subtract), sync, LLR, equaliser, pipeline driver.
110//! - [`fec`] — LDPC(174, 91), LDPC(240, 101), convolutional r=½ K=32
111//! Fano, Reed-Solomon(63, 12) over GF(2⁶), and the QRA(15, 65)
112//! over GF(2⁶) Q-ary RA codec used by Q65 (belief-propagation
113//! decoder via Walsh-Hadamard messages).
114//! - [`msg`] — 77-bit WSJT, 72-bit JT, 50-bit WSPR and Q65 message
115//! codecs + callsign hash table.
116//! - [`ft8`] / [`ft4`] / [`fst4`] / [`wspr`] / [`jt9`] / [`jt65`] /
117//! [`q65`] — per-protocol ZSTs, decoders and synthesisers. Each is
118//! gated behind a feature of the same name.
119//!
120//! ## Feature flags
121//!
122//! | Feature | Default? | What it enables |
123//! |---------------|----------|----------------------------------------------|
124//! | `ft8` | yes | FT8 (15 s, 8-GFSK, LDPC(174,91)) |
125//! | `ft4` | yes | FT4 (7.5 s, 4-GFSK, LDPC(174,91)) |
126//! | `fst4` | | FST4-60A (60 s, 4-GFSK, LDPC(240,101)) |
127//! | `wspr` | | WSPR (120 s, 4-FSK, conv r=½ K=32 + Fano) |
128//! | `jt9` | | JT9 (60 s, 9-FSK, conv r=½ K=32 + Fano) |
129//! | `jt65` | | JT65 (60 s, 65-FSK, RS(63,12)) |
130//! | `q65` | | Q65-30A + Q65-60A‥E (65-FSK, QRA(15,65) GF(64)) |
131//! | `full` | | Aggregate of all seven protocols + uvpacket + packet-bytes |
132//! | `parallel` | yes | Rayon-parallel candidate processing |
133//! | `fft-rustfft` | yes | Default host FFT backend (`rustfft`, requires `std`) |
134//! | `fft-extern` | | Pluggable FFT trait — caller binary supplies an `FftPlanner` impl |
135//! | `fixed-point` | | Embedded integer pipeline: u16 spec + i16 DFT + Q11i16 LLR + integer NMS BP |
136//! | `profile-coarse` | | Always-on coarse_sync sub-stage profiling |
137//!
138//! ## Runtime registry
139//!
140//! [`PROTOCOLS`] is a `&'static [ProtocolMeta]` listing every
141//! `Protocol` impl wired into the current build. Each entry carries
142//! the protocol's id, display name, and every constant the trait
143//! surface exposes (modulation / frame / FEC / message). Use it
144//! when a UI layer or FFI bridge needs to enumerate "what does this
145//! build support?" without hardcoding its own list:
146//!
147//! ```
148//! # use mfsk_core::PROTOCOLS;
149//! for p in PROTOCOLS {
150//! println!("{}: {} tones × {} bits, {} s slot",
151//! p.name, p.ntones, p.bits_per_symbol, p.t_slot_s);
152//! }
153//! ```
154//!
155//! [`by_id`] / [`by_name`] / [`for_protocol_id`] cover the common
156//! lookup patterns. All six Q65 sub-modes (Q65-30A, Q65-60A‥E)
157//! appear as distinct registry entries because their NSPS and tone
158//! spacing differ; they share `ProtocolId::Q65` because the FFI
159//! protocol tag is family-level.
160//!
161//! ## Trait surface verification
162//!
163//! `tests/protocol_invariants.rs` runs a single generic
164//! `assert_protocol_invariants::<P: Protocol>` over every wired ZST
165//! (FT8 / FT4 / FST4 / WSPR / JT9 / JT65 plus all six Q65 sub-modes
166//! — 11 in total; uvpacket adds four more under `--features uvpacket`).
167//! It pins ~25 trait-level invariants split across three layers:
168//! modulation (`2^BITS_PER_SYMBOL ≤ NTONES`, `SYMBOL_DT × 12000 ==
169//! NSPS`, GRAY_MAP coverage and uniqueness, GFSK / tone-spacing
170//! positivity), frame layout (`N_SYMBOLS == N_DATA + N_SYNC`, sync
171//! pattern in-range, `T_SLOT_S > 0`), and codec consistency
172//! (`FecCodec::K ≥ MessageCodec::PAYLOAD_BITS`, `FecCodec::N ≤
173//! N_DATA × BITS_PER_SYMBOL`). Adding a new `Protocol` impl is a
174//! one-line registry edit + a one-line test invocation; the same
175//! generic body proves the new ZST's constants are internally
176//! consistent without any per-protocol glue. Drift between trait
177//! doc and implementation is caught mechanically — the work that
178//! landed Q65 surfaced one such discrepancy in `GRAY_MAP` and fixed
179//! it in the same pass.
180//!
181//! The default features (`ft8`, `ft4`) only exercise the two default
182//! protocols; run `cargo test --features full` (or enable a specific
183//! protocol feature) to cover the rest. The registry size and
184//! `ProtocolId` uniqueness checks adapt to whatever feature
185//! combination is active.
186//!
187//! ## Library stack
188//!
189//! ```text
190//! ┌─────────────────────────────────────────────────────┐
191//! │ ft8 ft4 fst4 wspr jt9 jt65 … │ per-protocol ZSTs
192//! │ (each implements Protocol + FrameLayout) │ (feature-gated)
193//! └─────────────┬─────────────────┬─────────────────────┘
194//! │ │
195//! ┌────────▼────────┐ ┌─────▼──────┐
196//! │ msg │ │ fec │ shared codecs
197//! │ Wsjt77 · Jt72 │ │ LDPC · RS │ behind traits
198//! │ Wspr50 · Hash │ │ ConvFano │
199//! └────────┬────────┘ └─────┬──────┘
200//! │ │
201//! ┌───▼─────────────────▼───┐
202//! │ core │ Protocol trait, DSP
203//! │ sync · llr · equalize · │ (resample / GFSK /
204//! │ pipeline · tx · dsp │ downsample / subtract)
205//! └─────────────────────────┘
206//! ```
207//!
208//! Each protocol declares its slot length, tone count, Gray map,
209//! Costas / sync pattern, FEC codec and message codec at compile time
210//! via the [`Protocol`] trait. The generic code in [`core`] —
211//! coarse sync, fine sync, LLR computation, LDPC / RS / convolutional
212//! decode, GFSK synthesis — works for any type that satisfies the
213//! trait.
214//!
215//! ## Quick start
216//!
217//! ```toml
218//! # Cargo.toml
219//! [dependencies]
220//! mfsk-core = { version = "0.5", features = ["ft8", "ft4"] }
221//! ```
222//!
223//! Round-trip a synthesised FT8 frame through the decoder:
224//!
225//! ```
226//! # #[cfg(feature = "ft8")] {
227//! use mfsk_core::ft8::{
228//! decode::{decode_frame, DecodeDepth},
229//! wave_gen::{message_to_tones, tones_to_i16},
230//! };
231//! use mfsk_core::msg::wsjt77::{pack77, unpack77};
232//!
233//! // 1. Pack a standard FT8 message and synthesise 12 kHz i16 PCM.
234//! // The synth produces just the transmitted frame (~12.64 s);
235//! // pad to the full 15 s slot with the signal starting at 0.5 s.
236//! let msg77 = pack77("CQ", "JA1ABC", "PM95").expect("pack");
237//! let tones = message_to_tones(&msg77);
238//! let frame = tones_to_i16(&tones, /* freq */ 1500.0, /* amp */ 20_000);
239//!
240//! let mut audio = vec![0i16; 180_000]; // 15 s @ 12 kHz
241//! let start = (0.5 * 12_000.0) as usize;
242//! for (i, &s) in frame.iter().enumerate() {
243//! if start + i < audio.len() { audio[start + i] = s; }
244//! }
245//!
246//! // 2. Decode it back across the full FT8 band.
247//! let results = decode_frame(
248//! &audio,
249//! /* freq_min */ 100.0,
250//! /* freq_max */ 3_000.0,
251//! /* sync_min */ 1.0,
252//! /* freq_hint */ None,
253//! DecodeDepth::BpAllOsd,
254//! /* max_cand */ 50,
255//! );
256//! assert!(!results.is_empty(), "roundtrip must decode");
257//! let text = unpack77(&results[0].message77).expect("unpack");
258//! assert_eq!(text, "CQ JA1ABC PM95");
259//! # }
260//! ```
261//!
262//! ## `no_std` usage
263//!
264//! ```toml
265//! # Cargo.toml — TX-only, no_std + alloc, no FFT backend needed
266//! [dependencies]
267//! mfsk-core = { version = "0.7", default-features = false, features = ["alloc", "ft8"] }
268//! ```
269//!
270//! Encoding (`message_to_tones` / `tones_to_i16`) never touches `std` — no
271//! FFT, no heap-backed collections beyond `alloc::vec::Vec`. This is the
272//! same call as the [`ft8::wave_gen`] encoder-only example above; the
273//! `alloc ft8` and `alloc ft8 fft-extern` legs of CI's feature-matrix build
274//! this exact combination (`.github/workflows/ci.yml`) to confirm it
275//! compiles under `#![no_std]`. Decoding additionally needs a
276//! [`core::fft::FftPlanner`] impl — bring your own via `fft-extern` (the
277//! embedded ports use this for esp-dsp / CMSIS-DSP) since `fft-rustfft`
278//! requires `std`.
279//!
280//! ```
281//! # #[cfg(feature = "ft8")] {
282//! use mfsk_core::ft8::wave_gen::{message_to_tones, tones_to_i16};
283//! use mfsk_core::msg::wsjt77::pack77;
284//!
285//! let msg77 = pack77("CQ", "JA1ABC", "PM95").expect("pack");
286//! let tones = message_to_tones(&msg77);
287//! let pcm = tones_to_i16(&tones, /* freq */ 1500.0, /* amp */ 20_000);
288//! assert!(!pcm.is_empty());
289//! # }
290//! ```
291
292// Several clippy lints fight with the style of this crate:
293//
294// - `too_many_arguments` triggers on inner FEC / DSP helpers that are
295// one-to-one ports of Fortran subroutines; splitting them into
296// "smaller" functions would just obscure the correspondence with
297// the upstream algorithm.
298// - `needless_range_loop` flags `for i in 0..N` loops that index into
299// fixed-size arrays. Algorithmic code ported from WSJT-X reads more
300// clearly with the index variable in scope (sync pattern iteration,
301// LDPC check-node passes, Reed-Solomon syndrome computation), so
302// the .iter().enumerate() form is not always an improvement.
303// - `unusual_byte_groupings` trips on magic constants where the digit
304// grouping encodes a bit-layout meaning (WSPR bit-reversal constants,
305// LDPC generator polynomial byte boundaries). Normalising the
306// grouping would obscure the intent.
307#![allow(
308 clippy::too_many_arguments,
309 clippy::needless_range_loop,
310 clippy::unusual_byte_groupings
311)]
312// `no_std` build is gated on the absence of the default `std` feature.
313// `alloc` is unconditional — every protocol module uses Vec / String,
314// so making it optional would just push the dep up to every caller.
315// (The `alloc` Cargo feature still exists as a no-op alias kept around
316// for back-compat with consumers that listed it explicitly.)
317#![cfg_attr(not(feature = "std"), no_std)]
318
319extern crate alloc;
320
321/// Crate version string, taken from Cargo.toml at compile time. Useful
322/// for FFI / WASM consumers that need to verify which mfsk-core they
323/// actually linked against (e.g. through a [patch.crates-io] path
324/// override that didn't get re-fingerprinted).
325pub const VERSION: &str = env!("CARGO_PKG_VERSION");
326
327pub mod core;
328pub mod fec;
329pub mod msg;
330
331#[cfg(feature = "ft8")]
332pub mod ft8;
333
334#[cfg(feature = "ft4")]
335pub mod ft4;
336
337#[cfg(feature = "fst4")]
338pub mod fst4;
339
340#[cfg(feature = "wspr")]
341pub mod wspr;
342
343#[cfg(feature = "jt9")]
344pub mod jt9;
345
346#[cfg(feature = "jt65")]
347pub mod jt65;
348
349#[cfg(feature = "q65")]
350pub mod q65;
351
352#[cfg(feature = "uvpacket")]
353pub mod uvpacket;
354
355#[cfg(feature = "msk144")]
356pub mod msk144;
357
358pub mod registry;
359
360// Flatten commonly-used types to the crate root.
361pub use crate::core::{
362 DecodeContext, FecCodec, FecOpts, FecResult, FrameLayout, MessageCodec, MessageFields,
363 ModulationParams, Protocol, ProtocolId, SyncBlock, SyncMode,
364};
365pub use crate::registry::{PROTOCOLS, ProtocolMeta, by_id, by_name, for_protocol_id};
366
367#[cfg(feature = "fst4")]
368pub use crate::fst4::Fst4s60;
369#[cfg(feature = "ft4")]
370pub use crate::ft4::Ft4;
371#[cfg(feature = "ft8")]
372pub use crate::ft8::Ft8;
373#[cfg(feature = "jt9")]
374pub use crate::jt9::Jt9;
375#[cfg(feature = "jt65")]
376pub use crate::jt65::Jt65;
377#[cfg(feature = "q65")]
378pub use crate::q65::Q65a30;
379#[cfg(feature = "uvpacket")]
380pub use crate::uvpacket::{UvExpress, UvRobust, UvStandard, UvUltraRobust};
381#[cfg(feature = "wspr")]
382pub use crate::wspr::Wspr;