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,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:])
|
||||
}
|
||||
Reference in New Issue
Block a user