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) + } + } +}