mfsk-core
Pure-Rust library for WSJT-family digital amateur-radio modes — a single crate that implements FT8, FT4, FST4, WSPR, JT9, JT65 and Q65-30A decode / encode / synthesis on top of a small set of shared primitives (DSP, sync correlation, LLR, LDPC / convolutional / Reed-Solomon / QRA FEC, message codecs).
Why this exists
WSJT-X is the reference implementation of these modes and will stay that way — it is battle-tested on the desktop, heavily optimised, and the source of truth for every protocol constant you will find in this crate. But it is also a mixed Fortran / C / Qt application built around a specific desktop workflow. That makes it a poor fit whenever you want to run the decoders somewhere else:
- in a browser as a WASM PWA,
- on Android or iOS for portable operation, where linking a Fortran runtime is a non-starter,
- in a headless Rust application (skimmer, monitoring station, remote SDR front end),
- on embedded MCUs (ESP32-S3 with esp-dsp, RP2350 with CMSIS-DSP,
Cortex-M) via
no_std + alloc— the M5Stack Core2 PoC decodes 3–7 FT8 results per 15 s cycle on Xtensa LX6 with the fixed-point hot path, - or as the core of a new protocol experiment that reuses FT8's LDPC and sync machinery for a different modulation / FEC / message recipe.
The seven protocols share roughly 80 % of their signal path. In the Fortran codebase that commonality is expressed by copy-and-paste between per-mode source files; here it is expressed by traits.
The abstraction
┌────────────────────────────────────────────────────────┐
│ ft8 ft4 fst4 wspr jt9 jt65 q65 │ per-protocol ZSTs
│ (each implements Protocol + FrameLayout) │ (feature-gated)
└─────────────┬─────────────────┬────────────────────────┘
│ │
┌────────▼─────────┐ ┌────▼─────────┐
│ msg │ │ fec │ shared codecs
│ Wsjt77 · Jt72 │ │ LDPC · RS │ behind traits
│ Wspr50 · Q65 │ │ ConvFano·QRA │
│ · Hash table │ │ │
└────────┬─────────┘ └────┬─────────┘
│ │
┌───▼─────────────────▼───┐
│ core │ Protocol trait, DSP
│ sync · llr · equalize · │ (resample / GFSK /
│ pipeline · tx · dsp │ downsample / subtract)
└─────────────────────────┘
Each protocol declares its slot length, tone count, Gray map, Costas
/ sync pattern, FEC codec and message codec at compile time via the
Protocol trait. The generic code in core — coarse sync, fine
sync, LLR computation, LDPC / RS / convolutional decode, GFSK
synthesis — works for any type that satisfies the trait. Dispatch is
monomorphised, so the machine code is byte-identical to a hand-
written per-protocol decoder.
Adding a new protocol is a trait impl on a ZST, not a cross-cutting refactor: FST4-60A joined the crate post-hoc without changing any shared pipeline code.
The receive path is a chain of free functions in core::sync →
core::llr → core::equalize → core::pipeline (each generic
over P: Protocol), not a Demodulator / Receiver trait. See
docs/LIBRARY.md §4
for the data-flow diagram.
[]
= { = "0.6", = ["ft8", "ft4"] }
Attribution
Every algorithm in this crate is derived from
WSJT-X (Joe Taylor K1JT and
collaborators). Source files cite the corresponding upstream
lib/ft8/*, lib/ft4/*, lib/fst4/*, lib/wsprd/*, lib/jt65_*,
lib/jt9_*, lib/packjt.f90, etc. that they port from. This is a
Rust re-implementation aimed at broadening the set of platforms
(browser / WASM, Android, embedded) that can host the decoders —
not a replacement for WSJT-X itself, which remains the reference
implementation.
License matches upstream: GPL-3.0-or-later.
Protocols
| Protocol | Slot | FEC | Message | Sync | Feature |
|---|---|---|---|---|---|
| FT8 | 15 s | LDPC(174, 91) + CRC-14 | 77 bit | 3 × Costas-7 | ft8 |
| FT4 | 7.5 s | LDPC(174, 91) + CRC-14 | 77 bit | 4 × Costas-4 | ft4 |
| FST4-60A | 60 s | LDPC(240, 101) + CRC-24 | 77 bit | 5 × Costas-8 | fst4 |
| WSPR | 120 s | Convolutional r=½ K=32 + Fano | 50 bit | Per-symbol LSB (npr3) | wspr |
| JT9 | 60 s | Convolutional r=½ K=32 + Fano | 72 bit | 16 distributed slots | jt9 |
| JT65 | 60 s | Reed-Solomon(63, 12) GF(2⁶) | 72 bit | 63 distributed slots | jt65 |
| Q65-30A | 30 s | QRA(15, 65) GF(2⁶) + CRC-12 | 77 bit | 22 distributed slots | q65 |
| Q65-60A‥E | 60 s | (same QRA codec) | 77 bit | (same sync layout) | q65 |
Seven protocol families, eleven wired ZSTs in the registry: Q65 contributes one
30-s sub-mode (Q65-30A) plus five 60-s EME sub-modes (Q65-60A‥E) that share
the FEC, message codec and sync layout but differ in NSPS / tone spacing.
PROTOCOLS
exposes one entry per wired ZST; uvpacket (when enabled) adds four
more for its rate ladder.
Static set of protocols
PROTOCOLS is a const slice — the set of supported protocols is
fixed at compile time by Cargo features. There is no runtime
register_protocol() API by design: every wired ZST is verified by
tests/protocol_invariants.rs to satisfy the trait surface, and that
guarantee can't be extended to types unknown at compile time. UI /
FFI consumers should iterate PROTOCOLS (or filter via by_id /
by_name) at startup; if you need a new protocol, add the ZST + a
protocol_meta! line and rebuild.
Applied example: uvpacket (experimental)
The uvpacket module (--features uvpacket, off by default) is
an in-tree example showing the trait abstractions extend beyond
WSJT-X — a π/4-DQPSK packet protocol for NFM / SSB voice channels
that reuses the shared Ldpc240_101 codec. Experimental and
its public API may change — pin to an exact version. See
docs/UVPACKET.md
(日本語)
for the design narrative, modulation / equaliser / framing details
and per-mode performance characterisation.
Modules
mfsk_core::core— protocol traits, DSP (resample / downsample / GFSK / subtract), sync, LLR, equaliser, pipeline driver.mfsk_core::fec—Ldpc174_91/Ldpc240_101/ConvFano/ConvFano232/Rs63_12/qra::Q65Codec(with theqra15_65_64::QRA15_65_64_IRR_E23code instance) for Q65.mfsk_core::msg— 77-bit (Wsjt77Message), 72-bit (Jt72Codec), 50-bit (Wspr50Message) and Q65 (Q65Message, 77-bit ↔ 13-symbol packing helpers) message codecs; callsign hash table.mfsk_core::{ft8, ft4, fst4, wspr, jt9, jt65, q65}— per-protocol ZSTs, decoders and synthesisers (each feature-gated). Theq65module exposes one ZST per wired sub-mode —Q65a30for terrestrial work, plusQ65a60/Q65b60/Q65c60/Q65d60/Q65e60for EME at 6 m through 10 GHz+ — with genericsynthesize_standard_for<P>/decode_at_for<P>/decode_scan_for<P>helpers that pick the right NSPS and tone spacing from the type parameter.
Features
| Feature | Default | What it enables |
|---|---|---|
ft8 |
✓ | FT8 decode / synth |
ft4 |
✓ | FT4 decode / synth |
fst4 |
FST4-60A decode / synth | |
wspr |
WSPR decode / synth | |
jt9 |
JT9 decode / synth | |
jt65 |
JT65 decode / synth (+ erasure-aware RS) | |
q65 |
Q65-30A decode / synth (QRA soft-decision) | |
uvpacket |
Applied example (experimental): NFM voice-channel packet protocol (QPSK + LDPC), reuses Ldpc240_101 |
|
full |
Aggregate of all seven WSJT protocols + uvpacket + packet-bytes | |
parallel |
✓ | Rayon-parallel candidate processing |
fft-rustfft |
✓ | Default host FFT backend (rustfft, requires std) |
fft-extern |
Pluggable FFT trait — caller binary supplies an FftPlanner impl (esp-dsp on ESP32-S3, CMSIS-DSP on RP2350, …) |
|
fixed-point |
Embedded integer pipeline: u16 spectrogram + i16 DFT + Q11i16 LLR + integer NMS BP | |
profile-coarse |
Always-on coarse_sync sub-stage profiling (host has MFSK_PROFILE_COARSE env var alternative) |
Quick example
use ;
use ;
// 1. Synthesise an FT8 frame and pad it into a 15-second slot.
let msg77 = pack77.unwrap;
let tones = message_to_tones;
let frame = tones_to_i16;
let mut audio = vec!; // 15 s @ 12 kHz
let start = as usize;
for in frame.iter.enumerate
// 2. Decode it back.
for r in decode_frame
Each protocol module documents its top-level entry points and carries its own Quick example:
mfsk_core::ft8—decode_frame+decode_sniper_ap(narrow-band "sniper" mode)mfsk_core::ft4—decode_framemfsk_core::fst4— FST4-60Adecode_framemfsk_core::wspr—decode::decode_scan_defaultmfsk_core::jt9—decode_scan_defaultmfsk_core::jt65—decode_scan_default+decode_at_with_erasures(for low SNR)mfsk_core::q65—decode_scan_default(Q65-30A); genericdecode_scan_for<P>for any wired sub-mode including the Q65-60A‥E EME variants;decode_scan_with_ap/decode_scan_with_ap_for<P>for AP-biased decoding (~2 dB threshold gain when call signs are known); anddecode_scan_fading_for<P>for the fast-fading metric (Gaussian / Lorentzian channel models) that recovers 5–8 dB on Doppler-spread channels — required for microwave EME at 5.7 / 10 / 24 GHz; anddecode_scan_with_ap_list_for<P>(paired withstandard_qso_codewords) for BP-free template matching against the full WSJT-X "AP list" of standard exchanges (~3 dB threshold gain when the callsign pair is known up-front)
C / C++ / Kotlin
The mfsk-ffi sibling crate in this repository builds a
libmfsk.{so,a,dylib} + mfsk.h (via cbindgen) that exposes the
same decoder and synthesiser surface through an opaque-handle C ABI.
mfsk-ffi is not published to crates.io — consumers clone this repo and run:
cargo build -p mfsk-ffi --release
See mfsk-ffi/examples/cpp_smoke/ for an end-to-end driver test
(including multi-threaded usage) and mfsk-ffi/examples/kotlin_jni/
for an Android/JNI skeleton.
Contributing
PRs welcome — recent forks have shipped FT4 SIC, FT4/FST4 depth + strictness controls, and the FT8 wide-band AP path. The local-fence
- CI gates are uniform across direct commits and fork PRs:
-
Pre-commit hook:
.githooks/pre-commitrunscargo fmt --check,cargo clippy --workspace --all-targets --features full -- -D warnings, andRUSTDOCFLAGS=-D warnings cargo doc -p mfsk-core --features full --no-deps(~10–20 s on a warm cache). Enable once per clone:The hook deliberately skips the full
cargo testsuite (kept in CI to keep commits snappy); fmt / clippy / rustdoc each catch a failure mode that would otherwise trip CI after the push. -
CI gates (
.github/workflows/ci.yml): same fmt + clippy fence, pluscargo test -p mfsk-core --features full --release -- --include-ignored(slow synthetic-SNR / AP / fast-fading sweeps enabled), a 13-cell feature matrix that builds every protocol in isolation + the embeddedalloc + ft8 + fft-extern + fixed-pointpreset, the C++ driver againstmfsk-ffi, rustdoc with-D warnings, and acargo publish --dry-runformfsk-core. -
Release: tag-driven (
v0.6.x). Pushing a tag that matchesmfsk-core/Cargo.toml::versionand is reachable frommaintriggersrelease.yml, which publishes to crates.io and cuts a GitHub release with auto-generated notes. Embeddedmfsk-ffi-ft8binaries follow on the same tag.
For non-trivial changes, please open an issue first so the
WSJT-X-source-faithfulness lineage of any DSP or FEC change is
visible in review (every protocol constant in this crate cites the
upstream lib/*.f90 it ports from — drift from that lineage tends
to be the failure mode caught by the WSJT-X golden harnesses).
Architecture & ABI reference
For a deeper look at the design — trait hierarchy with worked examples, shared DSP / sync / LLR / pipeline primitives, the C ABI memory model, Kotlin/Android scaffolding — see the library reference:
- English:
docs/LIBRARY.md - 日本語:
docs/LIBRARY.ja.md - Embedded targets:
English
docs/EMBEDDED.md/ 日本語docs/EMBEDDED.ja.md— generic-scalar architecture (one codebase for f32 host and fixed-point embedded), feature-flag map, FFT / dot-product extern contracts, BASIS scratch placement, Q-format reference, Core2 perf ballpark + footprint.
Status
0.6.x — API is deliberately not frozen. Breaking changes follow
cargo-style minor bumps (0.5 → 0.6). The 0.5.x line landed the
embedded baseline (no_std + alloc, pluggable FFT backend,
caller-buffer TX APIs) and the first end-to-end real-audio embedded
port. The 0.6.x line consolidates the FT8 sync + per-candidate
pipeline: host (decode_frame*) and embedded (decode_block) now
share decode_block::coarse_sync (WSJT-X sync8.f90-faithful 16-bin
allsum estimator) and a unified process_one_candidate_inner for the
LLR / BP / OSD / AP staircase. See CHANGELOG.md for the full per-
release breakdown.
Latest M5StickS3 (Xtensa LX7) ship config decodes 6 / 18 JTDX-golden
FT8 callsigns + 1 bonus = 7 total on the WSJT-X-distributed busy-band
reference (samples/FT8/210703_133430.wav) in ~1.19 s post-SlotEnd
via the streaming pipeline (FFT overlapped with capture). The 0.6.2
LLR migration (Q3i8 → Q11i16, double-width but better precision under
esp-dsp i16 spectrogram noise) added one entry (XE2X HA2NP RR73) over
the 0.5.x baseline. M5Stack Core2 (LX6) on the same WAV ~2.8 s. See
docs/EMBEDDED.md
for the integration contract, runtime BP / nstep-half tuning knobs,
and the structural recall ceiling (no fine_refine_pass1 on Xtensa
without 192k FFT — investigated and deferred);
embedded-poc/m5stack-{s3,core2}/ are the working example binaries.
Host AP-on multipass recall on the same WAV is materially higher.
After 0.6.2's host pipeline catch-up (cs-source unification +
subtract_signal_lpf matching decode_block_multipass),
decode_frame_subtract_with_ap produces 18 decodes including
5 / 6 of the JTDX AP-on extras (was 1 / 6 pre-0.6.2). The single
remaining AP-on extra (K1BZM DK8NE -19 dB) needs a wider AP-list /
callsign hash table — out of 0.6.x scope.
Algorithm correctness is covered by the workspace test suite:
end-to-end synth → decode roundtrips for every protocol, an AWGN
sensitivity sweep that confirms Q65-30A hits its WSJT-X-published
−24 dB threshold, an AP-vs-plain comparison that shows the expected
~2 dB gain from a-priori call sign information, an AP-list
(template matching) comparison that decodes 6/6 frames at SNR −25 dB
where plain BP fails 0/6, a real 6 m EME recording (W7GJ exchanges
from the WSJT-X reference set), and a real 10 GHz EME recording that
the fast-fading metric is required to decode. The trait surface
itself is pinned by tests/protocol_invariants.rs — a single generic
<P: Protocol> checker run across every wired ZST. Run with
--features full for the eleven-ZST coverage; the default features
(ft8, ft4) only exercise the two default protocols.
Recall against the WSJT-X-distributed reference recordings
(samples/{WSPR,FT4,JT9}/*.wav) is locked by golden harnesses in
tests/{wspr,ft4,jt9}_wsjtx_samples.rs (run only when the WSJT-X
tree is present at the expected sibling path):
| Reference WAV | Recall | Notes |
|---|---|---|
WSPR/150426_0918.wav (8 frames) |
8 / 8 | sub-bin demod + neg-dt |
FT4/000000_000002.wav (6 frames) |
6 / 6 | Nuttall + sync4d |
JT9/130418_1742.wav (5 frames) |
5 / 5 | full WSJT-X-faithful softsym pipeline (afc9 + chkss2 + xx0 mettab + sync9 collapse) |
MSK144/181211_120500.wav (n/a) |
— | not implemented — #25 |
JT65/* (n/a) |
— | golden harness pending — #24 |
FST4/210115_0058.wav (1 frame) |
— | golden harness pending — #23 |
Known limitations / quirks
- JT65 —
decode_scandecodes synthesised JT65A frames cleanly via the Reed-Solomon(63, 12) FEC and the AP-list path lifts weak signals to within ~2 dB of WSJT-X. There's no golden WAV in this crate's regression set because the WSJT-X v3 reference samples themselves don't decode cleanly without the soft-symbol erasure metadata that lives in private WSJT-X branches. Tracked in #24. - FST4 — only the FST4-60A long-period variant is wired (sample
duration / Costas layout for FST4-15 / FST4W are out of scope of
the 0.5.x line). Recall against
samples/FST4/210115_0058.wavis not yet locked by a golden harness — tracked in #23. - MSK144 — not implemented in 0.5.x. The decode path needs a different correlator geometry from the rest of the FT/JT/Q-family decoders this crate is built around. Tracked in #25.
What's solid
- FT8 — synth → decode round-trip lib tests green, real WSJT-X
reference (
210703_133430.wav):- WSJT-X 8-entry golden: 7 / 8 (host
decode_frame_with_apand embeddeddecode_blockboth, post-0.6.0 sync consolidation +i_start as i32fix). - JTDX 18-entry golden: 16 / 18 (
decode_block). - Host AP-on multipass JTDX-extras: 5 / 6 (was 1 / 6 pre-0.6.2)
via
decode_frame_subtract_with_apafter the cs-source +subtract_signal_lpfunification. - Embedded S3 fixed-point: 6 / 18 + 1 bonus = 7 total in
~1.19 s post-SlotEnd. AP-hint biasing exposed on the narrow-band
(
decode_sniper_ap), wide-band single-pass (decode_frame_with_ap), multipass (decode_frame_subtract_with_ap) and embedded (decode_block_with_ap, new in 0.6.1) paths.
- WSJT-X 8-entry golden: 7 / 8 (host
- WSPR — 8 / 8 WSJT-X golden, ~0.88 s end-to-end on a desktop build. Sub-bin demod + 2-pass subtract+re-coarse + OSD-2 fallback + Type-3 phantom filter.
- FT4 — 6 / 6 WSJT-X golden after the 0.5.9 multi-slice port of
the WSJT-X demod path (Nuttall window,
nsym = 4LLR aggregation,sync4d2-pass refine,rvecscrambler). Successive-interference cancellation primitives (subtract_signal*,refine_signal_freq) ported fromlib/ft4_subtract.f90. WSJT-X Decode menu (Fast / Normal / Deep) exposed viadecode_frame_with_optionsfor FT4 and FST4-60A. - JT9 — 5 / 5 WSJT-X golden on
samples/JT9/130418_1742.wavvia the full WSJT-X-faithful softsym pipeline (afc9+chkss2xx0mettab +sync9per-freq collapse). Closed #19.
- Q65 — fast-fading + AP-list paths exercise both the WSJT-X 6 m EME and the 10 GHz EME reference recordings.
- SNR (FT8) —
xsnr2_db_simplecalibration (0.5.7 + 0.5.8) lands reported SNR within ±3 dB of JTDX absolute on real silicon.