From cafade0dbb42fa44791e563e3555077242413410 Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 20 Jul 2026 19:00:09 +0200 Subject: [PATCH] remove CW decoder (audio->text) entirely; fix CWX macro not stopping on call typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removes the RX-audio CW decoder: deletes app_cw.go + internal/cwdecode, the cwDecoder/cwPitchHz/cwMu/cwStop App fields, and all frontend state/effects, the header Ear button, the Tools menu item and the decoded-text strip. The CW KEYER (WinKeyer/Icom/Flex) is untouched. - Fix: typing a callsign while a macro is keying now aborts the CURRENT engine (Flex CWX / Icom / WinKeyer) on the first character, not just WinKeyer during an auto-call loop — via a shared stopKeyerTx() helper. --- app.go | 5 - app_cw.go | 149 ----------- frontend/src/App.tsx | 115 ++------- frontend/wailsjs/go/main/App.d.ts | 10 - frontend/wailsjs/go/main/App.js | 20 -- internal/cwdecode/cwdecode.go | 389 ----------------------------- internal/cwdecode/cwdecode_test.go | 201 --------------- 7 files changed, 14 insertions(+), 875 deletions(-) delete mode 100644 app_cw.go delete mode 100644 internal/cwdecode/cwdecode.go delete mode 100644 internal/cwdecode/cwdecode_test.go diff --git a/app.go b/app.go index 09617b6..78d15f6 100644 --- a/app.go +++ b/app.go @@ -32,7 +32,6 @@ import ( "hamlog/internal/clublog" "hamlog/internal/cluster" "hamlog/internal/contest" - "hamlog/internal/cwdecode" "hamlog/internal/db" "hamlog/internal/dxcc" "hamlog/internal/email" @@ -482,10 +481,6 @@ type App struct { alertStore *alerts.Store // DX-cluster spot alert rules (global JSON) - cwMu sync.Mutex // guards the CW decoder lifecycle - cwStop chan struct{} // stops the CW decoder capture loop; nil when off - cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch) - cwPitchHz int // manual pitch override (0 = auto / follow Flex) startupProfile string // --profile from the command line (activate at startup) dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends diff --git a/app_cw.go b/app_cw.go deleted file mode 100644 index 1d5a150..0000000 --- a/app_cw.go +++ /dev/null @@ -1,149 +0,0 @@ -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 -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d5f0b4..60c2be6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock, + Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Eraser, Hash, Loader2, Lock, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap, } from 'lucide-react'; @@ -38,7 +38,6 @@ import { IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState, FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW, GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop, - StartCWDecoder, StopCWDecoder, SetCWDecoderPitch, ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators, QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock, GetAwardDefs, @@ -813,36 +812,6 @@ export default function App() { useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]); // === Digital Voice Keyer (DVK) === - // CW decoder: taps RX audio and decodes Morse. Runs only when enabled AND the - // mode is CW. The decoded text appears in a strip above the tabs. - const [cwEnabled, setCwEnabled] = useState(() => localStorage.getItem('opslog.cwDecoder') === '1'); - const [cwText, setCwText] = useState(''); - const [cwStatus, setCwStatus] = useState<{ wpm: number; pitch: number; level: number; active: boolean }>({ wpm: 0, pitch: 0, level: 0, active: false }); - const cwOn = cwEnabled && mode === 'CW'; - // Keep the decoded line scrolled to the newest text (left-aligned, no scrollbar). - const cwScrollRef = useRef(null); - useEffect(() => { const el = cwScrollRef.current; if (el) el.scrollLeft = el.scrollWidth; }, [cwText]); - // Manual pitch override ('' = Auto: follow the radio's CW pitch / search). - const [cwPitch, setCwPitch] = useState(() => localStorage.getItem('opslog.cwPitch') || ''); - useEffect(() => { - const hz = parseInt(cwPitch, 10); - SetCWDecoderPitch(Number.isFinite(hz) ? hz : 0).catch(() => {}); - localStorage.setItem('opslog.cwPitch', cwPitch); - }, [cwPitch, cwOn]); - useEffect(() => { - const offT = EventsOn('cw:text', (t: string) => setCwText((s) => (s + t).slice(-200))); - const offS = EventsOn('cw:status', (st: any) => setCwStatus(st)); - const offE = EventsOn('cw:error', (e: string) => { setError(String(e)); setCwEnabled(false); }); - return () => { offT?.(); offS?.(); offE?.(); }; - }, []); - // Start/stop the backend decoder as the (enabled, mode) combination changes. - useEffect(() => { - if (cwOn) { StartCWDecoder().catch((e: any) => { setError(String(e?.message ?? e)); setCwEnabled(false); }); } - else { StopCWDecoder().catch(() => {}); } - }, [cwOn]); - function toggleCwDecoder() { - setCwEnabled((v) => { const n = !v; localStorage.setItem('opslog.cwDecoder', n ? '1' : '0'); return n; }); - } // === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK === const [chatAvailable, setChatAvailable] = useState(false); @@ -2164,6 +2133,13 @@ export default function App() { } // stopAutoCall cancels any running auto-call loop. function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; } + // stopKeyerTx aborts the CW being sent RIGHT NOW, routed to the active engine + // (was WinKeyer-only in a few places, so a Flex CWX / Icom macro kept going). + function stopKeyerTx() { + if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {}); + else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {}); + else WinkeyerStop().catch(() => {}); + } // runAutoCall sends macro i, waits for the keyer to finish, waits the chosen // gap, then resends — looping until cancelled (reply entered, Stop, unchecked). async function runAutoCall(i: number) { @@ -2670,7 +2646,11 @@ export default function App() { // rather than finishing the buffered call. (autoCallMacroRef flips to -1 on // the first keystroke, so we only abort once.) if (v.trim() !== '') { - if (autoCallMacroRef.current !== -1) WinkeyerStop().catch(() => {}); + // Someone answered: on the FIRST character of a new call (the field was + // empty), abort whatever CW is being sent — a single macro OR an auto-call + // CQ loop — routed to the ACTIVE engine (was WinKeyer-only, so a Flex CWX / + // Icom macro kept keying over the answering station). + if (callsignValRef.current.trim() === '') stopKeyerTx(); stopAutoCall(); } // No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call @@ -2794,7 +2774,6 @@ export default function App() { { type: 'separator' }, { type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' }, { type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' }, - { type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' }, { type: 'separator' }, { type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' }, { type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' }, @@ -2810,7 +2789,7 @@ export default function App() { { name: 'help', label: t('menu.help'), items: [ { type: 'item', label: t('help.about'), action: 'help.about' }, ]}, - ], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, t]); + ], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, t]); function handleMenu(action: string) { switch (action) { @@ -2831,7 +2810,6 @@ export default function App() { case 'tools.qsldesigner': setQslDesignerOpen(true); break; case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.dvk': setDvkEnabled((v) => !v); break; - case 'tools.cwdecoder': toggleCwDecoder(); break; case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break; case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break; case 'tools.alerts': setAlertsOpen(true); break; @@ -3857,24 +3835,6 @@ export default function App() { {wkStatus.busy && } - - ))} - - )} - - - - )} - {/* ===== LOWER: tabbed table / cluster / band map ===== */} {compact ? null : <>
,arg2:string,arg3:string):Prom export function BulkUpdateQSL(arg1:Array,arg2:main.QSLBulkUpdate):Promise; -export function CWDecoderRunning():Promise; - export function ChatAvailable():Promise; export function CheckForUpdate():Promise; @@ -325,8 +323,6 @@ export function GetCATSettings():Promise; export function GetCATState():Promise; -export function GetCWDecoderPitch():Promise; - export function GetCatalogCodes():Promise>; export function GetChatHistory(arg1:number):Promise>; @@ -801,8 +797,6 @@ export function SetCATFrequency(arg1:number):Promise; export function SetCATMode(arg1:string):Promise; -export function SetCWDecoderPitch(arg1:number):Promise; - export function SetClublogCtyEnabled(arg1:boolean):Promise; export function SetClusterAutoConnect(arg1:boolean):Promise; @@ -821,12 +815,8 @@ export function SetUIPref(arg1:string,arg2:string):Promise; export function SetUltrabeamDirection(arg1:number):Promise; -export function StartCWDecoder():Promise; - export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise; -export function StopCWDecoder():Promise; - export function SwitchCATRig(arg1:number):Promise; export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 794373c..5cd8100 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -102,10 +102,6 @@ export function BulkUpdateQSL(arg1, arg2) { return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2); } -export function CWDecoderRunning() { - return window['go']['main']['App']['CWDecoderRunning'](); -} - export function ChatAvailable() { return window['go']['main']['App']['ChatAvailable'](); } @@ -606,10 +602,6 @@ export function GetCATState() { return window['go']['main']['App']['GetCATState'](); } -export function GetCWDecoderPitch() { - return window['go']['main']['App']['GetCWDecoderPitch'](); -} - export function GetCatalogCodes() { return window['go']['main']['App']['GetCatalogCodes'](); } @@ -1558,10 +1550,6 @@ export function SetCATMode(arg1) { return window['go']['main']['App']['SetCATMode'](arg1); } -export function SetCWDecoderPitch(arg1) { - return window['go']['main']['App']['SetCWDecoderPitch'](arg1); -} - export function SetClublogCtyEnabled(arg1) { return window['go']['main']['App']['SetClublogCtyEnabled'](arg1); } @@ -1598,18 +1586,10 @@ export function SetUltrabeamDirection(arg1) { return window['go']['main']['App']['SetUltrabeamDirection'](arg1); } -export function StartCWDecoder() { - return window['go']['main']['App']['StartCWDecoder'](); -} - export function StationSetRelay(arg1, arg2, arg3) { return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3); } -export function StopCWDecoder() { - return window['go']['main']['App']['StopCWDecoder'](); -} - export function SwitchCATRig(arg1) { return window['go']['main']['App']['SwitchCATRig'](arg1); } diff --git a/internal/cwdecode/cwdecode.go b/internal/cwdecode/cwdecode.go deleted file mode 100644 index 28e7e8b..0000000 --- a/internal/cwdecode/cwdecode.go +++ /dev/null @@ -1,389 +0,0 @@ -// 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 adaptive envelope/threshold on the LOCKED -// tone (level-independent, so weak or strong signals both key cleanly), 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 pitch lock keeps QRM on other tones -// out of the decode. -package cwdecode - -import ( - "math" - "sort" - "sync/atomic" -) - -// 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 - - // Fixed-pitch target (Hz). 0 = auto-search; >0 = lock to the nearest bin and - // ignore everything else (e.g. follow the radio's CW pitch). Set live from - // another goroutine, so it's atomic. - targetHz atomic.Int32 - - // Pitch lock. - 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 - quietHops int // consecutive key-up hops while locked - noise float64 // broadband noise estimate (percentile of bins) - relockHops int // quiet hops before the lock is released - acqSNR float64 // tone/noise ratio to acquire after a few stable hops - strongSNR float64 // tone/noise ratio to lock immediately (1 hop) - - // Adaptive keying envelope, on the LOCKED bin's magnitude. - peak, floor float64 - state bool // true = mark (key down) - stateHops int - dotHops float64 // adaptive dot length, in hops - markCount int // marks seen since lock (fast WPM adaptation while small) - 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 / 72, // ~14 ms Goertzel window (selective, fairly snappy) - dotHops: 15, // ~20 WPM seed - acqSNR: 1.9, // ignore noise spikes (looser locked onto noise = garbage) - strongSNR: 3.2, // only a genuinely strong tone locks in 1 hop - 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: 400–1000 Hz every 25 Hz. Deliberately NOT lower: strong - // low-frequency noise/hum (pink/red noise rises toward DC) would otherwise win - // the argmax and lock the decoder onto ~250 Hz junk instead of the signal. - for f := 400.0; f <= 1000.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 -} - -// SetTarget fixes the decode pitch to hz (lock to the nearest bin, ignore other -// tones), or returns to auto-search when hz <= 0. Safe to call concurrently. -func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) } - -// nearestBin returns the bin index closest to hz. -func (d *Decoder) nearestBin(hz float64) int { - best, bestD := 0, math.Inf(1) - for i, f := range d.freqs { - if dd := math.Abs(f - hz); dd < bestD { - bestD, best = dd, i - } - } - return best -} - -// 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.quietHops = -1, -1, 0, 0 - d.peak, d.floor = 0, 0 - d.state = false - d.stateHops = 0 - d.dotHops = 15 - d.markCount = 0 - 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, estimates the noise floor, and maintains the -// pitch lock (which tone the envelope detector then follows). -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) - - // Fixed-pitch mode: lock straight to the target bin, skip the auto search. - // A narrow filter at the known pitch is exactly how a skimmer avoids QRM. - if th := int(d.targetHz.Load()); th > 0 { - d.lockIdx = d.nearestBin(float64(th)) - d.lastPitch = d.freqs[d.lockIdx] - return - } - - // Noise floor = 40th percentile of the bins (robust to a few strong tones). - copy(d.nbuf, d.mags) - sort.Float64s(d.nbuf) - d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)] - - if d.lockIdx < 0 { - if maxIdx == d.candIdx { - d.candHops++ - } else { - d.candIdx, d.candHops = maxIdx, 1 - } - snr := maxMag / (d.noise + 1e-9) - // Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we - // don't eat the first element of a strong signal), a marginal/weak tone - // locks after a couple of stable hops (so we don't lock onto pure noise). - if snr > d.strongSNR || (d.candHops >= 2 && snr > d.acqSNR) { - d.lockIdx = maxIdx - d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin - d.quietHops = 0 - d.markCount = 0 // relearn WPM fast for this new signal - } - } - if d.lockIdx >= 0 { - d.lastPitch = d.freqs[d.lockIdx] - } else { - d.lastPitch = 0 - } -} - -// step runs the adaptive envelope on the locked bin and the timing state -// machine, one hop. The envelope adapts to the signal level (not an absolute -// threshold), so weak and strong signals both key correctly. -func (d *Decoder) step() { - on := false - if d.lockIdx >= 0 { - m := d.mags[d.lockIdx] - // Peak: fast attack, slow release. - if m > d.peak { - d.peak += (m - d.peak) * 0.4 - } else { - d.peak += (m - d.peak) * 0.02 - } - // Floor: drops fast toward the signal, but only RISES between marks (when - // keyed up). Letting the floor rise during a long dash would shrink the - // span until the dash drops below the threshold and fragments into dots — - // the cause of the "all dots" garbage on a strong clean signal. - if m < d.floor { - d.floor += (m - d.floor) * 0.4 - } else if !d.state { - d.floor += (m - d.floor) * 0.02 - } - span := d.peak - d.floor - // The frozen floor already stops dashes fragmenting, so keep balanced - // thresholds: low enough that short inter-element GAPS are still seen - // (otherwise elements merge into >7-symbol runs that decode to nothing). - if span > d.floor*0.3+1e-9 { - onTh := d.floor + 0.55*span - offTh := d.floor + 0.35*span - if d.state { - on = m > offTh - } else { - on = m > onTh - } - } - // Release the lock after a long quiet so we can retune to a new signal. - if on { - d.quietHops = 0 - } else { - d.quietHops++ - if d.quietHops > d.relockHops { - // End of the over: flush any pending character and drop a word - // space so the next transmission starts a fresh word (the word-gap - // timer above can't fire once the lock is gone). - if len(d.elem) > 0 && !d.charEmitted { - d.flushChar() - d.charEmitted = true - } - if !d.wordEmitted && d.onChar != nil { - d.onChar(" ") - d.wordEmitted = true - } - d.lockIdx, d.candIdx, d.candHops = -1, -1, 0 - } - } - } - - 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) - // Reject clicks/noise: shorter than a third of a dot AND an absolute floor - // of ~4 hops (~16 ms, i.e. faster than ~75 WPM) so noise can't drag the - // dot-length estimate down to the clamp (which produced 100 WPM garbage). - if h < d.dotHops*0.35 || h < 4 { - 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 ~5–60 WPM). The EMA is deliberately GENTLE (0.2) and NOT accelerated on the -// opening marks: a fast alpha let short noise blips (misclassified as dots) drag -// the dot-length down to the clamp within a few marks — the "60 WPM, all dits" -// garbage. The slow EMA is self-correcting because genuine marks pull it back up. -func (d *Decoder) adaptDot(obs float64) { - const alpha = 0.2 - d.markCount++ - d.dotHops = d.dotHops*(1-alpha) + obs*alpha - if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100 - d.dotHops = 5 - } - if d.dotHops > 55 { - d.dotHops = 55 - } -} - -// 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 && len(d.elem) <= 7 { - // Only flag a genuinely Morse-shaped but unknown char with "?". An - // over-long element run is noise — drop it silently rather than spam "?". - 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}) -} diff --git a/internal/cwdecode/cwdecode_test.go b/internal/cwdecode/cwdecode_test.go deleted file mode 100644 index 0faefef..0000000 --- a/internal/cwdecode/cwdecode_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package cwdecode - -import ( - "math" - "strings" - "testing" -) - -// reverse Morse map for the synthesizer. -func charToMorse() map[byte]string { - m := map[byte]string{} - for code, ch := range morse { - m[ch] = code - } - return m -} - -// 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 - phase := 0.0 - dphi := 2 * math.Pi * pitch / float64(fs) - - tone := func(n int) { - for i := 0; i < n; i++ { - out = append(out, int16(amp*math.Sin(phase))) - phase += dphi - } - } - silence := func(n int) { - for i := 0; i < n; i++ { - out = append(out, 0) - } - } - - silence(fs / 4) // 250 ms lead-in for AGC warmup - for i := 0; i < len(msg); i++ { - ch := msg[i] - if ch == ' ' { - silence(7 * dot) - continue - } - code := c2m[ch] - for j := 0; j < len(code); j++ { - if code[j] == '.' { - tone(dot) - } else { - tone(3 * dot) - } - silence(dot) // inter-element gap - } - silence(3 * dot) // inter-character gap (on top of the trailing element gap) - } - silence(fs / 4) - return out -} - -func TestDecodeCleanSignal(t *testing.T) { - const fs = 16000 - var sb strings.Builder - d := New(fs, func(s string) { sb.WriteString(s) }, nil) - - // Repeat so AGC warm-up only costs the first word. - samples := keyMessage("PARIS PARIS PARIS", fs, 22, 700) - // Feed in small chunks like the live capture would. - for i := 0; i < len(samples); i += 256 { - end := i + 256 - if end > len(samples) { - end = len(samples) - } - d.Process(samples[i:end]) - } - - got := strings.ToUpper(sb.String()) - if !strings.Contains(got, "PARIS") { - t.Fatalf("decoded %q, want it to contain PARIS", got) - } -} - -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 TestDecodeFirstCharStrong(t *testing.T) { - const fs = 16000 - var sb strings.Builder - d := New(fs, func(s string) { sb.WriteString(s) }, nil) - // Strong signal: the very first element (T = a dash) must not be eaten by - // lock acquisition. Output should begin with the first character. - samples := keyMessageAmp("TEST DE", fs, 20, 700, 16000) - for i := 0; i < len(samples); i += 200 { - end := i + 200 - if end > len(samples) { - end = len(samples) - } - d.Process(samples[i:end]) - } - got := strings.ToUpper(strings.TrimSpace(sb.String())) - if !strings.HasPrefix(got, "TEST") { - t.Fatalf("first chars lost on a strong signal: decoded %q, want it to start with TEST", got) - } -} - -func TestDecodeWithAmplitudeRipple(t *testing.T) { - const fs = 16000 - // A real signal's tone amplitude wobbles within a mark; if the floor chases - // it, dashes fragment into dots ("all dots" garbage). Apply ±30% ripple. - samples := keyMessageAmp("CQ TEST DE OM", fs, 24, 800, 10000) - rp := 0.0 - for i := range samples { - rp += 2 * math.Pi * 35 / float64(fs) // 35 Hz amplitude wobble - samples[i] = int16(float64(samples[i]) * (1 + 0.3*math.Sin(rp))) - } - var sb strings.Builder - d := New(fs, func(s string) { sb.WriteString(s) }, nil) - for i := 0; i < len(samples); i += 256 { - end := i + 256 - if end > len(samples) { - end = len(samples) - } - d.Process(samples[i:end]) - } - got := strings.ToUpper(sb.String()) - if !strings.Contains(got, "TEST DE OM") { - t.Fatalf("dashes fragmented under amplitude ripple: decoded %q", got) - } -} - -func TestDecodeCQFixedPitch(t *testing.T) { - const fs = 16000 - var sb strings.Builder - d := New(fs, func(s string) { sb.WriteString(s) }, nil) - d.SetTarget(700) // fixed pitch like the user's manual override - samples := keyMessageAmp("CQ CQ CQ DE OM", fs, 26, 700, 9000) - for i := 0; i < len(samples); i += 200 { - end := i + 200 - if end > len(samples) { - end = len(samples) - } - d.Process(samples[i:end]) - } - got := strings.ToUpper(sb.String()) - if n := strings.Count(got, "CQ"); n < 2 { - t.Fatalf("first element of CQ dropped: decoded %q (only %d CQ)", got, n) - } -} - -func TestDecodeNumbersAndProsign(t *testing.T) { - const fs = 16000 - var sb strings.Builder - d := New(fs, func(s string) { sb.WriteString(s) }, nil) - samples := keyMessage("TEST 599 TEST", fs, 18, 650) - for i := 0; i < len(samples); i += 200 { - end := i + 200 - if end > len(samples) { - end = len(samples) - } - d.Process(samples[i:end]) - } - got := strings.ToUpper(sb.String()) - if !strings.Contains(got, "599") { - t.Fatalf("decoded %q, want it to contain 599", got) - } -}