CW — native Morse decoder (soft decisions + duration HMM)
Machine reading of hand- and machine-sent Morse, Bell-1977 style: a Goertzel tone detector with log-domain AGC feeds per-5-ms soft mark probabilities into a semi-Markov Viterbi decoder whose states are the Morse elements themselves — dit, dah, element/character/word space, idle — with log-normal duration priors scaled by a continuously tracked dit period. Pure Python stdlib, real time, and deliberately paranoid about emitting text that isn't really Morse.
Rafe project · app/radio/cwdecoder.py, test_cw.py · RX only (transmit is the radio's own keyer via CI-V cw_send) · approach credits: soft-decision statistical decoding after RSCW (PA3FWM); duration-model Viterbi after Bell, MIT 1977 · supersedes the "current decoder" described in cw-decoder-roadmap.md — this is that roadmap's phases 1–2 implemented
Abstract
CW is the hardest "easy" mode in the catalogue. There is no framing, no sync word, no FEC, no fixed speed — just a keyed tone whose durations carry the information, timed by a human wrist through QSB. A threshold-and-run-length decoder (the classic approach) collapses the moment fading shortens a dah or bridges a gap. This decoder keeps two pieces of information such decoders throw away: how confident each 5 ms slice is (a soft probability, not a hard mark/space), and how long Morse elements are allowed to be (explicit duration priors). A Viterbi search over the element grammar then finds the globally best reading of the last few seconds — healing fade-shortened dahs and bridged gaps that no local decision could — before text is committed.
The other half of the design is restraint: an SNR + spectral-purity gate decides when a CW signal exists at all, a two-means duration clustering confirms the signal has genuine Morse structure, and all decoded text is quarantined until that confirmation arrives — so speech, data modes and noise produce silence, not gibberish.
1. Front end
| Constant | Value | Meaning |
|---|---|---|
| input | 48 kHz s16le mono | from the radio audio chain |
DECIM |
4 → 12 kHz | boxcar-by-4 decimation |
FRAME |
60 samples = 5 ms hop | analysis granularity |
PHIST |
900 hops = 4.5 s | level history for floor/peak statistics |
GATE_ON / GATE_OFF |
9 dB / 5 dB | activation hysteresis on peak−floor |
K_LOGI |
0.8 /dB | logistic slope of the soft decision |
MIN_SEP |
9 dB | minimum floor-to-threshold separation |
EM_CLAMP |
−6 | per-hop log-likelihood clamp |
DELAY |
500 hops = 2.5 s | decode delay before commitment |
COMMIT_EVERY |
100 hops | traceback cadence |
IDLE_PEN |
11 | log-cost of entering the idle state |
Each hop: a Goertzel single-bin energy at the tracked tone (a 200 Hz-wide 5 ms filter), averaged with the previous hop (a symmetric 2-hop boxcar — no IIR release tail to smear mark edges), and converted to dB. Every 10 hops the level history yields the noise floor (15th percentile) and peak (95th percentile); their gap is the SNR statistic driving the gate.
Log-domain AGC. A fast-attack/slow-decay peak tracker (decay 0.03 dB/hop ≈ 6 dB/s) rides the QSB envelope in dB, where multiplicative fading becomes a slowly varying additive offset. The decision threshold is the midpoint between the floor and the tracked peak (never closer than floor + 2·MIN_SEP), so both the threshold and the soft-decision slope follow fades automatically.
Soft decision. Each hop's dB level maps through a logistic to \(p(\text{mark})\) with slope 0.8/dB about the threshold; the decoder stores \(\log p(\text{mark})\) and \(\log p(\text{space})\), clamped at −6 so no single noise spike can veto a path (the same LLR-clamping logic as every soft decoder in the catalogue — maths guide §10.3).
2. Tone tracking
A separate 256-sample buffer at 6 kHz (~43 ms — short enough to fit inside a dah at 40 wpm, so a "keyed" sweep sees pure tone, not keying splatter) is swept 300–1000 Hz in 12.5 Hz steps with Goertzel:
- Keyed sweeps fire when a mark run is in progress (≥ 8 hops, rate-limited to one per 80 hops; before activation ≥ 6 hops / 30, so a lone
Kcan still be caught); timer sweeps fill the gaps every 130 hops. The two run on separate rate limits so a timer sweep landing in a gap can't starve the keyed ones. - A sweep reports the best bin, whether the line dominates the spectrum (> 6× mean; > 15× ⇒ counts as keyed even without a mark run) and whether it is pure (best > 3× the strongest peak more than 75 Hz away — keying sidebands live within ~75 Hz, a second station or speech formant rarely does).
- Retune happens on a consistent, clearly stronger, pure peak away from the current tone (≥ 3× the current tone's power while active and agreeing with the previous sweep; ≥ 10× when idle). A small step (slow drift) is transparent to the 200 Hz hop filter; a big jump (> 75 Hz) invalidates history — an established session is flushed (station change), a young one is restarted, and pre-start buffered emissions are recomputed from raw samples at the new tone so the catch-up decode sees the signal's real first characters.
The gate. Activation needs two consecutive pure keyed sweeps within 30 Hz of each other (or one overwhelming sweep, > 20× mean) and ≥ 9 dB SNR. Deactivation: SNR < 5 dB, or two consecutive strong-but-impure sweeps — the latter aborts an unconfirmed session (it was never CW) but flushes a confirmed one (the CW ended or was steamrollered).
3. Speed bootstrap — two-means duration clustering
Threshold-run mark lengths (runs of 3–90 hops whose peak cleared the floor by ≥ 13 dB — keeps borderline noise pops out of the statistics) accumulate in a deque, and _cluster_tb fits two clusters in log-duration space with an outlier-shedding budget. The model: dit marks measure \(T+B\) hops, dahs \(3T+B\) — one shared dit period \(T\) and one keying bias \(B\) (marks stretched, spaces shortened, by the same amount — the physical signature of edge timing). From cluster centres \(c_1, c_2\):
\[T = \frac{c_2 - c_1}{2},\qquad B = c_1 - T\]
Accepted only when the structure is genuinely Morse-like: dah/dit ratio \(c_2/c_1 \in [1.8, 3.8]\), log-domain RMS deviation ≤ 0.22, \(T \in [3, 42]\) hops (≈ 6–68 wpm; wpm = 240/T at 5 ms hops), \(B\) small relative to \(T\). A tighter fit (RMS ≤ 0.17, ≥ 3 members per cluster, ratio 2.0–3.6) sets the strict flag used for text confirmation (§6). Degenerate cases have fallbacks: a long single tight cluster is read as dits; an over too short to cluster at all (a lone K, E E) gets _fallback_tb's dah/dit split or nearest-to-20 wpm single-length reading, so even two-mark overs decode.
The HMM starts once the gate is open, a speed estimate exists, and ≥ 20 mark hops are buffered (or the over already ended with ≥ 3 mark hops — decode the buffer with the fallback speed). At start, a trial traceback re-fits \((T, B)\) on the lattice's own segmentation of the buffered opening — the threshold bootstrap is often a couple of hops off — and rebuilds before anything is committed.
4. The semi-Markov Viterbi
Six states with an alternation grammar — a mark must be followed by a space and vice versa:
┌──────────── dit ◄──┐ mark states: dit, dah
▼ │
element-space char-space word-space idle
│ ▲ (geometric, entry
└──────────── dah ◄──┘ penalty 11; exits
to a mark)
Each element's duration is scored by a log-normal prior around its Morse-grammar mean, scaled by the tracked \((T, B)\):
| Element | Mean (hops) | σ (log) | Support |
|---|---|---|---|
| dit | \(T + B\) (≥ 2.5) | 0.25 | \([\max(2, 0.45\,\mu_{dit}),\ 1.15\,m]\) |
| dah | \(3T + B\) | 0.22 | \([0.85\,m,\ 1.9\,\mu_{dah}]\) |
| element space | \(T - B\) (≥ 1.5) | 0.35 | \([\max(2, 0.4\,\mu_{esp}),\ 1.2\,s]\) |
| char space | \(3T - B\) (≥ 3) | 0.30 | \([0.8\,s,\ 0.55(\mu_{csp}{+}\mu_{wsp})]\) |
| word space | \(7T - B\) (≥ 6) | 0.40 | \([0.45(\mu_{csp}{+}\mu_{wsp}),\ 1.9\,\mu_{wsp}]\) |
(\(m\) = dit/dah mean midpoint, \(s\) = esp/csp mean midpoint; prior cost \(= -\tfrac{z^2}{2} - \ln d - \ln\sigma - \ln\sqrt{2\pi}\) with \(z = \ln(d/\mu)/\sigma\), weight PW = 1.)
The lattice keeps cumulative mark/space log-likelihood sums, so scoring "a dah occupying hops \(j..i\)" is two array lookups + the prior — each hop's update scans the allowed duration window per state (a semi-Markov, i.e. explicit-duration, Viterbi rather than a per-hop HMM). The idle state has geometric duration with an 11-unit entry penalty: long dead air costs something to enter, then nothing — so the lattice neither invents structure in silence nor resists returning from it.
Delayed commitment. Every 100 hops, the best path is traced back and everything older than 2.5 s is committed — young enough to feel live, old enough that path merges have collapsed the ambiguity (the survivor-merge argument of Viterbi decoding — maths guide §10.2). Committed lattice history is trimmed; an over ends (flush) when silence exceeds \(\max(12T, 240)\) hops or the gate closes.
5. Speed tracking while decoding
Committed mark durations feed a second clustering (deque of 24, re-fit every 4 new marks). Small drift blends in at 0.3/update; a fit differing by > 12 % in \(T\) (or 1.5 hops in \(B\)) snaps and schedules a lattice rebuild, and if the tracked speed moved > 4 % since the priors were built they are rebuilt — so a mid-over speed change (or a bad bootstrap) retroactively re-decodes the uncommitted window. The threshold-run clusters keep running in parallel as a scale-lock check: firm disagreement > 25 % forces the correction.
6. Quarantine: only Morse gets out
All output is buffered until the session confirms:
- the committed marks form a strict two-cluster fit (§3), and
- the committed mark hops are near-saturated — geometric-mean \(p(\text{mark}) \ge 0.955\) over ≥ 40 mark hops (real CW measures ≥ 0.967 even at 40 wpm / 4 dB / deep QSB; speech-like envelopes top out ≈ 0.95 — the 5 ms edges of real keying are what saturate the hops), and
- ≥ 5 characters, of which ≤ 60 % are E/T/I/
#(the letters noise makes).
Confirmed sessions stream text live from then on. Sessions that end unconfirmed release only if short (≤ 10 marks with the contrast bar met, or ≤ 34 marks with contrast + a valid cluster + ≥ 2 characters) — a long unconfirmed session is structureless noise and is discarded. Sustained cluster failure (3 consecutive failed fits with ≥ 20 marks banked, without a crisp two-level contrast excuse) aborts: everything unconfirmed is dropped, and a 30 s distrust window suppresses the short-over release rules. One more nicety: a lone dit/dah at session start followed by a ≥ 9 dit-period gap is dropped as a lead-in noise blip rather than emitted as a one-letter word.
7. The Morse table
Decoded element strings look up directly; unknown patterns emit #:
A .- B -... C -.-. D -.. E . F ..-. G --.
H .... I .. J .--- K -.- L .-.. M -- N -.
O --- P .--. Q --.- R .-. S ... T - U ..-
V ...- W .-- X -..- Y -.-- Z --..
0 ----- 1 .---- 2 ..--- 3 ...-- 4 ....- 5 .....
6 -.... 7 --... 8 ---.. 9 ----.
. .-.-.- , --..-- ? ..--.. / -..-. = -...- + .-.-.
( -.--. ) -.--.- @ .--.-. : ---... - -....- " .-..-.
_ ..--.- ' .----. ! -.-.--
<SK> ...-.- <AS> .-... <KA> -.-.- <ERR> ........
8. Validation
test_cw.py synthesises keyed CW at 48 kHz with 5 ms raised-cosine edges, calibrated noise, and optional QSB, then requires exact text:
| Text | wpm | Tone | SNR | Stress |
|---|---|---|---|---|
CQ CQ CQ DE M0ABC M0ABC K |
20 | 650 | 25 dB | baseline |
CQ TEST 5NN 73 |
12 | 700 | 20 dB | slow |
THE QUICK BROWN FOX 599 |
28 | 500 | 15 dB | fast, all letters |
R TU 73 E E |
35 | 800 | 20 dB | very fast, single-dit words |
HELLO WORLD |
20 | 650 | 8 dB | weak |
CQ DX DE G4XYZ |
24 | 600 | 12 dB | weak-ish |
CQ CQ DE F5ABC K |
16 | 500 | 18 dB | QSB 0.55 deep fading |
9. Limitations and pointers
- One signal at a time per audio channel: the tracked tone follows the strongest pure line in 300–1000 Hz; a second CW signal is competition, not a second decode. (Multi-signal decoding is the natural next phase in cw-decoder-roadmap.md.)
- Text lags real time by the 2.5 s commitment delay by design.
- No linguistic priors: the decoder is deliberately language-agnostic (a character model would help marginal copy and hurt callsigns/serials).
- Transmit is not an audio modem here — keying is delegated to the radio's own CW keyer over CI-V (
cw_send).
Related: Feld Hell and ISM/OOK (the other on-off-keyed modes, with fixed machine timing where CW has a human wrist), the Beginner's Tour stage 1, and the maths guide on detection and Viterbi.