Files
OpsLog/internal/cwdecode/cwdecode.go
T
rouggy 32878c17be fix: bug when autocall for cw keyer is on which was
autocalling no matter which macro now only on CQ
fix: ESC stop transmission but also autocall
2026-06-20 02:05:12 +02:00

309 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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: 2501200 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 ~5100 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})
}