Files
OpsLog/internal/cwdecode/cwdecode.go
T
2026-06-19 17:31:10 +02:00

276 lines
7.8 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 (auto-picking the dominant pitch), an adaptive
// envelope/threshold to recover key-down/key-up, 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. Robustness on weak/QRM/QSB signals is limited
// (as with every audio CW decoder); it does well on clean signals.
package cwdecode
import "math"
// 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
Level float64 `json:"level"` // 0..1 rough signal strength (SNR proxy)
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 envelope 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
// Adaptive envelope (relative, so absolute gain is irrelevant).
peak, floor float64
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 // current space already flushed a character
wordEmitted bool // current space already flushed a word gap
lastPitch float64
lastRMS float64 // 0..1 input level of the current window (for the UI meter)
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 envelope resolution
win: sampleRate / 62, // ~16 ms Goertzel window
dotHops: 15, // ~20 WPM seed (15 hops * 4 ms = 60 ms)
statusEvery: 25, // ~10 Hz status
onChar: onChar,
onStatus: onStatus,
}
if d.hop < 1 {
d.hop = 1
}
// Candidate CW tones: 2501200 Hz every 25 Hz (wide enough for most rigs'
// audio offset). The dominant bin is the pitch (auto), and its magnitude
// drives the envelope.
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)))
}
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.peak, d.floor = 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
mag, pitch := d.toneMag()
d.step(mag, pitch)
}
}
}
// toneMag runs the Goertzel bank over the current window and returns the
// strongest bin's magnitude and its frequency (the auto-detected pitch).
func (d *Decoder) toneMag() (float64, float64) {
best, bestF := 0.0, d.lastPitch
n := float64(len(d.ring))
var sumSq float64
for i, coeff := range d.coeffs {
var s1, s2 float64
for _, x := range d.ring {
s0 := x + coeff*s1 - s2
s2 = s1
s1 = s0
}
power := s1*s1 + s2*s2 - coeff*s1*s2
if power > best {
best = power
bestF = d.freqs[i]
}
}
for _, x := range d.ring {
sumSq += x * x
}
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4) // ×4 so quiet audio is visible
// Normalise by window length so the magnitude scale is rate-independent.
return math.Sqrt(math.Max(best, 0)) / n, bestF
}
// step advances the envelope follower + timing state machine by one hop.
func (d *Decoder) step(mag, pitch float64) {
// Envelope: fast attack / slow release for the peak, fast drop / slow rise
// for the noise floor. Tracks the signal even through QSB.
if mag > d.peak {
d.peak += (mag - d.peak) * 0.4
} else {
d.peak += (mag - d.peak) * 0.02
}
if mag < d.floor {
d.floor += (mag - d.floor) * 0.4
} else {
d.floor += (mag - d.floor) * 0.01
}
span := d.peak - d.floor
// Hysteresis thresholds; require a minimum SNR span to call anything a tone.
on := d.state
if span > d.floor*0.3+1e-9 {
onTh := d.floor + 0.55*span
offTh := d.floor + 0.35*span
if d.state {
on = mag > offTh
} else {
on = mag > onTh
}
if on {
d.lastPitch = pitch
}
} else {
on = false
}
if on == d.state {
d.stateHops++
if !d.state {
d.spaceProgress() // flush char/word as the gap grows
}
} else {
if d.state {
d.endMark(d.stateHops)
}
d.state = on
d.stateHops = 1
if on {
// A new mark starts → the previous space is over; re-arm flushing.
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.
func (d *Decoder) endMark(hops int) {
h := float64(hops)
// Reject impulse noise far shorter than a dot.
if h < d.dotHops*0.35 {
return
}
dash := h > d.dotHops*2
if dash {
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})
}