fix: improve cw decoding with qrm

This commit is contained in:
2026-06-20 02:25:53 +02:00
parent 32878c17be
commit 2228816057
3 changed files with 87 additions and 74 deletions
+59 -43
View File
@@ -1,15 +1,15 @@
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
// mono PCM samples into decoded text. The pipeline is the classic one — a bank
// of Goertzel tone detectors, a pitch LOCK that follows a single tone (so QRM
// at other pitches is ignored), an SNR-based key-down/key-up detector measured
// against the broadband noise floor (so QRN bursts that lift every bin are
// rejected), an adaptive dot-length (WPM) estimate, and a timing state machine
// that maps marks/spaces to Morse and then to characters.
// at other pitches is ignored), an adaptive envelope/threshold on the LOCKED
// tone (level-independent, so weak or strong signals both key cleanly), an
// adaptive dot-length (WPM) estimate, and a timing state machine that maps
// marks/spaces to Morse and then to characters.
//
// It is deliberately self-contained and dependency-free so it can be unit
// tested with synthetic signals. As with every audio CW decoder, weak signals
// and very heavy QRM still degrade it; the lock + SNR gate trade a little
// sensitivity for far fewer false decodes.
// and very heavy QRM still degrade it; the pitch lock keeps QRM on other tones
// out of the decode.
package cwdecode
import (
@@ -39,17 +39,17 @@ type Decoder struct {
mags []float64 // per-bin magnitude this hop
nbuf []float64 // scratch for the noise percentile
// Pitch lock + noise.
lockIdx int // index of the locked tone bin, -1 = unlocked
candIdx int // current argmax candidate while unlocked
candHops int // consecutive hops the candidate has been dominant
unlockHops int // consecutive low-SNR hops while locked
noise float64
// Pitch lock.
lockIdx int // index of the locked tone bin, -1 = unlocked
candIdx int // current argmax candidate while unlocked
candHops int // consecutive hops the candidate has been dominant
quietHops int // consecutive key-up hops while locked
noise float64 // broadband noise estimate (percentile of bins)
relockHops int // quiet hops before the lock is released
onSNR float64 // SNR to call key-down / to acquire a lock
offSNR float64 // SNR below which it's key-up
acqSNR float64 // minimum tone/noise ratio to acquire a lock
// Keying / timing.
// Adaptive keying envelope, on the LOCKED bin's magnitude.
peak, floor float64
state bool // true = mark (key down)
stateHops int
dotHops float64 // adaptive dot length, in hops
@@ -90,8 +90,7 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
hop: sampleRate / 250, // ~4 ms resolution
win: sampleRate / 62, // ~16 ms Goertzel window
dotHops: 15, // ~20 WPM seed
onSNR: 4.0,
offSNR: 2.5,
acqSNR: 1.8, // mild: just enough to ignore pure noise
lockIdx: -1,
candIdx: -1,
statusEvery: 25, // ~10 Hz
@@ -117,7 +116,8 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
func (d *Decoder) Reset() {
d.ring = d.ring[:0]
d.acc = 0
d.lockIdx, d.candIdx, d.candHops, d.unlockHops = -1, -1, 0, 0
d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
d.peak, d.floor = 0, 0
d.state = false
d.stateHops = 0
d.dotHops = 15
@@ -141,8 +141,8 @@ func (d *Decoder) Process(samples []int16) {
}
}
// analyze runs the Goertzel bank over the current window, estimates the noise
// floor, and maintains the pitch lock.
// analyze runs the Goertzel bank, estimates the noise floor, and maintains the
// pitch lock (which tone the envelope detector then follows).
func (d *Decoder) analyze() {
n := float64(len(d.ring))
var sumSq float64
@@ -166,34 +166,23 @@ func (d *Decoder) analyze() {
}
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4)
// Noise floor = 40th percentile of the bins (robust to a few strong tones,
// so one or two QRM signals don't inflate it).
// Noise floor = 40th percentile of the bins (robust to a few strong tones).
copy(d.nbuf, d.mags)
sort.Float64s(d.nbuf)
d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)]
eps := d.noise + 1e-9
if d.lockIdx < 0 {
// Acquire: lock once the same bin has been dominant for a few hops and
// is clearly above the noise.
// Acquire: lock when the same bin has been dominant for a few hops and
// is at least mildly above the noise (so we don't lock onto pure noise).
if maxIdx == d.candIdx {
d.candHops++
} else {
d.candIdx, d.candHops = maxIdx, 1
}
if d.candHops >= 4 && maxMag/eps > d.onSNR {
d.lockIdx, d.unlockHops = maxIdx, 0
}
} else {
// Hold the lock through key-up gaps; release only after a long quiet so
// we can retune to a new signal/pitch.
if d.mags[d.lockIdx]/eps < d.offSNR {
d.unlockHops++
} else {
d.unlockHops = 0
}
if d.unlockHops > d.relockHops {
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
if d.candHops >= 5 && maxMag/(d.noise+1e-9) > d.acqSNR {
d.lockIdx = maxIdx
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
d.quietHops = 0
}
}
if d.lockIdx >= 0 {
@@ -203,15 +192,42 @@ func (d *Decoder) analyze() {
}
}
// step advances the keying detector + timing state machine by one hop.
// step runs the adaptive envelope on the locked bin and the timing state
// machine, one hop. The envelope adapts to the signal level (not an absolute
// threshold), so weak and strong signals both key correctly.
func (d *Decoder) step() {
on := false
if d.lockIdx >= 0 {
snr := d.mags[d.lockIdx] / (d.noise + 1e-9)
if d.state {
on = snr > d.offSNR // hysteresis: stay keyed until it clearly drops
m := d.mags[d.lockIdx]
// Fast-attack / slow-release peak; fast-drop / slow-rise floor.
if m > d.peak {
d.peak += (m - d.peak) * 0.4
} else {
on = snr > d.onSNR
d.peak += (m - d.peak) * 0.02
}
if m < d.floor {
d.floor += (m - d.floor) * 0.4
} else {
d.floor += (m - d.floor) * 0.01
}
span := d.peak - d.floor
if span > d.floor*0.3+1e-9 {
onTh := d.floor + 0.55*span
offTh := d.floor + 0.35*span
if d.state {
on = m > offTh
} else {
on = m > onTh
}
}
// Release the lock after a long quiet so we can retune to a new signal.
if on {
d.quietHops = 0
} else {
d.quietHops++
if d.quietHops > d.relockHops {
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
}
}
}