package cwdecode import ( "math" "strings" "testing" ) // keyLoose keys a message with SLIGHTLY WIDE letter spacing — the way a great // many operators actually send. letterGapDits replaces the standard 3. // // Same envelope shaping as keyMessage: hard edges would make an easier signal // than anything on the air, and would prove nothing. func keyLoose(msg string, fs, wpm int, pitch, amp float64, letterGapDits float64) []int16 { dot := fs * 1200 / (wpm * 1000) edge := fs * 5 / 1000 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) phase += dphi } } silence(fs / 4) for i := 0; i < len(msg); i++ { ch := msg[i] if ch == ' ' { silence(4 * dot) continue } code := c2m[ch] for j := 0; j < len(code); j++ { if code[j] == '.' { tone(dot) } else { tone(3 * dot) } silence(dot) } // The element gap above already contributes one dit. silence(int((letterGapDits - 1) * float64(dot))) } silence(fs / 2) return toInt16(out) } // The first character of an over must decode, not arrive as "?". // // Reported on the air: "the first letter or digit often turns into ?". Each // element is classified against the running dit estimate, and at the start of // an over that estimate belongs to whatever was decoded last — a different // operator, at a different speed. The first character pays for it. func TestFirstCharacterOfAnOver(t *testing.T) { const fs = 16000 for _, wpm := range []int{15, 22, 30} { got := decode(t, keyMessage("F4BPO DE OY1CT K", fs, wpm, 700, 9000), 0) if strings.HasPrefix(got, "?") { t.Errorf("@%d wpm: decoded %q — the first character was lost", wpm, got) } } } // A callsign must not be broken up when the sender's letter spacing is a little // wide. // // Reported on the air: "sometimes there are spaces inside the call". A word // boundary is declared above 4.6 dits of silence, so an operator whose letter // gaps run to 4.5 dits has every letter turned into a word of its own. func TestWideLetterSpacingDoesNotSplitTheCall(t *testing.T) { const fs = 16000 for _, gap := range []float64{3.5, 4.0, 4.5} { got := decode(t, keyLoose("DE OY1CT K", fs, 20, 700, 9000, gap), 0) for _, split := range []string{"O Y", "Y 1", "1 C", "C T"} { if strings.Contains(got, split) { t.Errorf("letter gap %.1f dits: decoded %q — the callsign was broken at %q", gap, got, split) } } } } // decodeSeq runs several transmissions through the SAME decoder, as happens on // the air: the estimate carried into an over is whatever the previous station // left behind. func decodeSeq(t *testing.T, parts ...[]int16) []string { t.Helper() var cur strings.Builder d := New(16000, func(s string) { cur.WriteString(s) }, nil) out := make([]string, 0, len(parts)) for _, p := range parts { cur.Reset() for i := 0; i < len(p); i += 256 { end := i + 256 if end > len(p) { end = len(p) } d.Process(p[i:end]) } out = append(out, strings.ToUpper(cur.String())) } return out } // A fast station, then a slow one — the real reason the first character is // lost. A fresh decoder starts from a neutral estimate and adapts within a // character or two; a decoder that has just followed someone at 30 wpm judges // the newcomer's first dits against 30 wpm. func TestFirstCharacterAfterASpeedChange(t *testing.T) { const fs = 16000 got := decodeSeq(t, keyMessage("CQ CQ DE DL1ABC K", fs, 32, 700, 9000), keyMessage("DL1ABC DE OY1CT K", fs, 14, 700, 9000), ) if strings.HasPrefix(got[1], "?") { t.Errorf("after 32→14 wpm: %q — the first character of the reply was lost", got[1]) } if !strings.Contains(got[1], "OY1CT") { t.Errorf("after 32→14 wpm: %q — want the callsign intact", got[1]) } } // Hand-sent letter gaps run wide. Above 4.6 dits every letter becomes a word, // so a callsign arrives in pieces. func TestVeryWideLetterSpacing(t *testing.T) { const fs = 16000 for _, gap := range []float64{5.0, 5.5, 6.0} { got := decode(t, keyLoose("DE OY1CT K", fs, 18, 700, 9000, gap), 0) if strings.Contains(got, "O Y") || strings.Contains(got, "Y 1") || strings.Contains(got, "1 C") { t.Errorf("letter gap %.1f dits: decoded %q — the callsign was broken up", gap, got) } } }