fix: CW decoder loses the first characters after a speed change, and splits callsigns

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.
This commit is contained in:
2026-07-31 11:15:02 +02:00
parent 4afd7dda90
commit bd2a8524dc
3 changed files with 229 additions and 3 deletions
+77 -1
View File
@@ -125,6 +125,10 @@ type Decoder struct {
pendHops int // consecutive hops the raw state has disagreed
// Two-cluster element timing (ms).
// muLetterGap is this operator's typical gap BETWEEN LETTERS (ms), so the
// word boundary can follow a wide fist instead of chopping up callsigns.
// Zero until enough gaps have been seen.
muLetterGap float64
muDit, muDah float64
marksSeen int
@@ -195,6 +199,19 @@ const (
charGapDits = 2.2 // gap > this ⇒ character boundary (geom. mean of 1 & 3 ≈ 1.7, plus margin for sloppy fists)
wordGapDits = 4.6 // gap > this ⇒ word boundary (geom. mean of 3 & 7)
// newOverFloorMs is the shortest silence that may end an over — see newOverMs.
// Past it the speed estimate is dropped: the next voice on the frequency is
// probably somebody else, and the last operator's speed is not evidence
// about them. Two seconds is far longer than any word gap (7 dits is 0.42 s
// even at 10 wpm) and far shorter than a pause between overs.
newOverFloorMs = 600.0
// wordGapRatio places the word boundary relative to the letter gaps this
// operator ACTUALLY sends, for fists whose letter spacing runs wide. The
// fixed 4.6-dit rule turned a 5-dit letter gap into a word, so a callsign
// arrived as "O Y 1 C T" — which is worse than two words run together.
wordGapRatio = 1.6
)
// New builds a decoder for the given sample rate. onChar receives decoded text
@@ -260,6 +277,7 @@ func (d *Decoder) Reset() {
d.stableHops, d.pendHops = 0, 0
d.bankTick, d.betterHops = 0, 0
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
d.muLetterGap = 0
d.elemMs = d.elemMs[:0]
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
}
@@ -578,6 +596,30 @@ func (d *Decoder) endMark(hops int) {
// steadier than dits in hand keying).
func (d *Decoder) endSpace(hops int) {
ms := float64(hops)*d.hopMs + d.biasMs // gaps shrink by what marks gained
// A long silence ends the over — see newOverMs. Forget the speed: coming back at 14 wpm
// after following someone at 32, every element of the newcomer measured
// longer than the stale dah threshold and the first words decoded as a run
// of T's. Starting from the seed instead, the estimate re-converges within a
// character — which is exactly how a freshly started decoder behaves, and
// that case was always fine.
if d.marksSeen > 0 && ms > d.newOverMs() {
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
d.muLetterGap = 0
d.elemMs = d.elemMs[:0]
return
}
// Letter gaps: everything between a character boundary and a word boundary.
// Kept so the word boundary can follow this operator's own spacing.
if d.marksSeen > 0 && ms > charGapDits*d.muDit && ms < wordGapDits*d.muDit*2 {
if d.muLetterGap == 0 {
d.muLetterGap = ms
} else {
d.muLetterGap += (ms - d.muLetterGap) * 0.2
}
}
if d.marksSeen < 1 || ms < 0.35*d.muDit || ms > 1.7*d.muDit {
return
}
@@ -592,6 +634,40 @@ func (d *Decoder) endSpace(hops int) {
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
}
// newOverMs is the silence above which the speed estimate is dropped.
//
// It cannot be a fixed duration: 0.75 s is a quick turnaround at 30 wpm but is
// barely a word gap at 10 wpm (7 dits = 0.84 s). So it scales with the current
// estimate, with a floor so a fast operator pausing to think is not mistaken
// for a new station on every hesitation.
func (d *Decoder) newOverMs() float64 {
if v := 12 * d.muDit; v > newOverFloorMs {
return v
}
return newOverFloorMs
}
// wordGapMs is the silence above which a word boundary is declared.
//
// The textbook answer is 4.6 dits — the geometric mean of a 3-dit letter gap
// and a 7-dit word gap. It assumes the operator sends textbook spacing. Many do
// not: a fist that leaves 5 dits between letters had every letter turned into a
// word, so callsigns arrived in pieces.
//
// So the boundary also follows the letter gaps actually observed. Whichever is
// larger wins: a textbook fist keeps the textbook boundary, a wide one gets a
// wider boundary. The cost is that a wide sender's words may run together —
// which is a far smaller price than a callsign broken into letters.
func (d *Decoder) wordGapMs() float64 {
fixed := wordGapDits * d.muDit
if d.muLetterGap > 0 {
if adaptive := wordGapRatio * d.muLetterGap; adaptive > fixed {
return adaptive
}
}
return fixed
}
// spaceProgress emits the pending character / word space LIVE once the current
// gap crosses each boundary (instead of waiting for the next mark), so text
// appears as it is sent.
@@ -601,7 +677,7 @@ func (d *Decoder) spaceProgress() {
d.flushChar()
d.charEmitted = true
}
if !d.wordEmitted && gapMs > wordGapDits*d.muDit {
if !d.wordEmitted && gapMs > d.wordGapMs() {
if d.textSince && d.onChar != nil {
d.onChar(" ")
}
+148
View File
@@ -0,0 +1,148 @@
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)
}
}
}