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:
@@ -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(" ")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user