1 unstable release
| new 0.5.0 | Mar 28, 2026 |
|---|
#465 in Encoding
Used in 4 crates
120KB
2.5K
SLoC
wsjtr
wsjtr is a standalone FT8 decoding library written in Rust. Its algorithms are derived from the reference implementation in WSJT-X by Joe Taylor (K1JT) and the WSJT Development Group, which is licensed under the GNU General Public License v3.0. This project is likewise licensed under GPLv3.
wsjtr can be used as:
- The decoding engine for FT-Activ8, a mobile FT4/FT8 app for Android
- A supplemental decoder for WSJT-X, injecting additional decodes that WSJT-X may have missed
- A tool for exploring how modifications to the FT4/FT8 decoding process affect speed, computational efficiency, and decoding performance.
- A library in your own amateur radio projects.
This project is written and maintained by Brian Bodiya (KC1WIH).
Usage
Run wsjtr-supplement (TUI supplemental decoder)
A Windows/Linux terminal UI (ratatui) application that runs wsjtr alongside WSJT-X and displays supplemental decodes — messages found by wsjtr but not WSJT-X. Rust replacement for supplement_wsjtx.py. Validates unique-to-wsjtr callsigns via QRZ, highlights invalid ones, and can optionally inject supplemental decodes back into WSJT-X.
target/release/wsjtr-supplement
Options:
--config <path>: path to config file (default:~/.config/wsjtr-supplement/config.toml)
Configuration (TOML) includes: operator callsign, QRZ credentials, wsjtr binary path and arguments, WSJT-X UDP address/port, early decode delay, and decode injection toggle. Settings can also be edited in-app.
WSJT-X must be configured to send UDP messages (Settings → Reporting → UDP Server). If multicast is not used only one application will be able to connect to WSJT-X at a time.
Run supplement_wsjtx.py (WSJT-X supplemental decoder)
A Linux-only PyQt5 GUI that runs wsjtr alongside WSJT-X and displays supplemental decodes — messages found by wsjtr but not WSJT-X. Validates unique-to-wsjtr callsigns via QRZ and highlights invalid ones. This script was the predecessor to wsjtr-supplement.
python3 supplement_wsjtx.py
WSJT-X must be configured to send UDP messages (Settings → Reporting → UDP Server). If multicast is not used only one application will be able to connect to WSJT-X at a time.
wsjtr (multi-pass)
Decode an existing WAV using multi-pass subtraction (per-pass WAVs go into wsjtr-wav/ by default):
target/release/wsjtr --wav ../stored-runs/10m_220315/10m_220315.wav --passes 4 --depths 1,2,3,3 --keep-wav
Decode multiple WAVs in sequence (enables cross-sequence A7 decoding using callsigns from prior windows):
target/release/wsjtr --wav file1.wav --wav file2.wav --wav file3.wav -p 3 -d 1,1,3
Live decode from audio input:
target/release/wsjtr --audio-device <device_name> --passes 4 --depths 1,2,3,3 --keep-wav
Audio backends
wsjtr has two audio capture backends:
| Backend | Platforms | Default on | --audio-device format |
|---|---|---|---|
| parec | Linux (PulseAudio/PipeWire) | Linux | PulseAudio source name |
| miniaudio | Linux, Windows, macOS | Windows, macOS | Device name substring match |
On Linux, parec is the default because PipeWire/PulseAudio manages audio devices and can route specific sources. Use --miniaudio to force the miniaudio backend on Linux. On Windows, miniaudio uses the WinMM backend which sees USB Audio Class 1.0 devices (e.g., Icom IC-7300) that WASAPI may not enumerate.
Finding your audio device (Linux — parec, default)
The --audio-device flag takes a PulseAudio source name. To list available sources:
pactl list sources short
This prints lines like:
48 alsa_output.pci-0000_00_1f.3.analog-stereo.monitor PipeWire s32le 2ch 48000Hz IDLE
49 alsa_input.pci-0000_00_1f.3.analog-stereo PipeWire s32le 2ch 48000Hz SUSPENDED
52 alsa_input.usb-Burr-Brown_from_TI_USB_Audio_CODEC-00.analog-stereo PipeWire s16le 2ch 48000Hz RUNNING
The second column is the source name to pass to --audio-device. Choose the source corresponding to your radio's USB audio interface — typically an alsa_input.usb-* entry. Sources named *.monitor are loopback monitors of output sinks (speakers), not physical inputs.
If your radio is connected via a USB sound card (e.g., the SignaLink or an Icom's built-in USB audio), look for the USB device name in the source list. You can also use pactl list sources (without short) for more detail including the device.description field, which shows a human-readable name.
To verify you have the right source, record a few seconds and check for audio:
parec --device=alsa_input.usb-Burr-Brown_from_TI_USB_Audio_CODEC-00.analog-stereo \
--format=s16le --rate=12000 --channels=1 --file-format=wav test_capture.wav
# Ctrl-C after a few seconds, then play it back or inspect in Audacity
Finding your audio device (Windows/macOS — miniaudio)
List available input devices:
target/release/wsjtr --list-devices
Pass a substring of the device name to --audio-device:
target/release/wsjtr --audio-device "USB Audio" -p 3
If only one capture device is connected (e.g., your radio's USB audio), it will be auto-selected without needing --audio-device.
Disable cross-sequence decoding (A7 is enabled by default):
target/release/wsjtr --audio-device <device_name> -p 3 --no-a7
Decode diversity — vary subtraction order for better weak-signal recovery:
target/release/wsjtr --wav file.wav -p 1 -d 3 -c 2000 -m 100 --no-early-exit \
--subtraction-order snr_asc
Run multiple diversity trials (unions results from N independent decode runs with varied parameters):
target/release/wsjtr --wav file.wav -p 1 -d 3 -c 2000 -m 100 --no-early-exit \
--diversity 4 --diversity-strategy candidate-sort --subtraction-order snr_asc
If you want to experiment with subtraction timing (debug/compat), tweak the offset applied to decoded DT:
target/release/wsjtr --wav ../stored-runs/10m_220315/10m_220315.wav --passes 10 --depths 3 --no-early-exit --subtract-dt-offset 0
ft8coder
Encode a message to 79 channel symbols:
target/release/ft8coder "CQ KC1WIH FN42"
gen_ft8wav
Generate a WAV file from an FT8 message using gen_ft8wav (built as part of the jt9r crate):
target/release/gen_ft8wav "CQ KC1WIH FN42" -f 1500 --pad -o cq_KC1WIH.wav
Options:
-f, --freq <Hz>: audio frequency offset (default: 1000)-r, --sample-rate <Hz>: output sample rate (default: 12000)-o, --output <path>: output WAV file (default: ft8_tx.wav)-p, --pad: zero-pad to 15 seconds (required for decoding with jt9r/wsjtr)
Roundtrip example — encode a message to WAV, then decode it back:
target/release/gen_ft8wav "CQ KC1WIH FN42" -f 1500 --pad -o test.wav
target/release/jt9r test.wav
jt9r
Decode a WAV file (FT8):
target/release/jt9r ../stored-runs/10m_220315/10m_220315.wav
Notes:
- Output format matches
jt9style:HHMMSS SNR DT FREQ ~ MESSAGE SNRis a WSJT-X-style dB estimate (computed from decoded tones + spectrum baseline, similar tojt9 --ft8).- Internals are modeled after WSJT-X FT8 decoding (downsample + fine sync + soft metrics + hybrid LDPC BP/OSD + subtraction between passes).
- The WSJT-X-style subtraction passes make jt9r heavier than the earlier single-pass scaffold; in
--releaseit's typically comparable to/usr/bin/jt9on the stored WAVs.
Useful flags:
-d, --depth {1,2,3}: controls how aggressively jt9r searches/decodes.1: BP-only LDPC; stricter sync-quality gate; 2 internal subtraction passes2: enables OSD fallback; 3 internal subtraction passes3: enables OSD fallback plus CQ/MyCall a-priori attempts; 3 internal subtraction passes
-s, --sync-min <float>: candidate sync threshold (used duringsync8-style candidate selection).-c, --max-candidates <N>/-m, --max-decodes <N>: bounds on work and output.-i, --iterations <N>: LDPC BP iterations.-f/--freq-minand-F/--freq-max: decoded audio passband (defaults cover the same general range asjt9 --ft8).--my-call <callsign>: operator callsign for AP mode 2 (MyCall) decoding at depth=3.-v: verbose timings/counters.
force_decode
Debug tool that bypasses the normal candidate search and forces FT8 decoding at specific frequency and time-offset coordinates. Useful for diagnosing why the sync finder missed a signal, or for testing decoder sensitivity at known signal locations.
target/release/force_decode ../stored-runs/10m_144115/10m_144130_final.wav \
--cand 2232,-0.7
Options:
--cand <freq_hz,dt_s>: frequency (Hz) and time offset (seconds) to decode at (repeatable for multiple candidates)-d, --depth <1-3>: decoding depth (default: 3)-i, --iterations <N>: max LDPC BP iterations (default: 30)
Development
Layout
wsjtr/
Cargo.toml
supplement_wsjtx.py # WSJT-X supplemental decoder UI
docs/
ft8coder.md # ft8core/ft8coder implementation reference
jt9r.md # jt9r decoder implementation reference
wsjtr.md # wsjtr multi-pass decoder reference
cross_sequence_decoding.md # A7 cross-sequence decoding design
decoding-experiments/
wsjtr_wsjtx_capture.py # Capture wsjtr vs WSJT-X for comparison
compare_wsjtr_wsjtx_capture.py # Analyze captured comparison data
crates/
ft8core/
src/
ft8coder/
src/
tests/
jt9r/
src/
wsjtr/
src/
wsjtr-supplement/
src/
ft8-engine/
src/
Prerequisites
- Rust toolchain (cargo + rustc)
libasound2-dev(Linux only, required by the miniaudio audio backend)/usr/bin/ft8code(for end-to-end encoder comparisons)/usr/bin/jt9(optional, for jt9r output comparisons)
If you just installed Rust using rustup, make sure your shell environment is updated:
source ~/.cargo/env
Build
From this directory:
cargo build --release
Cross-compilation (Windows)
Build Windows executables from Linux using the MinGW cross-compiler:
# One-time setup
sudo apt install gcc-mingw-w64-x86-64
rustup target add x86_64-pc-windows-gnu
# Build
cargo build --release --target x86_64-pc-windows-gnu
Output binaries:
target/x86_64-pc-windows-gnu/release/force_decode.exe
target/x86_64-pc-windows-gnu/release/ft8coder.exe
target/x86_64-pc-windows-gnu/release/gen_ft8wav.exe
target/x86_64-pc-windows-gnu/release/jt9r.exe
target/x86_64-pc-windows-gnu/release/wsjtr.exe
target/x86_64-pc-windows-gnu/release/wsjtr-supplement.exe
Note: On Windows, wsjtr uses the miniaudio backend (WinMM) for live capture. Use --list-devices to find available input devices, or --wav for WAV file decoding.
Experimentation
Capture wsjtr vs WSJT-X UDP decodes
To run wsjtr and record WSJT-X decodes via its UDP interface at the same time, use:
python3 decoding-experiments/wsjtr_wsjtx_capture.py --windows 10 --audio-device <device_name>
This writes a run folder under stored-runs/ containing:
wsjtrstdout/stderr and parsed decodes- WSJT-X UDP messages (including decodes)
wsjtrper-window/per-pass WAV snapshots
WSJT-X must be configured to send UDP messages to the capture tool (Settings → Reporting → UDP Server).
Benchmarks
Quick timing comparison on one WAV (prints times to stderr):
cargo build -p jt9r --release
/usr/bin/time -p target/release/jt9r ../stored-runs/10m_220315/10m_220315.wav >/dev/null
/usr/bin/time -p /usr/bin/jt9 --ft8 ../stored-runs/10m_220315/10m_220315.wav >/dev/null
Tests
Run all tests:
cargo test
End-to-end tests compare ft8coder output with /usr/bin/ft8code. If /usr/bin/ft8code is missing, those tests are skipped.
For jt9r comparison against /usr/bin/jt9:
cargo test -p jt9r --test jt9_compare
Notes
ft8coreimplements the encoder pipeline: pack77, CRC-14, LDPC (174,91), Gray mapping, Costas sync insertion, and runtime callsign hash table.jt9ruses WSJT-X-style FT8 pieces:sync8-style coarse candidate search (time/frequency grid)sync8d-style coherent fine sync at 200 Hz complex basebandft8b-style soft metrics (nsym=1/2/3) feeding the LDPC decodersubtractft8-style waveform subtraction between internal passesft8_a7-style cross-sequence decoding using callsigns from prior windows
wsjtrmaintains a callsign hash table across windows, resolving non-standard callsigns (displayed as<CALL>) that were previously seen in unhashed form.- The design docs (
docs/ft8coder.md,docs/jt9r.md,docs/wsjtr.md) describe intended architecture and planned work.
Architectural Differences from WSJT-X
wsjtr is a from-scratch Rust reimplementation of the FT8 decoding pipeline. While the core DSP algorithms (sync detection, downsampling, soft metrics, LDPC decoding, signal subtraction) are faithful ports of WSJT-X's Fortran routines, the system architecture differs in several significant ways. The WSJT-X reference source used for comparison is 3.0.0-rc1.
Process model
WSJT-X uses a two-process architecture: a Qt GUI that captures audio and manages state, and a separate jt9 process that does the actual decoding. They communicate through Qt shared memory — a large structure (dec_data) containing raw audio samples (180k int16), symbol spectra, and ~90 decoding parameters. The GUI writes audio and sets ipc(2)=1; jt9 polls for this flag, decodes, and clears it. This IPC design dates back to when multiple mode decoders (JT9, JT65, FT8, etc.) all lived in the same jt9 binary.
wsjtr is a single binary. In live mode it captures audio on a background thread — using either vendored miniaudio (cross-platform via WinMM/CoreAudio/ALSA, default on Windows/macOS) or a parec subprocess (PulseAudio/PipeWire, default on Linux) — while the main thread runs the decode loop. There is no shared memory — decoded results are printed directly to stdout. The jt9r crate exposes a library API (Decoder::decode() / Decoder::decode_single_pass()) that wsjtr calls directly, so multi-pass orchestration is just Rust function calls rather than IPC.
Multi-pass subtraction architecture
Both systems use multi-pass decoding with signal subtraction, but the orchestration differs:
WSJT-X runs a single-level loop inside ft8_decode.f90. For each of 1–3 passes, it calls sync8() to find candidates, then iterates over them calling ft8b() to decode. Each successful decode immediately triggers subtractft8() to remove that signal from the shared audio buffer dd. The next candidate in the same pass sees the already-modified audio. Between passes, the sync threshold is lowered (2.1 → 1.3) to pick up weaker signals revealed by subtraction.
wsjtr has a two-level structure:
- Inner passes (inside
jt9r'sDecoder::decode()): 2–3 internal passes with subtraction, similar to WSJT-X. This is what you get when runningjt9rstandalone. - Outer passes (inside
wsjtr's main loop): Each outer pass re-applies all previously decoded signals' subtractions from the original audio, then runsjt9r::decode_single_pass()on the residual. This "subtract from scratch" approach avoids accumulating numerical artifacts from sequential subtraction.
The outer passes also support per-pass depth configuration (--depths 1,2,3,3), so early passes can use fast BP-only decoding while later passes enable OSD and AP modes on the already-cleaned residual.
Subtraction algorithm
The core subtraction math is the same — both implement the conjugate-multiply, low-pass filter, reconstruct, subtract approach from the subtractft8.f90 algorithm:
camp(i) = received(i) × conj(reference(i)) # demodulate
cfilt = LPF(camp) # extract amplitude envelope
dd(i) -= 2 × Re(cfilt(i) × reference(i)) # subtract reconstructed signal
Where they differ:
- DT refinement: wsjtr optionally searches multiple time offsets around the decoded DT (controlled by
--subtract-dt-rangeand--subtract-dt-steps), evaluates subtraction quality at each, and uses parabolic interpolation to find the optimal sub-sample offset. This compensates for the ±2.5 ms quantization in the decoded DT. WSJT-X has a simplerlrefinedtoption that searches ±90 samples but without the parabolic interpolation or quality-based skip logic. - Skip logic: If wsjtr's DT refinement can't find a clear minimum, it skips the subtraction entirely to avoid corrupting the audio. WSJT-X always subtracts.
- SNR filtering: wsjtr supports
--subtract-min-snrto skip subtraction of very weak signals whose decode parameters may be unreliable.
Early decoding
WSJT-X can attempt decoding before the full 15-second T/R period ends. The jt9 program triggers multimode_decoder() at three symbol counts — 41, 47, and 50 symbols (roughly 9.6s, 11.0s, 11.7s into the period). Earlier passes zero-pad the remaining audio. This lets strong signals appear in the GUI up to 5 seconds before the period ends.
wsjtr implements the same concept at the outer-pass level: configurable per-pass durations (--durations) default to 9.6s, 11.0s, and 11.7s. In live mode it waits for sufficient audio before each pass; in offline mode it truncates/zero-pads the WAV accordingly. The difference is that wsjtr's early decoding is part of the multi-pass loop rather than multiple invocations from the GUI.
Cross-sequence (A7) decoding
wsjtr implements cross-sequence decoding where callsigns decoded in one T/R sequence are used as a priori information in the next. After all passes complete for a window, wsjtr generates candidate messages from previously-seen callsigns (e.g., if KC1WIH was decoded calling CQ in the even sequence, try "KC1WIH W2XYZ -12" combinations in the odd sequence). These candidates are correlated against the residual audio using a distance metric with acceptance thresholds.
WSJT-X 3.0-rc1 has an ft8_a7 module but its integration is more limited — it operates within the existing AP framework rather than as a separate post-decode phase.
Concurrency
WSJT-X 3.0 uses OpenMP to split the decode frequency band across up to 12 threads — each thread runs the full multi-pass decode pipeline on its slice of the spectrum independently. The number of threads is auto-calculated from CPU core count (or manually set via --MTft8-threads). Within each thread, candidate decoding and subtraction are sequential. FFTs use single-threaded FFTW despite initializing thread support.
wsjtr parallelizes differently: rayon is used for data parallelism within the pipeline — spectrogram computation, candidate sync evaluation, and diversity trials all run across cores. The decode loop itself (candidate → ft8b → LDPC → subtract) is sequential since subtraction is order-dependent, but the expensive FFT and correlation work leading up to it is parallelized. wsjtr does not split by frequency band.
Diversity decoding
wsjtr supports running multiple independent decode trials with varied parameters (--diversity N --diversity-strategy candidate-sort), then taking the union of all results. Different trials may use different candidate sort orders or subtraction orderings (--subtraction-order snr_asc), which can recover signals that are masked by a particular subtraction sequence. This is unique to wsjtr — WSJT-X has no equivalent.
What wsjtr does not implement (yet)
- AP strategies 3–6: WSJT-X supports six a priori decoding types, selected based on QSO progress (which Tx message is active). Types 1 (CQ) and 2 (MyCall) constrain 32 of 77 bits. Types 3–6 require knowing both callsigns and constrain 61–77 bits: type 3 fixes MyCall+DxCall (61 bits), types 4/5/6 fix the entire message to MyCall+DxCall+RRR/73/RR73 respectively. wsjtr only implements types 1 and 2, since types 3–6 require QSO state tracking from a GUI or sequencer.
- Contest modes: WSJT-X has specialized HOUND/FOX (ncontest=7) and contest-specific AP modes (Field Day, RTTY, etc.). wsjtr has no contest support.
- Multi-cycle decoding: WSJT-X 3.0 adds
nft8cycles(1–3), where each cycle is a full 3-pass decode+subtract sequence — so up to 9 total passes. wsjtr's outer passes achieve a similar effect but without the cycle abstraction. - Other protocols: WSJT-X decodes JT9, JT65, JT4, Q65, FST4, MSK144, and WSPR. wsjtr only handles FT4/FT8.
- Waterfall / GUI integration: WSJT-X computes and shares symbol spectra (
ssarray) for the waterfall display. wsjtr has no GUI (thoughsupplement_wsjtx.pyprovides a supplemental decode display alongside WSJT-X).