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.
150 lines
3.8 KiB
Go
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
|
|
}
|