32878c17be
autocalling no matter which macro now only on CQ fix: ESC stop transmission but also autocall
309 lines
8.6 KiB
Go
309 lines
8.6 KiB
Go
// 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.
|
||
//
|
||
// 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.
|
||
package cwdecode
|
||
|
||
import (
|
||
"math"
|
||
"sort"
|
||
)
|
||
|
||
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
|
||
type Status struct {
|
||
WPM int `json:"wpm"`
|
||
Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked)
|
||
Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter
|
||
Active bool `json:"active"` // a tone is currently keyed down
|
||
}
|
||
|
||
// Decoder consumes PCM and emits decoded characters via onChar (one or more
|
||
// characters at a time, including " " for word gaps) and periodic onStatus.
|
||
type Decoder struct {
|
||
fs int
|
||
hop int // samples between updates
|
||
win int // Goertzel window length
|
||
freqs []float64
|
||
coeffs []float64 // precomputed 2*cos(w) per freq
|
||
|
||
ring []float64 // last win samples
|
||
acc int // samples since last hop
|
||
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
|
||
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
|
||
|
||
// Keying / timing.
|
||
state bool // true = mark (key down)
|
||
stateHops int
|
||
dotHops float64 // adaptive dot length, in hops
|
||
elem []byte // current "." / "-" run for the in-progress character
|
||
charEmitted bool
|
||
wordEmitted bool
|
||
|
||
lastPitch float64
|
||
lastRMS float64
|
||
|
||
statusEvery int
|
||
sinceStatus int
|
||
|
||
onChar func(string)
|
||
onStatus func(Status)
|
||
}
|
||
|
||
var morse = map[string]byte{
|
||
".-": 'A', "-...": 'B', "-.-.": 'C', "-..": 'D', ".": 'E', "..-.": 'F',
|
||
"--.": 'G', "....": 'H', "..": 'I', ".---": 'J', "-.-": 'K', ".-..": 'L',
|
||
"--": 'M', "-.": 'N', "---": 'O', ".--.": 'P', "--.-": 'Q', ".-.": 'R',
|
||
"...": 'S', "-": 'T', "..-": 'U', "...-": 'V', ".--": 'W', "-..-": 'X',
|
||
"-.--": 'Y', "--..": 'Z',
|
||
"-----": '0', ".----": '1', "..---": '2', "...--": '3', "....-": '4',
|
||
".....": '5', "-....": '6', "--...": '7', "---..": '8', "----.": '9',
|
||
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
|
||
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
|
||
}
|
||
|
||
// New builds a decoder for the given sample rate. onChar receives decoded text
|
||
// incrementally; onStatus receives ~10 snapshots/second. Either may be nil.
|
||
func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
|
||
if sampleRate <= 0 {
|
||
sampleRate = 16000
|
||
}
|
||
d := &Decoder{
|
||
fs: sampleRate,
|
||
hop: sampleRate / 250, // ~4 ms resolution
|
||
win: sampleRate / 62, // ~16 ms Goertzel window
|
||
dotHops: 15, // ~20 WPM seed
|
||
onSNR: 4.0,
|
||
offSNR: 2.5,
|
||
lockIdx: -1,
|
||
candIdx: -1,
|
||
statusEvery: 25, // ~10 Hz
|
||
onChar: onChar,
|
||
onStatus: onStatus,
|
||
}
|
||
if d.hop < 1 {
|
||
d.hop = 1
|
||
}
|
||
d.relockHops = int(0.8 * float64(d.fs) / float64(d.hop)) // release lock after ~0.8 s quiet
|
||
// Candidate CW tones: 250–1200 Hz every 25 Hz (covers most rigs' audio
|
||
// offset). The locked bin is the pitch; only its magnitude is decoded.
|
||
for f := 250.0; f <= 1200.0; f += 25 {
|
||
d.freqs = append(d.freqs, f)
|
||
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
|
||
}
|
||
d.mags = make([]float64, len(d.freqs))
|
||
d.nbuf = make([]float64, len(d.freqs))
|
||
return d
|
||
}
|
||
|
||
// Reset clears decode state (e.g. when the user re-arms the 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.state = false
|
||
d.stateHops = 0
|
||
d.dotHops = 15
|
||
d.elem = d.elem[:0]
|
||
d.charEmitted, d.wordEmitted = false, false
|
||
}
|
||
|
||
// Process feeds a block of mono samples through the decoder.
|
||
func (d *Decoder) Process(samples []int16) {
|
||
for _, s := range samples {
|
||
d.ring = append(d.ring, float64(s))
|
||
if len(d.ring) > d.win {
|
||
d.ring = d.ring[len(d.ring)-d.win:]
|
||
}
|
||
d.acc++
|
||
if d.acc >= d.hop && len(d.ring) >= d.win {
|
||
d.acc = 0
|
||
d.analyze()
|
||
d.step()
|
||
}
|
||
}
|
||
}
|
||
|
||
// analyze runs the Goertzel bank over the current window, estimates the noise
|
||
// floor, and maintains the pitch lock.
|
||
func (d *Decoder) analyze() {
|
||
n := float64(len(d.ring))
|
||
var sumSq float64
|
||
maxIdx, maxMag := 0, -1.0
|
||
for i, coeff := range d.coeffs {
|
||
var s1, s2 float64
|
||
for _, x := range d.ring {
|
||
s0 := x + coeff*s1 - s2
|
||
s2 = s1
|
||
s1 = s0
|
||
}
|
||
m := math.Sqrt(math.Max(s1*s1+s2*s2-coeff*s1*s2, 0)) / n
|
||
d.mags[i] = m
|
||
if m > maxMag {
|
||
maxMag = m
|
||
maxIdx = i
|
||
}
|
||
}
|
||
for _, x := range d.ring {
|
||
sumSq += x * x
|
||
}
|
||
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).
|
||
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.
|
||
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.lockIdx >= 0 {
|
||
d.lastPitch = d.freqs[d.lockIdx]
|
||
} else {
|
||
d.lastPitch = 0
|
||
}
|
||
}
|
||
|
||
// step advances the keying detector + timing state machine by one hop.
|
||
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
|
||
} else {
|
||
on = snr > d.onSNR
|
||
}
|
||
}
|
||
|
||
if on == d.state {
|
||
d.stateHops++
|
||
if !d.state {
|
||
d.spaceProgress()
|
||
}
|
||
} else {
|
||
if d.state {
|
||
d.endMark(d.stateHops)
|
||
}
|
||
d.state = on
|
||
d.stateHops = 1
|
||
if on {
|
||
d.charEmitted, d.wordEmitted = false, false
|
||
}
|
||
}
|
||
d.emitStatus(on)
|
||
}
|
||
|
||
// endMark classifies a finished key-down run as a dot or dash and adapts the
|
||
// dot-length estimate. Runs shorter than a third of a dot are rejected as
|
||
// clicks/noise.
|
||
func (d *Decoder) endMark(hops int) {
|
||
h := float64(hops)
|
||
if h < d.dotHops*0.35 {
|
||
return
|
||
}
|
||
if h > d.dotHops*2 {
|
||
d.elem = append(d.elem, '-')
|
||
d.adaptDot(h / 3)
|
||
} else {
|
||
d.elem = append(d.elem, '.')
|
||
d.adaptDot(h)
|
||
}
|
||
}
|
||
|
||
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
|
||
// to ~5–100 WPM).
|
||
func (d *Decoder) adaptDot(obs float64) {
|
||
d.dotHops = d.dotHops*0.7 + obs*0.3
|
||
if d.dotHops < 3 {
|
||
d.dotHops = 3
|
||
}
|
||
if d.dotHops > 60 {
|
||
d.dotHops = 60
|
||
}
|
||
}
|
||
|
||
// spaceProgress flushes the current character once the gap exceeds a character
|
||
// gap, and a word space once it exceeds a word gap.
|
||
func (d *Decoder) spaceProgress() {
|
||
g := float64(d.stateHops)
|
||
if !d.charEmitted && g > d.dotHops*2 {
|
||
d.flushChar()
|
||
d.charEmitted = true
|
||
}
|
||
if !d.wordEmitted && g > d.dotHops*5 {
|
||
if d.onChar != nil {
|
||
d.onChar(" ")
|
||
}
|
||
d.wordEmitted = true
|
||
}
|
||
}
|
||
|
||
// flushChar looks up the accumulated element string and emits the character.
|
||
func (d *Decoder) flushChar() {
|
||
if len(d.elem) == 0 {
|
||
return
|
||
}
|
||
if c, ok := morse[string(d.elem)]; ok {
|
||
if d.onChar != nil {
|
||
d.onChar(string(c))
|
||
}
|
||
} else if d.onChar != nil {
|
||
d.onChar("?")
|
||
}
|
||
d.elem = d.elem[:0]
|
||
}
|
||
|
||
func (d *Decoder) emitStatus(on bool) {
|
||
d.sinceStatus++
|
||
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
|
||
return
|
||
}
|
||
d.sinceStatus = 0
|
||
hopMs := float64(d.hop) / float64(d.fs) * 1000
|
||
wpm := 0
|
||
if d.dotHops > 0 {
|
||
wpm = int(math.Round(1200 / (d.dotHops * hopMs)))
|
||
}
|
||
d.onStatus(Status{WPM: wpm, Pitch: int(math.Round(d.lastPitch)), Level: d.lastRMS, Active: on})
|
||
}
|