mfsk-core 0.9.1

Pure-Rust WSJT-family decoders + synthesisers (FT8 FT4 FST4 WSPR JT9 JT65 Q65) behind a zero-cost Protocol trait. Host (rustfft) or no_std embedded (ESP32-S3, RP2350, Cortex-M) via a pluggable FFT backend; fixed-point hot path for FPU-less MCUs. Ships with embedded-poc/m5stack-s3-app, a working M5StickS3 FT8 controller (LCD UI, BLE CI-V to IC-705, acoustic mic, QSO FSM) decoding real on-air signals in ~1.2 s post-SlotEnd on Xtensa LX7.
Documentation

mfsk-core

CI crates.io docs.rs License

What is this?

mfsk-core is a 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). It runs anywhere Rust runs: desktop, WASM in the browser, Android/iOS, and no_std embedded MCUs.

The embedded-poc/m5stack-s3-app crate shipped with the source tree is a working M5StickS3 FT8 controller running the same library on Xtensa LX7 — LCD UI, BLE CI-V to IC-705, acoustic mic capture, QSO FSM. The image above is one of its decode slots.

Every algorithm is a Rust re-implementation of WSJT-X (Joe Taylor K1JT and collaborators), which remains the reference implementation — see Attribution below.

Why mfsk-core

  • At or near WSJT-X sensitivity parity on every mode. FST4 is within 0.1-0.6 dB of WSJT-X's published thresholds across all five sub-modes; FT4's AWGN gap is ~0.3 dB; MSK144 matches a real WSJT-X jt9 build on 25/28 AWGN cross-check cells exactly; FT8 matches the WSJT-X golden set 8/8 and JTDX's 18/18; WSPR and JT9 are 8/8 and 7/7 on their WSJT-X reference recordings. JT65's own long-disclosed ~7-8 dB gap vs. WSJT-X's stochastic ftrsdap decoder was closed 2026-08-08 (#169): a faithful port of ftrsdap itself (jt65::decode_at_with_chase, magic numbers included) plus an FFT bin-alignment fix that turned out to be the bigger factor (affecting every JT65 decode path, not just the new one). Full numbers, per protocol, including the honest caveats on the WSJT-X comparison methodology: docs/notes/BENCHMARKS.md.
  • Runs where WSJT-X can't. Same algorithms, no_std-portable: a real shipping product (embedded-poc/m5stack-s3-app, pictured above) decodes real on-air FT8 in ~1.2 s post-slot on an ESP32-S3, plus WASM in the browser and Android/iOS via FFI — none of which a Fortran/C/Qt desktop application can target.
  • A Protocol trait, not per-mode copy-paste. Eight protocol families share one generic, monomorphised decode pipeline (no vtable, no dynamic dispatch on the hot path) — adding FST4-60A to the crate was a trait impl on one ZST, not a cross-cutting refactor. See Design Philosophy.

Supported 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
FST4-15/30/120/300 15-300 s (same LDPC(240, 101)) 77 bit (same sync layout) 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
MSK144 15 s LDPC(128, 90) + CRC-13 77 bit Meteor-ping burst-scan (matched filter) msk144

Eight protocol families, sixteen wired Protocol-trait ZSTs in the registry: FST4 contributes five T/R-period sub-modes (FST4-15, -30, -60A, -120, -300) and Q65 contributes one 30-s sub-mode (Q65-30A) plus five 60-s EME sub-modes (Q65-60A‥E) — both families share FEC, message codec and sync layout across their sub-modes, differing only in NSPS / tone spacing (and, for FST4-15 alone, the T/R start offset). MSK144 is the exception: its continuous-phase binary-MSK modulation and transient-burst timing don't fit the static-slot model every other protocol here shares, so no ZST implements Protocol for it — its own msk144::decode::decode_slot driver bypasses engine::pipeline entirely by design (see docs/reference/LIBRARY.md §0.5 for why — see the MSK144 row and its footnote in the generic-vs-bespoke table). PROTOCOLS exposes one entry per wired ZST (so MSK144 doesn't appear there); uvpacket (when enabled) adds four more for its rate ladder. See Static set of protocols for why there's no runtime register_protocol().

Quick Start

# Cargo.toml
[dependencies]
mfsk-core = { version = "0.8", features = ["ft8", "ft4"] }

New features and fixes land on main immediately as PRs merge, but crates.io releases are cut on a throttled cadence (see Status) — if you want a specific fix or new mode before it ships to crates.io, point at the git repo instead:

mfsk-core = { git = "https://github.com/jl1nie/mfsk-core", branch = "main", features = ["ft8", "ft4"] }

Synthesise an FT8 frame and decode it back:

use mfsk_core::ft8::Ft8;
use mfsk_core::ft8::wave_gen::{message_to_tones, tones_to_i16};
use mfsk_core::msg::decode_request::DecodeRequest;
use mfsk_core::msg::wsjt77::{pack77, unpack77};

// 1. Synthesise an FT8 frame and pad it into a 15-second slot.
let msg77 = pack77("CQ", "JA1ABC", "PM95").unwrap();
let tones = message_to_tones(&msg77);
let frame = tones_to_i16(&tones, /* freq */ 1500.0, /* amp */ 20_000);

let mut audio = vec![0i16; 180_000]; // 15 s @ 12 kHz
let start = (0.5 * 12_000.0) as usize;
let end = (start + frame.len()).min(audio.len());
audio[start..end].copy_from_slice(&frame[..end - start]);

// 2. Decode it back.
let results = DecodeRequest::<Ft8>::new(
    &audio,
    /* freq_min */ 100.0,
    /* freq_max */ 3_000.0,
    /* sync_min */ 1.0,
    /* max_cand */ 50,
)
.decode()
.results;
for r in &results {
    if let Some(text) = unpack77(r.message77()) {
        println!("{:7.1} Hz  dt={:+.2} s  SNR={:+.0} dB  {}",
                 r.freq_hz, r.dt_sec, r.snr_db, text);
    }
}

That's the whole round trip: pack a message → synthesise 12 kHz PCM → decode it back. Each protocol module documents its own top-level entry points and carries its own Quick example:

  • mfsk_core::ft8DecodeRequest::<Ft8> (wide-band) + SniperRequest::<Ft8> (narrow-band "sniper" mode)
  • mfsk_core::ft4DecodeRequest::<Ft4>
  • mfsk_core::fst4DecodeRequest::<Fst4s60> (FST4-60A); other sub-modes via DecodeRequest::<Fst4s120> etc.
  • mfsk_core::wsprdecode::decode_scan_default
  • mfsk_core::jt9decode_scan_default
  • mfsk_core::jt65decode_scan_default + decode_at_with_erasures (for low SNR)
  • mfsk_core::q65DecodeRequest::<P> (wide-band scan) / SniperRequest::<P> (narrow-band, known alignment) for any wired sub-mode including the Q65-60A‥E EME variants; .ap_hint(...) for AP-hint decoding (~2 dB threshold gain when call signs are known); .fading(model, b90_ts) 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; .ap_list(candidates) (paired with standard_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); and MultiPeriodRequest::<P> for averaged multi-slot decode (ionoscatter / weak-EME signals no single-period decode recovers). Q65's own dedicated builders — unlike FT8/FT4/FST4's msg::decode_request::{DecodeRequest, SniperRequest} — since every q65::rx function operates on &[f32] audio, not &[i16] (issue #204)

Features

Feature Default What it enables
ft8 FT8 decode / synth
ft4 FT4 decode / synth
fst4 FST4-60A decode / synth (+ FST4-15/30/120/300)
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 — see docs/reference/EMBEDDED.md for the Q-format reference
profile-coarse Always-on coarse_sync sub-stage profiling (automatically disabled on wasm32-unknown-unknown to prevent panics)

Architecture

Every protocol runs the same DSP pipeline, differing only in the constants each stage plugs in (tone count, sync pattern, FEC codec, message layout):

Audio (i16 PCM)
  │
  ▼
FFT              spectrogram over the slot
  │
  ▼
Candidate Search  coarse frequency/time sync — Costas/sync-pattern
  │                correlation across the spectrogram
  ▼
Synchronization   fine time/frequency refinement per candidate
  │
  ▼
Demodulation      tone → LLR (soft bits), GFSK/FSK-aware
  │
  ▼
FEC               LDPC / convolutional+Fano / Reed-Solomon / QRA decode
  │
  ▼
Decoded Message    77-/72-/50-bit unpack → callsign / grid / report

This is the same shape for FT8's LDPC(174,91) and JT65's Reed-Solomon(63,12) — only the boxes' contents change per protocol. See Design Philosophy for how that's expressed in code (a Protocol trait, not per-mode copy-paste), docs/reference/LIBRARY.md §0.5 for a per-protocol table of exactly which boxes are shared vs. bespoke, and docs/reference/LIBRARY.md §4 for the full data-flow diagram down to function level.

Design Philosophy

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.

Why a Protocol trait

The seven protocols share roughly 80 % of their signal path: 8-GFSK / FSK demodulation, soft-decision LDPC / convolutional / Reed-Solomon / QRA decoding, 77- / 72- / 50-bit WSJT message packing, spectrogram-based sync search. In the Fortran codebase that commonality is expressed by copy-and-paste between per-mode source files; here it is expressed by traits, split by what actually varies per protocol:

  • Shared (lives in engine, generic over any P: Protocol): coarse sync, fine sync, LLR computation, equalisation, the decode pipeline driver, GFSK synthesis.
  • Protocol-specific (declared as const associated items + ZSTs on the protocol type): tone count, symbol rate, Gray map, GFSK shaping constants (ModulationParams); total symbols, sync/data layout, slot length (FrameLayout); which FEC codec (Protocol::Fec, e.g. LDPC vs Reed-Solomon) and which message codec (Protocol::Msg, e.g. 77-bit WSJT vs 50-bit WSPR) the protocol plugs in.
         ┌────────────────────────────────────────────────────────┐
         │   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)
                   └─────────────────────────┘

Zero-cost: generic decoder, not a runtime dispatch table

Because everything above is expressed as const associated items + ZSTs, the generic pipeline code — coarse_sync::<P>, decode_frame::<P>, the LDPC inner loop — is monomorphised per protocol. LLVM sees a fully specialised function for each P, inlines the constants, and autovectorises the hot loops on native targets, where SIMD is enabled by default (SSE/AVX on x86_64, NEON on aarch64). The generated machine code is byte-identical to a hand-written per-protocol decoder; the receive path is a chain of free functions in engine::syncengine::llrengine::equalizeengine::pipeline (each generic over P: Protocol), not a Demodulator / Receiver trait object — there is no vtable, no dynamic dispatch, on the hot path. On wasm32-unknown-unknown this autovectorization requires an explicit build flag — see Building for WebAssembly below.

This pays off most clearly when adding a new protocol: it's 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 entire implementation is the trait impl block on a single ZST plus a Costas pattern table. Similarly, swapping an LDPC codec between two LDPC modes, or exposing the same 77-bit message layer to FT8, FT4 and FST4, are one-line changes, not cross-cutting refactors.

Building for WebAssembly

Unlike x86_64/aarch64 hosts, where SSE/AVX or NEON are enabled by default, wasm32-unknown-unknown ships with no SIMD by default. mfsk-core has zero hand-written SIMD (by design — it keeps the crate no_std/portable), so every hot loop depends entirely on LLVM's autovectorizer, which on wasm32 only emits v128 instructions when +simd128 is explicitly enabled. This also gates rustfft (the default FFT backend)'s own wasm-SIMD butterfly kernels. Without the flag, both mfsk-core's DSP and the FFT backend run fully scalar.

Add this to your consuming project's .cargo/config.toml (this is a build flag for the binary/wasm-bindgen crate that embeds mfsk-core, not something the library itself can impose on downstream builds):

[target.wasm32-unknown-unknown]
rustflags = ["-C", "target-feature=+simd128"]

Measured effect (Node, --target nodejs, real decode_wav() FT8 decode calls, median of 5-7 runs per config, same input, steady-state; methodology and a reproducible harness in docs/notes/BENCHMARKS.md):

WAV without +simd128 with +simd128 speedup
sim_busy_band.wav 194.2 ms 156.5 ms 19.4%
sim_extreme_hard.wav 216.5 ms 173.1 ms 20.0%

Browser support for wasm SIMD has been universal since ~2021 (Chrome/Firefox/Safari 16.4+); in practice there's no reason not to set this flag for a wasm build.

Why Rust

  • Safety: bit-level FEC routines (LDPC belief propagation, Karn's Berlekamp-Massey + Forney for RS, Fano sequential decoding) are textbook index-heavy code. Writing them in safe Rust eliminates an entire class of memory-corruption bugs that Fortran / C ports have historically hidden.
  • Generics + trait bounds: describing a protocol family as data + traits is natural. The equivalent in C++ would be template metaprogramming with subtler error messages; in Fortran, it simply isn't on offer.
  • Targets: the same code compiles to wasm32-unknown-unknown (WASM SIMD 128-bit via rustfft, requires +simd128 — see Building for WebAssembly above), to Android arm64-v8a via the NDK (NEON SIMD), to no_std + alloc embedded MCUs, and to any x86_64-*-unknown host for servers — from a single source tree.
  • Ecosystem: rustfft, num-complex, crc, rayon are plug-and-play, so the crate's dependency graph is small and reviewable.

Benchmarks vs. WSJT-X

Not a competitor — a different point in the design space. WSJT-X is the reference implementation and the source of truth for every protocol constant in this crate; mfsk-core exists to run the same algorithms in places a Fortran/C/Qt desktop application can't reach.

WSJT-X mfsk-core
Language Fortran + C + Qt Rust
Distribution Desktop application Library crate (cargo add)
no_std / embedded ✓ (ESP32-S3, RP2350, Cortex-M)
WASM ✓ (wasm32-unknown-unknown, +simd128 recommended)
Android / iOS ✓ (NDK / FFI)
FFT backend fixed (FFTW) pluggable (rustfft or caller-supplied, e.g. esp-dsp/CMSIS-DSP)
Reference implementation derived from WSJT-X, cites source per file

Headline decode numbers (full per-protocol writeup, including how each sweep was generated and reproduced, in docs/notes/BENCHMARKS.md):

Protocol Golden-WAV recall AWGN gap vs. WSJT-X
FT8 8/8 host full-parity (WSJT-X), 18/18 (JTDX) CCIR fading gap closed
FT4 6/6 ~0.3 dB
FST4 1/1 (FST4-60A) 0.10-0.60 dB across 5 sub-modes
WSPR 8/8 matches published sensitivity floor
JT9 7/7 no measurable gap
JT65 none available ~0 dB (2026-08-08, #169: faithful ftrsdap port + FFT bin-alignment fix — see BENCHMARKS.md for comparison caveats)
Q65 2 real EME recordings matches WSJT-X with AP hint; 2 sub-modes measurably beat WSJT-X's own plain decode
MSK144 3/3 (incl. exact SNR match) 25/28 cells exact match vs. a real jt9 build

Embedded wall-clock: M5StickS3 (Xtensa LX7, fixed-point) decodes a real on-air busy-band FT8 slot in ~1.19 s post-SlotEnd via the streaming pipeline (FFT overlapped with capture); M5Stack Core2 (Xtensa LX6) on the same recording: ~2.8 s. See docs/reference/EMBEDDED.md for the byte-level BP-scratch / spectrogram memory budget.

FAQ

How does this differ from WSJT-X? See Benchmarks vs. WSJT-X and Why this exists above — same algorithms, different deployment targets (library vs desktop app, no_std embedded, WASM).

Does it support no_std? Yes — default-features = false, features = ["alloc", "ft8", "fft-extern"] (or similar) builds without std. std is only required by the default fft-rustfft backend; embedded targets swap in their own FFT via fft-extern. See docs/reference/EMBEDDED.md.

Can I swap the FFT backend? Yes — enable fft-extern instead of fft-rustfft and provide an FftPlanner impl (engine::fft); the embedded ports use this for esp-dsp (ESP32-S3) and CMSIS-DSP (RP2350).

How do I use this on embedded hardware? Start with docs/reference/EMBEDDED.md (feature-flag map, FFT-extern contract, Q-format reference, full C ABI tutorial) and, for a complete working example, embedded-poc/m5stack-s3-app (a shipping M5StickS3 FT8 controller).

Is the API stable? Not yet — see Status. Breaking changes follow cargo-style minor bumps (0.x line).

License

GPL-3.0-or-later, matching upstream WSJT-X. See LICENSE.


Reference

The sections above cover getting started and the design rationale. The rest of this document is reference material: attribution, module layout, the FFI surface, contribution workflow, and the detailed per-protocol status / recall tables.

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.

Protocol registry details

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/reference/UVPACKET.md (日本語) for the design narrative, modulation / equaliser / framing details and per-mode performance characterisation.

Modules

  • mfsk_core::engine — protocol traits, DSP (resample / downsample / GFSK / subtract), sync, LLR, equaliser, pipeline driver.
  • mfsk_core::fecLdpc174_91 / Ldpc240_101 / ConvFano / ConvFano232 / Rs63_12 / qra::Q65Codec (with the qra15_65_64::QRA15_65_64_IRR_E23 code 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). The q65 module exposes one ZST per wired sub-mode — Q65a30 for terrestrial work, plus Q65a60 / Q65b60 / Q65c60 / Q65d60 / Q65e60 for EME at 6 m through 10 GHz+ — with generic synthesize_standard_for<P> helper plus the DecodeRequest<P>/SniperRequest<P> builders that pick the right NSPS and tone spacing from the type parameter.

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, but every tagged release attaches a prebuilt linux-x86_64 tarball to the GitHub Release; other platforms/ABIs (including Android) build locally:

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. Embedded targets (ESP32-S3, RP2350, Cortex-M) instead use the sibling mfsk-ffi-ft8 crate — see its own prebuilt binaries below.

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-commit runs cargo fmt --check, cargo clippy --workspace --all-targets --features full -- -D warnings, and RUSTDOCFLAGS=-D warnings cargo doc -p mfsk-core --features full --no-deps (~10–20 s on a warm cache). Enable once per clone:

    git config core.hooksPath .githooks
    

    The hook deliberately skips the full cargo test suite (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, plus cargo 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 embedded alloc + ft8 + fft-extern + fixed-point preset, cargo test + the C++ driver for mfsk-ffi and cargo test for mfsk-ffi-ft8, rustdoc with -D warnings, and a cargo publish --dry-run for mfsk-core.

  • Release: tag-driven (v0.6.x). Pushing a tag that matches the workspace version (Cargo.toml::[workspace.package].version, inherited by mfsk-core/mfsk-ffi/mfsk-ffi-ft8 alike) and is reachable from main triggers release.yml, which publishes mfsk-core to crates.io and cuts a GitHub release with auto-generated notes. Prebuilt mfsk-ffi (linux-x86_64) and mfsk-ffi-ft8 (linux-x86_64, esp32-xtensa, esp32s3-xtensa) binaries 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:

Status

Latest published tag: v0.9.0 — API is deliberately not frozen: breaking changes follow cargo-style minor bumps, while a new protocol/mode addition on its own is patch-level (e.g. MSK144 shipped as 0.7.4, not 0.8.0) — minor bumps mark more structural changes. See CHANGELOG.md for the per-release breakdown and docs/notes/ROADMAP.md for open follow-ups.

Release cadence: PRs merge to main continuously — CHANGELOG.md's top section always reflects the latest unreleased state, so tracking main directly (see Quick Start) gets you every change immediately. Actual crates.io tags/GitHub Releases are cut on a biweekly cadence (bundling everything merged since the last tag) rather than after every individual change, to keep update notifications for crates.io consumers from firing too often — an out-of-cadence release is still fine for a security fix, a serious correctness bug, or on explicit request.

Algorithm correctness is covered by the workspace test suite: end-to-end synth → decode roundtrips for every protocol, real WSJT-X-distributed reference recordings, *sim-generated AWGN sensitivity sweeps, and AP-list / fast-fading comparisons — see Benchmarks vs. WSJT-X above and docs/notes/BENCHMARKS.md for the full results. 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 full coverage; the default features (ft8, ft4) only exercise the two default protocols.

embedded-poc/m5stack-s3-app/ (demo / acoustic-fallback, since the StickS3 board can't do USB host) and embedded-poc/m5stack-core2-app/ (wav_sim-only LX6 sibling) are production FT8 controller crates (LCD UI + QSO FSM + WiFi-UDP log streaming), both consuming the board-agnostic embedded-poc/mfsk-app-shared/. embedded-poc/m5stack-cores3-app/ is the main UAC controller target (M5Stack CoreS3 has the PMIC/IO-expander wiring StickS3 lacks) — board bring-up and UAC host code are shipped, but live IC-705 hardware verification hasn't happened yet (issue #163). See docs/notes/ROADMAP.md's Phase B-Core section for the current status. embedded-poc/m5stack-s3/ is a decoder-only compute-bench crate for S3 timing-regression tracking. See docs/reference/EMBEDDED.md for the integration contract and runtime tuning knobs, and docs/reference/MANUAL_M5STICKS3.md for the M5StickS3 controller's build/flash/UI workflow.