From 95d37da3bb7c01328714f83a02fe1589d49394c6 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 20 Jun 2026 15:48:21 +0200 Subject: [PATCH] feat: While importing ADIF, update MY fields --- app.go | 99 +++++++++++--- app_cw.go | 69 +++++++++- frontend/src/App.tsx | 34 ++++- frontend/src/components/FlexPanel.tsx | 110 +++++++++++----- frontend/wailsjs/go/main/App.d.ts | 22 +++- frontend/wailsjs/go/main/App.js | 44 ++++++- frontend/wailsjs/go/models.ts | 20 +++ internal/cat/cat.go | 20 +++ internal/cat/flex.go | 182 ++++++++++++++++++++++++++ internal/cwdecode/cwdecode.go | 82 +++++++++--- internal/cwdecode/cwdecode_test.go | 44 +++++++ 11 files changed, 647 insertions(+), 79 deletions(-) diff --git a/app.go b/app.go index 6abeb4a..402be55 100644 --- a/app.go +++ b/app.go @@ -25,6 +25,7 @@ import ( "hamlog/internal/backup" "hamlog/internal/cat" "hamlog/internal/clublog" + "hamlog/internal/cwdecode" "hamlog/internal/cluster" "hamlog/internal/db" "hamlog/internal/dxcc" @@ -378,8 +379,10 @@ type App struct { ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off audioMgr *audio.Manager qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) - cwMu sync.Mutex // guards the CW decoder lifecycle - cwStop chan struct{} // stops the CW decoder capture loop; nil when off + 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) dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends pttMu sync.Mutex @@ -1481,7 +1484,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) { } } }() - a.applyStationDefaults(&q) + a.applyStationDefaults(&q, true) a.applyDXCCNumber(&q) a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries @@ -1604,7 +1607,7 @@ func (a *App) refineDistrictZones(q *qso.QSO) { // currently-active profile's values. Multi-profile support means a user // can be /P with a different callsign + grid + SOTA ref than home — the // QSO carries whichever profile was selected at log time. -func (a *App) applyStationDefaults(q *qso.QSO) { +func (a *App) applyStationDefaults(q *qso.QSO, includeIdentity bool) { if a.profiles == nil { return } @@ -1612,15 +1615,17 @@ func (a *App) applyStationDefaults(q *qso.QSO) { if err != nil { return } - if q.StationCallsign == "" { + // STATION_CALLSIGN drives upload routing, so only stamp it on NEW QSOs — on + // import backfill, stamping the active call onto a QSO that lacked one could + // misroute it in a mixed-call log. + if includeIdentity && q.StationCallsign == "" { q.StationCallsign = p.Callsign } + // OPERATOR and OWNER_CALLSIGN are descriptive (not used for routing), so fill + // them whenever empty — including on import. if q.Operator == "" { q.Operator = p.Operator } - // OWNER_CALLSIGN is a valid ADIF field but not a promoted column, so it - // lives in Extras (exported verbatim, round-trips, and is filterable via - // json_extract). Stamp it from the active profile when set. if strings.TrimSpace(p.OwnerCallsign) != "" { if q.Extras == nil { q.Extras = map[string]string{} @@ -3477,17 +3482,19 @@ func (a *App) OpenADIFFile() (string, error) { // cty.dat for every record, overriding what the file carries — corrects the // wrong COUNTRY that contest software often exports (e.g. RG2Y as Asiatic // Russia). Everything else in the ADIF is still preserved verbatim. -func (a *App) ImportADIF(path string, dupMode string, applyCty bool) (adif.ImportResult, error) { +func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStation bool) (adif.ImportResult, error) { if a.qso == nil { return adif.ImportResult{}, fmt.Errorf("db not initialized") } if path == "" { return adif.ImportResult{}, fmt.Errorf("empty path") } - // Import preserves the ADIF verbatim — NO station / confirmation defaults - // are applied. Defaults are for NEW QSOs only (manual entry + UDP auto-log); - // stamping them on a historical import would, e.g., flag old QSOs as - // "LoTW requested" and try to re-upload them. + // Import preserves the ADIF verbatim by default — confirmation/sent-status + // defaults are NEVER applied (they'd flag old QSOs "LoTW requested" and try to + // re-upload). When applyStation is on, we DO backfill empty MY_* station + // fields (grid/rig/antenna/QTH/address…) from the active profile — those are + // descriptive metadata and safe to fill (identity fields are still left + // alone, see applyStationDefaults). im := &adif.Importer{Repo: a.qso} switch dupMode { case "update": @@ -3508,11 +3515,18 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool) (adif.Impor _ = a.clublog.EnsureLoaded() } clLoaded := a.clublog != nil && a.clublog.Loaded() - if applyCty { + if applyCty || applyStation { im.Enrich = func(q *qso.QSO) { - a.enrichContactedFromCtyForce(q) - if clLoaded { - a.applyClublogException(q, true) // force: explicit import-time correction + if applyCty { + a.enrichContactedFromCtyForce(q) + if clLoaded { + a.applyClublogException(q, true) // force: explicit import-time correction + } + } + if applyStation { + // Backfill empty MY_* descriptive fields from the active profile + // (identity fields left alone to keep mixed-call routing intact). + a.applyStationDefaults(q, false) } } } @@ -6328,7 +6342,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { // (station callsign, grid, country, zones, and the profile's default // MY_RIG / MY_ANTENNA) from the active profile. Without this a UDP / // WSJT-X auto-logged QSO carried none of the operator's own data. - a.applyStationDefaults(&q) + a.applyStationDefaults(&q, true) // ── DXCC# + QSL defaults ── // applyDXCCNumber stamps the contacted-station DXCC# from the @@ -6910,6 +6924,55 @@ func (a *App) FlexSetANFLevel(l int) error { return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANFLevel(l) }) } +func (a *App) FlexSetAPF(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAPF(on) }) +} +func (a *App) FlexSetAPFLevel(l int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAPFLevel(l) }) +} +func (a *App) FlexSetCWSpeed(wpm int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSpeed(wpm) }) +} +func (a *App) FlexSetCWPitch(hz int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWPitch(hz) }) +} +func (a *App) FlexSetCWBreakInDelay(ms int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWBreakInDelay(ms) }) +} +func (a *App) FlexSetCWSidetone(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSidetone(on) }) +} +func (a *App) FlexSetSidetoneLevel(l int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSidetoneLevel(l) }) +} +func (a *App) FlexSetCWFilter(bw int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWFilter(bw) }) +} + // SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without // requiring a trip through the full Settings panel. Persists the choice // so it survives restart. diff --git a/app_cw.go b/app_cw.go index 8a81a3b..1d5a150 100644 --- a/app_cw.go +++ b/app_cw.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "time" "hamlog/internal/applog" "hamlog/internal/audio" @@ -13,6 +14,31 @@ import ( // 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 @@ -43,6 +69,8 @@ func (a *App) StartCWDecoder() error { } }, ) + dec.SetTarget(a.cwTargetPitch()) + a.cwDecoder = dec stop := make(chan struct{}) a.cwStop = stop @@ -53,21 +81,38 @@ func (a *App) StartCWDecoder() error { wruntime.EventsEmit(a.ctx, "cw:error", err.Error()) } } - // Capture ended (stopped or errored) — clear state so a restart works. 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) @@ -80,3 +125,25 @@ func (a *App) CWDecoderRunning() bool { 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 03b65da..81b3994 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,7 +29,7 @@ import { GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus, WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace, GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop, - StartCWDecoder, StopCWDecoder, + StartCWDecoder, StopCWDecoder, SetCWDecoderPitch, QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, GetAwardDefs, GetUIPref, @@ -600,6 +600,13 @@ export default function App() { // 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)); @@ -769,6 +776,7 @@ export default function App() { const [pendingImportPath, setPendingImportPath] = useState(null); const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip'); const [importApplyCty, setImportApplyCty] = useState(true); + const [importApplyStation, setImportApplyStation] = useState(false); // QRZ profile photo lightbox (full-size, in-app — not the browser). const [photoModal, setPhotoModal] = useState(null); // Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the @@ -1939,7 +1947,7 @@ export default function App() { setImportErrorsOpen(false); setImportDupsOpen(false); try { - const res = await ImportADIF(path, importDupMode, importApplyCty); + const res = await ImportADIF(path, importDupMode, importApplyCty, importApplyStation); setImportResult(res); await refresh(); } catch (e: any) { @@ -3215,6 +3223,15 @@ export default function App() { {cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'} + {/* Lock pitch: blank = Auto (follow the Flex CW pitch / search). */} + setCwPitch(e.target.value)} + placeholder="auto" + title="Lock the decoder to this pitch (Hz). Blank = follow the radio's CW pitch / auto-search." + className="shrink-0 w-14 h-5 rounded border border-emerald-300/70 bg-white/60 px-1 text-[10px] font-mono text-center outline-none" + /> {/* Left-aligned single line, no scrollbar; auto-scrolled to the newest text (see cwScrollRef effect) so the latest stays in view. */}
@@ -3874,6 +3891,19 @@ export default function App() { +
diff --git a/frontend/src/components/FlexPanel.tsx b/frontend/src/components/FlexPanel.tsx index 0ec7ff2..daf53b0 100644 --- a/frontend/src/components/FlexPanel.tsx +++ b/frontend/src/components/FlexPanel.tsx @@ -1,11 +1,13 @@ import { useEffect, useRef, useState } from 'react'; -import { Radio, Zap, Mic2, Settings2, Power, AudioLines, Antenna, Flame, Gauge } from 'lucide-react'; +import { Radio, Zap, Power, AudioLines, Flame, Gauge } from 'lucide-react'; import { GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, - FlexMox, FlexATUStart, FlexATUBypass, FlexSetATUMemories, FlexAmpOperate, + FlexMox, FlexAmpOperate, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, + FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, + FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, } from '../../wailsjs/go/main/App'; import { cn } from '@/lib/utils'; @@ -18,6 +20,9 @@ type FlexState = { atu_status?: string; atu_memories: boolean; rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number; + mode?: string; + cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number; + apf: boolean; apf_level: number; filter_lo: number; filter_hi: number; amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string; meters?: Meter[]; }; @@ -30,6 +35,8 @@ const ZERO: FlexState = { mon: false, mon_level: 0, mic_level: 0, atu_memories: false, rx_avail: false, agc_threshold: 0, audio_level: 0, nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0, + cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0, + apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0, amp_available: false, amp_operate: false, }; @@ -174,6 +181,15 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin export function FlexPanel() { const [st, setSt] = useState(ZERO); const hold = useRef>({}); + // Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters + // read steadily instead of jumping every poll. + const peak = useRef>({}); + const peakHold = (key: string, val: number) => { + const now = Date.now(); + const p = peak.current[key]; + if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; } + return p.v; + }; useEffect(() => { let alive = true; @@ -204,8 +220,11 @@ export function FlexPanel() { const off = !st.available; const rxOff = off || !st.rx_avail; + const isCW = (st.mode || '').toUpperCase().includes('CW'); const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }]; const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }]; + const CW_BW = [100, 200, 300, 400, 500]; + const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0)); return (
@@ -262,6 +281,7 @@ export function FlexPanel() {
+ {!isCW ? (
{st.mic_level}
+ ) : ( + /* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */ +
+
+ Speed + change('cw_speed', v, () => FlexSetCWSpeed(v))} /> + {st.cw_speed} wpm +
+
+ Pitch + change('cw_pitch', v, () => FlexSetCWPitch(v))} /> + {st.cw_pitch} Hz +
+
+ Delay + change('cw_break_in_delay', v, () => FlexSetCWBreakInDelay(v))} /> + {st.cw_break_in_delay} ms +
+ change('cw_sidetone', !st.cw_sidetone, () => FlexSetCWSidetone(!st.cw_sidetone))} + onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} /> +
+ )} {/* RECEIVE */} @@ -318,29 +361,30 @@ export function FlexPanel() { onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))} onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} /> + {isCW && ( +
+ change('apf', !st.apf, () => FlexSetAPF(!st.apf))} + onLevel={(v) => change('apf_level', v, () => FlexSetAPFLevel(v))} /> +
+ Filter +
+ {CW_BW.map((bw) => ( + + ))} +
+ Hz +
+
+ )} - {/* ATU */} - -
- - - change('atu_memories', !st.atu_memories, () => FlexSetATUMemories(!st.atu_memories))} /> -
- {st.atu_status && ( - {st.atu_status.replace(/_/g, ' ')} - )} -
- - {/* External amplifier (PowerGenius XL) — only when detected. */} {st.amp_available && ( @@ -378,9 +422,6 @@ export function FlexPanel() { const sig = radio('LEVEL') || radio('SIGNAL'); const fwd = radio('FWDPWR'); const swr = radio('SWR'); - const alc = radio('ALC'); - const temp = radio('PATEMP'); - const volts = radio('13.8B') || meters.find((m) => /volts/i.test(m.unit || '') && !(m.src || '').toUpperCase().includes('AMP')); const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP') && !/^(RL|DRV)$/i.test((m.name || '').trim())); const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626' @@ -396,16 +437,15 @@ export function FlexPanel() { return { display: `S${Math.max(0, Math.round(s))}`, bar: Math.max(0, s) }; }; const cur = [ - sig && (() => { const s = sUnit(sig.value); return ( - { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return ( + { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 14 ? '#f59e0b' : '#dc2626'; }} /> ); })(), - fwd && , - swr && , - alc && , - temp && , - volts && , + fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return ( + + ); })(), + swr && , ].filter(Boolean); return ( <> @@ -416,9 +456,9 @@ export function FlexPanel() {
{amp.map((m) => { if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) { - return ; + return ; } - return ; + return ; })}
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 25121d1..20daba4 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -139,10 +139,24 @@ export function FlexSetANF(arg1:boolean):Promise; export function FlexSetANFLevel(arg1:number):Promise; +export function FlexSetAPF(arg1:boolean):Promise; + +export function FlexSetAPFLevel(arg1:number):Promise; + export function FlexSetATUMemories(arg1:boolean):Promise; export function FlexSetAudioLevel(arg1:number):Promise; +export function FlexSetCWBreakInDelay(arg1:number):Promise; + +export function FlexSetCWFilter(arg1:number):Promise; + +export function FlexSetCWPitch(arg1:number):Promise; + +export function FlexSetCWSidetone(arg1:boolean):Promise; + +export function FlexSetCWSpeed(arg1:number):Promise; + export function FlexSetMic(arg1:number):Promise; export function FlexSetMon(arg1:boolean):Promise; @@ -163,6 +177,8 @@ export function FlexSetProcessor(arg1:boolean):Promise; export function FlexSetProcessorLevel(arg1:number):Promise; +export function FlexSetSidetoneLevel(arg1:number):Promise; + export function FlexSetTunePower(arg1:number):Promise; export function FlexSetVox(arg1:boolean):Promise; @@ -197,6 +213,8 @@ export function GetCATSettings():Promise; export function GetCATState():Promise; +export function GetCWDecoderPitch():Promise; + export function GetClublogCtyInfo():Promise; export function GetClusterAutoConnect():Promise; @@ -265,7 +283,7 @@ export function GetWinkeyerStatus():Promise; export function HasBuiltinReferences(arg1:string):Promise; -export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise; +export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise; export function ImportAwardReferencesText(arg1:string,arg2:string):Promise; @@ -453,6 +471,8 @@ 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; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 4e37db5..df3a978 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -250,6 +250,14 @@ export function FlexSetANFLevel(arg1) { return window['go']['main']['App']['FlexSetANFLevel'](arg1); } +export function FlexSetAPF(arg1) { + return window['go']['main']['App']['FlexSetAPF'](arg1); +} + +export function FlexSetAPFLevel(arg1) { + return window['go']['main']['App']['FlexSetAPFLevel'](arg1); +} + export function FlexSetATUMemories(arg1) { return window['go']['main']['App']['FlexSetATUMemories'](arg1); } @@ -258,6 +266,26 @@ export function FlexSetAudioLevel(arg1) { return window['go']['main']['App']['FlexSetAudioLevel'](arg1); } +export function FlexSetCWBreakInDelay(arg1) { + return window['go']['main']['App']['FlexSetCWBreakInDelay'](arg1); +} + +export function FlexSetCWFilter(arg1) { + return window['go']['main']['App']['FlexSetCWFilter'](arg1); +} + +export function FlexSetCWPitch(arg1) { + return window['go']['main']['App']['FlexSetCWPitch'](arg1); +} + +export function FlexSetCWSidetone(arg1) { + return window['go']['main']['App']['FlexSetCWSidetone'](arg1); +} + +export function FlexSetCWSpeed(arg1) { + return window['go']['main']['App']['FlexSetCWSpeed'](arg1); +} + export function FlexSetMic(arg1) { return window['go']['main']['App']['FlexSetMic'](arg1); } @@ -298,6 +326,10 @@ export function FlexSetProcessorLevel(arg1) { return window['go']['main']['App']['FlexSetProcessorLevel'](arg1); } +export function FlexSetSidetoneLevel(arg1) { + return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1); +} + export function FlexSetTunePower(arg1) { return window['go']['main']['App']['FlexSetTunePower'](arg1); } @@ -366,6 +398,10 @@ export function GetCATState() { return window['go']['main']['App']['GetCATState'](); } +export function GetCWDecoderPitch() { + return window['go']['main']['App']['GetCWDecoderPitch'](); +} + export function GetClublogCtyInfo() { return window['go']['main']['App']['GetClublogCtyInfo'](); } @@ -502,8 +538,8 @@ export function HasBuiltinReferences(arg1) { return window['go']['main']['App']['HasBuiltinReferences'](arg1); } -export function ImportADIF(arg1, arg2, arg3) { - return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3); +export function ImportADIF(arg1, arg2, arg3, arg4) { + return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4); } export function ImportAwardReferencesText(arg1, arg2) { @@ -878,6 +914,10 @@ 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); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 9ac0630..3e97a9c 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -458,6 +458,16 @@ export namespace cat { nr_level: number; anf: boolean; anf_level: number; + mode?: string; + cw_speed: number; + cw_pitch: number; + cw_break_in_delay: number; + cw_sidetone: boolean; + cw_mon_level: number; + apf: boolean; + apf_level: number; + filter_lo: number; + filter_hi: number; amp_available: boolean; amp_model?: string; amp_operate: boolean; @@ -496,6 +506,16 @@ export namespace cat { this.nr_level = source["nr_level"]; this.anf = source["anf"]; this.anf_level = source["anf_level"]; + this.mode = source["mode"]; + this.cw_speed = source["cw_speed"]; + this.cw_pitch = source["cw_pitch"]; + this.cw_break_in_delay = source["cw_break_in_delay"]; + this.cw_sidetone = source["cw_sidetone"]; + this.cw_mon_level = source["cw_mon_level"]; + this.apf = source["apf"]; + this.apf_level = source["apf_level"]; + this.filter_lo = source["filter_lo"]; + this.filter_hi = source["filter_hi"]; this.amp_available = source["amp_available"]; this.amp_model = source["amp_model"]; this.amp_operate = source["amp_operate"]; diff --git a/internal/cat/cat.go b/internal/cat/cat.go index 6121b6c..598c6b6 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -257,6 +257,17 @@ type FlexTXState struct { NRLevel int `json:"nr_level"` ANF bool `json:"anf"` ANFLevel int `json:"anf_level"` + // CW / mode-specific controls. + Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…) + CWSpeed int `json:"cw_speed"` + CWPitch int `json:"cw_pitch"` + CWBreakInDelay int `json:"cw_break_in_delay"` + CWSidetone bool `json:"cw_sidetone"` + CWMonLevel int `json:"cw_mon_level"` // sidetone level + APF bool `json:"apf"` + APFLevel int `json:"apf_level"` + FilterLo int `json:"filter_lo"` + FilterHi int `json:"filter_hi"` // External amplifier (PowerGenius XL). AmpAvailable bool `json:"amp_available"` AmpModel string `json:"amp_model,omitempty"` @@ -307,6 +318,15 @@ type FlexController interface { SetNRLevel(int) error SetANF(bool) error SetANFLevel(int) error + SetAPF(bool) error + SetAPFLevel(int) error + // CW keyer + mode-specific controls. + SetCWSpeed(int) error + SetCWPitch(int) error + SetCWBreakInDelay(int) error + SetCWSidetone(bool) error + SetSidetoneLevel(int) error + SetCWFilter(int) error // External amplifier (PowerGenius XL) operate/standby. SetAmpOperate(bool) error } diff --git a/internal/cat/flex.go b/internal/cat/flex.go index 6ab51c8..984f6a5 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -78,6 +78,10 @@ type flexSlice struct { nrLevel int anf bool // auto notch filter anfLevel int + apf bool // CW audio peaking filter + apfLevel int + filterLo int // slice filter low cut (Hz) + filterHi int // slice filter high cut (Hz) } // flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style @@ -97,6 +101,12 @@ type flexTX struct { micLevel int atuStatus string atuMemories bool + // CW keyer params (set via the top-level "cw" commands). + cwSpeed int // WPM + cwPitch int // Hz + cwBreakInDelay int // ms (QSK delay) + cwSidetone bool // sidetone (audible monitor) enable + cwMonLevel int // sidetone level (mon_gain_cw) } // flexAmp mirrors the external amplifier object (PowerGenius XL). handle is the @@ -176,6 +186,7 @@ func (f *Flex) Connect() error { f.send("sub atu all") // antenna-tuner status + memories f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby f.send("sub radio all") // radio-wide incl. interlock (TX/RX state) + f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default) f.startMeters(conn) // open the UDP VITA-49 stream for live meters if f.spotsEnabled { // Subscribe so the radio pushes existing spots (we learn their indices), @@ -364,12 +375,43 @@ func (f *Flex) handleStatus(payload string) { f.tx.mon = val == "1" case "mon_gain_sb": f.tx.monLevel = atoiDefault(val, f.tx.monLevel) + case "mon_gain_cw": + f.tx.cwMonLevel = atoiDefault(val, f.tx.cwMonLevel) + case "sidetone", "cw_sidetone": + f.tx.cwSidetone = val == "1" + // NOTE: speed/pitch/break_in_delay also appear in the transmit + // object, but they are STALE defaults there — the LIVE values come + // from the cwx object (see the cwx branch). Parsing them here too + // would let the stale value overwrite the correct one depending on + // status arrival order, so we deliberately ignore them here. case "mic_level", "miclevel": f.tx.micLevel = atoiDefault(val, f.tx.micLevel) } } f.mu.Unlock() } + // CWX object — the LIVE CW keyer values (speed/pitch/break-in delay). + // SmartSDR reads these here; the transmit object only carries a static + // default. Logged in full so we can confirm the exact field names. + if len(fields) >= 1 && fields[0] == "cwx" { + debugLog.Printf("Flex: status %s", payload) + f.mu.Lock() + for _, kv := range fields[1:] { + key, val, ok := splitKV(kv) + if !ok { + continue + } + switch key { + case "wpm", "speed", "cw_speed": + f.tx.cwSpeed = atoiDefault(val, f.tx.cwSpeed) + case "pitch", "cw_pitch": + f.tx.cwPitch = atoiDefault(val, f.tx.cwPitch) + case "delay", "break_in_delay", "cw_break_in_delay": + f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay) + } + } + f.mu.Unlock() + } // ATU object — auto-tuner status + memories. if len(fields) >= 1 && fields[0] == "atu" { debugLog.Printf("Flex: status %s", payload) @@ -425,6 +467,16 @@ func (f *Flex) handleStatus(payload string) { f.amp.operate = val == "1" || strings.EqualFold(val, "OPERATE") case "mode": f.amp.operate = strings.EqualFold(val, "OPERATE") + case "state": + // The PowerGenius XL reports its live state here (the status + // push has no operate= field). Anything but STANDBY/OFF means + // the amp is IN LINE (OPERATE) — IDLE = operate, not keyed. + switch strings.ToUpper(val) { + case "STANDBY", "OFF", "POWERED_OFF", "DISCONNECTED": + f.amp.operate = false + case "OPERATE", "IDLE", "TRANSMIT", "TX", "RECEIVE", "RX", "KEYED", "OPERATING": + f.amp.operate = true + } case "fault": f.amp.fault = val } @@ -582,11 +634,28 @@ func (f *Flex) handleStatus(payload string) { s.anf = val == "1" case "anf_level": s.anfLevel = atoiDefault(val, s.anfLevel) + case "apf": + s.apf = val == "1" + case "apf_level": + s.apfLevel = atoiDefault(val, s.apfLevel) + case "filter_lo": + s.filterLo = atoiDefault(val, s.filterLo) + case "filter_hi": + s.filterHi = atoiDefault(val, s.filterHi) } } f.mu.Unlock() } +// defInt returns v, or def when v is zero (so sliders show sane defaults before +// the radio has pushed the real value). +func defInt(v, def int) int { + if v == 0 { + return def + } + return v +} + // ReadState returns the cached state derived from the radio's push messages — // no round-trip, so it's always current. func (f *Flex) ReadState() (RigState, error) { @@ -899,9 +968,16 @@ func (f *Flex) FlexState() FlexTXState { MicLevel: f.tx.micLevel, ATUStatus: f.tx.atuStatus, ATUMemories: f.tx.atuMemories, + // CW keyer (defaults applied so the sliders show sane values pre-read). + CWSpeed: defInt(f.tx.cwSpeed, 25), + CWPitch: defInt(f.tx.cwPitch, 600), + CWBreakInDelay: defInt(f.tx.cwBreakInDelay, 30), + CWSidetone: f.tx.cwSidetone, + CWMonLevel: f.tx.cwMonLevel, } if _, rx := f.rxSliceLocked(); rx != nil { st.RXAvail = true + st.Mode = strings.ToUpper(rx.mode) st.AGCMode = rx.agcMode st.AGCThreshold = rx.agcThreshold st.AudioLevel = rx.audioLevel @@ -911,6 +987,10 @@ func (f *Flex) FlexState() FlexTXState { st.NRLevel = rx.nrLevel st.ANF = rx.anf st.ANFLevel = rx.anfLevel + st.APF = rx.apf + st.APFLevel = rx.apfLevel + st.FilterLo = rx.filterLo + st.FilterHi = rx.filterHi } if f.amp.handle != "" { st.AmpAvailable = true @@ -960,6 +1040,10 @@ func (f *Flex) sendSlice(param string, val any) error { rx.anf = val == "1" case "anf_level": rx.anfLevel = toInt(val) + case "apf": + rx.apf = val == "1" + case "apf_level": + rx.apfLevel = toInt(val) } } f.mu.Unlock() @@ -1000,6 +1084,104 @@ func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex( func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) } func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) } func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) } +func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) } +func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) } + +// ── CW keyer controls (top-level "cw" commands) ── + +func (f *Flex) SetCWSpeed(wpm int) error { + if wpm < 5 { + wpm = 5 + } else if wpm > 100 { + wpm = 100 + } + f.mu.Lock() + f.tx.cwSpeed = wpm + f.mu.Unlock() + f.send(fmt.Sprintf("cw wpm %d", wpm)) + return nil +} + +func (f *Flex) SetCWPitch(hz int) error { + if hz < 100 { + hz = 100 + } else if hz > 6000 { + hz = 6000 + } + f.mu.Lock() + f.tx.cwPitch = hz + f.mu.Unlock() + f.send(fmt.Sprintf("cw pitch %d", hz)) + return nil +} + +func (f *Flex) SetCWBreakInDelay(ms int) error { + if ms < 0 { + ms = 0 + } else if ms > 2000 { + ms = 2000 + } + f.mu.Lock() + f.tx.cwBreakInDelay = ms + f.mu.Unlock() + f.send(fmt.Sprintf("cw break_in_delay %d", ms)) + return nil +} + +func (f *Flex) SetCWSidetone(on bool) error { + f.mu.Lock() + f.tx.cwSidetone = on + f.mu.Unlock() + f.send("cw sidetone " + boolWord(on)) + return nil +} + +// SetSidetoneLevel sets the CW sidetone (audible monitor) gain via mon_gain_cw. +func (f *Flex) SetSidetoneLevel(l int) error { + l = clampLevel(l) + return f.txSet(fmt.Sprintf("transmit set mon_gain_cw=%d", l), "mon_gain_cw", func(t *flexTX) { t.cwMonLevel = l }) +} + +// SetCWFilter changes the CW passband WIDTH to bw Hz while keeping the current +// filter CENTER fixed — so the frequency never shifts. The new low/high cuts are +// center ± bw/2, where center is the midpoint of the slice's current filter +// (falling back to the CW pitch only if the filter isn't known yet). +func (f *Flex) SetCWFilter(bw int) error { + if bw < 50 { + bw = 50 + } + f.mu.Lock() + idx, rx := f.rxSliceLocked() + connected := f.conn != nil + center := 0 + if rx != nil && (rx.filterLo != 0 || rx.filterHi != 0) { + center = (rx.filterLo + rx.filterHi) / 2 + } else { + center = defInt(f.tx.cwPitch, 600) + } + lo := center - bw/2 + hi := center + bw/2 + if rx != nil { + rx.filterLo, rx.filterHi = lo, hi + } + f.mu.Unlock() + if !connected { + return fmt.Errorf("flex: not connected") + } + if rx == nil || idx < 0 { + return fmt.Errorf("flex: no receive slice") + } + f.send(fmt.Sprintf("filt %d %d %d", idx, lo, hi)) + return nil +} + +// boolWord renders a Flex on/off boolean as the word form some commands want. +func boolWord(on bool) string { + if on { + return "on" + } + return "off" +} // connected reports whether the TCP link is up (commands are no-ops otherwise). func (f *Flex) connected() bool { diff --git a/internal/cwdecode/cwdecode.go b/internal/cwdecode/cwdecode.go index c628b5c..02b4911 100644 --- a/internal/cwdecode/cwdecode.go +++ b/internal/cwdecode/cwdecode.go @@ -15,6 +15,7 @@ package cwdecode import ( "math" "sort" + "sync/atomic" ) // Status is a periodic snapshot for the UI (pitch lock, speed, signal). @@ -39,6 +40,11 @@ type Decoder struct { 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 @@ -89,10 +95,10 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder { d := &Decoder{ fs: sampleRate, hop: sampleRate / 250, // ~4 ms resolution - win: sampleRate / 100, // ~10 ms Goertzel window (snappy edges) + win: sampleRate / 72, // ~14 ms Goertzel window (selective, fairly snappy) dotHops: 15, // ~20 WPM seed - acqSNR: 1.5, // mild: ignore pure noise, still catch weak - strongSNR: 2.6, // a clearly-strong tone locks in 1 hop + 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 @@ -103,9 +109,10 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder { d.hop = 1 } 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 { + // 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))) } @@ -114,6 +121,21 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder { 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] @@ -168,6 +190,14 @@ func (d *Decoder) analyze() { } 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) @@ -183,7 +213,7 @@ func (d *Decoder) analyze() { // 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) { + if snr > d.strongSNR || (d.candHops >= 3 && snr > d.acqSNR) { d.lockIdx = maxIdx d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin d.quietHops = 0 @@ -203,21 +233,28 @@ func (d *Decoder) step() { on := false if d.lockIdx >= 0 { m := d.mags[d.lockIdx] - // Fast-attack / slow-release peak; fast-drop / slow-rise floor. + // 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 { - d.floor += (m - d.floor) * 0.005 // creep up slowly so marks aren't swallowed + } else if !d.state { + d.floor += (m - d.floor) * 0.02 } span := d.peak - d.floor - if span > d.floor*0.22+1e-9 { - onTh := d.floor + 0.50*span - offTh := d.floor + 0.30*span + // 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 { @@ -258,7 +295,10 @@ func (d *Decoder) step() { // clicks/noise. func (d *Decoder) endMark(hops int) { h := float64(hops) - if h < d.dotHops*0.35 { + // 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 { @@ -273,12 +313,12 @@ func (d *Decoder) endMark(hops int) { // adaptDot nudges the dot-length estimate toward an observation (EMA, clamped // to ~5–100 WPM). func (d *Decoder) adaptDot(obs float64) { - d.dotHops = d.dotHops*0.7 + obs*0.3 - if d.dotHops < 3 { - d.dotHops = 3 + d.dotHops = d.dotHops*0.8 + obs*0.2 // slower: a few odd marks can't yank it + if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100 + d.dotHops = 5 } - if d.dotHops > 60 { - d.dotHops = 60 + if d.dotHops > 55 { + d.dotHops = 55 } } @@ -307,7 +347,9 @@ func (d *Decoder) flushChar() { if d.onChar != nil { d.onChar(string(c)) } - } else if d.onChar != nil { + } 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] diff --git a/internal/cwdecode/cwdecode_test.go b/internal/cwdecode/cwdecode_test.go index eae177b..0faefef 100644 --- a/internal/cwdecode/cwdecode_test.go +++ b/internal/cwdecode/cwdecode_test.go @@ -138,6 +138,50 @@ func TestDecodeFirstCharStrong(t *testing.T) { } } +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