Skip to content

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:

  1. 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, supervisor manager.py).
  2. The radio-side UDP ports 50001–50003 are hardcoded (control.py:17-19) and bound locally with only SO_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:

DIGIMODE_BACKEND = ft8_lib | native | both     (default: ft8_lib)
  • ft8_lib — current behaviour (external binary), unchanged.
  • native — use the in-repo codec only.
  • both — run both per slot; emit decodes tagged src: "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_ft8 on prod. Target: bit-exact symbols.
  • Decoder (statistical): run native decode_slot and decode_ft8 on 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 to decode_ft8 — 94/94 across 5 recordings, zero native-only, zero ft8_lib-only. Both reach ~73% of the shipped WSJT-X-grade .txt reference; the remainder are weak signals that need multi-pass subtraction + OSD (below), which decode_ft8 also lacks. So native is a faithful drop-in for decode_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 provenancescripts/gen_hamdrm_constants.py parses 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.
  • Validationtest_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) drives manager.hamdrm_tx via the hamdrm_tx websocket 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 via scripts/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.
  • Teststest_datv.py (9 tests) incl. the leansdr interop stage (skipped when the oracle binaries aren't built).

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.py locks 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 streaming Receiver emits sync/aach/sysinfo/ mac events.
  • Validationtest_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 like buildMessageFrames; FrameAssembler reassembles multi-frame messages on RX and validates checksums.
  • Constants provenancescripts/gen_js8_constants.py parses the JS8Call reference (refs/js8call_src/, gitignored) into app/radio/js8/constants.py + _tables/jsc_words.txt.gz; tables are protocol facts, same policy as the WSJT-X modes (see NOTICE.md).
  • Validationtest_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 implementationscripts/js8_interop_test.py drives four oracles compiled from the JS8Call source itself (scripts/js8_oracle/build.sh: the Fortran genjs8 encoder, the full Fortran decode stack syncjs8/js8dec/BP+OSD, the genjs8refsig waveform generator, and the QtCore Varicode/DecodedText message 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 to DecodedText;
  • 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 integrationapp/radio/js8_mode.py (Js8Decoder, WSJT-X-style per-period slot capture), digimodes.py (modes js8/js8-fast/ js8-turbo/js8-slow), manager.js8_tx() (slot-aligned multi-frame TX), websocket js8_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 wsprd reaches ~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

  1. Encoder + both-mode TX symbol compare → prove parity, then flip TX to native.
  2. Decoder + both-mode RX compare live off-air → prove decode-rate parity.
  3. Flip DIGIMODE_BACKEND=native on prod once parity holds; keep ft8_lib installed as fallback.
  4. 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.