feat: While importing ADIF, update MY fields
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user