feat: CW decoder v2 — rebuilt decode engine, back in the UI

The first RX-audio CW decoder decoded poorly on real signals (vs SDC) and was
removed in cafade0. This rebuilds the DSP core from scratch in
internal/cwdecode, keeping v1's good ideas (pitch lock, Flex cw_pitch
targeting, tiered acquisition) and fixing its proven failures:

- Two-way DEBOUNCE (pending-commit ~0.3 dit): v1 rejected short marks but not
  short spaces, so a one-hop fade inside a dah shattered it into dits — the
  single worst real-signal failure.
- Per-character BATCH dit/dah classification at flush time, using the batch's
  own bimodal boundary — the first letter of an over decodes at any speed; the
  timing clusters (muDit/muDah) are updated with the same attribution, killing
  the classify-then-learn feedback spiral ("all dits at 60 WPM").
- dB-domain envelope with separate floor/peak trackers, hysteresis slicer,
  span CAP (30 dB — else quiet backgrounds put the off-threshold in the
  analysis-window skirts and letters merge) and window de-bias on durations.
- Double squelch: span >= 6 dB AND peak >= bank-median + 9.5 dB (span alone
  cannot reject pure noise).
- Hamming-windowed Goertzel, 16 ms window / 5 ms hop (a 20 ms window left
  40 WPM inter-element gaps with no envelope dip).
- Hardened acquisition (overlapping windows made "3 stable hops" meaningless)
  plus SUPERVISED RE-LOCK: a clearly stronger tone on another pitch sustained
  ~1 s takes over — heals noise locks and follows a QSY.
- Gap-fed dit-length tracking (inter-element gaps are timing evidence too).

Proven by a synthetic-signal test suite (all passing): clean 12-40 WPM, three
pitches, added noise, QSB fading 0.35-1.0, 10 ms dropouts inside dahs, QRM in
auto and targeted modes, noise-only squelch, mid-over speed change 25->15 WPM,
and jittered hand keying (+/-20-25%, 3 seeds).

Wiring and UI restored from git (app_cw.go, Ear header button, decoded-text
strip with WPM/pitch/level + pitch lock + click-a-word-to-fill-callsign, Tools
menu entry) — strip strings translated (cwd.* i18n keys, EN/FR) this time.
This commit is contained in:
2026-07-23 22:50:02 +02:00
parent 7b0ef0ba97
commit d6a2e84eed
10 changed files with 1349 additions and 2 deletions
+697
View File
@@ -0,0 +1,697 @@
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
// mono PCM samples into decoded text.
//
// This is the second generation of the decoder. The first one worked on clean
// machine keying but fell apart on real signals; every stage below exists to
// fix a specific failure of that version (and of naïve Goertzel decoders in
// general):
//
// audio ─ Hamming-windowed Goertzel bank ─ pitch lock ─ dB envelope with
// separate noise-floor / peak trackers ─ hysteresis slicer + SNR squelch ─
// DEBOUNCED mark/space stream ─ two-cluster dit/dah length tracking ─
// element / character / word segmentation ─ Morse table ─ text
//
// The fixes that matter, in order of impact:
//
// 1. Debounced transitions BOTH ways. The old decoder rejected too-short
// marks but not too-short SPACES, so a one-hop fade inside a dah (QSB,
// static crash) split it into two dits — the single biggest source of
// garbage on real signals. Here a state flip must persist for a glitch
// time (~0.3 dit) before it is committed; shorter flips are folded back
// into the surrounding element.
//
// 2. Two-cluster timing. Dit and dah lengths are tracked as two separate
// moving centres with the decision boundary at their geometric mean,
// instead of one EMA "dot length" that both classifies marks and is
// updated by that same classification (a feedback loop that spiralled to
// "all dits at 60 WPM" the moment it started mis-classifying). The
// inter-element gaps also feed the dit centre — spaces are timing
// evidence too, and hand keying is often more regular in its gaps than
// in its dits.
//
// 3. dB-domain envelope. Peak and noise floor are tracked in dB with
// asymmetric attack/decay, the slicer runs at 55%/38% of the span with
// hysteresis, and a minimum-span squelch (6 dB) keeps pure noise from
// keying at all. In the old linear-magnitude scheme weak signals lived
// in the bottom few percent of the scale and QSB swallowed them.
//
// 4. Windowed Goertzel. A Hamming window tames spectral leakage so a strong
// tone doesn't bleed across the whole bank and corrupt both the noise
// estimate and the lock choice.
//
// Kept from the first version because they were right: the pitch LOCK (decode
// one tone, ignore QRM at other pitches), pitch targeting (follow the radio's
// known CW pitch instead of searching — SetTarget), tiered acquisition
// (strong signals lock on the first hop so their opening dit isn't eaten),
// the floor frozen during key-down (a rising floor mid-dah fragments it), and
// the end-of-over flush when the lock releases.
//
// Deliberately dependency-free and fed by plain []int16 so the whole pipeline
// is unit-tested with synthetic signals (see cwdecode_test.go: clean keying at
// several speeds, added noise, QSB fading, QRM on a nearby pitch, and a
// noise-only squelch test).
//
// Honest expectations: on clean or moderately noisy signals this decodes
// solidly; very weak signals in heavy QRM remain hard for any envelope
// decoder — the tools that shine there (CW Skimmer, SDC) use probabilistic
// sequence estimation on top. This stage is designed so such a layer could be
// added later without touching the DSP.
package cwdecode
import (
"math"
"sort"
"sync/atomic"
)
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
type Status struct {
WPM int `json:"wpm"`
Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked)
Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter
Active bool `json:"active"` // a tone is currently keyed down
}
// Decoder consumes PCM and emits decoded characters via onChar (one or more
// characters at a time, including " " for word gaps) and periodic onStatus.
// Process must be called from a single goroutine; SetTarget is safe to call
// concurrently.
type Decoder struct {
fs int
hop int // samples between analyses (~6 ms)
win int // Goertzel window length (~20 ms)
hopMs float64 // hop duration in ms
biasMs float64 // envelope widening caused by the analysis window (see below)
window []float64 // Hamming window, len win
ring []float64 // circular raw-sample buffer, len win
rpos int // next write position in ring
filled int // samples written so far (until >= win)
acc int // samples since last analysis
ws []float64 // scratch: windowed samples for this hop
// Search bank (only run while unlocked / untargeted).
freqs []float64
coeffs []float64
mags []float64 // dB per bin
nbuf []float64 // scratch for the median
// Fixed-pitch target (Hz). 0 = auto-search; >0 = decode exactly this pitch
// and ignore everything else (e.g. follow the radio's CW pitch). Set live
// from another goroutine, so it's atomic.
targetHz atomic.Int32
targetFor float64 // freq the cached target coeff was computed for
targetCoeff float64
// Pitch lock.
lockIdx int // bin index while auto-locked; -1 = unlocked
candIdx int
candHops int
quietHops int // consecutive key-up hops while locked (drives release)
bankTick int // hops since the bank last ran while locked
betterHops int // evidence that a different bin is the real signal
// Envelope (dB domain) on the locked/target tone.
floorDB, peakDB float64
bankNoiseDB float64 // broadband reference: EMA of the bank median
haveBankNoise bool
envSeeded bool
rawKey bool // slicer output this hop
// Debounced mark/space state machine.
key bool // committed state (true = mark)
stableHops int // hops in the committed state
pendHops int // consecutive hops the raw state has disagreed
// Two-cluster element timing (ms).
muDit, muDah float64
marksSeen int
// Character assembly. Element DURATIONS are stored and only classified
// into dits/dahs when the character is flushed: by then the character's
// own marks are all known, and a bimodal batch carries its own dit/dah
// boundary — so even the very first character of an over decodes
// correctly at any speed, before the global clusters have converged.
elemMs []float64
charEmitted bool
wordEmitted bool
textSince bool // something was decoded since lock (guards leading spaces)
lastPitch float64
lastRMS float64
statusEvery int
sinceStatus int
onChar func(string)
onStatus func(Status)
}
var morse = map[string]byte{
".-": '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',
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
}
// Tunables (hops are ~6 ms).
const (
minDitMs = 20.0 // 60 WPM ceiling
maxDitMs = 240.0 // 5 WPM floor
seedDit = 60.0 // 20 WPM seed before any marks are seen
acqStrongDB = 12.0 // lock on the FIRST hop above this SNR (don't eat the opening dit)
acqWeakDB = 8.5 // lock after sustained hops above this SNR
// Successive analysis windows overlap ~70%, so consecutive hops are highly
// correlated — a noise spike easily "persists" 23 hops. Requiring ~2 full
// window lengths of persistence makes a false noise lock genuinely rare.
acqWeakHops = 6
squelchDB = 6.0 // minimum peak-floor span to key at all
// The span alone can't reject pure noise: a noise bin's dB level swings
// ±56 dB, which the peak/floor trackers happily turn into a keyable
// span. The second squelch is ABSOLUTE: the envelope peak must stand well
// above the broadband noise reference (the bank median) — a keyed tone
// does, noise never sustainably does.
snrSquelchDB = 9.5
// The slicer's span is CAPPED: with a very quiet background (high SNR, or
// digitally silent test signals) the raw floor sits so far below the peak
// that the off-threshold lands in the analysis window's skirts — every
// mark then stretches by almost a full window and every gap shrinks,
// until character gaps fall below the segmentation threshold and letters
// merge. Capping the usable span keeps the slicer crossing near the
// signal edges regardless of how quiet the background is.
spanCapDB = 30.0
onFrac = 0.55 // slicer thresholds as a fraction of the (capped) span…
offFrac = 0.38 // …with hysteresis
charGapDits = 2.2 // gap > this ⇒ character boundary (geom. mean of 1 & 3 ≈ 1.7, plus margin for sloppy fists)
wordGapDits = 4.6 // gap > this ⇒ word boundary (geom. mean of 3 & 7)
)
// New builds a decoder for the given sample rate. onChar receives decoded text
// incrementally; onStatus receives ~10 snapshots/second. Either may be nil.
func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
if sampleRate <= 0 {
sampleRate = 16000
}
d := &Decoder{
fs: sampleRate,
hop: sampleRate * 5 / 1000, // 5 ms — resolves dits up to ~50 WPM
win: sampleRate * 16 / 1000, // 16 ms window (≈80 Hz bandwidth with Hamming; a 20 ms window left 40 WPM inter-element gaps with almost no envelope dip)
lockIdx: -1,
candIdx: -1,
muDit: seedDit,
muDah: 3 * seedDit,
onChar: onChar,
onStatus: onStatus,
}
if d.hop < 1 {
d.hop = 1
}
if d.win < 4*d.hop {
d.win = 4 * d.hop
}
d.hopMs = float64(d.hop) / float64(d.fs) * 1000
// Even with the span cap, the analysis window widens every mark and
// narrows every gap by roughly half a window on each edge (the tone leaks
// into windows that straddle an edge). Durations are de-biased by this
// constant so the timing clusters and gap thresholds see true lengths.
d.biasMs = 0.6 * float64(d.win) / float64(d.fs) * 1000
d.statusEvery = int(math.Max(1, 100/d.hopMs)) // ~10 Hz
d.ring = make([]float64, d.win)
d.ws = make([]float64, d.win)
// Hamming window: 43 dB sidelobes keep a strong tone from bleeding across
// the bank (rectangular Goertzel leaks at 13 dB, enough to fool the lock).
d.window = make([]float64, d.win)
for i := range d.window {
d.window[i] = 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(d.win-1))
}
// Candidate CW tones: 4001000 Hz every 30 Hz. Deliberately NOT lower:
// low-frequency hum/rumble rises toward DC and would win the argmax.
for f := 400.0; f <= 1000.0; f += 30 {
d.freqs = append(d.freqs, f)
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
}
d.mags = make([]float64, len(d.freqs))
d.nbuf = make([]float64, len(d.freqs))
return d
}
// SetTarget fixes the decode pitch to hz (an exact-frequency detector, not the
// nearest search bin), or returns to auto-search when hz <= 0. Safe to call
// concurrently.
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }
// Reset clears decode state (e.g. when the user re-arms the decoder).
func (d *Decoder) Reset() {
d.rpos, d.filled, d.acc = 0, 0, 0
d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
d.envSeeded, d.rawKey, d.key = false, false, false
d.haveBankNoise = false
d.stableHops, d.pendHops = 0, 0
d.bankTick, d.betterHops = 0, 0
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
d.elemMs = d.elemMs[:0]
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
}
// Process feeds a block of mono samples through the decoder.
func (d *Decoder) Process(samples []int16) {
for _, s := range samples {
d.ring[d.rpos] = float64(s)
d.rpos++
if d.rpos == d.win {
d.rpos = 0
}
if d.filled < d.win {
d.filled++
}
d.acc++
if d.acc >= d.hop && d.filled >= d.win {
d.acc = 0
d.hopStep()
}
}
}
// goertzelDB returns the tone power (dB, arbitrary reference) of the windowed
// scratch buffer at the detector coefficient c.
func (d *Decoder) goertzelDB(c float64) float64 {
var s1, s2 float64
for _, x := range d.ws {
s0 := x + c*s1 - s2
s2 = s1
s1 = s0
}
p := s1*s1 + s2*s2 - c*s1*s2
if p < 1e-12 {
p = 1e-12
}
return 10 * math.Log10(p)
}
// hopStep runs one analysis hop: tone detection, envelope, slicer, timing.
func (d *Decoder) hopStep() {
// Materialize the window (oldest→newest; order is irrelevant for power)
// and the RMS level for the UI meter.
var sumSq float64
for i := 0; i < d.win; i++ {
x := d.ring[(d.rpos+i)%d.win]
d.ws[i] = x * d.window[i]
sumSq += x * x
}
d.lastRMS = math.Min(1, math.Sqrt(sumSq/float64(d.win))/32768*4)
toneDB, haveTone := 0.0, false
if th := float64(d.targetHz.Load()); th > 0 {
// Fixed pitch: one exact-frequency detector, like a skimmer channel.
if th != d.targetFor {
d.targetFor = th
d.targetCoeff = 2 * math.Cos(2*math.Pi*th/float64(d.fs))
d.envSeeded = false // re-seed the envelope for the new channel
}
toneDB = d.goertzelDB(d.targetCoeff)
d.lastPitch = th
d.lockIdx = -1 // targeting supersedes the auto lock
haveTone = true
// Refresh the broadband noise reference for the absolute squelch.
d.bankTick++
if d.bankTick >= 8 {
d.bankTick = 0
for i, c := range d.coeffs {
d.mags[i] = d.goertzelDB(c)
}
copy(d.nbuf, d.mags)
sort.Float64s(d.nbuf)
d.updateBankNoise(d.nbuf[len(d.nbuf)/2])
}
} else if d.lockIdx >= 0 {
// Auto-locked: only the locked bin is needed per hop.
toneDB = d.goertzelDB(d.coeffs[d.lockIdx])
d.lastPitch = d.freqs[d.lockIdx]
haveTone = true
// Supervised re-lock: periodically sweep the whole bank anyway. If a
// clearly stronger tone lives on a DIFFERENT pitch and keeps doing so,
// the current lock is wrong (locked onto noise, or the operator moved)
// — jump to the real signal instead of decoding garbage until the
// quiet-release finally fires.
d.bankTick++
if d.bankTick >= 8 {
d.bankTick = 0
bestIdx, bestDB := -1, math.Inf(-1)
for i, c := range d.coeffs {
m := d.goertzelDB(c)
d.mags[i] = m
if i >= d.lockIdx-1 && i <= d.lockIdx+1 {
continue
}
if m > bestDB {
bestDB, bestIdx = m, i
}
}
copy(d.nbuf, d.mags)
sort.Float64s(d.nbuf)
d.updateBankNoise(d.nbuf[len(d.nbuf)/2])
if bestIdx >= 0 && bestDB > toneDB+6 {
d.betterHops += 2
} else if d.betterHops > 0 {
d.betterHops--
}
if d.betterHops >= 24 && bestIdx >= 0 { // ~12 confirmations over ~1 s
d.flushPending()
copy(d.nbuf, d.mags)
sort.Float64s(d.nbuf)
d.lockIdx = bestIdx
// Seed the envelope honestly: floor from the bank median, NOT
// peakcap — fabricating a full span would let a noise re-lock
// key freely until the trackers converged.
d.floorDB, d.peakDB = d.nbuf[len(d.nbuf)/2], bestDB
d.quietHops, d.bankTick, d.betterHops = 0, 0, 0
d.marksSeen = 0
d.elemMs = d.elemMs[:0]
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
d.key, d.stableHops, d.pendHops = false, 0, 0
toneDB = d.goertzelDB(d.coeffs[d.lockIdx])
d.lastPitch = d.freqs[d.lockIdx]
}
}
} else {
// Unlocked: run the whole bank, estimate noise, hunt for a tone.
maxIdx, maxDB := 0, math.Inf(-1)
for i, c := range d.coeffs {
m := d.goertzelDB(c)
d.mags[i] = m
if m > maxDB {
maxDB, maxIdx = m, i
}
}
copy(d.nbuf, d.mags)
sort.Float64s(d.nbuf)
noise := d.nbuf[len(d.nbuf)/2] // median: robust to a few strong tones
d.updateBankNoise(noise)
snr := maxDB - noise
near := d.candIdx >= 0 && maxIdx >= d.candIdx-1 && maxIdx <= d.candIdx+1
if near {
d.candHops++
} else {
d.candIdx, d.candHops = maxIdx, 1
}
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so
// its opening dit isn't eaten), a marginal one must persist a few hops
// (so we don't lock onto a noise spike).
if snr > acqStrongDB || (d.candHops >= acqWeakHops && snr > acqWeakDB) {
d.lockIdx = maxIdx
d.floorDB, d.peakDB = noise, maxDB
d.envSeeded = true
d.quietHops = 0
d.bankTick, d.betterHops = 0, 0
d.marksSeen = 0 // relearn speed quickly for this signal (keep muDit as seed)
d.elemMs = d.elemMs[:0]
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
d.key, d.stableHops, d.pendHops = false, 0, 0
toneDB = maxDB
d.lastPitch = d.freqs[maxIdx]
haveTone = true
} else {
d.lastPitch = 0
}
}
if !haveTone {
d.rawKey = false
d.emitStatus()
return
}
if !d.envSeeded {
// First hop on a targeted channel: seed from the current reading.
d.floorDB, d.peakDB = toneDB, toneDB+squelchDB
d.envSeeded = true
}
// ---- Envelope: separate floor / peak trackers, dB domain. ----
// Floor drops fast, but only RISES while keyed up: a floor creeping up
// under a long dah shrinks the span until the dah fragments into dits.
if toneDB < d.floorDB {
d.floorDB += (toneDB - d.floorDB) * 0.3
} else if !d.rawKey {
d.floorDB += (toneDB - d.floorDB) * 0.015
}
// Peak attacks fast and decays slowly toward current conditions (~1.5 s),
// so QSB is followed without collapsing across ordinary word gaps.
if toneDB > d.peakDB {
d.peakDB += (toneDB - d.peakDB) * 0.4
} else {
d.peakDB += (toneDB - d.peakDB) * 0.004
}
if d.peakDB < d.floorDB {
d.peakDB = d.floorDB
}
// ---- Slicer with hysteresis + SNR squelch. ----
if d.peakDB-d.floorDB < squelchDB ||
(d.haveBankNoise && d.peakDB < d.bankNoiseDB+snrSquelchDB) {
d.rawKey = false
} else {
// Cap the usable span so the slicer crossings stay near the keying
// edges even over a dead-quiet background (see spanCapDB).
effFloor := math.Max(d.floorDB, d.peakDB-spanCapDB)
span := d.peakDB - effFloor
if d.rawKey {
d.rawKey = toneDB > effFloor+offFrac*span
} else {
d.rawKey = toneDB > effFloor+onFrac*span
}
}
// ---- Auto-lock release after a long quiet spell. ----
if d.lockIdx >= 0 {
if d.rawKey {
d.quietHops = 0
} else {
d.quietHops++
// Long enough to survive slow-speed word gaps (7 dits), short
// enough to retune to a new signal within a few seconds.
release := int(math.Max(2000, 12*d.muDit) / d.hopMs)
if d.quietHops > release {
d.flushPending()
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
d.rawKey = false
d.lastPitch = 0
}
}
}
d.timing()
d.emitStatus()
}
// updateBankNoise folds a fresh bank-median reading into the broadband noise
// reference used by the absolute squelch.
func (d *Decoder) updateBankNoise(medianDB float64) {
if !d.haveBankNoise {
d.bankNoiseDB, d.haveBankNoise = medianDB, true
return
}
d.bankNoiseDB += (medianDB - d.bankNoiseDB) * 0.2
}
// glitchHops is the debounce time in hops: ~0.3 dit, clamped to 1..3 hops
// (619 ms) so it neither swallows fast dits nor passes static crashes.
func (d *Decoder) glitchHops() int {
g := int(math.Round(0.3 * d.muDit / d.hopMs))
if g < 1 {
g = 1
}
if g > 3 {
g = 3
}
return g
}
// timing runs the debounced mark/space state machine for one hop.
func (d *Decoder) timing() {
if d.rawKey == d.key {
// Agreement folds any pending flip back into the committed state:
// a sub-glitch dropout inside a dah (or spike inside a gap) vanishes.
d.stableHops += 1 + d.pendHops
d.pendHops = 0
} else {
d.pendHops++
if d.pendHops > d.glitchHops() {
seg := d.stableHops
wasMark := d.key
d.key = d.rawKey
d.stableHops = d.pendHops
d.pendHops = 0
if wasMark {
d.endMark(seg)
} else {
d.endSpace(seg)
}
if d.key {
d.charEmitted, d.wordEmitted = false, false
}
}
}
if !d.key {
d.spaceProgress()
}
}
// endMark stores a finished key-down run for the character batch. Cluster
// updates deliberately do NOT happen here: attributing a mark to the dit or
// dah centre with the still-converging global boundary poisons the clusters
// (a dah-led character at an unexpected speed lands its dahs in the dit
// centre, which then oscillates). Both classification AND cluster updates
// happen per character in flushChar, where the batch's own contrast makes
// the attribution reliable.
func (d *Decoder) endMark(hops int) {
ms := float64(hops)*d.hopMs - d.biasMs // de-bias the window widening
if ms < 0.5*minDitMs {
return // debounce residue — not a credible element
}
d.elemMs = append(d.elemMs, ms)
// Bootstrap: a first mark SHORTER than the seeded dit can only be a dit of
// a faster sender — jump the estimate straight there instead of easing in.
if d.marksSeen == 0 && ms < d.muDit {
d.muDit = math.Max(ms, minDitMs)
}
d.marksSeen++
if len(d.elemMs) > 8 {
d.elemMs = d.elemMs[:0] // no Morse character is that long — noise run
}
}
// endSpace runs when a mark begins: the finished gap, if it was clearly an
// inter-element one, is extra evidence for the dit length (gaps are often
// steadier than dits in hand keying).
func (d *Decoder) endSpace(hops int) {
ms := float64(hops)*d.hopMs + d.biasMs // gaps shrink by what marks gained
if d.marksSeen < 1 || ms < 0.35*d.muDit || ms > 1.7*d.muDit {
return
}
// Early on, gaps are the FASTEST way to find the true dit length (the very
// first inter-element gap is one, whatever the first mark was); once the
// clusters have settled they are just a gentle refinement.
alpha := 0.08
if d.marksSeen < 8 {
alpha = 0.35
}
d.muDit += (ms - d.muDit) * alpha
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
}
// spaceProgress emits the pending character / word space LIVE once the current
// gap crosses each boundary (instead of waiting for the next mark), so text
// appears as it is sent.
func (d *Decoder) spaceProgress() {
gapMs := float64(d.stableHops)*d.hopMs + d.biasMs
if !d.charEmitted && gapMs > charGapDits*d.muDit {
d.flushChar()
d.charEmitted = true
}
if !d.wordEmitted && gapMs > wordGapDits*d.muDit {
if d.textSince && d.onChar != nil {
d.onChar(" ")
}
d.wordEmitted = true
}
}
// flushChar classifies the accumulated mark durations into dits/dahs and
// emits the character. Classification happens HERE, not as marks arrive: a
// character whose own marks are clearly bimodal carries its own dit/dah
// boundary (their geometric mean), which decodes correctly even before the
// global clusters have converged — the first character of an over included.
// Unimodal characters (EEE, TTT, 555…) fall back to the global boundary.
func (d *Decoder) flushChar() {
if len(d.elemMs) == 0 {
return
}
lo, hi := d.elemMs[0], d.elemMs[0]
for _, v := range d.elemMs[1:] {
lo = math.Min(lo, v)
hi = math.Max(hi, v)
}
b := math.Sqrt(d.muDit * d.muDah)
if hi >= 2*lo {
b = math.Sqrt(lo * hi) // the batch is bimodal: trust its own contrast
}
alpha := 0.18
if d.marksSeen <= 8 {
alpha = 0.45 // converge fast on a new sender
}
pat := make([]byte, len(d.elemMs))
for i, v := range d.elemMs {
if v < b {
pat[i] = '.'
d.muDit += (v - d.muDit) * alpha
} else {
pat[i] = '-'
d.muDah += (v - d.muDah) * alpha
}
}
// Ratio guards: dah stays 24.8 dits; dit stays within speed limits.
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
if d.muDah < 2.0*d.muDit {
d.muDah = 2.0 * d.muDit
}
if d.muDah > 4.8*d.muDit {
d.muDah = 4.8 * d.muDit
}
d.elemMs = d.elemMs[:0]
if c, ok := morse[string(pat)]; ok {
if d.onChar != nil {
d.onChar(string(c))
}
d.textSince = true
} else if len(pat) <= 7 {
// Morse-shaped but unknown → flag it; longer runs are noise, drop.
if d.onChar != nil {
d.onChar("?")
}
d.textSince = true
}
}
// flushPending finishes the in-progress character and word at end-of-over
// (lock release), so the last word isn't left hanging until the next signal.
func (d *Decoder) flushPending() {
if !d.charEmitted {
d.flushChar()
d.charEmitted = true
}
if !d.wordEmitted && d.textSince && d.onChar != nil {
d.onChar(" ")
d.wordEmitted = true
}
}
func (d *Decoder) emitStatus() {
d.sinceStatus++
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
return
}
d.sinceStatus = 0
wpm := 0
if d.muDit > 0 && (d.lockIdx >= 0 || d.targetHz.Load() > 0) {
wpm = int(math.Round(1200 / d.muDit))
}
d.onStatus(Status{
WPM: wpm,
Pitch: int(math.Round(d.lastPitch)),
Level: d.lastRMS,
Active: d.rawKey,
})
}