{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
diff --git a/internal/cwdecode/cwdecode.go b/internal/cwdecode/cwdecode.go
index 0687c52..6623d5b 100644
--- a/internal/cwdecode/cwdecode.go
+++ b/internal/cwdecode/cwdecode.go
@@ -1,48 +1,64 @@
// 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.
+// 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. Robustness on weak/QRM/QSB signals is limited
-// (as with every audio CW decoder); it does well on clean signals.
+// 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"
+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
- Level float64 `json:"level"` // 0..1 rough signal strength (SNR proxy)
+ 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 envelope updates
- win int // Goertzel window length
- freqs []float64
- coeffs []float64 // precomputed 2*cos(w) per freq
+ 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
- // Adaptive envelope (relative, so absolute gain is irrelevant).
- peak, floor float64
+ // 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
- 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)
+ lastPitch float64
+ lastRMS float64
statusEvery int
sinceStatus int
@@ -71,23 +87,29 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
}
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
+ 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
}
- // Candidate CW tones: 250–1200 Hz every 25 Hz (wide enough for most rigs'
- // audio offset). The dominant bin is the pitch (auto), and its magnitude
- // drives the envelope.
+ 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
}
@@ -95,7 +117,7 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
func (d *Decoder) Reset() {
d.ring = d.ring[:0]
d.acc = 0
- d.peak, d.floor = 0, 0
+ d.lockIdx, d.candIdx, d.candHops, d.unlockHops = -1, -1, 0, 0
d.state = false
d.stateHops = 0
d.dotHops = 15
@@ -113,18 +135,18 @@ func (d *Decoder) Process(samples []int16) {
d.acc++
if d.acc >= d.hop && len(d.ring) >= d.win {
d.acc = 0
- mag, pitch := d.toneMag()
- d.step(mag, pitch)
+ d.analyze()
+ d.step()
}
}
}
-// 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
+// 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 {
@@ -132,57 +154,71 @@ func (d *Decoder) toneMag() (float64, float64) {
s2 = s1
s1 = s0
}
- power := s1*s1 + s2*s2 - coeff*s1*s2
- if power > best {
- best = power
- bestF = d.freqs[i]
+ 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) // ×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
+ 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 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
+// 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 = mag > offTh
+ on = snr > d.offSNR // hysteresis: stay keyed until it clearly drops
} else {
- on = mag > onTh
+ on = snr > d.onSNR
}
- 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
+ d.spaceProgress()
}
} else {
if d.state {
@@ -191,24 +227,21 @@ func (d *Decoder) step(mag, pitch float64) {
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.
+// 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)
- // Reject impulse noise far shorter than a dot.
if h < d.dotHops*0.35 {
return
}
- dash := h > d.dotHops*2
- if dash {
+ if h > d.dotHops*2 {
d.elem = append(d.elem, '-')
d.adaptDot(h / 3)
} else {
diff --git a/internal/cwdecode/cwdecode_test.go b/internal/cwdecode/cwdecode_test.go
index 460dcfd..e91f63a 100644
--- a/internal/cwdecode/cwdecode_test.go
+++ b/internal/cwdecode/cwdecode_test.go
@@ -17,6 +17,10 @@ func charToMorse() map[byte]string {
// keyMessage synthesizes a clean keyed tone for msg at the given WPM/pitch.
func keyMessage(msg string, fs, wpm int, pitch float64) []int16 {
+ return keyMessageAmp(msg, fs, wpm, pitch, 9000)
+}
+
+func keyMessageAmp(msg string, fs, wpm int, pitch, amp float64) []int16 {
dot := fs * 1200 / (wpm * 1000) // samples per dot
c2m := charToMorse()
var out []int16
@@ -25,7 +29,7 @@ func keyMessage(msg string, fs, wpm int, pitch float64) []int16 {
tone := func(n int) {
for i := 0; i < n; i++ {
- out = append(out, int16(9000*math.Sin(phase)))
+ out = append(out, int16(amp*math.Sin(phase)))
phase += dphi
}
}
@@ -79,6 +83,41 @@ func TestDecodeCleanSignal(t *testing.T) {
}
}
+func TestDecodeWithQRM(t *testing.T) {
+ const fs = 16000
+ // Target at 700 Hz; a strong interfering keyed signal at 950 Hz, slightly
+ // quieter, sending different text. The pitch lock should hold on the target.
+ target := keyMessageAmp("PARIS PARIS PARIS", fs, 20, 700, 9000)
+ qrm := keyMessageAmp("BK DE QRZ QRZ TEST", fs, 26, 950, 6500)
+ mix := make([]int16, len(target))
+ for i := range target {
+ v := int(target[i])
+ if i < len(qrm) {
+ v += int(qrm[i])
+ }
+ if v > 32767 {
+ v = 32767
+ } else if v < -32768 {
+ v = -32768
+ }
+ mix[i] = int16(v)
+ }
+
+ var sb strings.Builder
+ d := New(fs, func(s string) { sb.WriteString(s) }, nil)
+ for i := 0; i < len(mix); i += 256 {
+ end := i + 256
+ if end > len(mix) {
+ end = len(mix)
+ }
+ d.Process(mix[i:end])
+ }
+ got := strings.ToUpper(sb.String())
+ if !strings.Contains(got, "PARIS") {
+ t.Fatalf("with QRM, decoded %q, want it to contain PARIS", got)
+ }
+}
+
func TestDecodeNumbersAndProsign(t *testing.T) {
const fs = 16000
var sb strings.Builder