Both faults reported on the air, both reproduced in synthetic signals first — the existing tests all passed because each starts a fresh decoder and sends textbook spacing, which is neither of the cases that hurt. Speed change. A 32 wpm station followed by a 14 wpm reply decoded as "TTT TTTT TTTTT TT…" for most of the over. Not the element classifier: the CHARACTER-gap threshold is 2.2 dits, so at the stale fast estimate it stood at 81 ms while the newcomer's element gaps were 86 ms. Every single element was flushed as its own character, and a lone element with no contrast reads as a dah. A silence long enough to end an over now drops the estimate back to the seed, which is exactly the state a freshly started decoder is in — and that case was always fine. The threshold scales with the current estimate (12 dits, floor 600 ms) because a fixed one cannot serve both 10 and 40 wpm. Callsign split. The word boundary sits at 4.6 dits, the geometric mean of a 3-dit letter gap and a 7-dit word gap. It assumes textbook spacing; a fist leaving 5 dits between letters had every letter turned into a word, so a callsign arrived as "O Y 1 C T". The boundary now also follows the letter gaps this operator actually sends, whichever is larger. A wide sender's words may run together — a far smaller price than a callsign in pieces.
149 lines
4.6 KiB
Go
149 lines
4.6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|