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.
|
// 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{
|
||||||
|
|||||||
@@ -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 })}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user