From bd2a8524dc8890dd53510fb6de5b7178c9991afb Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 31 Jul 2026 11:15:02 +0200 Subject: [PATCH] fix: CW decoder loses the first characters after a speed change, and splits callsigns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- changelog.json | 6 +- internal/cwdecode/cwdecode.go | 78 ++++++++++++++++- internal/cwdecode/onair_test.go | 148 ++++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 internal/cwdecode/onair_test.go diff --git a/changelog.json b/changelog.json index aa6df99..e6f0acb 100644 --- a/changelog.json +++ b/changelog.json @@ -6,13 +6,15 @@ "FlexRadio: in split, OpsLog stays on the receive slice instead of following the transmitter.", "QSO recording works on CW again (no sidetone).", "Playing a recording on the air now says why when it cannot.", - "The play-on-air button is hidden on CW." + "The play-on-air button is hidden on CW.", + "CW decoder: a station replying at a different speed no longer decodes as a run of dashes, and callsigns are no longer split into single letters by a wide fist." ], "fr": [ "FlexRadio : en split, OpsLog reste sur la slice de réception au lieu de suivre l'émission.", "L'enregistrement des QSO fonctionne à nouveau en CW (sans le signal d'écoute).", "L'émission d'un enregistrement indique désormais pourquoi elle échoue.", - "Le bouton d'émission de l'enregistrement est masqué en CW." + "Le bouton d'émission de l'enregistrement est masqué en CW.", + "Décodeur CW : une station qui répond à une autre vitesse ne se décode plus en série de traits, et les indicatifs ne sont plus coupés lettre par lettre par un espacement large." ] }, { diff --git a/internal/cwdecode/cwdecode.go b/internal/cwdecode/cwdecode.go index 840f530..2160b2e 100644 --- a/internal/cwdecode/cwdecode.go +++ b/internal/cwdecode/cwdecode.go @@ -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(" ") } diff --git a/internal/cwdecode/onair_test.go b/internal/cwdecode/onair_test.go new file mode 100644 index 0000000..f6860f8 --- /dev/null +++ b/internal/cwdecode/onair_test.go @@ -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) + } + } +}