fix: improve cw decoding with qrm
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user