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.
698 lines
24 KiB
Go
698 lines
24 KiB
Go
// 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" 2–3 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
|
||
// ±5–6 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: 400–1000 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
|
||
// peak−cap — 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
|
||
// (6–19 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 2–4.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,
|
||
})
|
||
}
|