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