FT8 & FT4: a native LDPC-coded weak-signal digimode
A self-contained, dependency-free reimplementation of the WSJT-X FT8 and FT4 protocols — GFSK modulation, Costas synchronisation, a systematic (174, 91) LDPC code and a 14-bit CRC — that is byte- and symbol-exact against the reference ft8_lib and drops into the app where the gen_ft8 / decode_ft8 binaries used to live.
Rafe project · app/radio/ftx/ · pure NumPy + SciPy erf at runtime
Abstract
FT8 and FT4 are the two most-used weak-signal digital modes on the amateur HF bands. Both carry a fixed 77-bit structured message, protect it with a 14-bit CRC and a rate-≈½ LDPC(174, 91) block code, and paint the 174 coded bits onto a narrow continuous-phase FSK carrier synchronised by Costas arrays: FT8 uses 8-GFSK in 79 symbols over a 15 s slot, FT4 uses 4-GFSK in 105 symbols over a 7.5 s slot. This document specifies the native in-repo codec (app/radio/ftx/), an independent Python port that follows Karlis Goba's MIT-licensed ft8_lib and the published WSJT-X protocol. Every protocol table — the Costas patterns, Gray maps, the LDPC generator and its Tanner-graph adjacency lists, the CRC polynomial and the FT4 whitening sequence — is generated verbatim from the vendored ft8_lib constants.c by scripts/gen_ftx_constants.py, so the tables are provably identical to the reference rather than hand-transcribed. The encoder is bit-for-bit identical to gen_ft8 on a suite of message types (payload bytes and channel tones); the decoder — a Hann-windowed STFT waterfall, a Costas sync search, max-log soft symbol metrics, sum-product LDPC belief propagation and a CRC gate — produces the identical message set to decode_ft8 (94/94 across five WebSDR recordings). An optional multi-pass coherent-subtraction stage re-synthesises and cancels each decode to expose weaker signals underneath. The result is a faithful, licensable (no GPL WSJT-X source, no external binary) FT8/FT4 engine that runs on the prod box in a few hundred lines of NumPy.
1. Background
1.1 Why FT8/FT4 look the way they do
FT8 (2017) and FT4 (2019), designed by Steve Franke K9AN, Bill Somerville G4WJS and Joe Taylor K1JT, are built for decoding signals well below the noise floor (FT8 to about −21 dB in 2500 Hz, FT4 a few dB less sensitive but twice as fast for contesting). Three ideas make that possible and dictate every number in this document:
- A fixed, tiny payload. Every transmission is exactly 77 bits. There is no variable-length framing to synchronise; the decoder always knows the frame is 174 coded bits long. The 77 bits are a highly-structured source code for the handful of things two stations say to each other — callsigns, grid squares, signal reports,
RRR/RR73/73, plus a free-text escape hatch. That packing is a codec in its own right and is documented separately in wsjtx-message-packing.md; this document treats the 77-bit payload as a 10-byte black box on the way in and out. - A strong modern block code. The 77 bits are extended with a 14-bit CRC to 91 bits and encoded by a systematic LDPC(174, 91) code — rate 0.523. LDPC with iterative soft-decision belief propagation is within ~1 dB of the Shannon limit, and the fixed block length lets the decoder run a fixed number of iterations.
- Coherent, spectrally-frugal modulation with self-contained sync. The coded bits are grouped 3-at-a-time (FT8) or 2-at-a-time (FT4) into FSK tones, Gray-mapped so a one-tone slip corrupts only one bit, and transmitted as Gaussian-shaped continuous-phase FSK (GFSK) so the occupied bandwidth is only ~50 Hz (FT8) or ~83 Hz (FT4). Synchronisation is not a preamble but Costas arrays interleaved with the data — 7×7 for FT8, 4×4 (four different arrays) for FT4 — which give a sharp two-dimensional (time × frequency) autocorrelation the receiver slides across a spectrogram to find each signal's start time and audio frequency simultaneously.
1.2 The reference and the port
The canonical open implementation is ft8_lib (Karlis Goba YL3JG, MIT licence), itself a clean-room follow of the WSJT-X design. Rafe vendors its source under refs/ft8_lib_src/ and ports it to NumPy under app/radio/ftx/. The MIT licence is what makes FT8/FT4 special among Rafe's native digimodes: the tables and algorithms could be derived directly from ft8_lib, whereas the GPLv3 WSJT-X modes (WSPR, JT65, FST4, …) had to be rebuilt clean-room from published specs. Because the reference is permissive, this port is validated against it as an oracle at the byte and symbol level (§7).
2. The codec chain
ENCODE (codec.encode_pcm / encode.py)
text ─▶ pack77 ─▶ 77-bit payload (10 bytes)
│ (FT4: XOR with whitening sequence first)
▼
add_crc ─▶ 91-bit message = 77 payload + 14 CRC (12 bytes)
▼
encode174 (LDPC) ─▶ 174-bit codeword = 91 systematic + 83 parity
▼
group 3 (FT8) / 2 (FT4) bits ─▶ Gray map ─▶ data tones
interleave Costas sync arrays ─▶ 79 (FT8) / 105 (FT4) channel tones
▼
GFSK phase-shaped synthesis ─▶ centred in 15 s / 7.5 s slot ─▶ PCM
DECODE (codec.decode_slot / decode.py)
PCM ─▶ Hann-STFT waterfall (2× time, 2× freq oversampled, dB, quantised)
─▶ Costas sync search ─▶ candidate (time, freq) list, scored
─▶ per-candidate max-log soft bit metrics (LLRs) ─▶ variance-normalise
─▶ LDPC sum-product belief propagation (≤25 iters) ─▶ 174 → 91 bits
─▶ CRC-14 gate ─▶ (FT4: un-XOR) ─▶ 77-bit payload ─▶ unpack77 ─▶ text
─▶ [optional] re-synthesise & coherently subtract; repeat for weaker signals
The public surface is app/radio/ftx/codec.py: encode_pcm(text, mode, df_hz, sample_rate) -> s16le PCM bytes and decode_slot(pcm, mode, sample_rate, passes) -> [{snr, dt, df, msg, score}] (codec.py, re-exporting decode.decode_slot). encode_tones(text, mode) exposes the payload bytes and channel-symbol list for validation (codec.py).
3. Signal, frame and symbol structure
All numbers below are the literal constants in app/radio/ftx/constants.py, which are generated from ft8_lib's constants.h/constants.c.
3.1 Common parameters
| Quantity | Symbol | Value | Source |
|---|---|---|---|
| Codeword length | LDPC_N |
174 bits | constants.py |
| Message length | LDPC_K |
91 bits | constants.py |
| Parity checks | LDPC_M |
83 | constants.py |
| Payload | — | 77 bits (10 bytes, low 3 bits of byte 9 padding) | pack.py |
| CRC | CRC_WIDTH / CRC_POLY |
14 bits, polynomial 0x2757 |
constants.py |
| GFSK shaping constant | GFSK_CONST_K |
5.336446 = π·√(2/ln 2) |
constants.py |
| Modulation index | h | 1 (continuous phase; peak deviation = one tone spacing) | encode.py |
3.2 FT8
| Quantity | Value | Notes / source |
|---|---|---|
Channel symbols FT8_NN |
79 | 58 data + 3×7 Costas sync (constants.py) |
Data symbols FT8_ND |
58 | 58 × 3 bits = 174 coded bits (constants.py) |
| Alphabet | 8-GFSK (tones 0–7, 3 bits/symbol) | encode.py |
Symbol period FT8_SYMBOL_PERIOD |
0.160 s → 6.25 baud | constants.py |
| Tone spacing | 1/0.160 = 6.25 Hz | = 1/symbol period |
| Occupied bandwidth | ≈ 8 × 6.25 = 50 Hz | |
Slot FT8_SLOT_TIME |
15.0 s | constants.py |
| Signal duration | 79 × 0.160 = 12.64 s (centred in slot) | encode.py |
GFSK bandwidth-time FT8_SYMBOL_BT |
2.0 | constants.py |
| Costas array | (3, 1, 4, 0, 6, 5, 2), 7×7 |
constants.py |
| Sync placement | symbol indices 0–6, 36–42, 72–78 | encode.py |
Gray map FT8_GRAY |
(0, 1, 3, 2, 5, 6, 4, 7) |
constants.py |
The 79-symbol frame layout (S = one Costas symbol, D = one data symbol):
index: 0 7 36 43 72 79
|SSSSSSS|DDDDDDDDDDDDDDDDDDDDDDDDDDDDD|SSSSSSS|DDDDDDDDDDDDDDDDDDDDDDDDDDDDD|SSSSSSS|
7 sync 29 data (kk = 0..28) 7 sync 29 data (kk = 29..57) 7 sync
Data symbol kk (0..57) lives at channel index kk + 7 for kk < 29 and kk + 14 for kk ≥ 29 (decode.py, encode.py implicit via the range tests). Each of the three Costas groups transmits the identical pattern 3 1 4 0 6 5 2.
3.3 FT4
| Quantity | Value | Notes / source |
|---|---|---|
Channel symbols FT4_NN |
105 | 87 data + 4×4 Costas + 2 ramp (constants.py) |
Data symbols FT4_ND |
87 | 87 × 2 bits = 174 coded bits (constants.py) |
| Alphabet | 4-GFSK (tones 0–3, 2 bits/symbol) | encode.py |
Symbol period FT4_SYMBOL_PERIOD |
0.048 s → 20.833 baud | constants.py (= WSJT-X NSPS=576) |
| Tone spacing | 1/0.048 = 20.833 Hz | |
| Occupied bandwidth | ≈ 4 × 20.833 = 83 Hz | |
Slot FT4_SLOT_TIME |
7.5 s | constants.py |
| Signal duration | 105 × 0.048 = 5.04 s (centred in slot) | encode.py |
GFSK bandwidth-time FT4_SYMBOL_BT |
1.0 | constants.py |
| Costas arrays | four different 4×4 (below) | constants.py |
| Sync placement | symbol indices 1–4, 34–37, 67–70, 100–103 | encode.py |
| Ramp symbols | indices 0 and 104, tone 0 | encode.py |
Gray map FT4_GRAY |
(0, 1, 3, 2) |
constants.py |
| Whitening | FT4_XOR (10 bytes, below) applied to payload |
constants.py, pack.py |
The four FT4 Costas arrays (constants.py) — each group uses a different one, which additionally identifies the group and resolves the frame's time ambiguity:
S0 = (0, 1, 3, 2) S1 = (1, 0, 2, 3) S2 = (2, 3, 1, 0) S3 = (3, 2, 0, 1)
The 105-symbol frame layout (R = ramp, S = Costas from the indicated array, D = data):
idx: 0 1 5 34 38 67 71 100 104
R|S0S0S0S0|DDD…(29)…DDD|S1S1S1S1|DDD…(29)…DDD|S2S2S2S2|DDD…(29)…DDD|S3S3S3S3|R
1 4-sync 29 data 4-sync 29 data 4-sync 29 data 4-sync 1
Data symbol kk (0..86) lives at channel index kk + 5 for kk < 29, kk + 9 for 29 ≤ kk < 58, kk + 13 for kk ≥ 58 (decode.py). The two ramp symbols carry tone 0 and exist only to give the GFSK phase a defined start/finish; they carry no data.
FT4 whitening. Before CRC/LDPC, FT4 XORs the 10 payload bytes with
FT4_XOR = (74, 94, 137, 180, 176, 138, 121, 85, 190, 40)
= (0x4A,0x5E,0x89,0xB4,0xB0,0x8A,0x79,0x55,0xBE,0x28)
(constants.py). The final byte 0x28 = 00101 000 whitens only the top 5 payload bits of byte 9 and leaves the low 3 (CRC/pad region) untouched. This pseudo-random mask ensures FT4 and FT8 never produce the same codeword for the same message, so a decoder locked to the wrong mode fails its CRC instead of mis-decoding.
3.4 The GFSK waveform (both modes)
The channel symbols (integers 0..N−1) are rendered to audio by encode.synth_gfsk (encode.py). Modulation is continuous-phase FSK with a Gaussian-shaped frequency pulse (GFSK), modulation index h = 1:
Samples per symbol
n_spsym = round(signal_rate · symbol_period). At 12 kHz: FT8 = 1920, FT4 = 576. At the codec default 48 kHz: FT8 = 7680, FT4 = 2304.Peak phase step
dphi_peak = 2π / n_spsym(encode.py) — the per-sample phase advance for a frequency offset of exactly one tone spacing. Tone s thus adds s tone-spacings of instantaneous frequency.Frequency pulse
_gfsk_pulse(encode.py) is a Gaussian-filtered rectangular pulse of unit-symbol width spanning 3 symbols:t = i / n_spsym − 1.5 i = 0 … 3·n_spsym−1 (t spans −1.5 … +1.5) k = GFSK_CONST_K · symbol_bt GFSK_CONST_K = π·√(2/ln 2) = 5.336446 pulse(t) = ½·( erf(k·(t + ½)) − erf(k·(t − ½)) )symbol_btis 2.0 (FT8) / 1.0 (FT4); a larger BT means a sharper, less-filtered pulse.Phase assembly. An instantaneous-frequency array is seeded everywhere with the carrier step
2π·f0/signal_rate; for each symbol s the termdphi_peak · s · pulseis added into a 3-symbol window centred on that symbol (encode.py). Two dummy lead/trail symbols equal to the first/last data symbol extend the pulse tails (encode.py). The centraln_wave-sample segment is cumulatively summed to phase, andsignal = sin(phase)(encode.py).Edge ramp. A raised-cosine envelope of
n_spsym // 8samples tapers the first and last samples to avoid a spectral splatter click (encode.py).
synth_slot (encode.py) then centres this GFSK burst in the slot with equal silence either side: num_silence = (slot·rate − N·period·rate) / 2. For FT8 at 12 kHz that is 14160 samples of silence, 151680 of signal, 14160 of silence = 180000 (15 s); for FT4, 14760 + 60480 + 14760 = 90000 (7.5 s). encode_pcm rounds and clips to s16le mono PCM (codec.py).
A companion synth_gfsk_iq (encode.py) produces the analytic complex baseband exp(jφ) (same phase, no carrier if f0 = 0) for the subtraction stage (§5.8).
4. Encode — step by step
Everything below is encode._codeword_bits (encode.py) → ft8_tones/ft4_tones (encode.py) → synth_slot.
4.1 Message → 77-bit payload
pack.pack77(text) (pack.py) upper-cases and trims the message, tokenises it into call_to / call_de / extra, and tries in order: standard message (i3 = 1/2: two 28-bit callsigns + 15-bit grid/report), non-standard/compound (i3 = 4: a 12-bit hashed call + a 58-bit call + flags), then free text (i3.n3 = 0.0: 13 characters base-42). It returns 10 bytes = 77 bits (the low 3 bits of byte 9 are padding). The full packing algorithm — the callsign radix encodings, the 22/12/10-bit callsign hashes, the grid/report field — is out of scope here; see wsjtx-message-packing.md.
4.2 FT4 payload whitening (FT4 only)
ft4_xor_payload (pack.py) XORs the 10 payload bytes with FT4_XOR (§3.3). FT8 skips this step.
4.3 Payload → 91-bit message (CRC-14)
crc.add_crc(payload) (crc.py):
Copy the 10 payload bytes into a 12-byte buffer
a91; clear the low 3 bits of byte 9 (a91[9] &= 0xF8) and zero bytes 10–11. The 77 payload bits now sit in bytes 0–8 (72 bits) plus the top 5 bits of byte 9, zero-extended to 82 bits.Compute the CRC over the first 82 bits (
96 − 14) of that buffer withcompute_crc(crc.py) — a textbook MSB-first bit-serial CRC:rem = 0 for each of 82 bit positions: if bit_index % 8 == 0: rem ^= message[byte++] << (14 − 8) # inject next byte if rem & 0x2000: rem = ((rem << 1) ^ 0x2757) & 0xFFFF # poly = 0x2757 else: rem = (rem << 1) & 0xFFFF crc = rem & 0x3FFF # 14-bit resultDeposit the 14 CRC bits immediately after the 77-bit payload, at bit positions 77–90:
a91[9] |= crc>>11,a91[10] = crc>>3,a91[11] = crc<<5(crc.py).
The result is 91 meaningful bits (bytes 0–10 plus the top 3 bits of byte 11) — a systematic message ready for LDPC.
4.4 91-bit message → 174-bit codeword (LDPC encode)
ldpc.encode174(a91) (ldpc.py) is a systematic generator-matrix encode. The first 91 bits of the codeword are the message (codeword[:12] = a91[:12]); the 83 parity bits are appended starting at bit index 91:
for i in 0 … 82: # one parity bit per check row
nsum = XOR over j in 0 … 11 of parity8( a91[j] & LDPC_GENERATOR[i][j] )
parity_bit_i = nsum & 1
parity8(x) (ldpc.py) is the bit-parity (popcount mod 2) of a byte via the fold x ^= x>>4; x ^= x>>2; x ^= x>>1; return x&1. In other words parity bit i is the mod-2 inner product of the 91-bit message with row i of the 83×96-bit LDPC_GENERATOR matrix (constants.py). The first parity bit lands in byte 11 at bit-mask 0x10 (0x80 >> (91 % 8)), advancing MSB-first through bytes 11…21 (ldpc.py). Output: 22 bytes = 174 bits (the last 2 bits of byte 21 are unused).
_codeword_bits then unpacks those 22 bytes to a 174-element MSB-first bit list (encode.py).
4.5 Codeword bits → channel tones
FT8 (ft8_tones, encode.py): walk channel indices 0..78. At a sync index (0–6, 36–42, 72–78) emit the Costas tone FT8_COSTAS[index mod 36 within group]. Otherwise consume the next 3 codeword bits MSB-first into b3 = (bit0<<2)|(bit1<<1)|bit2 and emit FT8_GRAY[b3] (Gray-mapped tone).
FT4 (ft4_tones, encode.py): channel indices 0 and 104 emit ramp tone 0; indices 1–4/34–37/67–70/100–103 emit the corresponding row of FT4_COSTAS; all others consume 2 bits into b2 = (bit0<<1)|bit1 and emit FT4_GRAY[b2].
Gray-coding means adjacent tones differ in exactly one bit, so the commonest demod error (a one-tone slip) flips only one codeword bit — which the LDPC then mops up.
4.6 Tones → audio
synth_slot(tones, mode, f0=df_hz, sample_rate) renders the GFSK waveform (§3.4) at audio frequency df_hz (default 1000 Hz) and centres it in the slot. f0 is the frequency of tone 0; tone s sits at f0 + s · (1/period) Hz.
4.7 Worked reference vector
From test_ftx.py (all confirmed identical to gen_ft8), FT8 CQ M0SUP IO90:
payload (10 bytes) = 00 00 00 20 50 e4 2f 0f 84 88
79 tones, grouped as Costas(7) · data(29) · Costas(7) · data(29) · Costas(7):
3140652 ← Costas group, indices 0–6
00000000100602551240740552223 ← 29 data tones, kk = 0..28
3140652 ← Costas group, indices 36–42
16472676432446435307665362576 ← 29 data tones, kk = 29..57
3140652 ← Costas group, indices 72–78
concatenated (79 chars):
3140652000000001006025512407405522233140652164726764324464353076653625763140652
Note the three identical 3140652 Costas groups at indices 0, 36, 72 (= FT8_COSTAS = (3, 1, 4, 0, 6, 5, 2)).
5. Decode — step by step (the DSP)
decode.decode_slot(pcm, mode, sample_rate=12000, passes=1) (decode.py). Input is a captured slot as s16le mono PCM (converted to float /32768) or a raw array. Governing constants (decode.py): MIN_SCORE = 10, MAX_CANDIDATES = 140, LDPC_ITERS = 25, TIME_OSR = 2, FREQ_OSR = 2, F_MIN = 200, F_MAX = 3000 — chosen to mirror decode_ft8 exactly.
5.1 The waterfall (Hann-windowed oversampled STFT)
build_waterfall (decode.py) turns the slot into a 4-D magnitude cube wf[block, time_sub, freq_sub, bin] of quantised dB values:
- Block = one symbol period.
block_size = round(sample_rate · period)(FT8 = 1920, FT4 = 576 at 12 kHz).max_blocks = int(slot / period)(FT8 = 93, FT4 = 156);num_blocks = min(available, max_blocks). - 2× time oversampling (
TIME_OSR = 2): each block is entered at two half-symbol phases by sliding a length-nfftbuffer forward bysubblock = block_size / 2samples each step (decode.py). - 2× frequency oversampling (
FREQ_OSR = 2):nfft = block_size · 2, so FFT bins are spaced ½ a tone apart. The twofreq_subplanes pick the even/odd half-bin (decode.py). - Window: a Hann window
sin²(π i / nfft)scaled by2/nfft(decode.py). - Magnitude → dB → quantised byte:
mag² = Re² + Im²;db = 10·log10(1e-12 + mag²); stored value =clip(2·db + 240, 0, 255)(decode.py). This2·db + 240mapping (½-dB resolution, 0–255 range) is exactlyft8_lib's magnitude quantisation and is inverted during LLR extraction. - Frequency window: only bins for
F_MIN…F_MAXHz are kept:min_bin = int(F_MIN · period),num_bins = int(F_MAX · period) + 1 − min_bin(FT8: bins 32…480, 449 bins; FT4: 9…144, 136 bins).
5.2 Costas synchronisation search
find_candidates (decode.py) slides the known sync tones over the waterfall and scores how sharply the expected Costas tones stand out in both time and frequency.
_sync_blocks(mode)(decode.py) enumerates the sync symbols as(block_offset, expected_tone, k, group_len): for FT8, three groups of 7 at block offsets36·m + k; for FT4, four groups of 4 at1 + 33·m + k.- Search grid: every time-sub (2) × freq-sub (2) × coarse time offset
toff ∈ [−10, 19](decode.py). For a given(ts, fs, toff)a score is accumulated across allL = num_bins − num_tones + 1candidate frequency offsets at once (vectorised). For each sync symbol the expected-tone binbaseis compared to its neighbours — the tone below (sm−1), the tone above (sm+1), the previous time block and the next time block of the same group — accumulatingbase − neighbourand countingnavgcomparisons (decode.py). The meanscore //= navgis the sync metric. - Frequency offsets whose score ≥
MIN_SCORE(10) become candidates(score, toff, foff, ts, fs). All candidates are sorted by descending score and the topMAX_CANDIDATES(140) are kept (decode.py).
This is a 2-D matched filter against the Costas pattern: because the pattern has a thumbtack ambiguity function, the true (time, frequency) of every signal in the slot produces a sharp local score maximum, and several signals can be found in one slot.
5.3 Soft symbol metrics (GFSK demod → LLRs)
For each surviving candidate, _extract_llr (decode.py) reads the tone-bin magnitudes at the candidate's (ts, fs, foff) and turns each data symbol into per-bit log-likelihood ratios by the max-log-MAP rule.
For FT8 (8 tones, 3 bits/symbol), for each data symbol kk at channel block toff + sym_idx:
row = wf[block, ts, fs, foff : foff+8] · 0.5 − 120 # de-quantise to dB
s[j] = row[ FT8_GRAY[j] ] for j = 0 … 7 # s[j] = evidence for symbol value j
log[3·kk + 0] = max(s4,s5,s6,s7) − max(s0,s1,s2,s3) # bit 0 (MSB)
log[3·kk + 1] = max(s2,s3,s6,s7) − max(s0,s1,s4,s5) # bit 1
log[3·kk + 2] = max(s1,s3,s5,s7) − max(s0,s2,s4,s6) # bit 2 (LSB)
The Gray map is applied on read (s[j] = row[FT8_GRAY[j]]), inverting the encoder's tone = FT8_GRAY[value], so s[j] is the evidence that the transmitted symbol value was j. Each LLR is (max evidence over symbol values whose bit is
- − (max over values whose bit is 0) — the standard max-log approximation to the bit LLR. FT4 (4 tones, 2 bits/symbol) is the same with a 4-way
sandlog[2·kk+0] = max(s2,s3) − max(s0,s1),log[2·kk+1] = max(s1,s3) − max(s0,s2)(decode.py). 58×3 = 87×2 = 174 LLRs.
This soft-metric is the max-log-MAP simplification;
ft8_lib's active decoder uses a slightly different weighted heuristic for the same bits. Both feed the same variance-normaliser and BP decoder and, after normalisation, converge to identical codewords in practice (see the 94/94 parity result in §7).
5.4 LLR normalisation
_normalize_logl (decode.py) rescales the 174 LLRs so their variance is 24, matching ft8_lib's norm_factor = sqrt(24 / variance):
var = ( Σ log² − (Σ log)² / 174 ) / 174
return log · sqrt(24 / var) # pure scaling; the mean is not subtracted
This puts the LLRs on the fixed scale the belief-propagation tanh/atanh approximations expect.
5.5 LDPC belief propagation (sum-product)
bp_decode (decode.py) runs iterative sum-product decoding on the Tanner graph for up to LDPC_ITERS = 25 iterations. The graph is regular from the variable side: each of the 174 variables touches exactly 3 checks (LDPC_MN), and each of the 83 checks touches 6 or 7 variables (LDPC_NM / LDPC_NUM_ROWS). The tables are converted to 0-based once at import (decode.py).
State: tov[174][3] (variable→check messages) and toc[83][7] (check→variable messages). Each iteration:
- Hard decision & syndrome check.
total = llr + Σ tov[:, 0..2];plain = (total > 0)._ldpc_check(decode.py) counts unsatisfied parity checks: for each check m, XOR theplainbits it touches; a nonzero result is one error. Ifplainis all-zero the iteration breaks (degenerate). The best (fewest-error)plainseen so far is remembered; 0 errors ends decoding immediately (decode.py). - Bits → checks (
decode.py): for check m and each of its variables n, form the extrinsic sumTnm = llr[n] + Σ tov[n][mi]over n's other two checks, thentoc[m][ni] = tanh(−Tnm / 2). - Checks → bits (
decode.py): for variable n and each of its three checks m, take the productTmn = Π toc[m][ni]over m's other variables, thentov[n][mi] = −2·atanh(Tmn).
tanh/atanh use fast rational (Padé) approximations:
_fast_tanh(x): x = clip(x, −4.97, 4.97); x2 = x²
a = x·(945 + x2·(105 + x2)); b = 945 + x2·(420 + x2·15); return a/b
_fast_atanh(x): x2 = x²
a = x·(945 + x2·(−735 + x2·64)); b = 945 + x2·(−1050 + x2·225); return a/b
(decode.py — these match ft8_lib's fast_tanh/fast_atanh.) bp_decode returns the best 174-bit hard decision and its residual error count; _decode_candidate accepts only errors == 0 (decode.py).
5.6 CRC gate and payload recovery
_decode_candidate (decode.py) packs the first 91 codeword bits MSB-first into 12 bytes, then re-derives and checks the CRC exactly as the encoder built it (decode.py):
crc_extracted = ((a91[9] & 0x07) << 11) | (a91[10] << 3) | (a91[11] >> 5)
a91[9] &= 0xF8; a91[10] = 0; a91[11] = 0
crc_calc = compute_crc(a91, 96 − 14)
reject unless crc_extracted == crc_calc
A passing candidate yields payload = a91[:10]; for FT4 each payload byte is XORed with FT4_XOR to undo the whitening (decode.py). The CRC is the sole correctness gate — a converged-but-wrong LDPC codeword is discarded here, so false decodes are extremely rare.
5.7 Message and report
unpack77(payload, hash_store) (unpack.py) reverses the packing to text, resolving compound-callsign hashes against a per-slot HashStore. decode_slot dedupes by payload, then reports (decode.py):
snr = round(score · 0.5 − 24) dB
dt = (toff + time_sub / TIME_OSR) · period s (signal start vs slot)
df = (min_bin + foff + freq_sub / FREQ_OSR) / period Hz (audio frequency of tone 0)
5.8 Multi-pass coherent subtraction (optional, passes > 1)
passes = 1 (the default) matches decode_ft8. With more passes, after each pass every fresh decode is re-synthesised and coherently subtracted from the waveform, then the waterfall and search are re-run to expose weaker signals hidden under stronger ones (decode.py). subtract.subtract_signal (subtract.py):
- Re-encode the payload to tones and synthesise the complex baseband GFSK reference
ref_bb = synth_gfsk_iq(tones, f0 = 0)(subtract.py). - Refine timing/frequency around the coarse candidate estimate
coarse_t0_samples(subtract.py) by a three-stage local search — coarse time, then frequency in 0.25 Hz steps over ±2.5 Hz, then fine time — each stage picking the offset that maximises the least-squares explained energy (subtract.py). - At the best
(t0, f), fit the received segment to the real/imaginary reference basisseg ≈ a·Re(ref) + b·Im(ref)by 2-D least squares (_ls_fit,subtract.py) — this cancels the signal regardless of its unknown phase — and subtracta·Re(ref) + b·Im(ref)in place (subtract.py).
The port notes this cancels cleanly; the remaining gap to WSJT-X sensitivity is raw BP sensitivity (an OSD stage), not masking, so the extra-pass gain is modest.
6. Constants & tables
Everything in app/radio/ftx/constants.py is auto-generated — never hand edited — by scripts/gen_ftx_constants.py, which parses the C array literals in refs/ft8_lib_src/constants.c (so the tables are provably identical to ft8_lib, Karlis Goba YL3JG, MIT). To regenerate: python scripts/gen_ftx_constants.py. The generator strips comments, brace-matches each named array, extracts every integer/hex literal, reshapes to the documented widths, and asserts the shapes (gen_ftx_constants.py).
6.1 Small tables (given in full)
| Name | Value | File |
|---|---|---|
FT8_COSTAS |
(3, 1, 4, 0, 6, 5, 2) |
constants.py |
FT4_COSTAS |
((0,1,3,2),(1,0,2,3),(2,3,1,0),(3,2,0,1)) |
constants.py |
FT8_GRAY |
(0, 1, 3, 2, 5, 6, 4, 7) |
constants.py |
FT4_GRAY |
(0, 1, 3, 2) |
constants.py |
FT4_XOR |
(74, 94, 137, 180, 176, 138, 121, 85, 190, 40) = (0x4A,0x5E,0x89,0xB4,0xB0,0x8A,0x79,0x55,0xBE,0x28) |
constants.py |
CRC_POLY / CRC_WIDTH |
0x2757 / 14 |
constants.py |
GFSK_CONST_K |
5.336446 (= π√(2/ln 2)) |
constants.py |
| Timing (FT8) | NN 79, ND 58, period 0.160, slot 15.0, BT 2.0 |
constants.py |
| Timing (FT4) | NN 105, ND 87, period 0.048, slot 7.5, BT 1.0 |
constants.py |
6.2 The LDPC(174, 91) code — the large tables
The LDPC code is described by four tables, reproduced here in full (verbatim from the generated constants.py; the authoritative copies are constants.py and the vendored constants.c, arrays kFTX_LDPC_*). All variable/check indices are 1-based, matching the source.
LDPC_GENERATOR — the 83×12-byte parity generator, one 96-bit row (only the first 91 bits used) per parity bit; parity bit i is the mod-2 inner product of the 91-bit message with row i (§4.4). The constants.py rows are decimal bytes; the constants.c source rows are hex. The decimal↔︎hex correspondence, on the first three rows:
constants.c row 0: { 0x83,0x29,0xce,0x11,0xbf,0x31,0xea,0xf5,0x09,0xf2,0x7f,0xc0 }
constants.py row 0: (131, 41, 206, 17, 191, 49, 234, 245, 9, 242, 127, 192)
constants.c row 1: { 0x76,0x1c,0x26,0x4e,0x25,0xc2,0x59,0x33,0x54,0x93,0x13,0x20 }
constants.py row 1: (118, 28, 38, 78, 37, 194, 89, 51, 84, 147, 19, 32)
constants.c row 2: { 0xdc,0x26,0x59,0x02,0xfb,0x27,0x7c,0x64,0x10,0xa1,0xbd,0xc0 }
constants.py row 2: (220, 38, 89, 2, 251, 39, 124, 100, 16, 161, 189, 192)
All 83 rows (decimal bytes, row : 12-tuple):
0: (131, 41, 206, 17, 191, 49, 234, 245, 9, 242, 127, 192)
1: (118, 28, 38, 78, 37, 194, 89, 51, 84, 147, 19, 32)
2: (220, 38, 89, 2, 251, 39, 124, 100, 16, 161, 189, 192)
3: ( 27, 63, 65, 120, 88, 205, 45, 211, 62, 199, 246, 32)
4: ( 9, 253, 164, 254, 224, 65, 149, 253, 3, 71, 131, 160)
5: ( 7, 124, 204, 193, 27, 136, 115, 237, 92, 61, 72, 160)
6: ( 41, 182, 42, 254, 60, 160, 54, 244, 254, 26, 157, 160)
7: ( 96, 84, 250, 245, 243, 93, 150, 211, 176, 200, 195, 224)
8: (226, 7, 152, 228, 49, 14, 237, 39, 136, 74, 233, 0)
9: (119, 92, 156, 8, 232, 14, 38, 221, 174, 86, 49, 128)
10: (176, 184, 17, 2, 140, 43, 249, 151, 33, 52, 135, 192)
11: ( 24, 160, 201, 35, 31, 198, 10, 223, 92, 94, 163, 32)
12: (118, 71, 30, 131, 2, 160, 114, 30, 1, 177, 43, 128)
13: (255, 188, 203, 128, 202, 131, 65, 250, 251, 71, 178, 224)
14: (102, 167, 42, 21, 143, 147, 37, 162, 191, 103, 23, 0)
15: (196, 36, 54, 137, 254, 133, 177, 197, 19, 99, 161, 128)
16: ( 13, 255, 115, 148, 20, 209, 161, 179, 75, 28, 39, 0)
17: ( 21, 180, 136, 48, 99, 108, 139, 153, 137, 73, 114, 224)
18: ( 41, 168, 156, 13, 61, 232, 29, 102, 84, 137, 176, 224)
19: ( 79, 18, 111, 55, 250, 81, 203, 230, 27, 214, 185, 64)
20: (153, 196, 114, 57, 208, 217, 125, 60, 132, 224, 148, 0)
21: ( 25, 25, 183, 81, 25, 118, 86, 33, 187, 79, 30, 128)
22: ( 9, 219, 18, 215, 49, 250, 238, 11, 134, 223, 107, 128)
23: ( 72, 143, 195, 61, 244, 63, 189, 238, 164, 234, 251, 64)
24: (130, 116, 35, 238, 64, 182, 117, 247, 86, 235, 95, 224)
25: (171, 225, 151, 196, 132, 203, 116, 117, 113, 68, 169, 160)
26: ( 43, 80, 14, 75, 192, 236, 90, 109, 43, 219, 221, 0)
27: (196, 116, 170, 83, 215, 2, 24, 118, 22, 105, 54, 0)
28: (142, 186, 26, 19, 219, 51, 144, 189, 103, 24, 206, 192)
29: (117, 56, 68, 103, 58, 39, 120, 44, 196, 32, 18, 224)
30: ( 6, 255, 131, 161, 69, 195, 112, 53, 165, 193, 38, 128)
31: ( 59, 55, 65, 120, 88, 204, 45, 211, 62, 195, 246, 32)
32: (154, 74, 90, 40, 238, 23, 202, 156, 50, 72, 66, 192)
33: (188, 41, 244, 101, 48, 156, 151, 126, 137, 97, 10, 64)
34: ( 38, 99, 174, 109, 223, 139, 92, 226, 187, 41, 72, 128)
35: ( 70, 242, 49, 239, 228, 87, 3, 76, 24, 20, 65, 128)
36: ( 63, 178, 206, 133, 171, 233, 176, 199, 46, 6, 251, 224)
37: (222, 135, 72, 31, 40, 44, 21, 57, 113, 160, 162, 224)
38: (252, 215, 204, 242, 60, 105, 250, 153, 187, 161, 65, 32)
39: (240, 38, 20, 71, 233, 73, 12, 168, 228, 116, 206, 192)
40: ( 68, 16, 17, 88, 24, 25, 111, 149, 205, 215, 1, 32)
41: ( 8, 143, 195, 29, 244, 191, 189, 226, 164, 234, 251, 64)
42: (184, 254, 241, 182, 48, 119, 41, 251, 10, 7, 140, 0)
43: ( 90, 254, 167, 172, 204, 183, 123, 188, 157, 153, 169, 0)
44: ( 73, 167, 1, 106, 198, 83, 246, 94, 205, 201, 7, 96)
45: ( 25, 68, 208, 133, 190, 78, 125, 168, 214, 204, 125, 0)
46: ( 37, 31, 98, 173, 196, 3, 47, 14, 231, 20, 0, 32)
47: ( 86, 71, 31, 135, 2, 160, 114, 30, 0, 177, 43, 128)
48: ( 43, 142, 73, 35, 242, 221, 81, 226, 213, 55, 250, 0)
49: (107, 85, 10, 64, 166, 111, 71, 85, 222, 149, 194, 96)
50: (161, 138, 210, 141, 78, 39, 254, 146, 164, 246, 200, 64)
51: ( 16, 194, 229, 134, 56, 140, 184, 42, 61, 128, 117, 128)
52: (239, 52, 164, 24, 23, 238, 2, 19, 61, 178, 235, 0)
53: (126, 156, 12, 84, 50, 90, 156, 21, 131, 110, 0, 0)
54: ( 54, 147, 229, 114, 209, 253, 228, 205, 240, 121, 232, 96)
55: (191, 178, 206, 197, 171, 225, 176, 199, 46, 7, 251, 224)
56: (126, 225, 130, 48, 197, 131, 204, 204, 87, 212, 176, 128)
57: (160, 102, 203, 47, 237, 175, 201, 245, 38, 100, 18, 96)
58: (187, 35, 114, 90, 188, 71, 204, 95, 76, 196, 205, 32)
59: (222, 217, 219, 163, 190, 228, 12, 89, 181, 96, 155, 64)
60: (217, 167, 1, 106, 198, 83, 230, 222, 205, 201, 3, 96)
61: (154, 212, 106, 237, 95, 112, 127, 40, 10, 181, 252, 64)
62: (229, 146, 28, 119, 130, 37, 135, 49, 109, 125, 60, 32)
63: ( 79, 20, 218, 130, 66, 168, 184, 109, 202, 115, 53, 32)
64: (139, 139, 80, 122, 212, 103, 212, 68, 29, 247, 112, 224)
65: ( 34, 131, 28, 156, 241, 22, 148, 103, 173, 4, 182, 128)
66: ( 33, 59, 131, 143, 226, 174, 84, 195, 142, 231, 24, 0)
67: ( 93, 146, 107, 109, 215, 31, 8, 81, 129, 164, 225, 32)
68: (102, 171, 121, 212, 178, 158, 230, 230, 149, 9, 229, 96)
69: (149, 129, 72, 104, 45, 116, 138, 56, 221, 104, 186, 160)
70: (184, 206, 2, 12, 240, 105, 195, 42, 114, 58, 177, 64)
71: (244, 51, 29, 109, 70, 22, 7, 233, 87, 82, 116, 96)
72: (109, 162, 59, 164, 36, 185, 89, 97, 51, 207, 156, 128)
73: (166, 54, 188, 188, 123, 48, 197, 251, 234, 230, 127, 224)
74: ( 92, 176, 216, 106, 7, 223, 101, 74, 144, 137, 162, 0)
75: (241, 31, 16, 104, 72, 120, 15, 201, 236, 221, 128, 160)
76: ( 31, 187, 83, 100, 251, 141, 44, 157, 115, 13, 91, 160)
77: (252, 184, 107, 199, 10, 80, 201, 208, 42, 93, 3, 64)
78: (165, 52, 67, 48, 41, 234, 193, 95, 50, 46, 52, 192)
79: (201, 137, 217, 199, 195, 211, 184, 197, 93, 117, 19, 0)
80: (123, 179, 139, 47, 1, 134, 212, 102, 67, 174, 150, 32)
81: ( 38, 68, 235, 173, 235, 68, 185, 70, 125, 31, 66, 192)
82: ( 96, 140, 200, 87, 89, 75, 251, 181, 93, 105, 96, 0)
LDPC_NM — for each of the 83 checks, the (1-based) variable-node indices it constrains to XOR to zero. Rows have 6 or 7 entries; 6-entry rows are padded with a trailing 0 (ignored — see LDPC_NUM_ROWS). All 83 rows (check : variables):
0: ( 4, 31, 59, 91, 92, 96, 153)
1: ( 5, 32, 60, 93, 115, 146, 0)
2: ( 6, 24, 61, 94, 122, 151, 0)
3: ( 7, 33, 62, 95, 96, 143, 0)
4: ( 8, 25, 63, 83, 93, 96, 148)
5: ( 6, 32, 64, 97, 126, 138, 0)
6: ( 5, 34, 65, 78, 98, 107, 154)
7: ( 9, 35, 66, 99, 139, 146, 0)
8: ( 10, 36, 67, 100, 107, 126, 0)
9: ( 11, 37, 67, 87, 101, 139, 158)
10: ( 12, 38, 68, 102, 105, 155, 0)
11: ( 13, 39, 69, 103, 149, 162, 0)
12: ( 8, 40, 70, 82, 104, 114, 145)
13: ( 14, 41, 71, 88, 102, 123, 156)
14: ( 15, 42, 59, 106, 123, 159, 0)
15: ( 1, 33, 72, 106, 107, 157, 0)
16: ( 16, 43, 73, 108, 141, 160, 0)
17: ( 17, 37, 74, 81, 109, 131, 154)
18: ( 11, 44, 75, 110, 121, 166, 0)
19: ( 45, 55, 64, 111, 130, 161, 173)
20: ( 8, 46, 71, 112, 119, 166, 0)
21: ( 18, 36, 76, 89, 113, 114, 143)
22: ( 19, 38, 77, 104, 116, 163, 0)
23: ( 20, 47, 70, 92, 138, 165, 0)
24: ( 2, 48, 74, 113, 128, 160, 0)
25: ( 21, 45, 78, 83, 117, 121, 151)
26: ( 22, 47, 58, 118, 127, 164, 0)
27: ( 16, 39, 62, 112, 134, 158, 0)
28: ( 23, 43, 79, 120, 131, 145, 0)
29: ( 19, 35, 59, 73, 110, 125, 161)
30: ( 20, 36, 63, 94, 136, 161, 0)
31: ( 14, 31, 79, 98, 132, 164, 0)
32: ( 3, 44, 80, 124, 127, 169, 0)
33: ( 19, 46, 81, 117, 135, 167, 0)
34: ( 7, 49, 58, 90, 100, 105, 168)
35: ( 12, 50, 61, 118, 119, 144, 0)
36: ( 13, 51, 64, 114, 118, 157, 0)
37: ( 24, 52, 76, 129, 148, 149, 0)
38: ( 25, 53, 69, 90, 101, 130, 156)
39: ( 20, 46, 65, 80, 120, 140, 170)
40: ( 21, 54, 77, 100, 140, 171, 0)
41: ( 35, 82, 133, 142, 171, 174, 0)
42: ( 14, 30, 83, 113, 125, 170, 0)
43: ( 4, 29, 68, 120, 134, 173, 0)
44: ( 1, 4, 52, 57, 86, 136, 152)
45: ( 26, 51, 56, 91, 122, 137, 168)
46: ( 52, 84, 110, 115, 145, 168, 0)
47: ( 7, 50, 81, 99, 132, 173, 0)
48: ( 23, 55, 67, 95, 172, 174, 0)
49: ( 26, 41, 77, 109, 141, 148, 0)
50: ( 2, 27, 41, 61, 62, 115, 133)
51: ( 27, 40, 56, 124, 125, 126, 0)
52: ( 18, 49, 55, 124, 141, 167, 0)
53: ( 6, 33, 85, 108, 116, 156, 0)
54: ( 28, 48, 70, 85, 105, 129, 158)
55: ( 9, 54, 63, 131, 147, 155, 0)
56: ( 22, 53, 68, 109, 121, 174, 0)
57: ( 3, 13, 48, 78, 95, 123, 0)
58: ( 31, 69, 133, 150, 155, 169, 0)
59: ( 12, 43, 66, 89, 97, 135, 159)
60: ( 5, 39, 75, 102, 136, 167, 0)
61: ( 2, 54, 86, 101, 135, 164, 0)
62: ( 15, 56, 87, 108, 119, 171, 0)
63: ( 10, 44, 82, 91, 111, 144, 149)
64: ( 23, 34, 71, 94, 127, 153, 0)
65: ( 11, 49, 88, 92, 142, 157, 0)
66: ( 29, 34, 87, 97, 147, 162, 0)
67: ( 30, 50, 60, 86, 137, 142, 162)
68: ( 10, 53, 66, 84, 112, 128, 165)
69: ( 22, 57, 85, 93, 140, 159, 0)
70: ( 28, 32, 72, 103, 132, 166, 0)
71: ( 28, 29, 84, 88, 117, 143, 150)
72: ( 1, 26, 45, 80, 128, 147, 0)
73: ( 17, 27, 89, 103, 116, 153, 0)
74: ( 51, 57, 98, 163, 165, 172, 0)
75: ( 21, 37, 73, 138, 152, 169, 0)
76: ( 16, 47, 76, 130, 137, 154, 0)
77: ( 3, 24, 30, 72, 104, 139, 0)
78: ( 9, 40, 90, 106, 134, 151, 0)
79: ( 15, 58, 60, 74, 111, 150, 163)
80: ( 18, 42, 79, 144, 146, 152, 0)
81: ( 25, 38, 65, 99, 122, 160, 0)
82: ( 17, 42, 75, 129, 170, 172, 0)
LDPC_MN — for each of the 174 variables, the three (1-based) checks it participates in; every variable has degree exactly 3. All 174 rows (var : checks):
0: (16, 45, 73)
1: (25, 51, 62)
2: (33, 58, 78)
3: ( 1, 44, 45)
4: ( 2, 7, 61)
5: ( 3, 6, 54)
6: ( 4, 35, 48)
7: ( 5, 13, 21)
8: ( 8, 56, 79)
9: ( 9, 64, 69)
10: (10, 19, 66)
11: (11, 36, 60)
12: (12, 37, 58)
13: (14, 32, 43)
14: (15, 63, 80)
15: (17, 28, 77)
16: (18, 74, 83)
17: (22, 53, 81)
18: (23, 30, 34)
19: (24, 31, 40)
20: (26, 41, 76)
21: (27, 57, 70)
22: (29, 49, 65)
23: ( 3, 38, 78)
24: ( 5, 39, 82)
25: (46, 50, 73)
26: (51, 52, 74)
27: (55, 71, 72)
28: (44, 67, 72)
29: (43, 68, 78)
30: ( 1, 32, 59)
31: ( 2, 6, 71)
32: ( 4, 16, 54)
33: ( 7, 65, 67)
34: ( 8, 30, 42)
35: ( 9, 22, 31)
36: (10, 18, 76)
37: (11, 23, 82)
38: (12, 28, 61)
39: (13, 52, 79)
40: (14, 50, 51)
41: (15, 81, 83)
42: (17, 29, 60)
43: (19, 33, 64)
44: (20, 26, 73)
45: (21, 34, 40)
46: (24, 27, 77)
47: (25, 55, 58)
48: (35, 53, 66)
49: (36, 48, 68)
50: (37, 46, 75)
51: (38, 45, 47)
52: (39, 57, 69)
53: (41, 56, 62)
54: (20, 49, 53)
55: (46, 52, 63)
56: (45, 70, 75)
57: (27, 35, 80)
58: ( 1, 15, 30)
59: ( 2, 68, 80)
60: ( 3, 36, 51)
61: ( 4, 28, 51)
62: ( 5, 31, 56)
63: ( 6, 20, 37)
64: ( 7, 40, 82)
65: ( 8, 60, 69)
66: ( 9, 10, 49)
67: (11, 44, 57)
68: (12, 39, 59)
69: (13, 24, 55)
70: (14, 21, 65)
71: (16, 71, 78)
72: (17, 30, 76)
73: (18, 25, 80)
74: (19, 61, 83)
75: (22, 38, 77)
76: (23, 41, 50)
77: ( 7, 26, 58)
78: (29, 32, 81)
79: (33, 40, 73)
80: (18, 34, 48)
81: (13, 42, 64)
82: ( 5, 26, 43)
83: (47, 69, 72)
84: (54, 55, 70)
85: (45, 62, 68)
86: (10, 63, 67)
87: (14, 66, 72)
88: (22, 60, 74)
89: (35, 39, 79)
90: ( 1, 46, 64)
91: ( 1, 24, 66)
92: ( 2, 5, 70)
93: ( 3, 31, 65)
94: ( 4, 49, 58)
95: ( 1, 4, 5)
96: ( 6, 60, 67)
97: ( 7, 32, 75)
98: ( 8, 48, 82)
99: ( 9, 35, 41)
100: (10, 39, 62)
101: (11, 14, 61)
102: (12, 71, 74)
103: (13, 23, 78)
104: (11, 35, 55)
105: (15, 16, 79)
106: ( 7, 9, 16)
107: (17, 54, 63)
108: (18, 50, 57)
109: (19, 30, 47)
110: (20, 64, 80)
111: (21, 28, 69)
112: (22, 25, 43)
113: (13, 22, 37)
114: ( 2, 47, 51)
115: (23, 54, 74)
116: (26, 34, 72)
117: (27, 36, 37)
118: (21, 36, 63)
119: (29, 40, 44)
120: (19, 26, 57)
121: ( 3, 46, 82)
122: (14, 15, 58)
123: (33, 52, 53)
124: (30, 43, 52)
125: ( 6, 9, 52)
126: (27, 33, 65)
127: (25, 69, 73)
128: (38, 55, 83)
129: (20, 39, 77)
130: (18, 29, 56)
131: (32, 48, 71)
132: (42, 51, 59)
133: (28, 44, 79)
134: (34, 60, 62)
135: (31, 45, 61)
136: (46, 68, 77)
137: ( 6, 24, 76)
138: ( 8, 10, 78)
139: (40, 41, 70)
140: (17, 50, 53)
141: (42, 66, 68)
142: ( 4, 22, 72)
143: (36, 64, 81)
144: (13, 29, 47)
145: ( 2, 8, 81)
146: (56, 67, 73)
147: ( 5, 38, 50)
148: (12, 38, 64)
149: (59, 72, 80)
150: ( 3, 26, 79)
151: (45, 76, 81)
152: ( 1, 65, 74)
153: ( 7, 18, 77)
154: (11, 56, 59)
155: (14, 39, 54)
156: (16, 37, 66)
157: (10, 28, 55)
158: (15, 60, 70)
159: (17, 25, 82)
160: (20, 30, 31)
161: (12, 67, 68)
162: (23, 75, 80)
163: (27, 32, 62)
164: (24, 69, 75)
165: (19, 21, 71)
166: (34, 53, 61)
167: (35, 46, 47)
168: (33, 59, 76)
169: (40, 43, 83)
170: (41, 42, 63)
171: (49, 75, 83)
172: (20, 44, 48)
173: (42, 49, 57)
LDPC_NUM_ROWS — the real length (6 or 7) of each LDPC_NM row, so the padding 0s are never read:
(7, 6, 6, 6, 7, 6, 7, 6, 6, 7, 6, 6, 7, 7, 6, 6, 6, 7, 6, 7, 6, 7, 6, 6, 6, 7,
6, 6, 6, 7, 6, 6, 6, 6, 7, 6, 6, 6, 7, 7, 6, 6, 6, 6, 7, 7, 6, 6, 6, 6, 7, 6,
6, 6, 7, 6, 6, 6, 6, 7, 6, 6, 6, 7, 6, 6, 6, 7, 7, 6, 6, 7, 6, 6, 6, 6, 6, 6,
6, 7, 6, 6, 6)
LDPC_NM and LDPC_MN are the two adjacency views of the same Tanner graph: LDPC_MN drives message passing, LDPC_NM (with LDPC_NUM_ROWS) drives the syndrome check and the check-node updates. The generator (LDPC_GENERATOR) is used only by the encoder; the decoder needs only the parity structure.
6.3 Regeneration recipe (authoritative)
# refs/ft8_lib_src/constants.c holds the canonical C literals (kFT8_Costas_pattern,
# kFT4_Costas_pattern, kFT8_Gray_map, kFT4_Gray_map, kFT4_XOR_sequence,
# kFTX_LDPC_generator [83×12], kFTX_LDPC_Nm [83×7], kFTX_LDPC_Mn [174×3],
# kFTX_LDPC_Num_rows [83]).
python scripts/gen_ftx_constants.py # → app/radio/ftx/constants.py
The script re-derives the timing/CRC/GFSK scalars from constants.h's documented values and asserts every array shape, so a re-clone of ft8_lib regenerates a byte-identical constants.py.
7. Interoperability & validation
Reference oracle. The port is validated against ft8_lib's own gen_ft8 (encode) and decode_ft8 (decode) binaries, built on the prod box from the vendored source. Because ft8_lib is MIT-licensed and is itself the accepted open FT8/FT4 reference, byte/symbol equality with it is the correctness criterion.
Encoder — deterministic, byte/symbol-exact. test_ftx.py carries seven fixtures spanning the message-type space (plain CQ + grid, signal report, RR73, 73, CQ DX, free text, and an FT4 vector), each asserting both the 10 payload bytes and the full channel-tone string equal gen_ft8's output (test_ftx.py). Example (FT8 CQ M0SUP IO90): payload 0000002050e42f0f8488, tones 3140652000000001006025512407405522233140652164726764324464353076653625763140652. Additional structural tests assert the three FT8 Costas groups are present at indices 0/36/72 (test_ftx.py) and that a slot is exactly slot·rate samples (test_ftx.py).
Decoder — statistical, parity with decode_ft8. Run natively and with decode_ft8 on identical captured slots, the port produces the identical message set — 94/94 decodes across five ft8_lib WebSDR recordings, zero native-only and zero reference-only. Both reach ~73 % of the WSJT-X-grade .txt reference; the shortfall is weak signals that need multi-pass subtraction + OSD (which decode_ft8 also lacks), so the port is a faithful drop-in for decode_ft8 with headroom above it. Round-trip tests (encode_pcm → decode_slot) recover clean and 3× RMS-noise FT8 slots (test_ftx.py).
FT4 timing. The FT4 symbol period this port uses, FT4_SYMBOL_PERIOD = 0.048 s (20.833 baud → 5.04 s signal), matches WSJT-X exactly: WSJT-X's own lib/ft4/ft4_params.f90 defines NSPS = 576 samples/symbol at 12000 S/s (576/12000 = 0.048 s), and ft8_lib uses the same value. So FT4 is on-air-compatible with production WSJT-X, not merely ft8_lib-compatible. (An earlier draft of this spec claimed WSJT-X FT4 was 23.4375 baud / 0.04267 s — that was wrong; the WSJT-X source is NSPS=576.)
Integration. codec.encode_pcm mirrors the old gen_ft8 WAV boundary (text in, PCM out) and codec.decode_slot mirrors decode_ft8 (PCM in, list of {snr, dt, df, msg} out), so the native codec replaced the two external binaries without touching the app's manager/monitor seams.
8. Limitations
- Single-pass BP by default.
passes = 1(thedecode_ft8equivalent) is the default. It misses signals buried under stronger ones; the fix is more passes (subtraction, §5.8) plus an OSD (ordered-statistics decoding) stage for codewords BP cannot converge on at very low SNR. OSD is not yet implemented, so raw BP sensitivity — not masking — is the main remaining gap to WSJT-X's.txt. - Pure-Python belief propagation is the runtime hot spot: the doubly-nested check/variable loops run 25 iterations per candidate over up to 140 candidates. It is correct and matches the reference, but a vectorised or compiled BP would be needed for very busy slots.
- Message coverage follows the packing module — standard calls with grid/report/
RRR/RR73/73(i3 = 1/2), compound/non-standard hashed calls (i3 = 4), and free text (i3.n3 = 0.0). The rarer WSJT-X message types (contest exchanges i3 = 3, EU VHF i3 = 5, telemetry decode-only) are partial; see the packing doc. - Soft metric is max-log, a small approximation to the exact bit LLR (and to
ft8_lib's weighted heuristic). Validation shows it reaches the same codewords, but at the very margin a full max-*-log or exact-MAP metric could recover a fraction of a dB.
9. References
- J. Taylor K1JT, S. Franke K9AN, B. Somerville G4WJS, The FT4 and FT8 Communication Protocols, QEX, 2020 — the authoritative protocol description (payload, LDPC, Costas, GFSK, FT4 timing).
- K. Goba YL3JG,
ft8_lib(MIT) — the reference encoder/decoder this port follows; vendored underrefs/ft8_lib_src/and the source of every constant. - WSJT-X project, The FT8 Operating Guide / WSJT-X source documentation — the 77-bit message specification (see wsjtx-message-packing.md).
- R. Gallager, Low-Density Parity-Check Codes, MIT Press, 1963 — the LDPC code family and iterative decoding.
- J. P. Costas, A study of a class of detection waveforms having nearly ideal range–Doppler ambiguity properties, Proc. IEEE, 1984 — Costas arrays.
- This repo:
docs/native-digimodes.md(native-digimode programme, validation strategy) anddocs/protocols/wsjtx-message-packing.md(the shared 77-bit payload codec).
Source files
| File | Role |
|---|---|
app/radio/ftx/constants.py |
All protocol tables (generated from ft8_lib) |
app/radio/ftx/crc.py |
CRC-14 (compute_crc, add_crc), poly 0x2757 |
app/radio/ftx/ldpc.py |
LDPC(174, 91) systematic encode (encode174) |
app/radio/ftx/pack.py |
pack77 (text → 77-bit payload), FT4 whitening, hash store |
app/radio/ftx/unpack.py |
unpack77 (77-bit payload → text) |
app/radio/ftx/text.py |
Character tables / helpers for packing |
app/radio/ftx/encode.py |
Tone assembly + GFSK synthesis + slot framing |
app/radio/ftx/decode.py |
Waterfall, Costas sync, LLRs, BP decode, CRC gate |
app/radio/ftx/subtract.py |
Coherent multi-pass signal subtraction |
app/radio/ftx/codec.py |
Public API (encode_pcm, encode_tones, decode_slot) |
scripts/gen_ftx_constants.py |
Regenerates constants.py from vendored constants.c |
test_ftx.py |
Byte/symbol-exact fixtures + round-trip tests |
Runtime is pure NumPy plus SciPy's erf (the GFSK pulse). The only external inputs are the committed constant tables, generated once from the MIT ft8_lib source exactly as the LoRa/Meshtastic tables were.