feat: While importing ADIF, update MY fields

This commit is contained in:
2026-06-20 15:48:21 +02:00
parent e1b3f0faf3
commit 95d37da3bb
11 changed files with 647 additions and 79 deletions
+81 -18
View File
@@ -25,6 +25,7 @@ import (
"hamlog/internal/backup" "hamlog/internal/backup"
"hamlog/internal/cat" "hamlog/internal/cat"
"hamlog/internal/clublog" "hamlog/internal/clublog"
"hamlog/internal/cwdecode"
"hamlog/internal/cluster" "hamlog/internal/cluster"
"hamlog/internal/db" "hamlog/internal/db"
"hamlog/internal/dxcc" "hamlog/internal/dxcc"
@@ -378,8 +379,10 @@ type App struct {
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
audioMgr *audio.Manager audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
cwMu sync.Mutex // guards the CW decoder lifecycle cwMu sync.Mutex // guards the CW decoder lifecycle
cwStop chan struct{} // stops the CW decoder capture loop; nil when off 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) dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
pttMu sync.Mutex 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.applyDXCCNumber(&q)
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries 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 // currently-active profile's values. Multi-profile support means a user
// can be /P with a different callsign + grid + SOTA ref than home — the // can be /P with a different callsign + grid + SOTA ref than home — the
// QSO carries whichever profile was selected at log time. // 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 { if a.profiles == nil {
return return
} }
@@ -1612,15 +1615,17 @@ func (a *App) applyStationDefaults(q *qso.QSO) {
if err != nil { if err != nil {
return 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 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 == "" { if q.Operator == "" {
q.Operator = p.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 strings.TrimSpace(p.OwnerCallsign) != "" {
if q.Extras == nil { if q.Extras == nil {
q.Extras = map[string]string{} 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 // cty.dat for every record, overriding what the file carries — corrects the
// wrong COUNTRY that contest software often exports (e.g. RG2Y as Asiatic // wrong COUNTRY that contest software often exports (e.g. RG2Y as Asiatic
// Russia). Everything else in the ADIF is still preserved verbatim. // 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 { if a.qso == nil {
return adif.ImportResult{}, fmt.Errorf("db not initialized") return adif.ImportResult{}, fmt.Errorf("db not initialized")
} }
if path == "" { if path == "" {
return adif.ImportResult{}, fmt.Errorf("empty path") return adif.ImportResult{}, fmt.Errorf("empty path")
} }
// Import preserves the ADIF verbatim — NO station / confirmation defaults // Import preserves the ADIF verbatim by default — confirmation/sent-status
// are applied. Defaults are for NEW QSOs only (manual entry + UDP auto-log); // defaults are NEVER applied (they'd flag old QSOs "LoTW requested" and try to
// stamping them on a historical import would, e.g., flag old QSOs as // re-upload). When applyStation is on, we DO backfill empty MY_* station
// "LoTW requested" and try to re-upload them. // 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} im := &adif.Importer{Repo: a.qso}
switch dupMode { switch dupMode {
case "update": case "update":
@@ -3508,11 +3515,18 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool) (adif.Impor
_ = a.clublog.EnsureLoaded() _ = a.clublog.EnsureLoaded()
} }
clLoaded := a.clublog != nil && a.clublog.Loaded() clLoaded := a.clublog != nil && a.clublog.Loaded()
if applyCty { if applyCty || applyStation {
im.Enrich = func(q *qso.QSO) { im.Enrich = func(q *qso.QSO) {
a.enrichContactedFromCtyForce(q) if applyCty {
if clLoaded { a.enrichContactedFromCtyForce(q)
a.applyClublogException(q, true) // force: explicit import-time correction 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 // (station callsign, grid, country, zones, and the profile's default
// MY_RIG / MY_ANTENNA) from the active profile. Without this a UDP / // 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. // WSJT-X auto-logged QSO carried none of the operator's own data.
a.applyStationDefaults(&q) a.applyStationDefaults(&q, true)
// ── DXCC# + QSL defaults ── // ── DXCC# + QSL defaults ──
// applyDXCCNumber stamps the contacted-station DXCC# from the // 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) }) 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 // SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without
// requiring a trip through the full Settings panel. Persists the choice // requiring a trip through the full Settings panel. Persists the choice
// so it survives restart. // so it survives restart.
+68 -1
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"time"
"hamlog/internal/applog" "hamlog/internal/applog"
"hamlog/internal/audio" "hamlog/internal/audio"
@@ -13,6 +14,31 @@ import (
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK // 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 // 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. // 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 // 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 // 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{}) stop := make(chan struct{})
a.cwStop = stop a.cwStop = stop
@@ -53,21 +81,38 @@ func (a *App) StartCWDecoder() error {
wruntime.EventsEmit(a.ctx, "cw:error", err.Error()) wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
} }
} }
// Capture ended (stopped or errored) — clear state so a restart works.
a.cwMu.Lock() a.cwMu.Lock()
if a.cwStop == stop { if a.cwStop == stop {
a.cwStop = nil a.cwStop = nil
a.cwDecoder = nil
} }
a.cwMu.Unlock() a.cwMu.Unlock()
}() }()
// Follow the radio's CW pitch live (every second) while this run is active.
go a.cwFollowPitch(stop, dec)
return nil 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. // StopCWDecoder halts the CW decoder if running.
func (a *App) StopCWDecoder() { func (a *App) StopCWDecoder() {
a.cwMu.Lock() a.cwMu.Lock()
stop := a.cwStop stop := a.cwStop
a.cwStop = nil a.cwStop = nil
a.cwDecoder = nil
a.cwMu.Unlock() a.cwMu.Unlock()
if stop != nil { if stop != nil {
close(stop) close(stop)
@@ -80,3 +125,25 @@ func (a *App) CWDecoderRunning() bool {
defer a.cwMu.Unlock() defer a.cwMu.Unlock()
return a.cwStop != nil 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
}
+32 -2
View File
@@ -29,7 +29,7 @@ import {
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace, WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop, GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
StartCWDecoder, StopCWDecoder, StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioBegin, QSOAudioCancel, QSOAudioRestart,
GetAwardDefs, GetAwardDefs,
GetUIPref, GetUIPref,
@@ -600,6 +600,13 @@ export default function App() {
// Keep the decoded line scrolled to the newest text (left-aligned, no scrollbar). // Keep the decoded line scrolled to the newest text (left-aligned, no scrollbar).
const cwScrollRef = useRef<HTMLDivElement>(null); const cwScrollRef = useRef<HTMLDivElement>(null);
useEffect(() => { const el = cwScrollRef.current; if (el) el.scrollLeft = el.scrollWidth; }, [cwText]); 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(() => { useEffect(() => {
const offT = EventsOn('cw:text', (t: string) => setCwText((s) => (s + t).slice(-200))); const offT = EventsOn('cw:text', (t: string) => setCwText((s) => (s + t).slice(-200)));
const offS = EventsOn('cw:status', (st: any) => setCwStatus(st)); const offS = EventsOn('cw:status', (st: any) => setCwStatus(st));
@@ -769,6 +776,7 @@ export default function App() {
const [pendingImportPath, setPendingImportPath] = useState<string | null>(null); const [pendingImportPath, setPendingImportPath] = useState<string | null>(null);
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip'); const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
const [importApplyCty, setImportApplyCty] = useState(true); const [importApplyCty, setImportApplyCty] = useState(true);
const [importApplyStation, setImportApplyStation] = useState(false);
// QRZ profile photo lightbox (full-size, in-app — not the browser). // QRZ profile photo lightbox (full-size, in-app — not the browser).
const [photoModal, setPhotoModal] = useState<string | null>(null); const [photoModal, setPhotoModal] = useState<string | null>(null);
// Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the // Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the
@@ -1939,7 +1947,7 @@ export default function App() {
setImportErrorsOpen(false); setImportErrorsOpen(false);
setImportDupsOpen(false); setImportDupsOpen(false);
try { try {
const res = await ImportADIF(path, importDupMode, importApplyCty); const res = await ImportADIF(path, importDupMode, importApplyCty, importApplyStation);
setImportResult(res); setImportResult(res);
await refresh(); await refresh();
} catch (e: any) { } catch (e: any) {
@@ -3215,6 +3223,15 @@ export default function App() {
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums"> <span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'} {cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
</span> </span>
{/* Lock pitch: blank = Auto (follow the Flex CW pitch / search). */}
<input
type="number"
value={cwPitch}
onChange={(e) => 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 {/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
text (see cwScrollRef effect) so the latest stays in view. */} text (see cwScrollRef effect) so the latest stays in view. */}
<div ref={cwScrollRef} className="flex-1 min-w-0 overflow-hidden font-mono leading-5"> <div ref={cwScrollRef} className="flex-1 min-w-0 overflow-hidden font-mono leading-5">
@@ -3874,6 +3891,19 @@ export default function App() {
</span> </span>
</span> </span>
</label> </label>
<label className="flex items-start gap-2 text-sm cursor-pointer pt-1">
<Checkbox
checked={importApplyStation}
onCheckedChange={(c) => setImportApplyStation(!!c)}
className="mt-0.5"
/>
<span>
Fill my station fields from my profile
<span className="block text-xs text-muted-foreground mt-0.5">
Backfill <strong>empty</strong> MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power) plus <strong>Operator</strong> and <strong>Owner callsign</strong> from your active profile. Existing values are kept. Only <strong>STATION_CALLSIGN</strong> is left untouched so a mixed-call log isn't re-routed. Enable when importing <em>your own</em> log.
</span>
</span>
</label>
</div> </div>
<DialogFooter className="px-2 bg-transparent border-t-0"> <DialogFooter className="px-2 bg-transparent border-t-0">
<Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button> <Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button>
+75 -35
View File
@@ -1,11 +1,13 @@
import { useEffect, useRef, useState } from 'react'; 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 { import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay, GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexATUStart, FlexATUBypass, FlexSetATUMemories, FlexAmpOperate, FlexMox, FlexAmpOperate,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -18,6 +20,9 @@ type FlexState = {
atu_status?: string; atu_memories: boolean; atu_status?: string; atu_memories: boolean;
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; 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; 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; amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
meters?: Meter[]; meters?: Meter[];
}; };
@@ -30,6 +35,8 @@ const ZERO: FlexState = {
mon: false, mon_level: 0, mic_level: 0, atu_memories: false, mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
rx_avail: false, agc_threshold: 0, audio_level: 0, rx_avail: false, agc_threshold: 0, audio_level: 0,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_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, amp_available: false, amp_operate: false,
}; };
@@ -174,6 +181,15 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
export function FlexPanel() { export function FlexPanel() {
const [st, setSt] = useState<FlexState>(ZERO); const [st, setSt] = useState<FlexState>(ZERO);
const hold = useRef<Record<string, number>>({}); const hold = useRef<Record<string, number>>({});
// 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<Record<string, { v: number; t: number }>>({});
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(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -204,8 +220,11 @@ export function FlexPanel() {
const off = !st.available; const off = !st.available;
const rxOff = off || !st.rx_avail; 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 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 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 ( return (
<div className="h-full min-h-0 overflow-auto bg-background"> <div className="h-full min-h-0 overflow-auto bg-background">
@@ -262,6 +281,7 @@ export function FlexPanel() {
</button> </button>
</div> </div>
{!isCW ? (
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Chip on={st.proc_enable} disabled={off} label="PROC" accent="violet" <Chip on={st.proc_enable} disabled={off} label="PROC" accent="violet"
@@ -288,6 +308,29 @@ export function FlexPanel() {
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span> <span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
</div> </div>
</div> </div>
) : (
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
<div className="border-t border-border/60 pt-3 space-y-3">
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Speed</span>
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => change('cw_speed', v, () => FlexSetCWSpeed(v))} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
</div>
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Pitch</span>
<Slider value={st.cw_pitch} disabled={off} max={1000} step={10} accent="#7c3aed" onChange={(v) => change('cw_pitch', v, () => FlexSetCWPitch(v))} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_pitch} Hz</span>
</div>
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Delay</span>
<Slider value={st.cw_break_in_delay} disabled={off} max={1000} step={1} accent="#d97706" onChange={(v) => change('cw_break_in_delay', v, () => FlexSetCWBreakInDelay(v))} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_break_in_delay} ms</span>
</div>
<LevelRow label="STONE" on={st.cw_sidetone} disabled={off} value={st.cw_mon_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('cw_sidetone', !st.cw_sidetone, () => FlexSetCWSidetone(!st.cw_sidetone))}
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
</div>
)}
</Card> </Card>
{/* RECEIVE */} {/* RECEIVE */}
@@ -318,29 +361,30 @@ export function FlexPanel() {
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))} onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} /> onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
</div> </div>
{isCW && (
<div className="border-t border-border/60 pt-3 space-y-3">
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
onToggle={() => change('apf', !st.apf, () => FlexSetAPF(!st.apf))}
onLevel={(v) => change('apf_level', v, () => FlexSetAPFLevel(v))} />
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Filter</span>
<div className="inline-flex rounded-md border border-border overflow-hidden">
{CW_BW.map((bw) => (
<button key={bw} type="button" disabled={rxOff}
onClick={() => { setSt((p) => { const c = ((p.filter_lo || 0) + (p.filter_hi || 0)) ? Math.round(((p.filter_lo || 0) + (p.filter_hi || 0)) / 2) : (p.cw_pitch || 600); return { ...p, filter_lo: c - bw / 2, filter_hi: c + bw / 2 }; }); FlexSetCWFilter(bw).catch(() => {}); }}
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
Math.abs(curBW - bw) <= 1 ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{bw}
</button>
))}
</div>
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
</div>
</div>
)}
</Card> </Card>
</div> </div>
{/* ATU */}
<Card icon={Settings2} title="Antenna Tuner">
<div className="flex items-center gap-2 flex-wrap">
<button type="button" disabled={off} onClick={() => FlexATUStart().catch(() => {})}
className="px-3 py-1.5 rounded-md text-xs font-bold border border-emerald-400 text-emerald-700 hover:bg-emerald-50 disabled:opacity-30">
<Antenna className="size-3.5 inline mr-1 -mt-0.5" /> Tune ATU
</button>
<button type="button" disabled={off} onClick={() => FlexATUBypass().catch(() => {})}
className="px-3 py-1.5 rounded-md text-xs font-bold border border-border text-muted-foreground hover:bg-muted disabled:opacity-30">
Bypass
</button>
<Chip on={st.atu_memories} disabled={off} label="MEM"
onClick={() => change('atu_memories', !st.atu_memories, () => FlexSetATUMemories(!st.atu_memories))} />
<div className="flex-1" />
{st.atu_status && (
<span className="text-xs font-mono text-muted-foreground">{st.atu_status.replace(/_/g, ' ')}</span>
)}
</div>
</Card>
{/* External amplifier (PowerGenius XL) — only when detected. */} {/* External amplifier (PowerGenius XL) — only when detected. */}
{st.amp_available && ( {st.amp_available && (
<Card icon={Flame} title={`Amplifier${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c"> <Card icon={Flame} title={`Amplifier${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
@@ -378,9 +422,6 @@ export function FlexPanel() {
const sig = radio('LEVEL') || radio('SIGNAL'); const sig = radio('LEVEL') || radio('SIGNAL');
const fwd = radio('FWDPWR'); const fwd = radio('FWDPWR');
const swr = radio('SWR'); 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') const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim())); && !/^(RL|DRV)$/i.test((m.name || '').trim()));
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626' 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) }; return { display: `S${Math.max(0, Math.round(s))}`, bar: Math.max(0, s) };
}; };
const cur = [ const cur = [
sig && (() => { const s = sUnit(sig.value); return ( sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${sig.value.toFixed(1)} dBm`} <MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 14 ? '#f59e0b' : '#dc2626'; }} /> segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 14 ? '#f59e0b' : '#dc2626'; }} />
); })(), ); })(),
fwd && <MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626" fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
value={isDbm(fwd) ? dbmToW(fwd.value) : fwd.value} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />, <MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
swr && <MeterBar key="w" label="SWR" value={swr.value} unit="" lo={1} hi={3} accent="#d97706" />, value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
alc && <MeterBar key="a" label="ALC" value={alc.value} unit={alc.unit} lo={alc.lo} hi={alc.hi || 100} accent="#7c3aed" />, ); })(),
temp && <MeterBar key="t" label="PA TEMP" value={temp.value} unit={temp.unit} lo={temp.lo || 0} hi={temp.hi || 80} accent="#ea580c" />, swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
volts && <MeterBar key="v" label="VOLTS" value={volts.value} unit={volts.unit} lo={volts.lo || 0} hi={volts.hi || 15} accent="#2563eb" />,
].filter(Boolean); ].filter(Boolean);
return ( return (
<> <>
@@ -416,9 +456,9 @@ export function FlexPanel() {
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{amp.map((m) => { {amp.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) { if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={dbmToW(m.value)} unit="W" lo={0} hi={1500} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />; return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />;
} }
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={m.value} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />; return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
})} })}
</div> </div>
</div> </div>
+21 -1
View File
@@ -139,10 +139,24 @@ export function FlexSetANF(arg1:boolean):Promise<void>;
export function FlexSetANFLevel(arg1:number):Promise<void>; export function FlexSetANFLevel(arg1:number):Promise<void>;
export function FlexSetAPF(arg1:boolean):Promise<void>;
export function FlexSetAPFLevel(arg1:number):Promise<void>;
export function FlexSetATUMemories(arg1:boolean):Promise<void>; export function FlexSetATUMemories(arg1:boolean):Promise<void>;
export function FlexSetAudioLevel(arg1:number):Promise<void>; export function FlexSetAudioLevel(arg1:number):Promise<void>;
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
export function FlexSetCWFilter(arg1:number):Promise<void>;
export function FlexSetCWPitch(arg1:number):Promise<void>;
export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
export function FlexSetCWSpeed(arg1:number):Promise<void>;
export function FlexSetMic(arg1:number):Promise<void>; export function FlexSetMic(arg1:number):Promise<void>;
export function FlexSetMon(arg1:boolean):Promise<void>; export function FlexSetMon(arg1:boolean):Promise<void>;
@@ -163,6 +177,8 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
export function FlexSetProcessorLevel(arg1:number):Promise<void>; export function FlexSetProcessorLevel(arg1:number):Promise<void>;
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
export function FlexSetTunePower(arg1:number):Promise<void>; export function FlexSetTunePower(arg1:number):Promise<void>;
export function FlexSetVox(arg1:boolean):Promise<void>; export function FlexSetVox(arg1:boolean):Promise<void>;
@@ -197,6 +213,8 @@ export function GetCATSettings():Promise<main.CATSettings>;
export function GetCATState():Promise<cat.RigState>; export function GetCATState():Promise<cat.RigState>;
export function GetCWDecoderPitch():Promise<number>;
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>; export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
export function GetClusterAutoConnect():Promise<boolean>; export function GetClusterAutoConnect():Promise<boolean>;
@@ -265,7 +283,7 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
export function HasBuiltinReferences(arg1:string):Promise<boolean>; export function HasBuiltinReferences(arg1:string):Promise<boolean>;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise<adif.ImportResult>; export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>; export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
@@ -453,6 +471,8 @@ export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>; export function SetCATMode(arg1:string):Promise<void>;
export function SetCWDecoderPitch(arg1:number):Promise<void>;
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>; export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
export function SetClusterAutoConnect(arg1:boolean):Promise<void>; export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
+42 -2
View File
@@ -250,6 +250,14 @@ export function FlexSetANFLevel(arg1) {
return window['go']['main']['App']['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) { export function FlexSetATUMemories(arg1) {
return window['go']['main']['App']['FlexSetATUMemories'](arg1); return window['go']['main']['App']['FlexSetATUMemories'](arg1);
} }
@@ -258,6 +266,26 @@ export function FlexSetAudioLevel(arg1) {
return window['go']['main']['App']['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) { export function FlexSetMic(arg1) {
return window['go']['main']['App']['FlexSetMic'](arg1); return window['go']['main']['App']['FlexSetMic'](arg1);
} }
@@ -298,6 +326,10 @@ export function FlexSetProcessorLevel(arg1) {
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1); return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
} }
export function FlexSetSidetoneLevel(arg1) {
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
}
export function FlexSetTunePower(arg1) { export function FlexSetTunePower(arg1) {
return window['go']['main']['App']['FlexSetTunePower'](arg1); return window['go']['main']['App']['FlexSetTunePower'](arg1);
} }
@@ -366,6 +398,10 @@ export function GetCATState() {
return window['go']['main']['App']['GetCATState'](); return window['go']['main']['App']['GetCATState']();
} }
export function GetCWDecoderPitch() {
return window['go']['main']['App']['GetCWDecoderPitch']();
}
export function GetClublogCtyInfo() { export function GetClublogCtyInfo() {
return window['go']['main']['App']['GetClublogCtyInfo'](); return window['go']['main']['App']['GetClublogCtyInfo']();
} }
@@ -502,8 +538,8 @@ export function HasBuiltinReferences(arg1) {
return window['go']['main']['App']['HasBuiltinReferences'](arg1); return window['go']['main']['App']['HasBuiltinReferences'](arg1);
} }
export function ImportADIF(arg1, arg2, arg3) { export function ImportADIF(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3); return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
} }
export function ImportAwardReferencesText(arg1, arg2) { export function ImportAwardReferencesText(arg1, arg2) {
@@ -878,6 +914,10 @@ export function SetCATMode(arg1) {
return window['go']['main']['App']['SetCATMode'](arg1); return window['go']['main']['App']['SetCATMode'](arg1);
} }
export function SetCWDecoderPitch(arg1) {
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
}
export function SetClublogCtyEnabled(arg1) { export function SetClublogCtyEnabled(arg1) {
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1); return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
} }
+20
View File
@@ -458,6 +458,16 @@ export namespace cat {
nr_level: number; nr_level: number;
anf: boolean; anf: boolean;
anf_level: number; 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_available: boolean;
amp_model?: string; amp_model?: string;
amp_operate: boolean; amp_operate: boolean;
@@ -496,6 +506,16 @@ export namespace cat {
this.nr_level = source["nr_level"]; this.nr_level = source["nr_level"];
this.anf = source["anf"]; this.anf = source["anf"];
this.anf_level = source["anf_level"]; 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_available = source["amp_available"];
this.amp_model = source["amp_model"]; this.amp_model = source["amp_model"];
this.amp_operate = source["amp_operate"]; this.amp_operate = source["amp_operate"];
+20
View File
@@ -257,6 +257,17 @@ type FlexTXState struct {
NRLevel int `json:"nr_level"` NRLevel int `json:"nr_level"`
ANF bool `json:"anf"` ANF bool `json:"anf"`
ANFLevel int `json:"anf_level"` 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). // External amplifier (PowerGenius XL).
AmpAvailable bool `json:"amp_available"` AmpAvailable bool `json:"amp_available"`
AmpModel string `json:"amp_model,omitempty"` AmpModel string `json:"amp_model,omitempty"`
@@ -307,6 +318,15 @@ type FlexController interface {
SetNRLevel(int) error SetNRLevel(int) error
SetANF(bool) error SetANF(bool) error
SetANFLevel(int) 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. // External amplifier (PowerGenius XL) operate/standby.
SetAmpOperate(bool) error SetAmpOperate(bool) error
} }
+182
View File
@@ -78,6 +78,10 @@ type flexSlice struct {
nrLevel int nrLevel int
anf bool // auto notch filter anf bool // auto notch filter
anfLevel int 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 // flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style
@@ -97,6 +101,12 @@ type flexTX struct {
micLevel int micLevel int
atuStatus string atuStatus string
atuMemories bool 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 // 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 atu all") // antenna-tuner status + memories
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby 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 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 f.startMeters(conn) // open the UDP VITA-49 stream for live meters
if f.spotsEnabled { if f.spotsEnabled {
// Subscribe so the radio pushes existing spots (we learn their indices), // 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" f.tx.mon = val == "1"
case "mon_gain_sb": case "mon_gain_sb":
f.tx.monLevel = atoiDefault(val, f.tx.monLevel) 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": case "mic_level", "miclevel":
f.tx.micLevel = atoiDefault(val, f.tx.micLevel) f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
} }
} }
f.mu.Unlock() 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. // ATU object — auto-tuner status + memories.
if len(fields) >= 1 && fields[0] == "atu" { if len(fields) >= 1 && fields[0] == "atu" {
debugLog.Printf("Flex: status %s", payload) 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") f.amp.operate = val == "1" || strings.EqualFold(val, "OPERATE")
case "mode": case "mode":
f.amp.operate = strings.EqualFold(val, "OPERATE") 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": case "fault":
f.amp.fault = val f.amp.fault = val
} }
@@ -582,11 +634,28 @@ func (f *Flex) handleStatus(payload string) {
s.anf = val == "1" s.anf = val == "1"
case "anf_level": case "anf_level":
s.anfLevel = atoiDefault(val, s.anfLevel) 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() 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 — // ReadState returns the cached state derived from the radio's push messages —
// no round-trip, so it's always current. // no round-trip, so it's always current.
func (f *Flex) ReadState() (RigState, error) { func (f *Flex) ReadState() (RigState, error) {
@@ -899,9 +968,16 @@ func (f *Flex) FlexState() FlexTXState {
MicLevel: f.tx.micLevel, MicLevel: f.tx.micLevel,
ATUStatus: f.tx.atuStatus, ATUStatus: f.tx.atuStatus,
ATUMemories: f.tx.atuMemories, 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 { if _, rx := f.rxSliceLocked(); rx != nil {
st.RXAvail = true st.RXAvail = true
st.Mode = strings.ToUpper(rx.mode)
st.AGCMode = rx.agcMode st.AGCMode = rx.agcMode
st.AGCThreshold = rx.agcThreshold st.AGCThreshold = rx.agcThreshold
st.AudioLevel = rx.audioLevel st.AudioLevel = rx.audioLevel
@@ -911,6 +987,10 @@ func (f *Flex) FlexState() FlexTXState {
st.NRLevel = rx.nrLevel st.NRLevel = rx.nrLevel
st.ANF = rx.anf st.ANF = rx.anf
st.ANFLevel = rx.anfLevel st.ANFLevel = rx.anfLevel
st.APF = rx.apf
st.APFLevel = rx.apfLevel
st.FilterLo = rx.filterLo
st.FilterHi = rx.filterHi
} }
if f.amp.handle != "" { if f.amp.handle != "" {
st.AmpAvailable = true st.AmpAvailable = true
@@ -960,6 +1040,10 @@ func (f *Flex) sendSlice(param string, val any) error {
rx.anf = val == "1" rx.anf = val == "1"
case "anf_level": case "anf_level":
rx.anfLevel = toInt(val) rx.anfLevel = toInt(val)
case "apf":
rx.apf = val == "1"
case "apf_level":
rx.apfLevel = toInt(val)
} }
} }
f.mu.Unlock() 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) 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) 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) 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). // connected reports whether the TCP link is up (commands are no-ops otherwise).
func (f *Flex) connected() bool { func (f *Flex) connected() bool {
+62 -20
View File
@@ -15,6 +15,7 @@ package cwdecode
import ( import (
"math" "math"
"sort" "sort"
"sync/atomic"
) )
// Status is a periodic snapshot for the UI (pitch lock, speed, signal). // 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 mags []float64 // per-bin magnitude this hop
nbuf []float64 // scratch for the noise percentile 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. // Pitch lock.
lockIdx int // index of the locked tone bin, -1 = unlocked lockIdx int // index of the locked tone bin, -1 = unlocked
candIdx int // current argmax candidate while 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{ d := &Decoder{
fs: sampleRate, fs: sampleRate,
hop: sampleRate / 250, // ~4 ms resolution 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 dotHops: 15, // ~20 WPM seed
acqSNR: 1.5, // mild: ignore pure noise, still catch weak acqSNR: 1.9, // ignore noise spikes (looser locked onto noise = garbage)
strongSNR: 2.6, // a clearly-strong tone locks in 1 hop strongSNR: 3.2, // only a genuinely strong tone locks in 1 hop
lockIdx: -1, lockIdx: -1,
candIdx: -1, candIdx: -1,
statusEvery: 25, // ~10 Hz statusEvery: 25, // ~10 Hz
@@ -103,9 +109,10 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
d.hop = 1 d.hop = 1
} }
d.relockHops = int(0.8 * float64(d.fs) / float64(d.hop)) // release lock after ~0.8 s quiet d.relockHops = int(0.8 * float64(d.fs) / float64(d.hop)) // release lock after ~0.8 s quiet
// Candidate CW tones: 2501200 Hz every 25 Hz (covers most rigs' audio // Candidate CW tones: 4001000 Hz every 25 Hz. Deliberately NOT lower: strong
// offset). The locked bin is the pitch; only its magnitude is decoded. // low-frequency noise/hum (pink/red noise rises toward DC) would otherwise win
for f := 250.0; f <= 1200.0; f += 25 { // 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.freqs = append(d.freqs, f)
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs))) 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 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). // Reset clears decode state (e.g. when the user re-arms the decoder).
func (d *Decoder) Reset() { func (d *Decoder) Reset() {
d.ring = d.ring[:0] d.ring = d.ring[:0]
@@ -168,6 +190,14 @@ func (d *Decoder) analyze() {
} }
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4) 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). // Noise floor = 40th percentile of the bins (robust to a few strong tones).
copy(d.nbuf, d.mags) copy(d.nbuf, d.mags)
sort.Float64s(d.nbuf) 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 // 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 // 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). // 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.lockIdx = maxIdx
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
d.quietHops = 0 d.quietHops = 0
@@ -203,21 +233,28 @@ func (d *Decoder) step() {
on := false on := false
if d.lockIdx >= 0 { if d.lockIdx >= 0 {
m := d.mags[d.lockIdx] m := d.mags[d.lockIdx]
// Fast-attack / slow-release peak; fast-drop / slow-rise floor. // Peak: fast attack, slow release.
if m > d.peak { if m > d.peak {
d.peak += (m - d.peak) * 0.4 d.peak += (m - d.peak) * 0.4
} else { } else {
d.peak += (m - d.peak) * 0.02 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 { if m < d.floor {
d.floor += (m - d.floor) * 0.4 d.floor += (m - d.floor) * 0.4
} else { } else if !d.state {
d.floor += (m - d.floor) * 0.005 // creep up slowly so marks aren't swallowed d.floor += (m - d.floor) * 0.02
} }
span := d.peak - d.floor span := d.peak - d.floor
if span > d.floor*0.22+1e-9 { // The frozen floor already stops dashes fragmenting, so keep balanced
onTh := d.floor + 0.50*span // thresholds: low enough that short inter-element GAPS are still seen
offTh := d.floor + 0.30*span // (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 { if d.state {
on = m > offTh on = m > offTh
} else { } else {
@@ -258,7 +295,10 @@ func (d *Decoder) step() {
// clicks/noise. // clicks/noise.
func (d *Decoder) endMark(hops int) { func (d *Decoder) endMark(hops int) {
h := float64(hops) 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 return
} }
if h > d.dotHops*2 { 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 // adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
// to ~5100 WPM). // to ~5100 WPM).
func (d *Decoder) adaptDot(obs float64) { func (d *Decoder) adaptDot(obs float64) {
d.dotHops = d.dotHops*0.7 + obs*0.3 d.dotHops = d.dotHops*0.8 + obs*0.2 // slower: a few odd marks can't yank it
if d.dotHops < 3 { if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
d.dotHops = 3 d.dotHops = 5
} }
if d.dotHops > 60 { if d.dotHops > 55 {
d.dotHops = 60 d.dotHops = 55
} }
} }
@@ -307,7 +347,9 @@ func (d *Decoder) flushChar() {
if d.onChar != nil { if d.onChar != nil {
d.onChar(string(c)) 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.onChar("?")
} }
d.elem = d.elem[:0] d.elem = d.elem[:0]
+44
View File
@@ -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) { func TestDecodeNumbersAndProsign(t *testing.T) {
const fs = 16000 const fs = 16000
var sb strings.Builder var sb strings.Builder