package cwdecode import ( "math" "strings" "testing" ) // reverse Morse map for the synthesizer. func charToMorse() map[byte]string { m := map[byte]string{} for code, ch := range morse { m[ch] = code } return m } // keyMessage synthesizes a clean keyed tone for msg at the given WPM/pitch. func keyMessage(msg string, fs, wpm int, pitch float64) []int16 { return keyMessageAmp(msg, fs, wpm, pitch, 9000) } func keyMessageAmp(msg string, fs, wpm int, pitch, amp float64) []int16 { dot := fs * 1200 / (wpm * 1000) // samples per dot c2m := charToMorse() var out []int16 phase := 0.0 dphi := 2 * math.Pi * pitch / float64(fs) tone := func(n int) { for i := 0; i < n; i++ { out = append(out, int16(amp*math.Sin(phase))) phase += dphi } } silence := func(n int) { for i := 0; i < n; i++ { out = append(out, 0) } } silence(fs / 4) // 250 ms lead-in for AGC warmup for i := 0; i < len(msg); i++ { ch := msg[i] if ch == ' ' { silence(7 * dot) continue } code := c2m[ch] for j := 0; j < len(code); j++ { if code[j] == '.' { tone(dot) } else { tone(3 * dot) } silence(dot) // inter-element gap } silence(3 * dot) // inter-character gap (on top of the trailing element gap) } silence(fs / 4) return out } func TestDecodeCleanSignal(t *testing.T) { const fs = 16000 var sb strings.Builder d := New(fs, func(s string) { sb.WriteString(s) }, nil) // Repeat so AGC warm-up only costs the first word. samples := keyMessage("PARIS PARIS PARIS", fs, 22, 700) // Feed in small chunks like the live capture would. for i := 0; i < len(samples); i += 256 { end := i + 256 if end > len(samples) { end = len(samples) } d.Process(samples[i:end]) } got := strings.ToUpper(sb.String()) if !strings.Contains(got, "PARIS") { t.Fatalf("decoded %q, want it to contain PARIS", got) } } func TestDecodeWithQRM(t *testing.T) { const fs = 16000 // Target at 700 Hz; a strong interfering keyed signal at 950 Hz, slightly // quieter, sending different text. The pitch lock should hold on the target. target := keyMessageAmp("PARIS PARIS PARIS", fs, 20, 700, 9000) qrm := keyMessageAmp("BK DE QRZ QRZ TEST", fs, 26, 950, 6500) mix := make([]int16, len(target)) for i := range target { v := int(target[i]) if i < len(qrm) { v += int(qrm[i]) } if v > 32767 { v = 32767 } else if v < -32768 { v = -32768 } mix[i] = int16(v) } var sb strings.Builder d := New(fs, func(s string) { sb.WriteString(s) }, nil) for i := 0; i < len(mix); i += 256 { end := i + 256 if end > len(mix) { end = len(mix) } d.Process(mix[i:end]) } got := strings.ToUpper(sb.String()) if !strings.Contains(got, "PARIS") { t.Fatalf("with QRM, decoded %q, want it to contain PARIS", got) } } func TestDecodeFirstCharStrong(t *testing.T) { const fs = 16000 var sb strings.Builder d := New(fs, func(s string) { sb.WriteString(s) }, nil) // Strong signal: the very first element (T = a dash) must not be eaten by // lock acquisition. Output should begin with the first character. samples := keyMessageAmp("TEST DE", fs, 20, 700, 16000) for i := 0; i < len(samples); i += 200 { end := i + 200 if end > len(samples) { end = len(samples) } d.Process(samples[i:end]) } got := strings.ToUpper(strings.TrimSpace(sb.String())) if !strings.HasPrefix(got, "TEST") { t.Fatalf("first chars lost on a strong signal: decoded %q, want it to start with TEST", got) } } func TestDecodeWithAmplitudeRipple(t *testing.T) { const fs = 16000 // A real signal's tone amplitude wobbles within a mark; if the floor chases // it, dashes fragment into dots ("all dots" garbage). Apply ±30% ripple. samples := keyMessageAmp("CQ TEST DE OM", fs, 24, 800, 10000) rp := 0.0 for i := range samples { rp += 2 * math.Pi * 35 / float64(fs) // 35 Hz amplitude wobble samples[i] = int16(float64(samples[i]) * (1 + 0.3*math.Sin(rp))) } var sb strings.Builder d := New(fs, func(s string) { sb.WriteString(s) }, nil) for i := 0; i < len(samples); i += 256 { end := i + 256 if end > len(samples) { end = len(samples) } d.Process(samples[i:end]) } got := strings.ToUpper(sb.String()) if !strings.Contains(got, "TEST DE OM") { t.Fatalf("dashes fragmented under amplitude ripple: decoded %q", got) } } func TestDecodeCQFixedPitch(t *testing.T) { const fs = 16000 var sb strings.Builder d := New(fs, func(s string) { sb.WriteString(s) }, nil) d.SetTarget(700) // fixed pitch like the user's manual override samples := keyMessageAmp("CQ CQ CQ DE OM", fs, 26, 700, 9000) for i := 0; i < len(samples); i += 200 { end := i + 200 if end > len(samples) { end = len(samples) } d.Process(samples[i:end]) } got := strings.ToUpper(sb.String()) if n := strings.Count(got, "CQ"); n < 2 { t.Fatalf("first element of CQ dropped: decoded %q (only %d CQ)", got, n) } } func TestDecodeNumbersAndProsign(t *testing.T) { const fs = 16000 var sb strings.Builder d := New(fs, func(s string) { sb.WriteString(s) }, nil) samples := keyMessage("TEST 599 TEST", fs, 18, 650) for i := 0; i < len(samples); i += 200 { end := i + 200 if end > len(samples) { end = len(samples) } d.Process(samples[i:end]) } got := strings.ToUpper(sb.String()) if !strings.Contains(got, "599") { t.Fatalf("decoded %q, want it to contain 599", got) } }