Native digital modes¶
Status: in progress — FT8/FT4 first. Tracking branch ft8-tx-swr-meter (rename TBD).
Goal¶
Move the digital modes off external programs and onto in-repo native code, so the app
is self-contained (no /opt/ft8_lib, jt9, multimon-ng, fldigi, codec2 … dependencies
for the core decode/encode path). Roll out mode-by-mode, starting with FT8/FT4, and run
the native path side-by-side with the existing external path so we can verify parity
(same decodes, same TX audio) before switching over.
What is external today (inventory)¶
Already native (pure Python, in-repo): CW RX, RTTY RX+TX, PSK31/63/125 RX+TX, NAVTEX RX, WEFAX RX, APRS TX.
External binaries we want to replace, in priority order:
| Mode | RX today | TX today | Seam (where the binary is called) |
|---|---|---|---|
| FT8 / FT4 | decode_ft8 (ft8_lib) |
gen_ft8 (ft8_lib) |
RX digimodes.py:_decode_slot (~340); TX manager.py:_ft_encode (~877) |
| JT65/JT9/JT4/Q65/FST4(W) | jt9 |
— | wsjtx.py |
| WSPR | wsprd |
— | wsjtx.py |
| POCSAG | multimon-ng |
— | digimodes.py:_start_multimon |
| APRS RX | multimon-ng |
native | digimodes.py |
| MFSK/Olivia/Contestia/THOR/DominoEX/Hell/THROB | fldigi (XML-RPC + PulseAudio) |
fldigi |
fldigi_bridge.py |
| FreeDV / M17 / P25 voice | codec2 / m17 / dsd-fme | codec2 / m17 | digivoice.py |
| ARDOP | ardopcf |
ardopcf |
ardop.py |
Inherently external (the IC-705 can't receive them; leave as-is): ADS-B (dump1090), AIS (AIS-catcher/UDP), sat image decode (SatDump), DX cluster, propagation, whisper STT.
The FT8 seam is ideal to start: both directions are already file/pipe-boundaried
(WAV in → text out for RX; text in → WAV out for TX) and env-gated
(DECODE_FT8_BIN, GEN_FT8_BIN), so native code drops straight in.
The parallel-deploy constraint (important)¶
We cannot run two full app instances against the same physical IC-705:
- The radio's RS-BA1 network server is single-session — a second login kicks the first,
so two instances ping-pong reconnecting (
control.py:153-157, supervisormanager.py). - The radio-side UDP ports 50001–50003 are hardcoded (
control.py:17-19) and bound locally with onlySO_REUSEADDR, so two instances on one host collide at the socket level.
Therefore "deploy in parallel to the existing instance" against the same radio is not achievable by simply standing up a second head. Instead we get a better comparison:
Comparison architecture — in-process dual decode (chosen)¶
Run both decoders on the same captured slot audio inside the one live instance and tag each result set. Identical input → a fair A/B; shares the single radio session; no ports to move. Selected by a backend env var:
ft8_lib— current behaviour (external binary), unchanged.native— use the in-repo codec only.both— run both per slot; emit decodes taggedsrc: "ft8_lib" | "native", plus a per-slot match summary (in-both / only-ft8_lib / only-native) to the log + UI so we can watch parity live off-air. TX likewise can encode with both and compare symbol streams.
Optional: a truly separate "native" head¶
If a literally separate deployable instance is wanted, the primary app can tee its RX
audio (the 12 kHz decimated slot stream) over UDP/WS to a second, radio-less process that
only runs the native decoder + a minimal UI on another port/vhost. This respects the
single-session constraint (the tee'd head never logs into the radio). Deferred until the
codec is proven in both mode.
Native FT8/FT4 codec — design¶
New package app/radio/ftx/ (ft8_lib calls the shared ft8/ft4 core "ftx"):
ftx/
constants.py # Costas, Gray, LDPC generator/Nm/Mn, CRC-14 params — generated from ft8_lib (MIT)
crc.py # CRC-14 (poly 0x2757)
ldpc.py # LDPC(174,91): encode (generator) + decode (belief propagation / min-sum)
pack.py # message pack77 / unpack77 (type-1 std calls + grid/report, CQ, free text …)
encode.py # text -> 77 bits -> 91 (+CRC) -> 174 (LDPC) -> 58 data symbols -> +Costas -> 79/105 tones
waveform.py # tones -> GFSK-shaped audio (FT8 6.25 baud / FT4 20.83 baud), PCM at 12k/48k
decode.py # audio -> sync search (Costas) -> 8-FSK demod -> LLRs -> LDPC -> CRC -> unpack
codec.py # public API used by the app (see below)
Public API (what the app calls; mirrors the current WAV boundary so integration is a swap):
encode_message(text: str, mode: str, df_hz: float, sample_rate=48000) -> bytes # s16le mono PCM
decode_slot(pcm12k: bytes, mode: str) -> list[dict] # [{snr, dt, df, msg}], same shape as _decode_slot
Protocol constants (FT8): 79 channel symbols = 58 data + 3×7 Costas sync at offsets 0/36/72;
8-FSK; symbol period 0.160 s (6.25 baud, 6.25 Hz tone spacing); 12.64 s signal in a 15 s slot.
FT4: 105 symbols = 87 data + 4×4 Costas + 2 ramp; 4-GFSK; 0.048 s symbols; 4.48 s in 7.5 s.
LDPC(174,91), 14-bit CRC (poly 0x2757). Tables are generated from the vendored ft8_lib
constants.c by scripts/gen_ftx_constants.py (no hand transcription).
Validation strategy¶
- Encoder (deterministic → exact match): for a suite of messages (CQ, grid, report,
R-report, RR73, 73, compound/nonstd calls, free text), assert the native 79-symbol stream
equals gen_ft8's, and the rendered audio matches within a tiny tolerance. Ground truth from
/opt/ft8_lib/gen_ft8on prod. Target: bit-exact symbols. - Decoder (statistical): run native
decode_slotanddecode_ft8on identical captured slots. Track decode-rate parity: native should find ≥ the messages ft8_lib finds. Result: on ft8_lib's own WebSDR test recordings, native produces the identical message set todecode_ft8— 94/94 across 5 recordings, zero native-only, zero ft8_lib-only. Both reach ~73% of the shipped WSJT-X-grade.txtreference; the remainder are weak signals that need multi-pass subtraction + OSD (below), whichdecode_ft8also lacks. So native is a faithful drop-in fordecode_ft8, with headroom to exceed it.
Beyond decode_ft8 (roadmap to WSJT-X sensitivity)¶
Single-pass BP decoding (what decode_ft8 and the current native decoder do) misses signals
buried under stronger ones. To close the gap to WSJT-X's .txt:
1. Multi-pass subtraction — after each pass, re-synthesise decoded signals (freq/time/phase/
amplitude fit) and subtract from the waveform, then re-run the waterfall + decode. Biggest win.
2. OSD (ordered-statistics decoding) — a-posteriori LDPC recovery for codewords BP can't
converge on at low SNR.
These make native better than the binary it replaces.
WSJT-X weak-signal modes (native)¶
Replacing the jt9 / wsprd binaries, one protocol at a time, same
validate-against-the-binary discipline as FT8. Licensing: WSJT-X is GPLv3 and
this project is PolyForm Noncommercial, so these are implemented clean-room from
published protocol specifications, not ported from WSJT-X source (unlike FT8,
which came from the MIT-licensed ft8_lib).
| Mode | Modulation | FEC | Status |
|---|---|---|---|
| WSPR | 4-FSK, 1.4648 baud, 162 sym | K=32 r=1/2 convolutional | encoder ✅ (wsprd 6/6); decoder ✅ works, ~2–3 dB less sensitive than wsprd |
| JT9 | 9-FSK, 85 sym | K=32 conv | encoder ✅ bit-exact vs jt9code + round-trips jt9; decoder ⏳ |
| JT4 | 4-FSK, 206 sym | K=32 conv | encoder ✅ bit-exact vs jt4code (incl. inverted-sync reports); decoder ⏳ |
| JT65 | 63-tone MFSK | Reed–Solomon(63,12)/GF(64) | encoder ✅ bit-exact vs jt65code + round-trips jt9 -6; decoder ⏳ |
| FST4 | 4-GFSK, 160 sym | LDPC(240,101) + CRC24 | encoder ✅ round-trips jt9 -7; decoder ⏳ |
| FST4W | 4-GFSK, 160 sym | LDPC(240,74) + CRC24 | encoder ✅ round-trips jt9 -W; decoder ⏳ |
| Q65 | 65-FSK, 85 sym | QRA65 (65,15)/GF(64) + CRC12 | encoder ✅ bit-exact vs q65code; decoder ⏳ |
All nine native encoders are complete and validated against WSJT-X's own reference tools (gen_ft8, wsprd/wsprcode, jt9code/jt4code/jt65code/q65code, and jt9 -6/-7/-W round-trips).
Decoder status¶
| Mode | Decoder | Validation |
|---|---|---|
| FT8/FT4 | ✅ | identical to decode_ft8 (deployed) |
| WSPR | ✅ | ~-28 dB; wsprd-comparable |
| JT9 | ✅ | round-trip + decodes jt49sim reference |
| JT4 | ✅ | round-trip + decodes jt49sim reference |
| JT65 | ✅ | RS hard-decision; round-trip + decodes jt65sim reference |
| FST4 | ✅ | LDPC(240,101) BP; round-trip + decodes fst4sim reference |
| FST4W | ✅ | LDPC(240,74) BP; round-trip |
| Q65 | ✅ | QRA65 message-passing/GF(64); round-trip + noisy + decodes q65sim reference |
All nine modes are now complete — native encoder AND decoder — each validated bidirectionally against WSJT-X's own reference tools.
Shared decode infra: jtx/fec.py (K=32 stack decoder, reused by JT9/JT4),
jtx/unpack.py (72-bit reverse packing), the FT8-style LDPC belief-propagation
(reused by FST4/FST4W), and a matched-filter demod pattern (sub-bin f0 + fine t0
alignment) shared across JT65/FST4.
Q65 decoder (done). The QRA65 non-binary iterative decoder is ported in
q65.py: qra_extrinsic (message passing on the V=65/C=116 factor graph, WHT
check convolutions, GF(64) weight permutations from q65_code.py) and
qra_mapdecode, fed by a 65-FSK Bessel-metric intrinsic (_bessel_metric,
EbNo=2.8 dB). CRC-12 check + 13-symbol → 77-bit unpack.
Improvement notes (revisit later)¶
- Q65 single-shot decode is solid at moderate SNR; WSJT-X's deep −26 dB sensitivity needs multi-frame averaging + a-priori/deep search (unported).
- Runtime: FST4/FST4W decode is ~7 s / ~26 s per slot in pure Python (fine within the 60/120 s T/R periods, but vectorize the BP + sync search for headroom).
- JT65 uses hard-decision RS; WSJT-X's soft/deep-search reaches a few dB deeper.
- WSPR decode is ~2–3 dB shy of wsprd (calibrated LLR / OSD would close it).
Blocker resolved (project owner's call): the mode-specific FEC/protocol tables
are transcribed as protocol facts from the WSJT-X source and confirmed behaviorally
against the *code/*sim oracles — so FST4/FST4W and Q65 are now unblocked too.
app/radio/jtx/ holds the shared JT4/JT9/JT65 core (72-bit packing, K=32 conv,
interleave). Remaining: JT9/JT4/JT65 decoders (9/4-FSK demod → deinterleave →
reuse the WSPR K=32 stack decoder → unpack), the JT65 RS encoder/decoder, and the
FST4/Q65 LDPC/QRA tables + modems.
Clean-room via the WSJT-X *code / *sim oracles¶
prod ships the reference utilities jt9code jt4code jt65code q65code msk144code
wsprcode (print packed bits / channel symbols for a message) and *sim (generate
reference .wav signals). These are behavioral oracles — observing input→output
of a black box, exactly like validating FT8 against gen_ft8. This keeps the port
clean-room (no GPL source copied) and gives exact reference vectors. wsprcode
independently confirms the WSPR codec (its sync + sync+2·data symbols match ours).
JT9 findings so far (derived from jt9code): 85 channel symbols; FEC is the
same K=32 r=1/2 convolutional code as WSPR (polys 0xF2D05351/0xE4613C47); the 16
sync symbol positions (tone 0) are [0,1,4,9,15,22,32,34,50,51,54,59,65,72,82,84];
the 69 data symbols carry tone = gray(3 bits)+1. Remaining to reverse-engineer from
the oracle: the exact 72-bit source packing (std/prefix/suffix/token, per jt9code -t)
and the 206-bit interleave permutation. JT4 (207 symbols, 4-FSK) and JT65 (RS(63,12))
follow the same oracle method.
Blocker for FST4/FST4W and Q65¶
These use large mode-specific FEC tables — the LDPC(240,101)/(240,74) parity
matrices (FST4) and the QRA65 Q-ary code (Q65) — that are impractical to derive
from a behavioral oracle and have no permissive (MIT/BSD) reference the way FT8 had
ft8_lib. A truly clean-room build is blocked on sourcing those tables. Options to
decide later: (a) treat the numeric FEC tables as facts and transcribe them (accepting
the GPL-provenance nuance vs the project's PolyForm licence), (b) find a permissive
reference, or (c) keep FST4/Q65 on the external jt9 binary. FT8/FT4, WSPR, and the
JT9/JT4/JT65 conv/RS family have clean paths and come first.
app/radio/wsprx/ holds the WSPR codec (constants/encode/codec). The public
encode_pcm(call, grid, dbm) renders a 120 s slot; validated by decoding the
synthesized signal with prod's wsprd across standard/space-prepended/short calls.
HamDRM digital SSTV (native, branch worktree-digital-tv)¶
app/radio/hamdrm/ is a complete native HamDRM (EasyPal-compatible
digital SSTV) implementation — image transfer over a ~2.5 kHz SSB
passband, both directions, no external program:
- Physical layer — amateur DRM (ETSI ES 201 980 narrowed): OFDM modes A/B/E at 12 kHz (Tu 288/256/160, guard Ts−Tu), ham spectrum occupancies SO_0/SO_1 (~2.3/2.5 kHz), scattered pilots via the W/Z/Q phase law + time/frequency references, ham-reduced 48-bit FAC (CRC-8), MSC in 4/16/64-QAM with multilevel coding (K=7 rate-1/4 mother code, the 13 DRM puncturing classes + tail patterns, DRM block bit-interleaver, PRBS energy dispersal, iterative hard-feedback MSD decode), long/short MSC cell interleaving. RX: guard-correlation mode detect + fractional frequency, integer offset from frequency pilots, time-reference frame sync, pilot channel estimation. Loopback decodes bit-perfect across every mode/occupancy/QAM/protection combination, at 10 dB SNR and ±8 Hz offset.
- Data layer — DRM packet mode + DAB-MOT file carousel with the EasyPal dialect: filename-hash TransportID, 98-byte header partitions, header resent every 50 body segments, CRC-16 (X.25) on packets and data groups; RS(255,224/192/160/128) repair containers (.rs1-.rs4, GF 0x11D, erasure decoding, the reference distribute interleaver) and BSR fix-requests (EasyPal + compat formats).
- Constants provenance —
scripts/gen_hamdrm_constants.pyparses both reference lineages in QSSTV (drmrx = RXAMADRM, drmtx = Dream) and cross-asserts duplicated tables; three reference-internal discrepancies are pinned (Mode A SO_0 boost set, Mode B time-pilot phase 944 vs 950, Mode E W/Z dims) with the TX side canonical. See NOTICE.md. - Validation —
test_hamdrm.py(13 tests): FEC/interleaver/dispersal inverses, MLC round-trips at every constellation, FAC round-trip + CRC rejection, full modem matrix, noise+offset, MOT reassembly with missing-segment reporting, RS burst-erasure repair, and a full image transfer through synthesized audio. On-air interop vs a live EasyPal/QSSTV station is the remaining real-world step. - App integration — mode
hamdrm("DSSTV" chip): received images land in recordings/ like SSTV/WEFAX; TX row (image file + QAM + RS choice) drivesmanager.hamdrm_txvia thehamdrm_txwebsocket command.
DATV — DVB-S / DVB-S2 (native, branch worktree-digital-tv)¶
app/radio/datv/ decodes reduced-bandwidth amateur television from the
app's SDR IQ sources (RTL-SDR/Lime, like native AIS) — the IC-705 audio
path cannot carry TV, so this is SDR-only. External ffmpeg (separate
process) renders video frames from the recovered MPEG-TS.
- DVB-S (
dvbs.py, EN 300 421): energy dispersal, RS(204,188) with t=8 Berlekamp-Massey correction, Forney I=12 interleaver, K=7 conv code with all five puncturings, QPSK/RRC. RX searches rotations x conjugation x puncture phase x bit alignment for the TS sync cadence. Interop validated bidirectionally against leansdr: leandvbtx -> native RX decodes 32/32 packets at every code rate; native TX -> leandvb decodes valid TS at every rate. - DVB-S2 (
dvbs2.py, EN 302 307-1, short FECFRAME QPSK — the QO-100 RB-DATV configurations): BBFRAME mode adaptation (CRC-8 sync replacement, BB scrambler), BCH t=12 over GF(2^14) (full Berlekamp-Massey + Chien correction), LDPC (Annex C address tables viascripts/gen_dvbs2_constants.py, numpy min-sum decoder), PL framing (SOF + Reed-Muller PLSC with SOF-coherent decoding, optional pilot blocks, Gold-code scrambling). Loopback decodes 100% of TS packets at every rate, pilots on/off, arbitrary phase. Roadmap: pilot-aided phase tracking for drifting real-world signals, 8PSK, normal frames. - App integration — mode
datv("DATV" chip): DatvReceiver reads IQ (env: DATV_FREQ/DATV_SR/DATV_STANDARD/DATV_RATE), appends TS to recordings/, snapshots frames via ffmpeg every 10 s into the image pane. - Tests —
test_datv.py(9 tests) incl. the leansdr interop stage (skipped when the oracle binaries aren't built).
TETRA downlink (native, branch tetra-decoder)¶
app/radio/tetra/ decodes non-encrypted TETRA (ETSI EN 300 392-2)
downlink signalling from the app's SDR IQ sources (RTL-SDR/Lime, like
native AIS/DATV) — the IC-705 audio path cannot carry a 25 kHz pi/4-DQPSK
carrier, so this is SDR-only and receive-only. Same class as the native
DMR/P25 signalling decoders.
- Physical layer (
dsp.py): pi/4-DQPSK at 18 ksym/s (RRC 0.35), differential demodulation (phase of z[n]·conj(z[n-1]) quantized to +/-pi/4, +/-3pi/4 -> dibits).burst.pylocks the 510-bit timeslot by correlating the sync/normal training sequences at their fixed offsets (bit 214 / 244) and slices each burst into logical-channel blocks. - Channel coding (
coding.py): the 32-bit Fibonacci-LFSR scrambler (BSCH fixed code + cell code from MCC/MNC/colour), block interleaver, the K=5 rate-1/4 mother code (G1..G4) with rate-2/3 puncturing and a vectorized Viterbi (trellis built by simulating the reference encoder), CRC-16 CCITT (residue 0x1D0F), and RM(30,14) for AACH with minimum-distance correction. - MAC (
mac.py): parses the BSCH SYNC PDU (MCC/MNC/colour + TDMA time, which yields the cell scrambling code), the AACH access-assignment, and the BNCH SYSINFO (carrier/frequency reconstruction, location area, and the air-encryption flag — encrypted user planes are flagged and skipped, never decrypted). A streamingReceiveremits sync/aach/sysinfo/ mac events. - Validation —
test_tetra.py(11 tests): conv+Viterbi round-trips per channel, interleaver/CRC/RM inverses, pi/4-DQPSK loopback, full block round-trips, and end-to-end recovery of network identity + SYSINFO through the modem under noise (carrier reconstructed to 445.006250 MHz). The scrambler LFSR and convolutional encoder are validated bit-exact against oracles compiled from osmo-tetra (refs/tetra_oracle/). On-air reception of a real TETRA cell is the remaining real-world step. - App integration — mode
tetra("TETRA" chip): TetraReceiver reads IQ (env: TETRA_FREQ/TETRA_GAIN), surfaces cell network info, live SYSINFO (carrier/LA/clear-or-encrypted) and access assignments in the digi pane. - Scope — clear signalling only (BSCH/AACH/BNCH/SCH). Voice (ACELP) and encrypted traffic are out of scope; the decoder leaves a seam for a future voice codec but attempts no decryption.
JS8 (native, branch js8-native)¶
app/radio/js8/ is a complete native JS8 (JS8Call) implementation — both
layers of the protocol, no external binary:
- Physical layer — the WSJT-X 1.9-era FT8 waveform JS8 forked from:
79 channel symbols (58 data + 3×7 Costas), 8-FSK with no Gray map,
LDPC(174,87) (generator + COLORDER permutation; message rides channel
bits 87..173), CRC-12 (poly 0xC06, boost-augmented over the 75-bit
payload, XOR 42). JS8 uses its own Costas arrays — submode A the original
(4,2,5,6,1,3,0)×3, submodes B/C/E three distinct "symmetrical" arrays — so FT8 and JS8 never cross-decode. All four active submodes: A normal (15 s / 6.25 Hz), B fast (10 s / 10 Hz), C turbo (6 s / 20 Hz), E slow (30 s / 3.125 Hz). Decoder is the ftx pipeline (waterfall → sync search → LLR → BP → CRC); encoder is continuous-phase FSK like the reference modulator. - Message layer — a full port of JS8Call's varicode: the 72-bit payload +
3-bit transmission header (first/last/data flags), six frame types
(heartbeat/CQ, compound callsign, compound directed, directed, huff data,
compressed data), 28-bit callsign packing (+ group calls), 50-bit
alphanumeric callsigns, 15-bit grids, the 32 directed commands (SNR?, MSG,
QUERY, relay
>, ACK/NACK …), CRC-16/CRC-32 checksums on buffered commands, Huffman varicode and JSC (s,c)-dense compression over the 262,144-word dictionary.messages.build_message_frames()chunks chat text into frames exactly likebuildMessageFrames;FrameAssemblerreassembles multi-frame messages on RX and validates checksums. - Constants provenance —
scripts/gen_js8_constants.pyparses the JS8Call reference (refs/js8call_src/, gitignored) intoapp/radio/js8/constants.py+_tables/jsc_words.txt.gz; tables are protocol facts, same policy as the WSJT-X modes (see NOTICE.md). - Validation —
test_js8.py(23 tests): the LDPC generator and parity-check matrices come from different reference files, so the encode→decode round-trip cross-validates both; audio round-trips all four submodes under noise; cross-submode rejection; field/frame/text codec round-trips; multi-frame over-the-air with checksum verification. - Interop vs the reference implementation —
scripts/js8_interop_test.pydrives four oracles compiled from the JS8Call source itself (scripts/js8_oracle/build.sh: the Fortrangenjs8encoder, the full Fortran decode stacksyncjs8/js8dec/BP+OSD, thegenjs8refsigwaveform generator, and the QtCoreVaricode/DecodedTextmessage layer). Results (2026-07-06, macOS arm64): - encoder: 654 frames bit-exact (87-bit codeword input + 79-tone channel symbols) across all 8 i3 headers and both Costas sets;
- message layer: 61 frames identical to
buildMessageFrames(incl. huff-vs-JSC selection, checksums, compound handshakes) and 61 RX renders identical toDecodedText; - audio, both directions: our TX decoded by the reference decoder 84/84, reference waveform decoded by our native decoder 84/84 (4 submodes × 3 audio frequencies × the message corpus);
- sensitivity: both decode 10/10 down to −18 dB (2.5 kHz ref); at −20 dB the reference's multi-pass BP+OSD leads (6/10 vs 3/10) — the same ~1 dB OSD/multi-pass gap as the FT8 native decoder roadmap. Remaining real-world step: an on-air QSO with a live JS8Call station.
- App integration —
app/radio/js8_mode.py(Js8Decoder, WSJT-X-style per-period slot capture),digimodes.py(modesjs8/js8-fast/js8-turbo/js8-slow),manager.js8_tx()(slot-aligned multi-frame TX), websocketjs8_tx, and a JS8 protocol box in the UI (chips share the keyboard terminal; decodes also land in the decode table).
Areas for improvement (revisit later)¶
- WSPR TX uses plain continuous-phase FSK; WSJT-X applies GFSK smoothing for a cleaner transmitted spectrum. Decodes fine, but worth adding for on-air cleanliness.
- WSPR decode sensitivity: the native decoder (FFT-per-symbol demod + stack
sequential decoder) decodes cleanly to ~-28 dB, but
wsprdreaches ~2–3 dB deeper (multi-seed at threshold: native 0/6 where wsprd 5/6). Avenues to close it, in rough order of expected gain: (a) calibrated LLR soft metric from the tone powers instead of the raw power difference; (b) sub-bin / fractional-frequency fit and frequency-drift compensation (matters most on real off-air signals, less on the on-bin synthetic tests); (c) a properly tuned Fano threshold or a deeper stack budget; (d) coherent multi-symbol processing and subtraction of decoded signals (wsprd's "deep search"). The decoder is a correct, faithful baseline to build on. - Runtime cost: the Python weak-signal decoders are CPU-heavy; the long T/R periods (60–120 s) give headroom, but prod real-time load needs measuring.
Rollout¶
- Encoder +
both-mode TX symbol compare → prove parity, then flip TX to native. - Decoder +
both-mode RX compare live off-air → prove decode-rate parity. - Flip
DIGIMODE_BACKEND=nativeon prod once parity holds; keep ft8_lib installed as fallback. - Repeat the pattern for the next mode (WSPR/jt9, then POCSAG/AFSK, then fldigi family).
Provenance¶
Native code is an independent Python port; the protocol constants and algorithms follow
ft8_lib by Karlis Goba YL3JG (MIT) and the WSJT-X specification. app/radio/ftx/constants.py
is generated (and committed) by scripts/gen_ftx_constants.py. To regenerate, clone ft8_lib
into refs/ft8_lib_src/ (gitignored) first: git clone https://github.com/kgoba/ft8_lib
refs/ft8_lib_src then copy ft8/constants.c in — the committed constants are the source of
truth for the running code and are verified bit-for-bit against gen_ft8 by test_ftx.py.