fix: improve cw decoding with qrm

This commit is contained in:
2026-06-20 02:25:53 +02:00
parent 32878c17be
commit 2228816057
3 changed files with 87 additions and 74 deletions
+18 -24
View File
@@ -6096,37 +6096,31 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
// tells the frontend to refresh that row's confirmation columns. // tells the frontend to refresh that row's confirmation columns.
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) { func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
date := time.Now().UTC().Format("20060102") date := time.Now().UTC().Format("20060102")
// Use a fresh background context, NOT a.ctx: this stamp often runs during
// the on-close upload, and a.ctx is cancelled as the app shuts down — which
// would silently abort the UPDATE and leave the QSO at "R" forever despite a
// successful upload.
ctx := context.Background()
if a.qso == nil {
return
}
var err error
switch svc { switch svc {
case extsvc.ServiceQRZ: case extsvc.ServiceQRZ:
if a.qso != nil { err = a.qso.MarkQRZUploaded(ctx, id, date)
if err := a.qso.MarkQRZUploaded(a.ctx, id, date); err != nil {
applog.Printf("extsvc: mark qrz uploaded %d: %v", id, err)
}
}
case extsvc.ServiceClublog: case extsvc.ServiceClublog:
if a.qso != nil { err = a.qso.MarkClublogUploaded(ctx, id, date)
if err := a.qso.MarkClublogUploaded(a.ctx, id, date); err != nil {
applog.Printf("extsvc: mark clublog uploaded %d: %v", id, err)
}
}
case extsvc.ServiceLoTW: case extsvc.ServiceLoTW:
if a.qso != nil { err = a.qso.MarkLoTWUploaded(ctx, id, date)
if err := a.qso.MarkLoTWUploaded(a.ctx, id, date); err != nil {
applog.Printf("extsvc: mark lotw uploaded %d: %v", id, err)
}
}
case extsvc.ServiceHRDLog: case extsvc.ServiceHRDLog:
if a.qso != nil { err = a.qso.MarkHRDLogUploaded(ctx, id, date)
if err := a.qso.MarkHRDLogUploaded(a.ctx, id, date); err != nil {
applog.Printf("extsvc: mark hrdlog uploaded %d: %v", id, err)
}
}
case extsvc.ServiceEQSL: case extsvc.ServiceEQSL:
if a.qso != nil { err = a.qso.MarkEQSLSent(ctx, id, date)
if err := a.qso.MarkEQSLSent(a.ctx, id, date); err != nil {
applog.Printf("extsvc: mark eqsl sent %d: %v", id, err)
}
} }
if err != nil {
applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)
} else {
applog.Printf("extsvc: marked %s QSO %d as sent", svc, id)
} }
if a.ctx != nil { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{ wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{
+9 -6
View File
@@ -597,6 +597,9 @@ export default function App() {
const [cwText, setCwText] = useState(''); const [cwText, setCwText] = useState('');
const [cwStatus, setCwStatus] = useState<{ wpm: number; pitch: number; level: number; active: boolean }>({ wpm: 0, pitch: 0, level: 0, active: false }); const [cwStatus, setCwStatus] = useState<{ wpm: number; pitch: number; level: number; active: boolean }>({ wpm: 0, pitch: 0, level: 0, active: false });
const cwOn = cwEnabled && mode === 'CW'; const cwOn = cwEnabled && mode === 'CW';
// Keep the decoded line scrolled to the newest text (left-aligned, no scrollbar).
const cwScrollRef = useRef<HTMLDivElement>(null);
useEffect(() => { const el = cwScrollRef.current; if (el) el.scrollLeft = el.scrollWidth; }, [cwText]);
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));
@@ -3202,7 +3205,7 @@ export default function App() {
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */} {/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
{cwOn && ( {cwOn && (
<div className="mx-2.5 mb-1 flex items-center gap-2 rounded-md border border-emerald-300/70 bg-emerald-50/60 px-2 py-1 text-xs"> <div className="ml-2.5 mb-1 w-[45%] flex items-center gap-2 rounded-md border border-emerald-300/70 bg-emerald-50/60 px-2 py-1 text-xs">
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-emerald-600' : 'text-muted-foreground')} /> <Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-emerald-600' : 'text-muted-foreground')} />
{/* Input-level meter — if this stays flat with a strong signal, the RX {/* Input-level meter — if this stays flat with a strong signal, the RX
audio device is wrong/silent rather than a decode problem. */} audio device is wrong/silent rather than a decode problem. */}
@@ -3212,18 +3215,18 @@ 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>
{/* Single-line rolling ticker — no scrollbar; newest text stays {/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
pinned to the right, older text scrolls off the left. */} text (see cwScrollRef effect) so the latest stays in view. */}
<div 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">
{cwText.trim() === '' ? ( {cwText.trim() === '' ? (
<span className="text-muted-foreground italic">listening</span> <span className="text-muted-foreground italic">listening</span>
) : ( ) : (
<div className="flex justify-end whitespace-nowrap"> <div className="inline-flex whitespace-nowrap">
{cwText.trim().split(/\s+/).map((tok, i) => ( {cwText.trim().split(/\s+/).map((tok, i) => (
<button <button
key={i} key={i}
type="button" type="button"
className="ml-1 shrink-0 rounded px-1 hover:bg-emerald-200/70" className="mr-1 shrink-0 rounded px-1 hover:bg-emerald-200/70"
title="Use as callsign" title="Use as callsign"
onClick={() => onCallsignInput(tok, { force: true })} onClick={() => onCallsignInput(tok, { force: true })}
> >
+56 -40
View File
@@ -1,15 +1,15 @@
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of // Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
// mono PCM samples into decoded text. The pipeline is the classic one — a bank // mono PCM samples into decoded text. The pipeline is the classic one — a bank
// of Goertzel tone detectors, a pitch LOCK that follows a single tone (so QRM // of Goertzel tone detectors, a pitch LOCK that follows a single tone (so QRM
// at other pitches is ignored), an SNR-based key-down/key-up detector measured // at other pitches is ignored), an adaptive envelope/threshold on the LOCKED
// against the broadband noise floor (so QRN bursts that lift every bin are // tone (level-independent, so weak or strong signals both key cleanly), an
// rejected), an adaptive dot-length (WPM) estimate, and a timing state machine // adaptive dot-length (WPM) estimate, and a timing state machine that maps
// that maps marks/spaces to Morse and then to characters. // marks/spaces to Morse and then to characters.
// //
// It is deliberately self-contained and dependency-free so it can be unit // It is deliberately self-contained and dependency-free so it can be unit
// tested with synthetic signals. As with every audio CW decoder, weak signals // tested with synthetic signals. As with every audio CW decoder, weak signals
// and very heavy QRM still degrade it; the lock + SNR gate trade a little // and very heavy QRM still degrade it; the pitch lock keeps QRM on other tones
// sensitivity for far fewer false decodes. // out of the decode.
package cwdecode package cwdecode
import ( import (
@@ -39,17 +39,17 @@ 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
// Pitch lock + noise. // 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
candHops int // consecutive hops the candidate has been dominant candHops int // consecutive hops the candidate has been dominant
unlockHops int // consecutive low-SNR hops while locked quietHops int // consecutive key-up hops while locked
noise float64 noise float64 // broadband noise estimate (percentile of bins)
relockHops int // quiet hops before the lock is released relockHops int // quiet hops before the lock is released
onSNR float64 // SNR to call key-down / to acquire a lock acqSNR float64 // minimum tone/noise ratio to acquire a lock
offSNR float64 // SNR below which it's key-up
// Keying / timing. // Adaptive keying envelope, on the LOCKED bin's magnitude.
peak, floor float64
state bool // true = mark (key down) state bool // true = mark (key down)
stateHops int stateHops int
dotHops float64 // adaptive dot length, in hops dotHops float64 // adaptive dot length, in hops
@@ -90,8 +90,7 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
hop: sampleRate / 250, // ~4 ms resolution hop: sampleRate / 250, // ~4 ms resolution
win: sampleRate / 62, // ~16 ms Goertzel window win: sampleRate / 62, // ~16 ms Goertzel window
dotHops: 15, // ~20 WPM seed dotHops: 15, // ~20 WPM seed
onSNR: 4.0, acqSNR: 1.8, // mild: just enough to ignore pure noise
offSNR: 2.5,
lockIdx: -1, lockIdx: -1,
candIdx: -1, candIdx: -1,
statusEvery: 25, // ~10 Hz statusEvery: 25, // ~10 Hz
@@ -117,7 +116,8 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
func (d *Decoder) Reset() { func (d *Decoder) Reset() {
d.ring = d.ring[:0] d.ring = d.ring[:0]
d.acc = 0 d.acc = 0
d.lockIdx, d.candIdx, d.candHops, d.unlockHops = -1, -1, 0, 0 d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
d.peak, d.floor = 0, 0
d.state = false d.state = false
d.stateHops = 0 d.stateHops = 0
d.dotHops = 15 d.dotHops = 15
@@ -141,8 +141,8 @@ func (d *Decoder) Process(samples []int16) {
} }
} }
// analyze runs the Goertzel bank over the current window, estimates the noise // analyze runs the Goertzel bank, estimates the noise floor, and maintains the
// floor, and maintains the pitch lock. // pitch lock (which tone the envelope detector then follows).
func (d *Decoder) analyze() { func (d *Decoder) analyze() {
n := float64(len(d.ring)) n := float64(len(d.ring))
var sumSq float64 var sumSq float64
@@ -166,34 +166,23 @@ 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)
// 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).
// so one or two QRM signals don't inflate it).
copy(d.nbuf, d.mags) copy(d.nbuf, d.mags)
sort.Float64s(d.nbuf) sort.Float64s(d.nbuf)
d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)] d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)]
eps := d.noise + 1e-9
if d.lockIdx < 0 { if d.lockIdx < 0 {
// Acquire: lock once the same bin has been dominant for a few hops and // Acquire: lock when the same bin has been dominant for a few hops and
// is clearly above the noise. // is at least mildly above the noise (so we don't lock onto pure noise).
if maxIdx == d.candIdx { if maxIdx == d.candIdx {
d.candHops++ d.candHops++
} else { } else {
d.candIdx, d.candHops = maxIdx, 1 d.candIdx, d.candHops = maxIdx, 1
} }
if d.candHops >= 4 && maxMag/eps > d.onSNR { if d.candHops >= 5 && maxMag/(d.noise+1e-9) > d.acqSNR {
d.lockIdx, d.unlockHops = maxIdx, 0 d.lockIdx = maxIdx
} d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
} else { d.quietHops = 0
// Hold the lock through key-up gaps; release only after a long quiet so
// we can retune to a new signal/pitch.
if d.mags[d.lockIdx]/eps < d.offSNR {
d.unlockHops++
} else {
d.unlockHops = 0
}
if d.unlockHops > d.relockHops {
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
} }
} }
if d.lockIdx >= 0 { if d.lockIdx >= 0 {
@@ -203,15 +192,42 @@ func (d *Decoder) analyze() {
} }
} }
// step advances the keying detector + timing state machine by one hop. // step runs the adaptive envelope on the locked bin and the timing state
// machine, one hop. The envelope adapts to the signal level (not an absolute
// threshold), so weak and strong signals both key correctly.
func (d *Decoder) step() { func (d *Decoder) step() {
on := false on := false
if d.lockIdx >= 0 { if d.lockIdx >= 0 {
snr := d.mags[d.lockIdx] / (d.noise + 1e-9) m := d.mags[d.lockIdx]
if d.state { // Fast-attack / slow-release peak; fast-drop / slow-rise floor.
on = snr > d.offSNR // hysteresis: stay keyed until it clearly drops if m > d.peak {
d.peak += (m - d.peak) * 0.4
} else { } else {
on = snr > d.onSNR d.peak += (m - d.peak) * 0.02
}
if m < d.floor {
d.floor += (m - d.floor) * 0.4
} else {
d.floor += (m - d.floor) * 0.01
}
span := d.peak - d.floor
if span > d.floor*0.3+1e-9 {
onTh := d.floor + 0.55*span
offTh := d.floor + 0.35*span
if d.state {
on = m > offTh
} else {
on = m > onTh
}
}
// Release the lock after a long quiet so we can retune to a new signal.
if on {
d.quietHops = 0
} else {
d.quietHops++
if d.quietHops > d.relockHops {
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
}
} }
} }