Files
OpsLog/internal/cwdecode/cwdecode_test.go
T
rouggy d6a2e84eed 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.
2026-07-23 22:50:02 +02:00

327 lines
8.6 KiB
Go

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