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:
@@ -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" 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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package cwdecode
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---- Synthesizer -----------------------------------------------------------
|
||||
|
||||
func charToMorse() map[byte]string {
|
||||
m := map[byte]string{}
|
||||
for code, ch := range morse {
|
||||
m[ch] = code
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// keyMessage synthesizes keyed CW for msg with raised-cosine edges (5 ms), so
|
||||
// the signal has realistic click-free envelopes rather than hard steps.
|
||||
func keyMessage(msg string, fs, wpm int, pitch, amp float64) []int16 {
|
||||
dot := fs * 1200 / (wpm * 1000) // samples per dit
|
||||
edge := fs * 5 / 1000 // 5 ms shaping
|
||||
c2m := charToMorse()
|
||||
var out []float64
|
||||
phase := 0.0
|
||||
dphi := 2 * math.Pi * pitch / float64(fs)
|
||||
|
||||
tone := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
g := 1.0
|
||||
if i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(i)/float64(edge))
|
||||
} else if n-1-i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(n-1-i)/float64(edge))
|
||||
}
|
||||
out = append(out, amp*g*math.Sin(phase))
|
||||
phase += dphi
|
||||
}
|
||||
}
|
||||
silence := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, 0)
|
||||
}
|
||||
}
|
||||
|
||||
silence(fs / 4) // lead-in for envelope warm-up
|
||||
for i := 0; i < len(msg); i++ {
|
||||
ch := msg[i]
|
||||
if ch == ' ' {
|
||||
silence(4 * dot) // + trailing 3 from the previous char = 7 total
|
||||
continue
|
||||
}
|
||||
code := c2m[ch]
|
||||
for j := 0; j < len(code); j++ {
|
||||
if code[j] == '.' {
|
||||
tone(dot)
|
||||
} else {
|
||||
tone(3 * dot)
|
||||
}
|
||||
silence(dot)
|
||||
}
|
||||
silence(2 * dot) // + trailing element gap = 3 total
|
||||
}
|
||||
silence(fs / 2)
|
||||
return toInt16(out)
|
||||
}
|
||||
|
||||
func toInt16(x []float64) []int16 {
|
||||
out := make([]int16, len(x))
|
||||
for i, v := range x {
|
||||
if v > 32767 {
|
||||
v = 32767
|
||||
} else if v < -32768 {
|
||||
v = -32768
|
||||
}
|
||||
out[i] = int16(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func addNoise(s []int16, sigma float64, seed int64) []int16 {
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
out := make([]int16, len(s))
|
||||
for i, v := range s {
|
||||
out[i] = int16(math.Max(-32768, math.Min(32767, float64(v)+r.NormFloat64()*sigma)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// applyQSB modulates the amplitude between lo..1.0 at rate Hz (slow fading).
|
||||
func applyQSB(s []int16, fs int, rate, lo float64) []int16 {
|
||||
out := make([]int16, len(s))
|
||||
for i, v := range s {
|
||||
g := lo + (1-lo)*(0.5+0.5*math.Sin(2*math.Pi*rate*float64(i)/float64(fs)))
|
||||
out[i] = int16(float64(v) * g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// applyDropouts blanks brief windows (ms long) every period ms — static-crash
|
||||
// style holes that land inside dahs and gaps alike.
|
||||
func applyDropouts(s []int16, fs int, everyMs, holeMs int) []int16 {
|
||||
out := make([]int16, len(s))
|
||||
copy(out, s)
|
||||
every := fs * everyMs / 1000
|
||||
hole := fs * holeMs / 1000
|
||||
for start := every; start+hole < len(out); start += every {
|
||||
for i := start; i < start+hole; i++ {
|
||||
out[i] = 0
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mix(a, b []int16) []int16 {
|
||||
n := len(a)
|
||||
if len(b) > n {
|
||||
n = len(b)
|
||||
}
|
||||
out := make([]int16, n)
|
||||
for i := 0; i < n; i++ {
|
||||
var v int
|
||||
if i < len(a) {
|
||||
v += int(a[i])
|
||||
}
|
||||
if i < len(b) {
|
||||
v += int(b[i])
|
||||
}
|
||||
if v > 32767 {
|
||||
v = 32767
|
||||
} else if v < -32768 {
|
||||
v = -32768
|
||||
}
|
||||
out[i] = int16(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decode runs samples through a fresh decoder in live-sized chunks.
|
||||
func decode(t *testing.T, samples []int16, targetHz int) string {
|
||||
t.Helper()
|
||||
var sb strings.Builder
|
||||
d := New(16000, func(s string) { sb.WriteString(s) }, nil)
|
||||
if targetHz > 0 {
|
||||
d.SetTarget(targetHz)
|
||||
}
|
||||
for i := 0; i < len(samples); i += 256 {
|
||||
end := i + 256
|
||||
if end > len(samples) {
|
||||
end = len(samples)
|
||||
}
|
||||
d.Process(samples[i:end])
|
||||
}
|
||||
return strings.ToUpper(sb.String())
|
||||
}
|
||||
|
||||
func wantContains(t *testing.T, got, want, label string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("%s: decoded %q, want it to contain %q", label, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tests -----------------------------------------------------------------
|
||||
|
||||
func TestCleanSignalSpeeds(t *testing.T) {
|
||||
const fs = 16000
|
||||
for _, wpm := range []int{12, 18, 25, 32, 40} {
|
||||
got := decode(t, keyMessage("CQ TEST DE F4BPO K", fs, wpm, 700, 9000), 0)
|
||||
wantContains(t, got, "CQ TEST DE F4BPO K", "clean @"+itoa(wpm)+"wpm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOtherPitches(t *testing.T) {
|
||||
const fs = 16000
|
||||
for _, pitch := range []float64{450, 600, 850} {
|
||||
got := decode(t, keyMessage("PARIS PARIS", fs, 22, pitch, 9000), 0)
|
||||
wantContains(t, got, "PARIS PARIS", "pitch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithNoise(t *testing.T) {
|
||||
const fs = 16000
|
||||
clean := keyMessage("CQ CQ DE HB9HBY", fs, 22, 700, 9000)
|
||||
noisy := addNoise(clean, 2000, 1) // ≈13 dB tone/noise in the audio band
|
||||
got := decode(t, noisy, 0)
|
||||
wantContains(t, got, "CQ CQ DE HB9HBY", "noise")
|
||||
}
|
||||
|
||||
// QSB fading between 35% and 100% amplitude — the adaptive dB envelope must
|
||||
// ride it. The old linear envelope lost the faded halves entirely.
|
||||
func TestQSBFading(t *testing.T) {
|
||||
const fs = 16000
|
||||
clean := keyMessage("CQ CQ CQ DE F4BPO F4BPO", fs, 20, 700, 12000)
|
||||
faded := applyQSB(clean, fs, 0.4, 0.35)
|
||||
got := decode(t, faded, 0)
|
||||
wantContains(t, got, "DE F4BPO", "qsb")
|
||||
}
|
||||
|
||||
// Brief 10 ms holes punched every 150 ms — they land inside dahs. Without the
|
||||
// two-sided debounce every hit dah shatters into dits (the old decoder's
|
||||
// single worst failure on real signals).
|
||||
func TestDropoutsInsideDahs(t *testing.T) {
|
||||
const fs = 16000
|
||||
clean := keyMessage("TEST TEST TEST", fs, 18, 700, 9000)
|
||||
holed := applyDropouts(clean, fs, 150, 10)
|
||||
got := decode(t, holed, 0)
|
||||
wantContains(t, got, "TEST TEST", "dropouts")
|
||||
}
|
||||
|
||||
// QRM: a second, slightly weaker keyed signal at 950 Hz. The pitch lock must
|
||||
// hold the 700 Hz target and ignore the interferer.
|
||||
func TestQRMAutoLock(t *testing.T) {
|
||||
const fs = 16000
|
||||
target := keyMessage("PARIS PARIS PARIS", fs, 20, 700, 9000)
|
||||
qrm := keyMessage("QRZ QRZ QRZ QRZ QRZ", fs, 26, 950, 5000)
|
||||
got := decode(t, mix(target, qrm), 0)
|
||||
wantContains(t, got, "PARIS", "qrm-auto")
|
||||
}
|
||||
|
||||
// Targeted mode: with two comparable signals, SetTarget must decode the chosen
|
||||
// one even though the other is as strong.
|
||||
func TestQRMTargeted(t *testing.T) {
|
||||
const fs = 16000
|
||||
want := keyMessage("SOS SOS SOS", fs, 20, 600, 8000)
|
||||
other := keyMessage("QRL QRL QRL QRL", fs, 24, 900, 8000)
|
||||
got := decode(t, mix(want, other), 600)
|
||||
wantContains(t, got, "SOS SOS", "qrm-target")
|
||||
}
|
||||
|
||||
// Pure noise must stay silent: the squelch keys nothing, so no text at all.
|
||||
func TestNoiseOnlySquelch(t *testing.T) {
|
||||
const fs = 16000
|
||||
noise := addNoise(make([]int16, fs*6), 3000, 7)
|
||||
got := strings.TrimSpace(decode(t, noise, 0))
|
||||
if len(got) > 2 { // tolerate at most a stray flagged char
|
||||
t.Fatalf("squelch: decoded %q from pure noise, want (almost) nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
// keyMessageJitter synthesizes hand-sent CW: every element and gap duration
|
||||
// is jittered (elements ±je, gaps ±jg, uniform), like a human fist.
|
||||
func keyMessageJitter(msg string, fs, wpm int, pitch, amp, je, jg float64, seed int64) []int16 {
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
dot := float64(fs) * 1200 / (float64(wpm) * 1000)
|
||||
edge := fs * 5 / 1000
|
||||
c2m := charToMorse()
|
||||
var out []float64
|
||||
phase := 0.0
|
||||
dphi := 2 * math.Pi * pitch / float64(fs)
|
||||
jit := func(n float64, j float64) int { return int(n * (1 + (r.Float64()*2-1)*j)) }
|
||||
tone := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
g := 1.0
|
||||
if i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(i)/float64(edge))
|
||||
} else if n-1-i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(n-1-i)/float64(edge))
|
||||
}
|
||||
out = append(out, amp*g*math.Sin(phase))
|
||||
phase += dphi
|
||||
}
|
||||
}
|
||||
silence := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, 0)
|
||||
}
|
||||
}
|
||||
silence(fs / 4)
|
||||
for i := 0; i < len(msg); i++ {
|
||||
ch := msg[i]
|
||||
if ch == ' ' {
|
||||
silence(jit(4*dot, jg))
|
||||
continue
|
||||
}
|
||||
code := c2m[ch]
|
||||
for j := 0; j < len(code); j++ {
|
||||
if code[j] == '.' {
|
||||
tone(jit(dot, je))
|
||||
} else {
|
||||
tone(jit(3*dot, je))
|
||||
}
|
||||
silence(jit(dot, jg))
|
||||
}
|
||||
silence(jit(2*dot, jg))
|
||||
}
|
||||
silence(fs / 2)
|
||||
return toInt16(out)
|
||||
}
|
||||
|
||||
// Hand keying: ±20% element jitter, ±25% gap jitter — a sloppy but readable
|
||||
// human fist. The batch classifier and the gap-fed dit tracking must ride it.
|
||||
func TestHandKeying(t *testing.T) {
|
||||
const fs = 16000
|
||||
for seed := int64(1); seed <= 3; seed++ {
|
||||
s := keyMessageJitter("CQ CQ DE HB9HBY HB9HBY K", fs, 22, 700, 9000, 0.20, 0.25, seed)
|
||||
got := decode(t, s, 0)
|
||||
wantContains(t, got, "HB9HBY", "hand-keying")
|
||||
}
|
||||
}
|
||||
|
||||
// Speed change mid-over: the cluster tracker must follow 25 → 15 WPM.
|
||||
func TestSpeedChange(t *testing.T) {
|
||||
const fs = 16000
|
||||
fast := keyMessage("CQ CQ CQ DE F4BPO", fs, 25, 700, 9000)
|
||||
slow := keyMessage("UR RST 599 599", fs, 15, 700, 9000)
|
||||
got := decode(t, append(fast, slow...), 0)
|
||||
wantContains(t, got, "F4BPO", "speed-fast-part")
|
||||
wantContains(t, got, "599", "speed-slow-part")
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [8]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package cwdecode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDebugTrace prints the element stream for a chosen case — a development
|
||||
// aid, not an assertion test. Run with: go test -run TestDebugTrace -v
|
||||
func TestDebugTrace(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("debug aid")
|
||||
}
|
||||
const fs = 16000
|
||||
samples := keyMessage("CQ TEST DE F4BPO K", fs, 40, 700, 9000)
|
||||
var d *Decoder
|
||||
d = New(fs, func(s string) { fmt.Printf("EMIT %q (muDit=%.0f muDah=%.0f)\n", s, d.muDit, d.muDah) }, nil)
|
||||
lastMarks := 0
|
||||
for i := 0; i < len(samples); i += 256 {
|
||||
end := i + 256
|
||||
if end > len(samples) {
|
||||
end = len(samples)
|
||||
}
|
||||
d.Process(samples[i:end])
|
||||
if d.marksSeen != lastMarks {
|
||||
lastMarks = d.marksSeen
|
||||
fmt.Printf("mark#%d elems=%v muDit=%.0f muDah=%.0f\n", d.marksSeen, d.elemMs, d.muDit, d.muDah)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user