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:
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_bt is 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 term
dphi_peak · s · pulse is 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 central n_wave-sample
segment is cumulatively summed to phase, and signal = sin(phase)
(encode.py).
- Edge ramp. A raised-cosine envelope of n_spsym // 8 samples 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 result
- Deposit 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
1) − (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 s and
log[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
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.