feat: CW decoder v2 — rebuilt decode engine, back in the UI
The first RX-audio CW decoder decoded poorly on real signals (vs SDC) and was
removed in cafade0. This rebuilds the DSP core from scratch in
internal/cwdecode, keeping v1's good ideas (pitch lock, Flex cw_pitch
targeting, tiered acquisition) and fixing its proven failures:
- Two-way DEBOUNCE (pending-commit ~0.3 dit): v1 rejected short marks but not
short spaces, so a one-hop fade inside a dah shattered it into dits — the
single worst real-signal failure.
- Per-character BATCH dit/dah classification at flush time, using the batch's
own bimodal boundary — the first letter of an over decodes at any speed; the
timing clusters (muDit/muDah) are updated with the same attribution, killing
the classify-then-learn feedback spiral ("all dits at 60 WPM").
- dB-domain envelope with separate floor/peak trackers, hysteresis slicer,
span CAP (30 dB — else quiet backgrounds put the off-threshold in the
analysis-window skirts and letters merge) and window de-bias on durations.
- Double squelch: span >= 6 dB AND peak >= bank-median + 9.5 dB (span alone
cannot reject pure noise).
- Hamming-windowed Goertzel, 16 ms window / 5 ms hop (a 20 ms window left
40 WPM inter-element gaps with no envelope dip).
- Hardened acquisition (overlapping windows made "3 stable hops" meaningless)
plus SUPERVISED RE-LOCK: a clearly stronger tone on another pitch sustained
~1 s takes over — heals noise locks and follows a QSY.
- Gap-fed dit-length tracking (inter-element gaps are timing evidence too).
Proven by a synthetic-signal test suite (all passing): clean 12-40 WPM, three
pitches, added noise, QSB fading 0.35-1.0, 10 ms dropouts inside dahs, QRM in
auto and targeted modes, noise-only squelch, mid-over speed change 25->15 WPM,
and jittered hand keying (+/-20-25%, 3 seeds).
Wiring and UI restored from git (app_cw.go, Ear header button, decoded-text
strip with WPM/pitch/level + pitch lock + click-a-word-to-fill-callsign, Tools
menu entry) — strip strings translated (cwd.* i18n keys, EN/FR) this time.
This commit is contained in:
@@ -33,6 +33,7 @@ import (
|
||||
"hamlog/internal/clublog"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/contest"
|
||||
"hamlog/internal/cwdecode"
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/dxcc"
|
||||
"hamlog/internal/email"
|
||||
@@ -492,6 +493,11 @@ type App struct {
|
||||
|
||||
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
|
||||
|
||||
cwMu sync.Mutex // guards the CW decoder lifecycle
|
||||
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
||||
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
|
||||
cwPitchHz int // manual pitch override (0 = auto / follow Flex)
|
||||
|
||||
startupProfile string // --profile <name> from the command line (activate at startup)
|
||||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/audio"
|
||||
"hamlog/internal/cwdecode"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
|
||||
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
|
||||
// only by the frontend, and only while the entry mode is CW.
|
||||
//
|
||||
// Pitch targeting: the single-channel decoder is far more reliable when it locks
|
||||
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
|
||||
// instead of auto-searching for the loudest tone. So we follow the radio's CW
|
||||
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
|
||||
// back to auto-search otherwise.
|
||||
|
||||
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
|
||||
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
|
||||
func (a *App) cwTargetPitch() int {
|
||||
if a.cwPitchHz > 0 {
|
||||
return a.cwPitchHz
|
||||
}
|
||||
if a.cat != nil {
|
||||
if st, ok := a.cat.FlexState(); ok && st.Available {
|
||||
// Only trust the radio's pitch when it's actually in CW.
|
||||
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
|
||||
if st.CWPitch > 0 {
|
||||
return st.CWPitch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// StartCWDecoder begins decoding CW from the configured RX audio device. The
|
||||
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
|
||||
// call repeatedly; a second call is a no-op while already running.
|
||||
func (a *App) StartCWDecoder() error {
|
||||
a.cwMu.Lock()
|
||||
defer a.cwMu.Unlock()
|
||||
if a.cwStop != nil {
|
||||
return nil // already running
|
||||
}
|
||||
dev := ""
|
||||
if a.settings != nil {
|
||||
dev, _ = a.settings.Get(a.ctx, keyAudioFromRadio)
|
||||
}
|
||||
if dev == "" {
|
||||
return fmt.Errorf("no RX audio device configured (set \"From radio\" in Audio settings)")
|
||||
}
|
||||
|
||||
dec := cwdecode.New(audio.SampleRate,
|
||||
func(text string) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cw:text", text)
|
||||
}
|
||||
},
|
||||
func(st cwdecode.Status) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cw:status", st)
|
||||
}
|
||||
},
|
||||
)
|
||||
dec.SetTarget(a.cwTargetPitch())
|
||||
a.cwDecoder = dec
|
||||
|
||||
stop := make(chan struct{})
|
||||
a.cwStop = stop
|
||||
go func() {
|
||||
if err := audio.StreamCapture(dev, stop, dec.Process); err != nil {
|
||||
applog.Printf("cw: capture failed: %v", err)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
|
||||
}
|
||||
}
|
||||
a.cwMu.Lock()
|
||||
if a.cwStop == stop {
|
||||
a.cwStop = nil
|
||||
a.cwDecoder = nil
|
||||
}
|
||||
a.cwMu.Unlock()
|
||||
}()
|
||||
// Follow the radio's CW pitch live (every second) while this run is active.
|
||||
go a.cwFollowPitch(stop, dec)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
|
||||
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
dec.SetTarget(a.cwTargetPitch())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StopCWDecoder halts the CW decoder if running.
|
||||
func (a *App) StopCWDecoder() {
|
||||
a.cwMu.Lock()
|
||||
stop := a.cwStop
|
||||
a.cwStop = nil
|
||||
a.cwDecoder = nil
|
||||
a.cwMu.Unlock()
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
}
|
||||
}
|
||||
|
||||
// CWDecoderRunning reports whether the decoder is currently capturing.
|
||||
func (a *App) CWDecoderRunning() bool {
|
||||
a.cwMu.Lock()
|
||||
defer a.cwMu.Unlock()
|
||||
return a.cwStop != nil
|
||||
}
|
||||
|
||||
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
|
||||
// the Flex CW pitch, or search). Applies live to a running decoder.
|
||||
func (a *App) SetCWDecoderPitch(hz int) {
|
||||
if hz < 0 {
|
||||
hz = 0
|
||||
}
|
||||
a.cwMu.Lock()
|
||||
a.cwPitchHz = hz
|
||||
dec := a.cwDecoder
|
||||
a.cwMu.Unlock()
|
||||
if dec != nil {
|
||||
dec.SetTarget(a.cwTargetPitch())
|
||||
}
|
||||
}
|
||||
|
||||
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
|
||||
func (a *App) GetCWDecoderPitch() int {
|
||||
a.cwMu.Lock()
|
||||
defer a.cwMu.Unlock()
|
||||
return a.cwPitchHz
|
||||
}
|
||||
@@ -3,12 +3,14 @@
|
||||
"version": "0.20.12",
|
||||
"date": "2026-07-23",
|
||||
"en": [
|
||||
"The CW decoder (RX audio → text) is back, with a rebuilt decoding engine. The first version struggled on real signals; the new one adds a windowed tone detector, an adaptive dB envelope with noise squelch, two-way debouncing (a brief fade inside a dash no longer shatters it into dots), per-character dit/dah classification (even the first letter of an over decodes at any speed), and gap-fed speed tracking. It rides QSB, ignores QRM on other pitches, follows 12–40 WPM and sloppy hand keying — all proven by a synthetic-signal test suite. Tools → CW decoder (or the ear button): decodes while the mode is CW, with WPM/pitch/level display, pitch lock (follows the FlexRadio CW pitch automatically), and click-a-word to fill the callsign. Weak signals in heavy QRM remain hard — that's CW Skimmer territory — but clean-to-moderate signals now decode solidly.",
|
||||
"The CW Keyer settings panel is now fully translated (it had stayed in English), and the long serial-engine explanation paragraph was removed to keep it clean.",
|
||||
"Serial CW keyer: fixed the rig dropping to RX between words during a macro (PTT not held). On USB-serial interfaces like the Yaesu SCU-17 (CP210x), the previous method of driving the DTR/RTS lines would drop the held RTS (PTT) as soon as DTR (CW) toggled. OpsLog now drives the lines with the direct Win32 method N1MM/WSJT use (EscapeCommFunction), so RTS stays asserted for the whole transmission. 'Key PTT line' also defaults on for the Serial engine, and PTT / key-line / speed changes apply immediately without reconnecting the keyer.",
|
||||
"New (Settings → General): show the ClubLog 'Most Wanted' rank in the entry-strip band matrix. When on, a 'MW #rank' pill appears next to the DXCC entity name (1 = the most wanted entity), coloured hotter the more wanted it is. The list is fetched from ClubLog and personalised to your callsign, refreshed daily.",
|
||||
"Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored."
|
||||
],
|
||||
"fr": [
|
||||
"Le décodeur CW (audio RX → texte) est de retour, avec un moteur de décodage reconstruit. La première version peinait sur les vrais signaux ; la nouvelle ajoute un détecteur de tonalité fenêtré, une enveloppe dB adaptative avec squelch de bruit, un anti-rebond bilatéral (un bref fading dans un trait ne le brise plus en points), une classification point/trait par caractère (même la première lettre d'un over se décode à n'importe quelle vitesse) et un suivi de vitesse nourri par les espaces. Il tient le QSB, ignore le QRM sur d'autres tonalités, suit de 12 à 40 WPM et la manipulation humaine approximative — le tout prouvé par une suite de tests sur signaux synthétiques. Outils → Décodeur CW (ou le bouton oreille) : décode quand le mode est CW, avec affichage WPM/tonalité/niveau, verrouillage de tonalité (suit automatiquement le pitch CW du FlexRadio) et clic-sur-un-mot pour remplir l'indicatif. Les signaux faibles dans un fort QRM restent difficiles — c'est le territoire de CW Skimmer — mais les signaux propres à moyens se décodent maintenant solidement.",
|
||||
"Le panneau de réglages du Manipulateur CW est maintenant entièrement traduit (il était resté en anglais), et le long paragraphe d'explication du moteur série a été retiré pour l'alléger.",
|
||||
"Keyer CW série : correction de la radio qui retombait en RX entre les mots pendant une macro (PTT non tenu). Sur les interfaces USB-série comme le Yaesu SCU-17 (CP210x), l'ancienne méthode de pilotage des lignes DTR/RTS faisait retomber le RTS (PTT) dès que DTR (CW) basculait. OpsLog pilote maintenant les lignes avec la méthode Win32 directe qu'utilisent N1MM/WSJT (EscapeCommFunction), donc RTS reste asserté toute l'émission. « Key PTT line » est aussi activé par défaut pour le moteur Série, et les changements PTT / ligne / vitesse s'appliquent immédiatement sans reconnecter le keyer.",
|
||||
"Nouveau (Réglages → Général) : afficher le rang « Most Wanted » de ClubLog dans la matrice de bandes de la barre de saisie. Activé, une pastille « MW #rang » apparaît à côté du nom de l'entité DXCC (1 = l'entité la plus recherchée), d'autant plus colorée qu'elle est recherchée. La liste est récupérée depuis ClubLog et personnalisée selon ton indicatif, rafraîchie quotidiennement.",
|
||||
|
||||
+97
-2
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Eraser, Hash, Loader2, Lock,
|
||||
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
||||
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
|
||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||
GetAwardDefs,
|
||||
@@ -852,6 +853,37 @@ export default function App() {
|
||||
|
||||
// === Digital Voice Keyer (DVK) ===
|
||||
|
||||
// CW decoder: taps RX audio and decodes Morse. Runs only when enabled AND the
|
||||
// mode is CW. The decoded text appears in a strip above the tabs.
|
||||
const [cwEnabled, setCwEnabled] = useState(() => localStorage.getItem('opslog.cwDecoder') === '1');
|
||||
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]);
|
||||
// Manual pitch override ('' = Auto: follow the radio's CW pitch / search).
|
||||
const [cwPitch, setCwPitch] = useState(() => localStorage.getItem('opslog.cwPitch') || '');
|
||||
useEffect(() => {
|
||||
const hz = parseInt(cwPitch, 10);
|
||||
SetCWDecoderPitch(Number.isFinite(hz) ? hz : 0).catch(() => {});
|
||||
localStorage.setItem('opslog.cwPitch', cwPitch);
|
||||
}, [cwPitch, cwOn]);
|
||||
useEffect(() => {
|
||||
const offT = EventsOn('cw:text', (txt: string) => setCwText((s) => (s + txt).slice(-200)));
|
||||
const offS = EventsOn('cw:status', (st: any) => setCwStatus(st));
|
||||
const offE = EventsOn('cw:error', (e: string) => { setError(String(e)); setCwEnabled(false); });
|
||||
return () => { offT?.(); offS?.(); offE?.(); };
|
||||
}, []);
|
||||
// Start/stop the backend decoder as the (enabled, mode) combination changes.
|
||||
useEffect(() => {
|
||||
if (cwOn) { StartCWDecoder().catch((e: any) => { setError(String(e?.message ?? e)); setCwEnabled(false); }); }
|
||||
else { StopCWDecoder().catch(() => {}); }
|
||||
}, [cwOn]);
|
||||
function toggleCwDecoder() {
|
||||
setCwEnabled((v) => { const n = !v; localStorage.setItem('opslog.cwDecoder', n ? '1' : '0'); return n; });
|
||||
}
|
||||
|
||||
// === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK ===
|
||||
const [chatAvailable, setChatAvailable] = useState(false);
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
@@ -2908,6 +2940,7 @@ export default function App() {
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||
{ type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
|
||||
{ type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
|
||||
{ type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
|
||||
@@ -2930,7 +2963,7 @@ export default function App() {
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||
]},
|
||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
|
||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
|
||||
|
||||
function handleMenu(action: string) {
|
||||
switch (action) {
|
||||
@@ -2951,6 +2984,7 @@ export default function App() {
|
||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||
case 'tools.cwdecoder': toggleCwDecoder(); break;
|
||||
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
|
||||
case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
|
||||
case 'tools.alerts': setAlertsOpen(true); break;
|
||||
@@ -4000,6 +4034,20 @@ export default function App() {
|
||||
<Zap className="size-4" />
|
||||
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCwDecoder}
|
||||
title={cwEnabled ? (mode === 'CW' ? t('cwd.tipOnCw') : t('cwd.tipOnIdle')) : t('cwd.tipOff')}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||
cwOn && cwStatus.active ? 'border-success bg-success-muted text-success-muted-foreground'
|
||||
: cwEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||
: 'border-border text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<Ear className="size-4" />
|
||||
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
|
||||
@@ -4664,6 +4712,53 @@ export default function App() {
|
||||
</div>{/* /entry + aside row */}
|
||||
|
||||
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
|
||||
{cwOn && (
|
||||
<div className="ml-2.5 mt-1.5 -mb-1 w-[45%] flex items-center gap-2 rounded-md border border-success-border/70 bg-success-muted/60 px-2 py-1.5 text-xs">
|
||||
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-success' : '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. */}
|
||||
<div className="shrink-0 w-12 h-1.5 rounded bg-muted overflow-hidden" title={t('cwd.levelTip', { pct: Math.round(cwStatus.level * 100) })}>
|
||||
<div className="h-full bg-success transition-[width] duration-100" style={{ width: `${Math.min(100, Math.round(cwStatus.level * 100))}%` }} />
|
||||
</div>
|
||||
<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>
|
||||
{/* Lock pitch: blank = Auto (follow the Flex CW pitch / search). */}
|
||||
<input
|
||||
type="number"
|
||||
value={cwPitch}
|
||||
onChange={(e) => setCwPitch(e.target.value)}
|
||||
placeholder="auto"
|
||||
title={t('cwd.pitchTip')}
|
||||
className="shrink-0 w-14 h-5 rounded border border-success-border/70 bg-card/60 px-1 text-[10px] font-mono text-center outline-none"
|
||||
/>
|
||||
{/* 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-none flex items-center">
|
||||
{cwText.trim() === '' ? (
|
||||
<span className="text-muted-foreground italic">{t('cwd.listening')}</span>
|
||||
) : (
|
||||
<div className="inline-flex items-center whitespace-nowrap">
|
||||
{cwText.trim().split(/\s+/).map((tok, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="mr-1 shrink-0 rounded px-1 leading-none hover:bg-success-muted/70"
|
||||
title={t('cwd.useCall')}
|
||||
onClick={() => onCallsignInput(tok, { force: true })}
|
||||
>
|
||||
{tok}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="shrink-0 text-muted-foreground hover:text-foreground" title={t('cwd.clear')} onClick={() => setCwText('')}>
|
||||
<Eraser className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
||||
{compact ? null : <>
|
||||
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
|
||||
|
||||
@@ -32,6 +32,12 @@ const en: Dict = {
|
||||
'view.refresh': 'Refresh', 'view.clearFilters': 'Clear filters',
|
||||
'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…',
|
||||
'tools.winkeyer': 'WinKeyer CW keyer', 'tools.dvk': 'Digital Voice Keyer', 'tools.cwDecoder': 'CW decoder (RX audio)',
|
||||
'cwd.listening': 'listening…', 'cwd.clear': 'Clear', 'cwd.useCall': 'Use as callsign',
|
||||
'cwd.pitchTip': "Lock the decoder to this pitch (Hz). Blank = follow the radio's CW pitch / auto-search.",
|
||||
'cwd.levelTip': 'Audio level {pct}%',
|
||||
'cwd.tipOnCw': 'CW decoder — on (decoding) · click to disable',
|
||||
'cwd.tipOnIdle': 'CW decoder — on, idle until CW mode · click to disable',
|
||||
'cwd.tipOff': 'CW decoder · click to enable (decodes RX audio in CW mode)',
|
||||
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode',
|
||||
'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear',
|
||||
'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…',
|
||||
@@ -366,6 +372,12 @@ const fr: Dict = {
|
||||
'view.refresh': 'Rafraîchir', 'view.clearFilters': 'Effacer les filtres',
|
||||
'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…',
|
||||
'tools.winkeyer': 'Manipulateur CW WinKeyer', 'tools.dvk': 'Manipulateur vocal numérique', 'tools.cwDecoder': 'Décodeur CW (audio RX)',
|
||||
'cwd.listening': 'écoute…', 'cwd.clear': 'Effacer', 'cwd.useCall': "Utiliser comme indicatif",
|
||||
'cwd.pitchTip': "Verrouille le décodeur sur cette tonalité (Hz). Vide = suit le pitch CW de la radio / recherche auto.",
|
||||
'cwd.levelTip': 'Niveau audio {pct}%',
|
||||
'cwd.tipOnCw': 'Décodeur CW — actif (décode) · clic pour désactiver',
|
||||
'cwd.tipOnIdle': 'Décodeur CW — actif, en veille hors mode CW · clic pour désactiver',
|
||||
'cwd.tipOff': 'Décodeur CW · clic pour activer (décode l’audio RX en mode CW)',
|
||||
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest',
|
||||
'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer',
|
||||
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…',
|
||||
|
||||
Vendored
+10
@@ -86,6 +86,8 @@ export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Prom
|
||||
|
||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||
|
||||
export function CWDecoderRunning():Promise<boolean>;
|
||||
|
||||
export function ChatAvailable():Promise<boolean>;
|
||||
|
||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||
@@ -368,6 +370,8 @@ export function GetCATSettings():Promise<main.CATSettings>;
|
||||
|
||||
export function GetCATState():Promise<cat.RigState>;
|
||||
|
||||
export function GetCWDecoderPitch():Promise<number>;
|
||||
|
||||
export function GetCatalogCodes():Promise<Array<string>>;
|
||||
|
||||
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
||||
@@ -858,6 +862,8 @@ export function SetCATFrequency(arg1:number):Promise<void>;
|
||||
|
||||
export function SetCATMode(arg1:string):Promise<void>;
|
||||
|
||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||
|
||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClublogMostWantedEnabled(arg1:boolean):Promise<void>;
|
||||
@@ -876,8 +882,12 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||
|
||||
export function StartCWDecoder():Promise<void>;
|
||||
|
||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||
|
||||
export function StopCWDecoder():Promise<void>;
|
||||
|
||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||
|
||||
@@ -126,6 +126,10 @@ export function BulkUpdateQSL(arg1, arg2) {
|
||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function CWDecoderRunning() {
|
||||
return window['go']['main']['App']['CWDecoderRunning']();
|
||||
}
|
||||
|
||||
export function ChatAvailable() {
|
||||
return window['go']['main']['App']['ChatAvailable']();
|
||||
}
|
||||
@@ -690,6 +694,10 @@ export function GetCATState() {
|
||||
return window['go']['main']['App']['GetCATState']();
|
||||
}
|
||||
|
||||
export function GetCWDecoderPitch() {
|
||||
return window['go']['main']['App']['GetCWDecoderPitch']();
|
||||
}
|
||||
|
||||
export function GetCatalogCodes() {
|
||||
return window['go']['main']['App']['GetCatalogCodes']();
|
||||
}
|
||||
@@ -1670,6 +1678,10 @@ export function SetCATMode(arg1) {
|
||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||
}
|
||||
|
||||
export function SetCWDecoderPitch(arg1) {
|
||||
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
||||
}
|
||||
|
||||
export function SetClublogCtyEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
||||
}
|
||||
@@ -1706,10 +1718,18 @@ export function SetUltrabeamDirection(arg1) {
|
||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||
}
|
||||
|
||||
export function StartCWDecoder() {
|
||||
return window['go']['main']['App']['StartCWDecoder']();
|
||||
}
|
||||
|
||||
export function StationSetRelay(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function StopCWDecoder() {
|
||||
return window['go']['main']['App']['StopCWDecoder']();
|
||||
}
|
||||
|
||||
export function SwitchCATRig(arg1) {
|
||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
|
||||
// mono PCM samples into decoded text.
|
||||
//
|
||||
// This is the second generation of the decoder. The first one worked on clean
|
||||
// machine keying but fell apart on real signals; every stage below exists to
|
||||
// fix a specific failure of that version (and of naïve Goertzel decoders in
|
||||
// general):
|
||||
//
|
||||
// audio ─ Hamming-windowed Goertzel bank ─ pitch lock ─ dB envelope with
|
||||
// separate noise-floor / peak trackers ─ hysteresis slicer + SNR squelch ─
|
||||
// DEBOUNCED mark/space stream ─ two-cluster dit/dah length tracking ─
|
||||
// element / character / word segmentation ─ Morse table ─ text
|
||||
//
|
||||
// The fixes that matter, in order of impact:
|
||||
//
|
||||
// 1. Debounced transitions BOTH ways. The old decoder rejected too-short
|
||||
// marks but not too-short SPACES, so a one-hop fade inside a dah (QSB,
|
||||
// static crash) split it into two dits — the single biggest source of
|
||||
// garbage on real signals. Here a state flip must persist for a glitch
|
||||
// time (~0.3 dit) before it is committed; shorter flips are folded back
|
||||
// into the surrounding element.
|
||||
//
|
||||
// 2. Two-cluster timing. Dit and dah lengths are tracked as two separate
|
||||
// moving centres with the decision boundary at their geometric mean,
|
||||
// instead of one EMA "dot length" that both classifies marks and is
|
||||
// updated by that same classification (a feedback loop that spiralled to
|
||||
// "all dits at 60 WPM" the moment it started mis-classifying). The
|
||||
// inter-element gaps also feed the dit centre — spaces are timing
|
||||
// evidence too, and hand keying is often more regular in its gaps than
|
||||
// in its dits.
|
||||
//
|
||||
// 3. dB-domain envelope. Peak and noise floor are tracked in dB with
|
||||
// asymmetric attack/decay, the slicer runs at 55%/38% of the span with
|
||||
// hysteresis, and a minimum-span squelch (6 dB) keeps pure noise from
|
||||
// keying at all. In the old linear-magnitude scheme weak signals lived
|
||||
// in the bottom few percent of the scale and QSB swallowed them.
|
||||
//
|
||||
// 4. Windowed Goertzel. A Hamming window tames spectral leakage so a strong
|
||||
// tone doesn't bleed across the whole bank and corrupt both the noise
|
||||
// estimate and the lock choice.
|
||||
//
|
||||
// Kept from the first version because they were right: the pitch LOCK (decode
|
||||
// one tone, ignore QRM at other pitches), pitch targeting (follow the radio's
|
||||
// known CW pitch instead of searching — SetTarget), tiered acquisition
|
||||
// (strong signals lock on the first hop so their opening dit isn't eaten),
|
||||
// the floor frozen during key-down (a rising floor mid-dah fragments it), and
|
||||
// the end-of-over flush when the lock releases.
|
||||
//
|
||||
// Deliberately dependency-free and fed by plain []int16 so the whole pipeline
|
||||
// is unit-tested with synthetic signals (see cwdecode_test.go: clean keying at
|
||||
// several speeds, added noise, QSB fading, QRM on a nearby pitch, and a
|
||||
// noise-only squelch test).
|
||||
//
|
||||
// Honest expectations: on clean or moderately noisy signals this decodes
|
||||
// solidly; very weak signals in heavy QRM remain hard for any envelope
|
||||
// decoder — the tools that shine there (CW Skimmer, SDC) use probabilistic
|
||||
// sequence estimation on top. This stage is designed so such a layer could be
|
||||
// added later without touching the DSP.
|
||||
package cwdecode
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
|
||||
type Status struct {
|
||||
WPM int `json:"wpm"`
|
||||
Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked)
|
||||
Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter
|
||||
Active bool `json:"active"` // a tone is currently keyed down
|
||||
}
|
||||
|
||||
// Decoder consumes PCM and emits decoded characters via onChar (one or more
|
||||
// characters at a time, including " " for word gaps) and periodic onStatus.
|
||||
// Process must be called from a single goroutine; SetTarget is safe to call
|
||||
// concurrently.
|
||||
type Decoder struct {
|
||||
fs int
|
||||
hop int // samples between analyses (~6 ms)
|
||||
win int // Goertzel window length (~20 ms)
|
||||
hopMs float64 // hop duration in ms
|
||||
biasMs float64 // envelope widening caused by the analysis window (see below)
|
||||
|
||||
window []float64 // Hamming window, len win
|
||||
ring []float64 // circular raw-sample buffer, len win
|
||||
rpos int // next write position in ring
|
||||
filled int // samples written so far (until >= win)
|
||||
acc int // samples since last analysis
|
||||
|
||||
ws []float64 // scratch: windowed samples for this hop
|
||||
|
||||
// Search bank (only run while unlocked / untargeted).
|
||||
freqs []float64
|
||||
coeffs []float64
|
||||
mags []float64 // dB per bin
|
||||
nbuf []float64 // scratch for the median
|
||||
|
||||
// Fixed-pitch target (Hz). 0 = auto-search; >0 = decode exactly this pitch
|
||||
// and ignore everything else (e.g. follow the radio's CW pitch). Set live
|
||||
// from another goroutine, so it's atomic.
|
||||
targetHz atomic.Int32
|
||||
targetFor float64 // freq the cached target coeff was computed for
|
||||
targetCoeff float64
|
||||
|
||||
// Pitch lock.
|
||||
lockIdx int // bin index while auto-locked; -1 = unlocked
|
||||
candIdx int
|
||||
candHops int
|
||||
quietHops int // consecutive key-up hops while locked (drives release)
|
||||
bankTick int // hops since the bank last ran while locked
|
||||
betterHops int // evidence that a different bin is the real signal
|
||||
|
||||
// Envelope (dB domain) on the locked/target tone.
|
||||
floorDB, peakDB float64
|
||||
bankNoiseDB float64 // broadband reference: EMA of the bank median
|
||||
haveBankNoise bool
|
||||
envSeeded bool
|
||||
rawKey bool // slicer output this hop
|
||||
|
||||
// Debounced mark/space state machine.
|
||||
key bool // committed state (true = mark)
|
||||
stableHops int // hops in the committed state
|
||||
pendHops int // consecutive hops the raw state has disagreed
|
||||
|
||||
// Two-cluster element timing (ms).
|
||||
muDit, muDah float64
|
||||
marksSeen int
|
||||
|
||||
// Character assembly. Element DURATIONS are stored and only classified
|
||||
// into dits/dahs when the character is flushed: by then the character's
|
||||
// own marks are all known, and a bimodal batch carries its own dit/dah
|
||||
// boundary — so even the very first character of an over decodes
|
||||
// correctly at any speed, before the global clusters have converged.
|
||||
elemMs []float64
|
||||
charEmitted bool
|
||||
wordEmitted bool
|
||||
textSince bool // something was decoded since lock (guards leading spaces)
|
||||
|
||||
lastPitch float64
|
||||
lastRMS float64
|
||||
|
||||
statusEvery int
|
||||
sinceStatus int
|
||||
|
||||
onChar func(string)
|
||||
onStatus func(Status)
|
||||
}
|
||||
|
||||
var morse = map[string]byte{
|
||||
".-": 'A', "-...": 'B', "-.-.": 'C', "-..": 'D', ".": 'E', "..-.": 'F',
|
||||
"--.": 'G', "....": 'H', "..": 'I', ".---": 'J', "-.-": 'K', ".-..": 'L',
|
||||
"--": 'M', "-.": 'N', "---": 'O', ".--.": 'P', "--.-": 'Q', ".-.": 'R',
|
||||
"...": 'S', "-": 'T', "..-": 'U', "...-": 'V', ".--": 'W', "-..-": 'X',
|
||||
"-.--": 'Y', "--..": 'Z',
|
||||
"-----": '0', ".----": '1', "..---": '2', "...--": '3', "....-": '4',
|
||||
".....": '5', "-....": '6', "--...": '7', "---..": '8', "----.": '9',
|
||||
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
|
||||
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
|
||||
}
|
||||
|
||||
// Tunables (hops are ~6 ms).
|
||||
const (
|
||||
minDitMs = 20.0 // 60 WPM ceiling
|
||||
maxDitMs = 240.0 // 5 WPM floor
|
||||
seedDit = 60.0 // 20 WPM seed before any marks are seen
|
||||
|
||||
acqStrongDB = 12.0 // lock on the FIRST hop above this SNR (don't eat the opening dit)
|
||||
acqWeakDB = 8.5 // lock after sustained hops above this SNR
|
||||
// Successive analysis windows overlap ~70%, so consecutive hops are highly
|
||||
// correlated — a noise spike easily "persists" 2–3 hops. Requiring ~2 full
|
||||
// window lengths of persistence makes a false noise lock genuinely rare.
|
||||
acqWeakHops = 6
|
||||
|
||||
squelchDB = 6.0 // minimum peak-floor span to key at all
|
||||
// The span alone can't reject pure noise: a noise bin's dB level swings
|
||||
// ±5–6 dB, which the peak/floor trackers happily turn into a keyable
|
||||
// span. The second squelch is ABSOLUTE: the envelope peak must stand well
|
||||
// above the broadband noise reference (the bank median) — a keyed tone
|
||||
// does, noise never sustainably does.
|
||||
snrSquelchDB = 9.5
|
||||
|
||||
// The slicer's span is CAPPED: with a very quiet background (high SNR, or
|
||||
// digitally silent test signals) the raw floor sits so far below the peak
|
||||
// that the off-threshold lands in the analysis window's skirts — every
|
||||
// mark then stretches by almost a full window and every gap shrinks,
|
||||
// until character gaps fall below the segmentation threshold and letters
|
||||
// merge. Capping the usable span keeps the slicer crossing near the
|
||||
// signal edges regardless of how quiet the background is.
|
||||
spanCapDB = 30.0
|
||||
|
||||
onFrac = 0.55 // slicer thresholds as a fraction of the (capped) span…
|
||||
offFrac = 0.38 // …with hysteresis
|
||||
|
||||
charGapDits = 2.2 // gap > this ⇒ character boundary (geom. mean of 1 & 3 ≈ 1.7, plus margin for sloppy fists)
|
||||
wordGapDits = 4.6 // gap > this ⇒ word boundary (geom. mean of 3 & 7)
|
||||
)
|
||||
|
||||
// New builds a decoder for the given sample rate. onChar receives decoded text
|
||||
// incrementally; onStatus receives ~10 snapshots/second. Either may be nil.
|
||||
func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
|
||||
if sampleRate <= 0 {
|
||||
sampleRate = 16000
|
||||
}
|
||||
d := &Decoder{
|
||||
fs: sampleRate,
|
||||
hop: sampleRate * 5 / 1000, // 5 ms — resolves dits up to ~50 WPM
|
||||
win: sampleRate * 16 / 1000, // 16 ms window (≈80 Hz bandwidth with Hamming; a 20 ms window left 40 WPM inter-element gaps with almost no envelope dip)
|
||||
lockIdx: -1,
|
||||
candIdx: -1,
|
||||
muDit: seedDit,
|
||||
muDah: 3 * seedDit,
|
||||
onChar: onChar,
|
||||
onStatus: onStatus,
|
||||
}
|
||||
if d.hop < 1 {
|
||||
d.hop = 1
|
||||
}
|
||||
if d.win < 4*d.hop {
|
||||
d.win = 4 * d.hop
|
||||
}
|
||||
d.hopMs = float64(d.hop) / float64(d.fs) * 1000
|
||||
// Even with the span cap, the analysis window widens every mark and
|
||||
// narrows every gap by roughly half a window on each edge (the tone leaks
|
||||
// into windows that straddle an edge). Durations are de-biased by this
|
||||
// constant so the timing clusters and gap thresholds see true lengths.
|
||||
d.biasMs = 0.6 * float64(d.win) / float64(d.fs) * 1000
|
||||
d.statusEvery = int(math.Max(1, 100/d.hopMs)) // ~10 Hz
|
||||
d.ring = make([]float64, d.win)
|
||||
d.ws = make([]float64, d.win)
|
||||
// Hamming window: −43 dB sidelobes keep a strong tone from bleeding across
|
||||
// the bank (rectangular Goertzel leaks at −13 dB, enough to fool the lock).
|
||||
d.window = make([]float64, d.win)
|
||||
for i := range d.window {
|
||||
d.window[i] = 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(d.win-1))
|
||||
}
|
||||
// Candidate CW tones: 400–1000 Hz every 30 Hz. Deliberately NOT lower:
|
||||
// low-frequency hum/rumble rises toward DC and would win the argmax.
|
||||
for f := 400.0; f <= 1000.0; f += 30 {
|
||||
d.freqs = append(d.freqs, f)
|
||||
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
|
||||
}
|
||||
d.mags = make([]float64, len(d.freqs))
|
||||
d.nbuf = make([]float64, len(d.freqs))
|
||||
return d
|
||||
}
|
||||
|
||||
// SetTarget fixes the decode pitch to hz (an exact-frequency detector, not the
|
||||
// nearest search bin), or returns to auto-search when hz <= 0. Safe to call
|
||||
// concurrently.
|
||||
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }
|
||||
|
||||
// Reset clears decode state (e.g. when the user re-arms the decoder).
|
||||
func (d *Decoder) Reset() {
|
||||
d.rpos, d.filled, d.acc = 0, 0, 0
|
||||
d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
|
||||
d.envSeeded, d.rawKey, d.key = false, false, false
|
||||
d.haveBankNoise = false
|
||||
d.stableHops, d.pendHops = 0, 0
|
||||
d.bankTick, d.betterHops = 0, 0
|
||||
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
|
||||
d.elemMs = d.elemMs[:0]
|
||||
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
|
||||
}
|
||||
|
||||
// Process feeds a block of mono samples through the decoder.
|
||||
func (d *Decoder) Process(samples []int16) {
|
||||
for _, s := range samples {
|
||||
d.ring[d.rpos] = float64(s)
|
||||
d.rpos++
|
||||
if d.rpos == d.win {
|
||||
d.rpos = 0
|
||||
}
|
||||
if d.filled < d.win {
|
||||
d.filled++
|
||||
}
|
||||
d.acc++
|
||||
if d.acc >= d.hop && d.filled >= d.win {
|
||||
d.acc = 0
|
||||
d.hopStep()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// goertzelDB returns the tone power (dB, arbitrary reference) of the windowed
|
||||
// scratch buffer at the detector coefficient c.
|
||||
func (d *Decoder) goertzelDB(c float64) float64 {
|
||||
var s1, s2 float64
|
||||
for _, x := range d.ws {
|
||||
s0 := x + c*s1 - s2
|
||||
s2 = s1
|
||||
s1 = s0
|
||||
}
|
||||
p := s1*s1 + s2*s2 - c*s1*s2
|
||||
if p < 1e-12 {
|
||||
p = 1e-12
|
||||
}
|
||||
return 10 * math.Log10(p)
|
||||
}
|
||||
|
||||
// hopStep runs one analysis hop: tone detection, envelope, slicer, timing.
|
||||
func (d *Decoder) hopStep() {
|
||||
// Materialize the window (oldest→newest; order is irrelevant for power)
|
||||
// and the RMS level for the UI meter.
|
||||
var sumSq float64
|
||||
for i := 0; i < d.win; i++ {
|
||||
x := d.ring[(d.rpos+i)%d.win]
|
||||
d.ws[i] = x * d.window[i]
|
||||
sumSq += x * x
|
||||
}
|
||||
d.lastRMS = math.Min(1, math.Sqrt(sumSq/float64(d.win))/32768*4)
|
||||
|
||||
toneDB, haveTone := 0.0, false
|
||||
|
||||
if th := float64(d.targetHz.Load()); th > 0 {
|
||||
// Fixed pitch: one exact-frequency detector, like a skimmer channel.
|
||||
if th != d.targetFor {
|
||||
d.targetFor = th
|
||||
d.targetCoeff = 2 * math.Cos(2*math.Pi*th/float64(d.fs))
|
||||
d.envSeeded = false // re-seed the envelope for the new channel
|
||||
}
|
||||
toneDB = d.goertzelDB(d.targetCoeff)
|
||||
d.lastPitch = th
|
||||
d.lockIdx = -1 // targeting supersedes the auto lock
|
||||
haveTone = true
|
||||
// Refresh the broadband noise reference for the absolute squelch.
|
||||
d.bankTick++
|
||||
if d.bankTick >= 8 {
|
||||
d.bankTick = 0
|
||||
for i, c := range d.coeffs {
|
||||
d.mags[i] = d.goertzelDB(c)
|
||||
}
|
||||
copy(d.nbuf, d.mags)
|
||||
sort.Float64s(d.nbuf)
|
||||
d.updateBankNoise(d.nbuf[len(d.nbuf)/2])
|
||||
}
|
||||
} else if d.lockIdx >= 0 {
|
||||
// Auto-locked: only the locked bin is needed per hop.
|
||||
toneDB = d.goertzelDB(d.coeffs[d.lockIdx])
|
||||
d.lastPitch = d.freqs[d.lockIdx]
|
||||
haveTone = true
|
||||
// Supervised re-lock: periodically sweep the whole bank anyway. If a
|
||||
// clearly stronger tone lives on a DIFFERENT pitch and keeps doing so,
|
||||
// the current lock is wrong (locked onto noise, or the operator moved)
|
||||
// — jump to the real signal instead of decoding garbage until the
|
||||
// quiet-release finally fires.
|
||||
d.bankTick++
|
||||
if d.bankTick >= 8 {
|
||||
d.bankTick = 0
|
||||
bestIdx, bestDB := -1, math.Inf(-1)
|
||||
for i, c := range d.coeffs {
|
||||
m := d.goertzelDB(c)
|
||||
d.mags[i] = m
|
||||
if i >= d.lockIdx-1 && i <= d.lockIdx+1 {
|
||||
continue
|
||||
}
|
||||
if m > bestDB {
|
||||
bestDB, bestIdx = m, i
|
||||
}
|
||||
}
|
||||
copy(d.nbuf, d.mags)
|
||||
sort.Float64s(d.nbuf)
|
||||
d.updateBankNoise(d.nbuf[len(d.nbuf)/2])
|
||||
if bestIdx >= 0 && bestDB > toneDB+6 {
|
||||
d.betterHops += 2
|
||||
} else if d.betterHops > 0 {
|
||||
d.betterHops--
|
||||
}
|
||||
if d.betterHops >= 24 && bestIdx >= 0 { // ~12 confirmations over ~1 s
|
||||
d.flushPending()
|
||||
copy(d.nbuf, d.mags)
|
||||
sort.Float64s(d.nbuf)
|
||||
d.lockIdx = bestIdx
|
||||
// Seed the envelope honestly: floor from the bank median, NOT
|
||||
// peak−cap — fabricating a full span would let a noise re-lock
|
||||
// key freely until the trackers converged.
|
||||
d.floorDB, d.peakDB = d.nbuf[len(d.nbuf)/2], bestDB
|
||||
d.quietHops, d.bankTick, d.betterHops = 0, 0, 0
|
||||
d.marksSeen = 0
|
||||
d.elemMs = d.elemMs[:0]
|
||||
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
|
||||
d.key, d.stableHops, d.pendHops = false, 0, 0
|
||||
toneDB = d.goertzelDB(d.coeffs[d.lockIdx])
|
||||
d.lastPitch = d.freqs[d.lockIdx]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unlocked: run the whole bank, estimate noise, hunt for a tone.
|
||||
maxIdx, maxDB := 0, math.Inf(-1)
|
||||
for i, c := range d.coeffs {
|
||||
m := d.goertzelDB(c)
|
||||
d.mags[i] = m
|
||||
if m > maxDB {
|
||||
maxDB, maxIdx = m, i
|
||||
}
|
||||
}
|
||||
copy(d.nbuf, d.mags)
|
||||
sort.Float64s(d.nbuf)
|
||||
noise := d.nbuf[len(d.nbuf)/2] // median: robust to a few strong tones
|
||||
d.updateBankNoise(noise)
|
||||
snr := maxDB - noise
|
||||
|
||||
near := d.candIdx >= 0 && maxIdx >= d.candIdx-1 && maxIdx <= d.candIdx+1
|
||||
if near {
|
||||
d.candHops++
|
||||
} else {
|
||||
d.candIdx, d.candHops = maxIdx, 1
|
||||
}
|
||||
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so
|
||||
// its opening dit isn't eaten), a marginal one must persist a few hops
|
||||
// (so we don't lock onto a noise spike).
|
||||
if snr > acqStrongDB || (d.candHops >= acqWeakHops && snr > acqWeakDB) {
|
||||
d.lockIdx = maxIdx
|
||||
d.floorDB, d.peakDB = noise, maxDB
|
||||
d.envSeeded = true
|
||||
d.quietHops = 0
|
||||
d.bankTick, d.betterHops = 0, 0
|
||||
d.marksSeen = 0 // relearn speed quickly for this signal (keep muDit as seed)
|
||||
d.elemMs = d.elemMs[:0]
|
||||
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
|
||||
d.key, d.stableHops, d.pendHops = false, 0, 0
|
||||
toneDB = maxDB
|
||||
d.lastPitch = d.freqs[maxIdx]
|
||||
haveTone = true
|
||||
} else {
|
||||
d.lastPitch = 0
|
||||
}
|
||||
}
|
||||
|
||||
if !haveTone {
|
||||
d.rawKey = false
|
||||
d.emitStatus()
|
||||
return
|
||||
}
|
||||
if !d.envSeeded {
|
||||
// First hop on a targeted channel: seed from the current reading.
|
||||
d.floorDB, d.peakDB = toneDB, toneDB+squelchDB
|
||||
d.envSeeded = true
|
||||
}
|
||||
|
||||
// ---- Envelope: separate floor / peak trackers, dB domain. ----
|
||||
// Floor drops fast, but only RISES while keyed up: a floor creeping up
|
||||
// under a long dah shrinks the span until the dah fragments into dits.
|
||||
if toneDB < d.floorDB {
|
||||
d.floorDB += (toneDB - d.floorDB) * 0.3
|
||||
} else if !d.rawKey {
|
||||
d.floorDB += (toneDB - d.floorDB) * 0.015
|
||||
}
|
||||
// Peak attacks fast and decays slowly toward current conditions (~1.5 s),
|
||||
// so QSB is followed without collapsing across ordinary word gaps.
|
||||
if toneDB > d.peakDB {
|
||||
d.peakDB += (toneDB - d.peakDB) * 0.4
|
||||
} else {
|
||||
d.peakDB += (toneDB - d.peakDB) * 0.004
|
||||
}
|
||||
if d.peakDB < d.floorDB {
|
||||
d.peakDB = d.floorDB
|
||||
}
|
||||
|
||||
// ---- Slicer with hysteresis + SNR squelch. ----
|
||||
if d.peakDB-d.floorDB < squelchDB ||
|
||||
(d.haveBankNoise && d.peakDB < d.bankNoiseDB+snrSquelchDB) {
|
||||
d.rawKey = false
|
||||
} else {
|
||||
// Cap the usable span so the slicer crossings stay near the keying
|
||||
// edges even over a dead-quiet background (see spanCapDB).
|
||||
effFloor := math.Max(d.floorDB, d.peakDB-spanCapDB)
|
||||
span := d.peakDB - effFloor
|
||||
if d.rawKey {
|
||||
d.rawKey = toneDB > effFloor+offFrac*span
|
||||
} else {
|
||||
d.rawKey = toneDB > effFloor+onFrac*span
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Auto-lock release after a long quiet spell. ----
|
||||
if d.lockIdx >= 0 {
|
||||
if d.rawKey {
|
||||
d.quietHops = 0
|
||||
} else {
|
||||
d.quietHops++
|
||||
// Long enough to survive slow-speed word gaps (7 dits), short
|
||||
// enough to retune to a new signal within a few seconds.
|
||||
release := int(math.Max(2000, 12*d.muDit) / d.hopMs)
|
||||
if d.quietHops > release {
|
||||
d.flushPending()
|
||||
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
|
||||
d.rawKey = false
|
||||
d.lastPitch = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d.timing()
|
||||
d.emitStatus()
|
||||
}
|
||||
|
||||
// updateBankNoise folds a fresh bank-median reading into the broadband noise
|
||||
// reference used by the absolute squelch.
|
||||
func (d *Decoder) updateBankNoise(medianDB float64) {
|
||||
if !d.haveBankNoise {
|
||||
d.bankNoiseDB, d.haveBankNoise = medianDB, true
|
||||
return
|
||||
}
|
||||
d.bankNoiseDB += (medianDB - d.bankNoiseDB) * 0.2
|
||||
}
|
||||
|
||||
// glitchHops is the debounce time in hops: ~0.3 dit, clamped to 1..3 hops
|
||||
// (6–19 ms) so it neither swallows fast dits nor passes static crashes.
|
||||
func (d *Decoder) glitchHops() int {
|
||||
g := int(math.Round(0.3 * d.muDit / d.hopMs))
|
||||
if g < 1 {
|
||||
g = 1
|
||||
}
|
||||
if g > 3 {
|
||||
g = 3
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// timing runs the debounced mark/space state machine for one hop.
|
||||
func (d *Decoder) timing() {
|
||||
if d.rawKey == d.key {
|
||||
// Agreement folds any pending flip back into the committed state:
|
||||
// a sub-glitch dropout inside a dah (or spike inside a gap) vanishes.
|
||||
d.stableHops += 1 + d.pendHops
|
||||
d.pendHops = 0
|
||||
} else {
|
||||
d.pendHops++
|
||||
if d.pendHops > d.glitchHops() {
|
||||
seg := d.stableHops
|
||||
wasMark := d.key
|
||||
d.key = d.rawKey
|
||||
d.stableHops = d.pendHops
|
||||
d.pendHops = 0
|
||||
if wasMark {
|
||||
d.endMark(seg)
|
||||
} else {
|
||||
d.endSpace(seg)
|
||||
}
|
||||
if d.key {
|
||||
d.charEmitted, d.wordEmitted = false, false
|
||||
}
|
||||
}
|
||||
}
|
||||
if !d.key {
|
||||
d.spaceProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// endMark stores a finished key-down run for the character batch. Cluster
|
||||
// updates deliberately do NOT happen here: attributing a mark to the dit or
|
||||
// dah centre with the still-converging global boundary poisons the clusters
|
||||
// (a dah-led character at an unexpected speed lands its dahs in the dit
|
||||
// centre, which then oscillates). Both classification AND cluster updates
|
||||
// happen per character in flushChar, where the batch's own contrast makes
|
||||
// the attribution reliable.
|
||||
func (d *Decoder) endMark(hops int) {
|
||||
ms := float64(hops)*d.hopMs - d.biasMs // de-bias the window widening
|
||||
if ms < 0.5*minDitMs {
|
||||
return // debounce residue — not a credible element
|
||||
}
|
||||
d.elemMs = append(d.elemMs, ms)
|
||||
// Bootstrap: a first mark SHORTER than the seeded dit can only be a dit of
|
||||
// a faster sender — jump the estimate straight there instead of easing in.
|
||||
if d.marksSeen == 0 && ms < d.muDit {
|
||||
d.muDit = math.Max(ms, minDitMs)
|
||||
}
|
||||
d.marksSeen++
|
||||
if len(d.elemMs) > 8 {
|
||||
d.elemMs = d.elemMs[:0] // no Morse character is that long — noise run
|
||||
}
|
||||
}
|
||||
|
||||
// endSpace runs when a mark begins: the finished gap, if it was clearly an
|
||||
// inter-element one, is extra evidence for the dit length (gaps are often
|
||||
// steadier than dits in hand keying).
|
||||
func (d *Decoder) endSpace(hops int) {
|
||||
ms := float64(hops)*d.hopMs + d.biasMs // gaps shrink by what marks gained
|
||||
if d.marksSeen < 1 || ms < 0.35*d.muDit || ms > 1.7*d.muDit {
|
||||
return
|
||||
}
|
||||
// Early on, gaps are the FASTEST way to find the true dit length (the very
|
||||
// first inter-element gap is one, whatever the first mark was); once the
|
||||
// clusters have settled they are just a gentle refinement.
|
||||
alpha := 0.08
|
||||
if d.marksSeen < 8 {
|
||||
alpha = 0.35
|
||||
}
|
||||
d.muDit += (ms - d.muDit) * alpha
|
||||
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
|
||||
}
|
||||
|
||||
// spaceProgress emits the pending character / word space LIVE once the current
|
||||
// gap crosses each boundary (instead of waiting for the next mark), so text
|
||||
// appears as it is sent.
|
||||
func (d *Decoder) spaceProgress() {
|
||||
gapMs := float64(d.stableHops)*d.hopMs + d.biasMs
|
||||
if !d.charEmitted && gapMs > charGapDits*d.muDit {
|
||||
d.flushChar()
|
||||
d.charEmitted = true
|
||||
}
|
||||
if !d.wordEmitted && gapMs > wordGapDits*d.muDit {
|
||||
if d.textSince && d.onChar != nil {
|
||||
d.onChar(" ")
|
||||
}
|
||||
d.wordEmitted = true
|
||||
}
|
||||
}
|
||||
|
||||
// flushChar classifies the accumulated mark durations into dits/dahs and
|
||||
// emits the character. Classification happens HERE, not as marks arrive: a
|
||||
// character whose own marks are clearly bimodal carries its own dit/dah
|
||||
// boundary (their geometric mean), which decodes correctly even before the
|
||||
// global clusters have converged — the first character of an over included.
|
||||
// Unimodal characters (EEE, TTT, 555…) fall back to the global boundary.
|
||||
func (d *Decoder) flushChar() {
|
||||
if len(d.elemMs) == 0 {
|
||||
return
|
||||
}
|
||||
lo, hi := d.elemMs[0], d.elemMs[0]
|
||||
for _, v := range d.elemMs[1:] {
|
||||
lo = math.Min(lo, v)
|
||||
hi = math.Max(hi, v)
|
||||
}
|
||||
b := math.Sqrt(d.muDit * d.muDah)
|
||||
if hi >= 2*lo {
|
||||
b = math.Sqrt(lo * hi) // the batch is bimodal: trust its own contrast
|
||||
}
|
||||
alpha := 0.18
|
||||
if d.marksSeen <= 8 {
|
||||
alpha = 0.45 // converge fast on a new sender
|
||||
}
|
||||
pat := make([]byte, len(d.elemMs))
|
||||
for i, v := range d.elemMs {
|
||||
if v < b {
|
||||
pat[i] = '.'
|
||||
d.muDit += (v - d.muDit) * alpha
|
||||
} else {
|
||||
pat[i] = '-'
|
||||
d.muDah += (v - d.muDah) * alpha
|
||||
}
|
||||
}
|
||||
// Ratio guards: dah stays 2–4.8 dits; dit stays within speed limits.
|
||||
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
|
||||
if d.muDah < 2.0*d.muDit {
|
||||
d.muDah = 2.0 * d.muDit
|
||||
}
|
||||
if d.muDah > 4.8*d.muDit {
|
||||
d.muDah = 4.8 * d.muDit
|
||||
}
|
||||
d.elemMs = d.elemMs[:0]
|
||||
if c, ok := morse[string(pat)]; ok {
|
||||
if d.onChar != nil {
|
||||
d.onChar(string(c))
|
||||
}
|
||||
d.textSince = true
|
||||
} else if len(pat) <= 7 {
|
||||
// Morse-shaped but unknown → flag it; longer runs are noise, drop.
|
||||
if d.onChar != nil {
|
||||
d.onChar("?")
|
||||
}
|
||||
d.textSince = true
|
||||
}
|
||||
}
|
||||
|
||||
// flushPending finishes the in-progress character and word at end-of-over
|
||||
// (lock release), so the last word isn't left hanging until the next signal.
|
||||
func (d *Decoder) flushPending() {
|
||||
if !d.charEmitted {
|
||||
d.flushChar()
|
||||
d.charEmitted = true
|
||||
}
|
||||
if !d.wordEmitted && d.textSince && d.onChar != nil {
|
||||
d.onChar(" ")
|
||||
d.wordEmitted = true
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Decoder) emitStatus() {
|
||||
d.sinceStatus++
|
||||
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
|
||||
return
|
||||
}
|
||||
d.sinceStatus = 0
|
||||
wpm := 0
|
||||
if d.muDit > 0 && (d.lockIdx >= 0 || d.targetHz.Load() > 0) {
|
||||
wpm = int(math.Round(1200 / d.muDit))
|
||||
}
|
||||
d.onStatus(Status{
|
||||
WPM: wpm,
|
||||
Pitch: int(math.Round(d.lastPitch)),
|
||||
Level: d.lastRMS,
|
||||
Active: d.rawKey,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package cwdecode
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---- Synthesizer -----------------------------------------------------------
|
||||
|
||||
func charToMorse() map[byte]string {
|
||||
m := map[byte]string{}
|
||||
for code, ch := range morse {
|
||||
m[ch] = code
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// keyMessage synthesizes keyed CW for msg with raised-cosine edges (5 ms), so
|
||||
// the signal has realistic click-free envelopes rather than hard steps.
|
||||
func keyMessage(msg string, fs, wpm int, pitch, amp float64) []int16 {
|
||||
dot := fs * 1200 / (wpm * 1000) // samples per dit
|
||||
edge := fs * 5 / 1000 // 5 ms shaping
|
||||
c2m := charToMorse()
|
||||
var out []float64
|
||||
phase := 0.0
|
||||
dphi := 2 * math.Pi * pitch / float64(fs)
|
||||
|
||||
tone := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
g := 1.0
|
||||
if i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(i)/float64(edge))
|
||||
} else if n-1-i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(n-1-i)/float64(edge))
|
||||
}
|
||||
out = append(out, amp*g*math.Sin(phase))
|
||||
phase += dphi
|
||||
}
|
||||
}
|
||||
silence := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, 0)
|
||||
}
|
||||
}
|
||||
|
||||
silence(fs / 4) // lead-in for envelope warm-up
|
||||
for i := 0; i < len(msg); i++ {
|
||||
ch := msg[i]
|
||||
if ch == ' ' {
|
||||
silence(4 * dot) // + trailing 3 from the previous char = 7 total
|
||||
continue
|
||||
}
|
||||
code := c2m[ch]
|
||||
for j := 0; j < len(code); j++ {
|
||||
if code[j] == '.' {
|
||||
tone(dot)
|
||||
} else {
|
||||
tone(3 * dot)
|
||||
}
|
||||
silence(dot)
|
||||
}
|
||||
silence(2 * dot) // + trailing element gap = 3 total
|
||||
}
|
||||
silence(fs / 2)
|
||||
return toInt16(out)
|
||||
}
|
||||
|
||||
func toInt16(x []float64) []int16 {
|
||||
out := make([]int16, len(x))
|
||||
for i, v := range x {
|
||||
if v > 32767 {
|
||||
v = 32767
|
||||
} else if v < -32768 {
|
||||
v = -32768
|
||||
}
|
||||
out[i] = int16(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func addNoise(s []int16, sigma float64, seed int64) []int16 {
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
out := make([]int16, len(s))
|
||||
for i, v := range s {
|
||||
out[i] = int16(math.Max(-32768, math.Min(32767, float64(v)+r.NormFloat64()*sigma)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// applyQSB modulates the amplitude between lo..1.0 at rate Hz (slow fading).
|
||||
func applyQSB(s []int16, fs int, rate, lo float64) []int16 {
|
||||
out := make([]int16, len(s))
|
||||
for i, v := range s {
|
||||
g := lo + (1-lo)*(0.5+0.5*math.Sin(2*math.Pi*rate*float64(i)/float64(fs)))
|
||||
out[i] = int16(float64(v) * g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// applyDropouts blanks brief windows (ms long) every period ms — static-crash
|
||||
// style holes that land inside dahs and gaps alike.
|
||||
func applyDropouts(s []int16, fs int, everyMs, holeMs int) []int16 {
|
||||
out := make([]int16, len(s))
|
||||
copy(out, s)
|
||||
every := fs * everyMs / 1000
|
||||
hole := fs * holeMs / 1000
|
||||
for start := every; start+hole < len(out); start += every {
|
||||
for i := start; i < start+hole; i++ {
|
||||
out[i] = 0
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mix(a, b []int16) []int16 {
|
||||
n := len(a)
|
||||
if len(b) > n {
|
||||
n = len(b)
|
||||
}
|
||||
out := make([]int16, n)
|
||||
for i := 0; i < n; i++ {
|
||||
var v int
|
||||
if i < len(a) {
|
||||
v += int(a[i])
|
||||
}
|
||||
if i < len(b) {
|
||||
v += int(b[i])
|
||||
}
|
||||
if v > 32767 {
|
||||
v = 32767
|
||||
} else if v < -32768 {
|
||||
v = -32768
|
||||
}
|
||||
out[i] = int16(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decode runs samples through a fresh decoder in live-sized chunks.
|
||||
func decode(t *testing.T, samples []int16, targetHz int) string {
|
||||
t.Helper()
|
||||
var sb strings.Builder
|
||||
d := New(16000, func(s string) { sb.WriteString(s) }, nil)
|
||||
if targetHz > 0 {
|
||||
d.SetTarget(targetHz)
|
||||
}
|
||||
for i := 0; i < len(samples); i += 256 {
|
||||
end := i + 256
|
||||
if end > len(samples) {
|
||||
end = len(samples)
|
||||
}
|
||||
d.Process(samples[i:end])
|
||||
}
|
||||
return strings.ToUpper(sb.String())
|
||||
}
|
||||
|
||||
func wantContains(t *testing.T, got, want, label string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("%s: decoded %q, want it to contain %q", label, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tests -----------------------------------------------------------------
|
||||
|
||||
func TestCleanSignalSpeeds(t *testing.T) {
|
||||
const fs = 16000
|
||||
for _, wpm := range []int{12, 18, 25, 32, 40} {
|
||||
got := decode(t, keyMessage("CQ TEST DE F4BPO K", fs, wpm, 700, 9000), 0)
|
||||
wantContains(t, got, "CQ TEST DE F4BPO K", "clean @"+itoa(wpm)+"wpm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOtherPitches(t *testing.T) {
|
||||
const fs = 16000
|
||||
for _, pitch := range []float64{450, 600, 850} {
|
||||
got := decode(t, keyMessage("PARIS PARIS", fs, 22, pitch, 9000), 0)
|
||||
wantContains(t, got, "PARIS PARIS", "pitch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithNoise(t *testing.T) {
|
||||
const fs = 16000
|
||||
clean := keyMessage("CQ CQ DE HB9HBY", fs, 22, 700, 9000)
|
||||
noisy := addNoise(clean, 2000, 1) // ≈13 dB tone/noise in the audio band
|
||||
got := decode(t, noisy, 0)
|
||||
wantContains(t, got, "CQ CQ DE HB9HBY", "noise")
|
||||
}
|
||||
|
||||
// QSB fading between 35% and 100% amplitude — the adaptive dB envelope must
|
||||
// ride it. The old linear envelope lost the faded halves entirely.
|
||||
func TestQSBFading(t *testing.T) {
|
||||
const fs = 16000
|
||||
clean := keyMessage("CQ CQ CQ DE F4BPO F4BPO", fs, 20, 700, 12000)
|
||||
faded := applyQSB(clean, fs, 0.4, 0.35)
|
||||
got := decode(t, faded, 0)
|
||||
wantContains(t, got, "DE F4BPO", "qsb")
|
||||
}
|
||||
|
||||
// Brief 10 ms holes punched every 150 ms — they land inside dahs. Without the
|
||||
// two-sided debounce every hit dah shatters into dits (the old decoder's
|
||||
// single worst failure on real signals).
|
||||
func TestDropoutsInsideDahs(t *testing.T) {
|
||||
const fs = 16000
|
||||
clean := keyMessage("TEST TEST TEST", fs, 18, 700, 9000)
|
||||
holed := applyDropouts(clean, fs, 150, 10)
|
||||
got := decode(t, holed, 0)
|
||||
wantContains(t, got, "TEST TEST", "dropouts")
|
||||
}
|
||||
|
||||
// QRM: a second, slightly weaker keyed signal at 950 Hz. The pitch lock must
|
||||
// hold the 700 Hz target and ignore the interferer.
|
||||
func TestQRMAutoLock(t *testing.T) {
|
||||
const fs = 16000
|
||||
target := keyMessage("PARIS PARIS PARIS", fs, 20, 700, 9000)
|
||||
qrm := keyMessage("QRZ QRZ QRZ QRZ QRZ", fs, 26, 950, 5000)
|
||||
got := decode(t, mix(target, qrm), 0)
|
||||
wantContains(t, got, "PARIS", "qrm-auto")
|
||||
}
|
||||
|
||||
// Targeted mode: with two comparable signals, SetTarget must decode the chosen
|
||||
// one even though the other is as strong.
|
||||
func TestQRMTargeted(t *testing.T) {
|
||||
const fs = 16000
|
||||
want := keyMessage("SOS SOS SOS", fs, 20, 600, 8000)
|
||||
other := keyMessage("QRL QRL QRL QRL", fs, 24, 900, 8000)
|
||||
got := decode(t, mix(want, other), 600)
|
||||
wantContains(t, got, "SOS SOS", "qrm-target")
|
||||
}
|
||||
|
||||
// Pure noise must stay silent: the squelch keys nothing, so no text at all.
|
||||
func TestNoiseOnlySquelch(t *testing.T) {
|
||||
const fs = 16000
|
||||
noise := addNoise(make([]int16, fs*6), 3000, 7)
|
||||
got := strings.TrimSpace(decode(t, noise, 0))
|
||||
if len(got) > 2 { // tolerate at most a stray flagged char
|
||||
t.Fatalf("squelch: decoded %q from pure noise, want (almost) nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
// keyMessageJitter synthesizes hand-sent CW: every element and gap duration
|
||||
// is jittered (elements ±je, gaps ±jg, uniform), like a human fist.
|
||||
func keyMessageJitter(msg string, fs, wpm int, pitch, amp, je, jg float64, seed int64) []int16 {
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
dot := float64(fs) * 1200 / (float64(wpm) * 1000)
|
||||
edge := fs * 5 / 1000
|
||||
c2m := charToMorse()
|
||||
var out []float64
|
||||
phase := 0.0
|
||||
dphi := 2 * math.Pi * pitch / float64(fs)
|
||||
jit := func(n float64, j float64) int { return int(n * (1 + (r.Float64()*2-1)*j)) }
|
||||
tone := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
g := 1.0
|
||||
if i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(i)/float64(edge))
|
||||
} else if n-1-i < edge {
|
||||
g = 0.5 - 0.5*math.Cos(math.Pi*float64(n-1-i)/float64(edge))
|
||||
}
|
||||
out = append(out, amp*g*math.Sin(phase))
|
||||
phase += dphi
|
||||
}
|
||||
}
|
||||
silence := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, 0)
|
||||
}
|
||||
}
|
||||
silence(fs / 4)
|
||||
for i := 0; i < len(msg); i++ {
|
||||
ch := msg[i]
|
||||
if ch == ' ' {
|
||||
silence(jit(4*dot, jg))
|
||||
continue
|
||||
}
|
||||
code := c2m[ch]
|
||||
for j := 0; j < len(code); j++ {
|
||||
if code[j] == '.' {
|
||||
tone(jit(dot, je))
|
||||
} else {
|
||||
tone(jit(3*dot, je))
|
||||
}
|
||||
silence(jit(dot, jg))
|
||||
}
|
||||
silence(jit(2*dot, jg))
|
||||
}
|
||||
silence(fs / 2)
|
||||
return toInt16(out)
|
||||
}
|
||||
|
||||
// Hand keying: ±20% element jitter, ±25% gap jitter — a sloppy but readable
|
||||
// human fist. The batch classifier and the gap-fed dit tracking must ride it.
|
||||
func TestHandKeying(t *testing.T) {
|
||||
const fs = 16000
|
||||
for seed := int64(1); seed <= 3; seed++ {
|
||||
s := keyMessageJitter("CQ CQ DE HB9HBY HB9HBY K", fs, 22, 700, 9000, 0.20, 0.25, seed)
|
||||
got := decode(t, s, 0)
|
||||
wantContains(t, got, "HB9HBY", "hand-keying")
|
||||
}
|
||||
}
|
||||
|
||||
// Speed change mid-over: the cluster tracker must follow 25 → 15 WPM.
|
||||
func TestSpeedChange(t *testing.T) {
|
||||
const fs = 16000
|
||||
fast := keyMessage("CQ CQ CQ DE F4BPO", fs, 25, 700, 9000)
|
||||
slow := keyMessage("UR RST 599 599", fs, 15, 700, 9000)
|
||||
got := decode(t, append(fast, slow...), 0)
|
||||
wantContains(t, got, "F4BPO", "speed-fast-part")
|
||||
wantContains(t, got, "599", "speed-slow-part")
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [8]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package cwdecode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDebugTrace prints the element stream for a chosen case — a development
|
||||
// aid, not an assertion test. Run with: go test -run TestDebugTrace -v
|
||||
func TestDebugTrace(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("debug aid")
|
||||
}
|
||||
const fs = 16000
|
||||
samples := keyMessage("CQ TEST DE F4BPO K", fs, 40, 700, 9000)
|
||||
var d *Decoder
|
||||
d = New(fs, func(s string) { fmt.Printf("EMIT %q (muDit=%.0f muDah=%.0f)\n", s, d.muDit, d.muDah) }, nil)
|
||||
lastMarks := 0
|
||||
for i := 0; i < len(samples); i += 256 {
|
||||
end := i + 256
|
||||
if end > len(samples) {
|
||||
end = len(samples)
|
||||
}
|
||||
d.Process(samples[i:end])
|
||||
if d.marksSeen != lastMarks {
|
||||
lastMarks = d.marksSeen
|
||||
fmt.Printf("mark#%d elems=%v muDit=%.0f muDah=%.0f\n", d.marksSeen, d.elemMs, d.muDit, d.muDah)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user