202 lines
5.2 KiB
Go
202 lines
5.2 KiB
Go
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)
|
|
}
|
|
}
|