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
+20
View File
@@ -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
}
+182
View File
@@ -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 {
+62 -20
View File
@@ -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: 2501200 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: 4001000 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 ~5100 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]
+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) {
const fs = 16000
var sb strings.Builder