Files
OpsLog/app_cw.go
T
rouggy d6a2e84eed feat: CW decoder v2 — rebuilt decode engine, back in the UI
The first RX-audio CW decoder decoded poorly on real signals (vs SDC) and was
removed in cafade0. This rebuilds the DSP core from scratch in
internal/cwdecode, keeping v1's good ideas (pitch lock, Flex cw_pitch
targeting, tiered acquisition) and fixing its proven failures:

- Two-way DEBOUNCE (pending-commit ~0.3 dit): v1 rejected short marks but not
  short spaces, so a one-hop fade inside a dah shattered it into dits — the
  single worst real-signal failure.
- Per-character BATCH dit/dah classification at flush time, using the batch's
  own bimodal boundary — the first letter of an over decodes at any speed; the
  timing clusters (muDit/muDah) are updated with the same attribution, killing
  the classify-then-learn feedback spiral ("all dits at 60 WPM").
- dB-domain envelope with separate floor/peak trackers, hysteresis slicer,
  span CAP (30 dB — else quiet backgrounds put the off-threshold in the
  analysis-window skirts and letters merge) and window de-bias on durations.
- Double squelch: span >= 6 dB AND peak >= bank-median + 9.5 dB (span alone
  cannot reject pure noise).
- Hamming-windowed Goertzel, 16 ms window / 5 ms hop (a 20 ms window left
  40 WPM inter-element gaps with no envelope dip).
- Hardened acquisition (overlapping windows made "3 stable hops" meaningless)
  plus SUPERVISED RE-LOCK: a clearly stronger tone on another pitch sustained
  ~1 s takes over — heals noise locks and follows a QSY.
- Gap-fed dit-length tracking (inter-element gaps are timing evidence too).

Proven by a synthetic-signal test suite (all passing): clean 12-40 WPM, three
pitches, added noise, QSB fading 0.35-1.0, 10 ms dropouts inside dahs, QRM in
auto and targeted modes, noise-only squelch, mid-over speed change 25->15 WPM,
and jittered hand keying (+/-20-25%, 3 seeds).

Wiring and UI restored from git (app_cw.go, Ear header button, decoded-text
strip with WPM/pitch/level + pitch lock + click-a-word-to-fill-callsign, Tools
menu entry) — strip strings translated (cwd.* i18n keys, EN/FR) this time.
2026-07-23 22:50:02 +02:00

150 lines
3.8 KiB
Go

package main
import (
"fmt"
"time"
"hamlog/internal/applog"
"hamlog/internal/audio"
"hamlog/internal/cwdecode"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
// only by the frontend, and only while the entry mode is CW.
//
// Pitch targeting: the single-channel decoder is far more reliable when it locks
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
// instead of auto-searching for the loudest tone. So we follow the radio's CW
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
// back to auto-search otherwise.
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
func (a *App) cwTargetPitch() int {
if a.cwPitchHz > 0 {
return a.cwPitchHz
}
if a.cat != nil {
if st, ok := a.cat.FlexState(); ok && st.Available {
// Only trust the radio's pitch when it's actually in CW.
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
if st.CWPitch > 0 {
return st.CWPitch
}
}
}
}
return 0
}
// StartCWDecoder begins decoding CW from the configured RX audio device. The
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
// call repeatedly; a second call is a no-op while already running.
func (a *App) StartCWDecoder() error {
a.cwMu.Lock()
defer a.cwMu.Unlock()
if a.cwStop != nil {
return nil // already running
}
dev := ""
if a.settings != nil {
dev, _ = a.settings.Get(a.ctx, keyAudioFromRadio)
}
if dev == "" {
return fmt.Errorf("no RX audio device configured (set \"From radio\" in Audio settings)")
}
dec := cwdecode.New(audio.SampleRate,
func(text string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:text", text)
}
},
func(st cwdecode.Status) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:status", st)
}
},
)
dec.SetTarget(a.cwTargetPitch())
a.cwDecoder = dec
stop := make(chan struct{})
a.cwStop = stop
go func() {
if err := audio.StreamCapture(dev, stop, dec.Process); err != nil {
applog.Printf("cw: capture failed: %v", err)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
}
}
a.cwMu.Lock()
if a.cwStop == stop {
a.cwStop = nil
a.cwDecoder = nil
}
a.cwMu.Unlock()
}()
// Follow the radio's CW pitch live (every second) while this run is active.
go a.cwFollowPitch(stop, dec)
return nil
}
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
dec.SetTarget(a.cwTargetPitch())
}
}
}
// StopCWDecoder halts the CW decoder if running.
func (a *App) StopCWDecoder() {
a.cwMu.Lock()
stop := a.cwStop
a.cwStop = nil
a.cwDecoder = nil
a.cwMu.Unlock()
if stop != nil {
close(stop)
}
}
// CWDecoderRunning reports whether the decoder is currently capturing.
func (a *App) CWDecoderRunning() bool {
a.cwMu.Lock()
defer a.cwMu.Unlock()
return a.cwStop != nil
}
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
// the Flex CW pitch, or search). Applies live to a running decoder.
func (a *App) SetCWDecoderPitch(hz int) {
if hz < 0 {
hz = 0
}
a.cwMu.Lock()
a.cwPitchHz = hz
dec := a.cwDecoder
a.cwMu.Unlock()
if dec != nil {
dec.SetTarget(a.cwTargetPitch())
}
}
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
func (a *App) GetCWDecoderPitch() int {
a.cwMu.Lock()
defer a.cwMu.Unlock()
return a.cwPitchHz
}