Skip to content

Repository files navigation

ft8-ft4-decoder

🇬🇧 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"

Contents

  1. What are FT8 and FT4?
  2. How to record (WebSDR, step by step)
  3. Usage and outputs
  4. FT8 and FT4 side by side
  5. The frame, bit by bit
  6. The 77-bit payload — what a message actually carries
  7. Is there a UTC timestamp in the message?
  8. How the decoder works
  9. Reading the frequency analysis
  10. Files, code map, limitations
  11. References

1. What are FT8 and FT4?

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:

  1. 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.
  2. 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.
  3. 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.

2. How to record

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):

WebSDR FT8/FT4 session

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.

3. Usage and outputs

pip3 install numpy scipy matplotlib
python3 ft8_ft4_decoder.py misc/ft8_ft4_10489540.0kHz.wav

The 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 filesdata/ 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-only

4. FT8 and FT4 side by side

Same 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.

5. The frame, bit by bit

5.1 FT8: 79 symbols

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.

5.2 FT4: 105 symbols

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.

5.3 From 77 bits to tones (both modes)

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.

5.4 How the ones and zeros actually fly (and why there are no bytes)

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.

5.5 At the receiver: from energies back to bits

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

6. The 77-bit payload

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 000CQ 999 (numeric CQ)
1003 … 532443 CQ ACQ 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.

7. Is there a UTC timestamp?

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.)

8. How the decoder works

Five layers, each one readable in isolation:

  1. Waterfall (waterfall): chop the audio into symbol-length windows (160 ms / 48 ms), FFT each, keep power in dB. Result: a matrix M[time_step, frequency_bin] with 2× oversampling on both axes — the decoder never touches raw audio again.
  2. Sync search (find_candidates): slide the mode's sync pattern over every (time, frequency) offset of M; 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).
  3. 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).
  4. 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.
  5. 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.

9. Reading the frequency analysis

Every run draws one of these. Below: the both-modes-at-once recording, four FT8 and twenty-six FT4 transmissions marked on one spectrogram:

FT8+FT4 frequency analysis

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.wav two weak FT4 stations plateau at 63–64 of 83 parity checks — the current sensitivity limit discussed in §10.

10. Files, code map, limitations

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.

11. References

About

Pure-Python FT8/FT4 decoder for QO-100 satellite WebSDR recordings — Costas sync search, GFSK demodulation with soft LLRs, LDPC(174,91) belief propagation, CRC-14 and 77-bit message unpacking, implemented from scratch with numpy/scipy.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages