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.
327 lines
8.6 KiB
Go
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:])
|
|
}
|