fix: improve cw decoding with qrm
This commit is contained in:
@@ -6096,37 +6096,31 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
|
||||
// tells the frontend to refresh that row's confirmation columns.
|
||||
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
||||
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 {
|
||||
case extsvc.ServiceQRZ:
|
||||
if a.qso != nil {
|
||||
if err := a.qso.MarkQRZUploaded(a.ctx, id, date); err != nil {
|
||||
applog.Printf("extsvc: mark qrz uploaded %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
err = a.qso.MarkQRZUploaded(ctx, id, date)
|
||||
case extsvc.ServiceClublog:
|
||||
if a.qso != nil {
|
||||
if err := a.qso.MarkClublogUploaded(a.ctx, id, date); err != nil {
|
||||
applog.Printf("extsvc: mark clublog uploaded %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
err = a.qso.MarkClublogUploaded(ctx, id, date)
|
||||
case extsvc.ServiceLoTW:
|
||||
if a.qso != nil {
|
||||
if err := a.qso.MarkLoTWUploaded(a.ctx, id, date); err != nil {
|
||||
applog.Printf("extsvc: mark lotw uploaded %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
err = a.qso.MarkLoTWUploaded(ctx, id, date)
|
||||
case extsvc.ServiceHRDLog:
|
||||
if a.qso != nil {
|
||||
if err := a.qso.MarkHRDLogUploaded(a.ctx, id, date); err != nil {
|
||||
applog.Printf("extsvc: mark hrdlog uploaded %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
err = a.qso.MarkHRDLogUploaded(ctx, id, date)
|
||||
case extsvc.ServiceEQSL:
|
||||
if a.qso != nil {
|
||||
if err := a.qso.MarkEQSLSent(a.ctx, id, date); err != nil {
|
||||
applog.Printf("extsvc: mark eqsl sent %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
err = a.qso.MarkEQSLSent(ctx, id, date)
|
||||
}
|
||||
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 {
|
||||
wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{
|
||||
|
||||
@@ -597,6 +597,9 @@ export default function App() {
|
||||
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 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(() => {
|
||||
const offT = EventsOn('cw:text', (t: string) => setCwText((s) => (s + t).slice(-200)));
|
||||
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) ===== */}
|
||||
{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')} />
|
||||
{/* Input-level meter — if this stays flat with a strong signal, the RX
|
||||
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">
|
||||
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
|
||||
</span>
|
||||
{/* Single-line rolling ticker — no scrollbar; newest text stays
|
||||
pinned to the right, older text scrolls off the left. */}
|
||||
<div className="flex-1 min-w-0 overflow-hidden font-mono leading-5">
|
||||
{/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
|
||||
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">
|
||||
{cwText.trim() === '' ? (
|
||||
<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) => (
|
||||
<button
|
||||
key={i}
|
||||
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"
|
||||
onClick={() => onCallsignInput(tok, { force: true })}
|
||||
>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// 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
|
||||
// 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
|
||||
// against the broadband noise floor (so QRN bursts that lift every bin are
|
||||
// rejected), an adaptive dot-length (WPM) estimate, and a timing state machine
|
||||
// that maps marks/spaces to Morse and then to characters.
|
||||
// at other pitches is ignored), an adaptive envelope/threshold on the LOCKED
|
||||
// tone (level-independent, so weak or strong signals both key cleanly), an
|
||||
// adaptive dot-length (WPM) estimate, and a timing state machine that maps
|
||||
// marks/spaces to Morse and then to characters.
|
||||
//
|
||||
// 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
|
||||
// and very heavy QRM still degrade it; the lock + SNR gate trade a little
|
||||
// sensitivity for far fewer false decodes.
|
||||
// and very heavy QRM still degrade it; the pitch lock keeps QRM on other tones
|
||||
// out of the decode.
|
||||
package cwdecode
|
||||
|
||||
import (
|
||||
@@ -39,17 +39,17 @@ type Decoder struct {
|
||||
mags []float64 // per-bin magnitude this hop
|
||||
nbuf []float64 // scratch for the noise percentile
|
||||
|
||||
// Pitch lock + noise.
|
||||
lockIdx int // index of the locked tone bin, -1 = unlocked
|
||||
candIdx int // current argmax candidate while unlocked
|
||||
candHops int // consecutive hops the candidate has been dominant
|
||||
unlockHops int // consecutive low-SNR hops while locked
|
||||
noise float64
|
||||
// Pitch lock.
|
||||
lockIdx int // index of the locked tone bin, -1 = unlocked
|
||||
candIdx int // current argmax candidate while unlocked
|
||||
candHops int // consecutive hops the candidate has been dominant
|
||||
quietHops int // consecutive key-up hops while locked
|
||||
noise float64 // broadband noise estimate (percentile of bins)
|
||||
relockHops int // quiet hops before the lock is released
|
||||
onSNR float64 // SNR to call key-down / to acquire a lock
|
||||
offSNR float64 // SNR below which it's key-up
|
||||
acqSNR float64 // minimum tone/noise ratio to acquire a lock
|
||||
|
||||
// Keying / timing.
|
||||
// Adaptive keying envelope, on the LOCKED bin's magnitude.
|
||||
peak, floor float64
|
||||
state bool // true = mark (key down)
|
||||
stateHops int
|
||||
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
|
||||
win: sampleRate / 62, // ~16 ms Goertzel window
|
||||
dotHops: 15, // ~20 WPM seed
|
||||
onSNR: 4.0,
|
||||
offSNR: 2.5,
|
||||
acqSNR: 1.8, // mild: just enough to ignore pure noise
|
||||
lockIdx: -1,
|
||||
candIdx: -1,
|
||||
statusEvery: 25, // ~10 Hz
|
||||
@@ -117,7 +116,8 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
|
||||
func (d *Decoder) Reset() {
|
||||
d.ring = d.ring[: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.stateHops = 0
|
||||
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
|
||||
// floor, and maintains the pitch lock.
|
||||
// analyze runs the Goertzel bank, estimates the noise floor, and maintains the
|
||||
// pitch lock (which tone the envelope detector then follows).
|
||||
func (d *Decoder) analyze() {
|
||||
n := float64(len(d.ring))
|
||||
var sumSq float64
|
||||
@@ -166,34 +166,23 @@ func (d *Decoder) analyze() {
|
||||
}
|
||||
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4)
|
||||
|
||||
// Noise floor = 40th percentile of the bins (robust to a few strong tones,
|
||||
// so one or two QRM signals don't inflate it).
|
||||
// Noise floor = 40th percentile of the bins (robust to a few strong tones).
|
||||
copy(d.nbuf, d.mags)
|
||||
sort.Float64s(d.nbuf)
|
||||
d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)]
|
||||
eps := d.noise + 1e-9
|
||||
|
||||
if d.lockIdx < 0 {
|
||||
// Acquire: lock once the same bin has been dominant for a few hops and
|
||||
// is clearly above the noise.
|
||||
// Acquire: lock when the same bin has been dominant for a few hops and
|
||||
// is at least mildly above the noise (so we don't lock onto pure noise).
|
||||
if maxIdx == d.candIdx {
|
||||
d.candHops++
|
||||
} else {
|
||||
d.candIdx, d.candHops = maxIdx, 1
|
||||
}
|
||||
if d.candHops >= 4 && maxMag/eps > d.onSNR {
|
||||
d.lockIdx, d.unlockHops = maxIdx, 0
|
||||
}
|
||||
} else {
|
||||
// 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.candHops >= 5 && maxMag/(d.noise+1e-9) > d.acqSNR {
|
||||
d.lockIdx = maxIdx
|
||||
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
|
||||
d.quietHops = 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() {
|
||||
on := false
|
||||
if d.lockIdx >= 0 {
|
||||
snr := d.mags[d.lockIdx] / (d.noise + 1e-9)
|
||||
if d.state {
|
||||
on = snr > d.offSNR // hysteresis: stay keyed until it clearly drops
|
||||
m := d.mags[d.lockIdx]
|
||||
// Fast-attack / slow-release peak; fast-drop / slow-rise floor.
|
||||
if m > d.peak {
|
||||
d.peak += (m - d.peak) * 0.4
|
||||
} 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user