Skip to main content

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;