🇬🇧 English | 🇹🇷 Türkçe
A pure-Python FT8 + FT4 decoder that reads digital ham-radio messages out of a WAV recording — and a step-by-step guide to both protocols, down to the last bit of the frame.
No WSJT-X, no black boxes: numpy, scipy and a WebSDR recording are all
you need. Protocol constants (sync patterns, LDPC matrix, CRC polynomial)
come from the WSJT-X specification; every algorithm is written from scratch.
WAV ──► waterfall (symbol-aligned FFT)
──► sync search (2D correlation: Costas 7×3 for FT8, 4×4 blocks for FT4)
──► soft demod (8-FSK / 4-GFSK → 174 log-likelihood ratios)
──► LDPC(174,91) belief propagation ──► CRC-14 gate
──► (FT4 only: un-XOR) ──► 77-bit message unpacking ──► "CQ DL6SMA JN58"
- What are FT8 and FT4?
- How to record (WebSDR, step by step)
- Usage and outputs
- FT8 and FT4 side by side
- The frame, bit by bit
- The 77-bit payload — what a message actually carries
- Is there a UTC timestamp in the message?
- How the decoder works
- Reading the frequency analysis
- Files, code map, limitations
- References
FT8 (Franke-Taylor design, 8-tone) and its faster sibling FT4 are the most popular digital modes in amateur radio today. They were designed for one job: completing a minimal two-way contact when the signal is so weak you cannot hear it — down to roughly −21 dB signal-to-noise ratio in a 2.5 kHz bandwidth, far below the threshold of the human ear.
Three ideas make this possible:
- Tiny messages. A transmission carries exactly 77 bits of payload — enough for two callsigns and a grid locator or report, nothing more. Less information means more energy per bit.
- Slow, narrow FSK. The bits are spread over many seconds of multi-tone frequency-shift keying occupying only 50 Hz (FT8) or 83 Hz (FT4). A receiver can integrate each tone for a long time.
- Heavy error correction. The 77 bits grow to 174 bits with a CRC and a low-density parity-check (LDPC) code before transmission; the decoder can repair a badly damaged frame — and prove the repair is correct with the CRC.
Everything is slot-synchronized: FT8 transmissions start every 15 s (at :00, :15, :30, :45 of the UTC minute), FT4 every 7.5 s. One station transmits in even slots, the other answers in odd slots; a full contact is a fixed choreography of CQ → answer → report → confirm.
Recordings for this project come from a QO-100 WebSDR — the satellite's narrow-band transponder carries FT8 and FT4 around the dial frequency 10489540 kHz. Screenshot of a live session (left panel: FT8 decodes; right panel: FT4 decodes; the waterfall column between them is the signal this decoder eats):
Settings that matter, on http://websdr.is0grb.it:8901/ (IS0GRB, Sardinia):
| Setting | Value | Why |
|---|---|---|
| Frequency | 10489540.00 kHz | FT8/FT4 segment on the QO-100 band plan |
| Mode | USB (USB3.1 filter) | both modes are transmitted as USB audio; tones land in 200–3100 Hz |
| DSP Noise Reduction | Disabled | it warps tone frequencies — frequency is the data |
| Autonotch | Off | steady FSK tones look like carriers to it; it would eat them |
| Squelch / Mute / High Boost | Off | nothing may gate or shape the audio |
| Audio AGC | Auto | fine |
Press Audio recording: start, let a few slots pass (each FT8 cycle is 15 s), press stop, download the WAV. Mono, ≥ 8 kHz sample rate. Record at least ~60 s so several slots are captured.
One practical warning learned the hard way: browser audio capture can drop samples under load. FT8's 160 ms symbols tolerate small drops; FT4's 48 ms symbols are three times more fragile — a recording that decodes fine as FT8 may yield weak-FT4 failures purely because of capture glitches. Keep the browser tab foregrounded and idle while recording.
pip3 install numpy scipy matplotlib
python3 ft8_ft4_decoder.py misc/ft8_ft4_10489540.0kHz.wavThe entry point runs the FT8 scan, then the FT4 scan, and merges the results chronologically. Real output from the included FT8 recording:
kayit : misc/ft8_ft4_10489540.0kHz.wav
sure : 103.5 s @ 8000 Hz
yayin : 2 cozuldu (2 FT8 + 0 FT4, 1 farkli mesaj)
mod t(s) freq(Hz) skor mesaj
----------------------------------------------------------------
FT8 23.0 1071.9 307 CQ DL6SMA JN58
FT8 53.0 1071.9 310 CQ DL6SMA JN58
And from the included FT4 recording (misc/sample3.wav):
mod t(s) freq(Hz) skor mesaj
----------------------------------------------------------------
FT4 2.3 1927.1 188 CQ OP0P JO20
Cross-check: at recording time the WebSDR's own decoder displayed
FT4 ... 1924 CQ OP0P JO20 — same message, same frequency. That is our
golden vector.
And the acid test — misc/ft8_ft4_10489540_sample.wav, an 82-second
recording made while both modes were active at once. The merged table
interleaves them chronologically (abridged; the full table is in
data/ft8_ft4_10489540_sample_message.txt):
yayin : 30 cozuldu (4 FT8 + 26 FT4, 17 farkli mesaj)
mod t(s) freq(Hz) skor mesaj
----------------------------------------------------------------
FT4 1.2 1291.7 194 OH6D EA6SX JM19
FT4 8.6 1385.4 215 CQ G3PMY IO90
FT8 9.0 693.8 476 SP9JQZ G3DXC IO91
FT4 9.0 1989.6 181 G0ABI IX1ABE JN35
FT4 15.9 1843.8 246 IX1ABE G0ABI IO80
FT4 23.6 1885.4 223 RN6MA OH6D +24
FT4 30.9 1843.8 232 IX1ABE G0ABI R-09
FT4 38.6 1885.4 251 RN6MA OH6D RR73
FT4 45.9 1843.8 220 IX1ABE G0ABI 73
FT8 68.8 1084.4 397 F5RRS SP9JQZ 73
...
Read the IX1ABE ↔ G0ABI rows top to bottom: grid → report → roger-report → RR73 → 73. That is a complete FT4 contact, five transmissions, 37 seconds — the full choreography of §1 caught in one recording.
Each run writes two files under data/, named after the input WAV, and
prints the same report to the console:
| File | Content |
|---|---|
data/<name>_message.txt |
metadata (duration, sample rate, counts) + the chronological message table |
data/<name>_freq_analysis.png |
spectrogram of the whole recording with every decoded transmission marked (FT8 red, FT4 orange, label = mode + message) |
Table columns: mod (FT8/FT4), t(s) — transmission start relative to the beginning of the recording, freq(Hz) — audio frequency of the lowest tone, skor — sync-correlation score (how loudly the sync pattern stood out; roughly, dB above the noise summed over all sync symbols), mesaj — the decoded 77-bit message, unpacked to text.
The standalone layers also run by themselves (useful when debugging one
mode in isolation). They print the same table format but scan only
their own mode and write no files — data/ outputs come solely
from the combined entry point:
python3 ft8_decoder.py misc/ft8_ft4_10489540.0kHz.wav # FT8 only, print-only
python3 ft4_decoder.py misc/sample3.wav # FT4 only, print-onlySame message layer, two different physical layers:
| Property | FT8 | FT4 |
|---|---|---|
| Slot period | 15 s | 7.5 s |
| Transmission duration | 12.64 s | 5.04 s |
| Symbols per transmission | 79 | 105 (2 of them ramp) |
| Symbol duration | 160 ms | 48 ms |
| Keying rate | 6.25 baud | 20.833 baud |
| Modulation | 8-FSK (3 bits/symbol) | 4-GFSK (2 bits/symbol) |
| Tone spacing | 6.25 Hz | 20.833 Hz |
| Occupied bandwidth | 50 Hz | 83 Hz |
| Sync | 3 × 7-symbol Costas array (identical) | 4 × 4-symbol blocks (all different) |
| Data symbols | 58 (58 × 3 = 174 bits) | 87 (87 × 2 = 174 bits) |
| Payload scrambling | none | 77-bit XOR before CRC |
| Decode threshold (spec) | ≈ −21 dB | ≈ −17.5 dB |
| Payload / FEC / CRC | identical: 77 bits + CRC-14 + LDPC(174,91) |
FT4 exists for contesting: twice the contacts per minute, paid for with ~3.5 dB of sensitivity. Everything from the LDPC decoder down is shared — which is exactly how this project's code is organized.
symbol # 0......6 7...............35 36.....42 43...............71 72.....78
┌────────┬──────────────────┬─────────┬──────────────────┬─────────┐
│ Costas │ 29 data syms │ Costas │ 29 data syms │ Costas │
│ sync │ (87 of 174 bits)│ sync │ (87 bits) │ sync │
└────────┴──────────────────┴─────────┴──────────────────┴─────────┘
◄────────────────── 79 × 160 ms = 12.64 s ──────────────────►
- The Costas array is the fixed 7-tone sequence
3 1 4 0 6 5 2, transmitted three times (start, middle, end). It is the transmission's fingerprint: the decoder slides this pattern across the whole time × frequency plane and wherever the correlation spikes, a signal starts. Three copies also give three time anchors against drift. - Each data symbol is one of 8 tones, 6.25 Hz apart. Three bits per
symbol, mapped through a Gray code (
0 1 3 2 5 6 4 7) so that the most likely receiver error — mistaking a tone for its neighbour — corrupts only one bit of the three. - 58 data symbols × 3 bits = the 174-bit codeword.
symbol # 0 1..4 5.........33 34..37 38........66 67..70 71........99 100..103 104
┌──┬─────┬────────────┬──────┬────────────┬──────┬────────────┬──────┬──┐
│R │ S1 │ 29 data │ S2 │ 29 data │ S3 │ 29 data │ S4 │R │
└──┴─────┴────────────┴──────┴────────────┴──────┴────────────┴──────┴──┘
◄──────────────────── 105 × 48 ms = 5.04 s ────────────────────►
S1 = 0 1 3 2 S2 = 1 0 2 3 S3 = 2 3 1 0 S4 = 3 2 0 1
- R is a ramp symbol (smooth key-up/key-down); it carries no data and the decoder ignores it.
- The four sync blocks are all different. With only 4 tones a single repeated 4-symbol block would false-trigger constantly; four distinct blocks at known positions (symbols 1, 34, 67, 100) make the composite pattern as distinctive as FT8's Costas arrays.
- Each data symbol is one of 4 tones (Gray map
0 1 3 2), two bits per symbol; 87 data symbols × 2 bits = the same 174-bit codeword. - The XOR twist: before the CRC is computed, the 77-bit payload is XOR-ed with a fixed pseudorandom sequence. Reason: a plain CQ message contains long runs of zeros; in 4-GFSK that would mean a long unmodulated stretch of the same tone, which hurts synchronization. The XOR whitens the payload. The decoder verifies the CRC first (on the scrambled bits, as specified) and only then un-XORs to unpack the message.
77-bit message
│ (FT4 only: XOR with pseudorandom sequence)
▼
77 + CRC-14 = 91 bits CRC over the message ⇒ false decodes die here
▼
LDPC(174,91) encoder 91 info bits + 83 parity bits = 174 bits
▼
Gray map, 3 bits → 8-FSK tone (FT8) / 2 bits → 4-GFSK tone (FT4)
▼
sync blocks inserted, tones transmitted
The LDPC(174,91) code is the workhorse: 83 parity-check equations
over 174 bits, defined by a fixed sparse matrix (in ft8_tables.py,
straight from the WSJT-X specification). The decoder does not need every
bit to arrive intact — it needs just enough soft evidence to converge to
the one codeword that satisfies all 83 checks.
The CRC-14 (polynomial 0x2757, computed over the 77 message bits
padded with 5 zeros) is the final gate. LDPC can occasionally converge to
a wrong valid-looking codeword; the CRC catches these, which is why
this decoder never prints a false decode — a message either passes both
gates or is discarded.
A computer stores the 174-bit codeword in bytes, but bytes never go on the air. A byte is a memory convention; the radio channel deals in symbols. The bit stream is chopped into groups — 3 bits at a time for FT8, 2 for FT4 — and each group selects one tone: a pure sine wave at one of 8 (or 4) fixed frequencies. Transmitting the message is playing these tones back to back.
Worked example, FT8. Take three consecutive bit-groups of a codeword:
codeword bits : ... │ 1 0 1 │ 0 0 1 │ 1 1 0 │ ...
group value : 5 1 6 (plain binary)
Gray map : 5 → tone 6, 1 → tone 1, 6 → tone 4
tone frequency: f = f₀ + tone × 6.25 Hz (f₀ = station's base freq)
And what that looks like on the air — a ladder of 8 rungs, one rung occupied at a time, 160 ms per step:
tone 7 f₀+43.75 ┤
tone 6 f₀+37.50 ┤ ██████ ← bits 1 0 1
tone 5 f₀+31.25 ┤
tone 4 f₀+25.00 ┤ ██████ ← bits 1 1 0
tone 3 f₀+18.75 ┤
tone 2 f₀+12.50 ┤
tone 1 f₀+ 6.25 ┤ ██████ ← bits 0 0 1
tone 0 f₀ ┤
└──────┴──────┴──────┴──► time
160 ms 160 ms 160 ms
Three physical facts define the scheme:
| Fact | Consequence |
|---|---|
| Amplitude is constant. All information lives in which frequency sounds, none in how loud. | Fading, AGC pumping and amplifier nonlinearity cannot corrupt a bit — the enemies of shortwave are neutralized by design. |
| Exactly one tone at a time. The signal is a single sine, hopping. | The receiver only ever asks "which of 8 bins holds the energy?" — the simplest possible decision. |
| Phase is continuous across tone changes (GFSK pulse shaping). | The spectrum stays 50 Hz (FT8) / 83 Hz (FT4) narrow; no key-click splatter onto neighbours. |
So: bits → 3-bit (2-bit) groups → Gray code → tone number → sine frequency. No bytes, no packets, no carrier bursts — just a narrow, patient melody where the melody line is the data.
Reception runs the same ladder backwards, with one crucial refinement — the decoder never makes early hard decisions.
Step 1 — measure the rungs. For each 160 ms symbol window, an FFT measures the energy at all 8 tone frequencies. A real symbol looks like this (numbers in dB):
| tone | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| energy | −62 | −61 | −60 | −48 | −59 | −63 | −61 | −60 |
Tone 3 clearly sounded. A naive decoder would now declare "tone 3, Gray⁻¹ → bits 010" and move on. Ours does not — because when the signal is at −18 dB SNR, the winner is often wrong, and a hard wrong bit is poison to the error corrector.
Step 2 — soft bits (LLR). Instead, each bit gets a confidence number, the log-likelihood ratio:
LLR(bit) = max(energy of tones where this bit = 1)
− max(energy of tones where this bit = 0)
For the table above (with e[j] = energy of tone Gray[j], so the −48 dB
winner tone 3 sits at index j = 2, i.e. bit pattern 010):
bit 0: max(e₄..e₇) − max(e₀..e₃) = −59 − (−48) = −11 → "0, confident"
bit 1: max(e₂,e₃,e₆,e₇) − max(e₀,e₁,e₄,e₅) = −48 − (−61) = +13 → "1, confident"
bit 2: max(e₁,e₃,e₅,e₇) − max(e₀,e₂,e₄,e₆) = −60 − (−48) = −12 → "0, confident"
Result 0 1 0 — exactly the bit pattern whose Gray image is tone 3.
Positive = probably 1, negative = probably 0, magnitude = confidence.
(Yes: sign convention matters. Getting it backwards yields exactly zero
decodes and one long debugging evening — see the project memory.)
Step 3 — let the code decide. The 174 LLRs go into LDPC belief propagation, which weighs every soft bit against all 83 parity equations simultaneously and iterates until one self-consistent codeword emerges. Only here do soft numbers harden back into ones and zeros. Then CRC-14 must agree, and only in the final unpacking step are bit-fields read as integers (c28, g15…) and turned into callsigns.
The chain in one line — and note where "bytes" live (nowhere on the radio side):
TX: 77 bits ─►(+CRC)─►(+LDPC)─► 174 bits ─► 3-bit groups ─► Gray ─► tones ─► antenna
RX: antenna ─► FFT energies ─► 174 soft LLRs ─► LDPC ─► 174 bits ─► CRC gate ─► 77 bits ─► text
For the standard message types (i3 = 1 or 2 — the overwhelming
majority of traffic), the 77 bits are packed like this:
bit: 0..........27 28 29..........56 57 58 59...........73 74..76
┌──────────────┬──┬──────────────┬──┬──┬───────────────┬──────┐
│ c28 │r1│ c28 │r1│ R│ g15 │ i3 │
│ callsign 1 │ │ callsign 2 │ │ │ grid / report │ type │
└──────────────┴──┴──────────────┴──┴──┴───────────────┴──────┘
c28 — a callsign in 28 bits. The field is a single integer whose range selects the interpretation — a beautiful packing trick:
| c28 value range | Meaning |
|---|---|
| 0, 1, 2 | the tokens DE, QRZ, CQ |
| 3 … 1002 | CQ 000 … CQ 999 (numeric CQ) |
| 1003 … 532443 | CQ A … CQ ZZZZ (directed CQ, base-27 letters) |
| + 4,194,304 values | 22-bit hash of a nonstandard callsign (shown as <...>) |
| everything above | a standard callsign, packed positionally: char₁ from 37 alphanumerics+space, char₂ from 36, char₃ a digit, chars₄₋₆ from 27 letters+space — 37·36·10·27³ ≈ 2.6 × 10⁸ combinations |
r1 — flag: the callsign carries a /R (rover) or /P (portable)
suffix.
R — the "R " prefix of a grid report (acknowledgement semantics).
g15 — grid or report in 15 bits. Values ≤ 32400 are a 4-character
Maidenhead locator (JN58 = two base-18 letters + two digits, packed
positionally). The values above 32400 encode the special reports:
blank, RRR, RR73, 73, and signal reports -30 … +32 dB.
i3 — message type. 1 = standard, 2 = EU VHF flavour; other
values select contest, telemetry and free-text layouts (this decoder
reports their raw bits without unpacking them).
Worked example — the golden vector CQ DL6SMA JN58:
c28₁ = 2 (CQ), c28₂ = the positional packing of DL6SMA,
g15 = the packing of JN58, i3 = 1. Total: two callsigns and a locator
in fewer bits than ten ASCII characters.
No. This surprises everyone: the 77 bits contain no time, no date, no UTC — only what §6 shows. Time lives entirely outside the message, by convention:
- Both stations' clocks are assumed synchronized to UTC (within ±1 s).
- Transmissions may only start on slot boundaries — every 15 s for FT8, every 7.5 s for FT4. The receiver knows when to listen without being told.
- The timestamps you see in WSJT-X or WebSDR decode lists
(
260806_213045 ...) are stamped by the receiver's own clock at decode time — display metadata, not payload.
Consequence for this project: a WAV file carries no absolute time, so our
message table reports t(s) — seconds from the start of the recording.
If you need UTC, note the recording start time when you press "record"
and add the offsets. (A second consequence, visible in the golden
recording: the same CQ DL6SMA JN58 appears at t = 23 s and t = 53 s —
that is the 30-second even/odd slot cadence of a station calling CQ.)
Five layers, each one readable in isolation:
- Waterfall (
waterfall): chop the audio into symbol-length windows (160 ms / 48 ms), FFT each, keep power in dB. Result: a matrixM[time_step, frequency_bin]with 2× oversampling on both axes — the decoder never touches raw audio again. - Sync search (
find_candidates): slide the mode's sync pattern over every (time, frequency) offset ofM; score = energy at the expected sync tones minus the average of all tones. Local maxima above threshold become candidates (top 40, with peak suppression so one loud signal doesn't monopolize the list). - Soft demodulation (
extract_llrs): for each candidate, read the tone energies of every data symbol and form one log-likelihood ratio per bit:max(energy of tones where bit = 1) − max(energy of tones where bit = 0). Sign convention matters: positive LLR = bit 1 (this cost us a day once — see the project's memory file). - LDPC decode (
ldpc_decode): attenuated min-sum belief propagation, 30 iterations. The health metric is the parity counter: how many of the 83 check equations are satisfied. 83/83 = converged; stuck in the 60s = the frame is beyond repair at this SNR. - CRC gate + unpacking (
crc14,unpack77): verify CRC-14; for FT4 un-XOR the payload; unpack per §6. Then duplicate suppression (same message in adjacent waterfall cells) — but the same message in a different slot is kept as a separate row, deliberately: each transmission is an on-air event in its own right.
Around the core, the entry point (ft8_ft4_decoder.py) simply runs the
FT8 scan then the FT4 scan and merges the tables — the two physical
layers never interact.
Every run draws one of these. Below: the both-modes-at-once recording, four FT8 and twenty-six FT4 transmissions marked on one spectrogram:
The spectrogram spans 0–3200 Hz of receiver audio over the whole recording. What to look for:
- Vertical stripes with ~15 s / 7.5 s rhythm — the slot structure of the band: many stations keying up together, then silence, then the answering slot.
- A narrow horizontal streak is one station: 50 Hz wide and 12.64 s long for FT8, 83 Hz wide and 5.04 s long for FT4 (visibly wider and shorter — once you've seen both you can identify the mode by eye).
- Coloured underline + label — a transmission this decoder actually decoded: red for FT8, orange for FT4; the line length equals the transmission duration, the label shows mode and message.
- The unmarked streaks are the honest part of the picture: signals
whose sync was found but which failed LDPC/CRC. The WebSDR decoded a
few more marginal FT8 transmissions in this same window than we did,
and in
misc/sample3.wavtwo weak FT4 stations plateau at 63–64 of 83 parity checks — the current sensitivity limit discussed in §10.
| File | Role |
|---|---|
ft8_ft4_decoder.py |
entry point: runs both scans, merges results, writes data/ outputs |
ft8_decoder.py |
FT8 physical layer + the shared message layer (LDPC, CRC-14, 77-bit unpacking) |
ft4_decoder.py |
FT4 physical layer; imports the shared layer from ft8_decoder |
ft8_tables.py |
protocol constants shared or FT8-specific: LDPC(174,91) matrix, Costas array, 8-tone Gray map |
ft4_tables.py |
FT4-specific constants: the four sync blocks, 4-tone Gray map, 77-bit XOR sequence |
misc/ |
test recordings (QO-100 via IS0GRB) + the recording-session screenshot |
data/ |
outputs: <name>_message.txt, <name>_freq_analysis.png |
Import structure mirrors the protocol itself: what the spec shares, the
code shares (ft4_decoder imports LDPC/CRC/unpacking from
ft8_decoder); what the spec separates, the code separates.
Known limitations (deliberate scope, documented so expectations are honest):
- No sub-cell time/frequency sync yet: the decoder aligns to half-symbol
and half-tone granularity. Strong signals decode; marginal ones
(the t = 83 s FT8 repeat in the golden recording, the weak FT4
transmissions in
sample3) fail the CRC. This is the top item on the roadmap. - Min-sum belief propagation only. WSJT-X additionally runs ordered statistics decoding (OSD) and a-priori decoding, which is where its last ~3 dB of sensitivity lives.
- Message types: standard (
i3 = 1/2) fully unpacked; hashed callsigns shown as<hash22:...>(a rolling hash table is future work); contest/free-text reported as raw bits. - Browser-captured WAVs may contain sample drops that disproportionately hurt FT4 (48 ms symbols). For serious FT4 work, record with a proper SDR client.
- S. Franke K9AN, B. Somerville WM8Q, J. Taylor K1JT, The FT4 and FT8 Communication Protocols, QEX July/August 2020 — the protocol specification this project follows: https://wsjt.sourceforge.io/FT4_FT8_QEX.pdf
- WSJT-X (the reference implementation): https://wsjt.sourceforge.io/
- kgoba/ft8_lib — compact open-source implementation whose protocol tables were used (BSD): https://github.com/kgoba/ft8_lib
- Signal Identification Wiki — FT8 / FT4 spectrograms and samples: https://www.sigidwiki.com/wiki/FT8
- AMSAT-DL, QO-100 narrow-band transponder band plan: https://amsat-dl.org/en/qo-100-nb-transponder-bandplan-and-operating-guidelines/
- Tested WebSDR (IS0GRB): http://websdr.is0grb.it:8901/
- Sister projects: qo100-beacon-decoder · sstv-decoder · sstv-encoder

