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:
2026-07-23 22:50:02 +02:00
parent 7b0ef0ba97
commit d6a2e84eed
10 changed files with 1349 additions and 2 deletions
+6
View File
@@ -33,6 +33,7 @@ import (
"hamlog/internal/clublog" "hamlog/internal/clublog"
"hamlog/internal/cluster" "hamlog/internal/cluster"
"hamlog/internal/contest" "hamlog/internal/contest"
"hamlog/internal/cwdecode"
"hamlog/internal/db" "hamlog/internal/db"
"hamlog/internal/dxcc" "hamlog/internal/dxcc"
"hamlog/internal/email" "hamlog/internal/email"
@@ -492,6 +493,11 @@ type App struct {
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON) 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) startupProfile string // --profile <name> from the command line (activate at startup)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
+149
View File
@@ -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
}
+2
View File
@@ -3,12 +3,14 @@
"version": "0.20.12", "version": "0.20.12",
"date": "2026-07-23", "date": "2026-07-23",
"en": [ "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 1240 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.", "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.", "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.", "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." "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": [ "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.", "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.", "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.", "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
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { 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, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react'; } from 'lucide-react';
@@ -39,6 +39,7 @@ import {
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState, IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW, FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop, GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators, ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock, QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
GetAwardDefs, GetAwardDefs,
@@ -852,6 +853,37 @@ export default function App() {
// === Digital Voice Keyer (DVK) === // === 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 === // === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK ===
const [chatAvailable, setChatAvailable] = useState(false); const [chatAvailable, setChatAvailable] = useState(false);
const [chatOpen, setChatOpen] = useState(false); const [chatOpen, setChatOpen] = useState(false);
@@ -2908,6 +2940,7 @@ export default function App() {
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' }, { type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
{ type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' }, { type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
{ type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' }, { type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
{ type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' }, { type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
@@ -2930,7 +2963,7 @@ export default function App() {
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: t('help.about'), action: 'help.about' }, { 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) { function handleMenu(action: string) {
switch (action) { switch (action) {
@@ -2951,6 +2984,7 @@ export default function App() {
case 'tools.qsldesigner': setQslDesignerOpen(true); break; case 'tools.qsldesigner': setQslDesignerOpen(true); break;
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
case 'tools.dvk': setDvkEnabled((v) => !v); 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.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.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; case 'tools.alerts': setAlertsOpen(true); break;
@@ -4000,6 +4034,20 @@ export default function App() {
<Zap className="size-4" /> <Zap className="size-4" />
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />} {wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
</button> </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 <button
type="button" type="button"
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }} onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
@@ -4664,6 +4712,53 @@ export default function App() {
</div>{/* /entry + aside row */} </div>{/* /entry + aside row */}
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */} {/* ===== 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 ===== */} {/* ===== LOWER: tabbed table / cluster / band map ===== */}
{compact ? null : <> {compact ? null : <>
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]', <div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
+12
View File
@@ -32,6 +32,12 @@ const en: Dict = {
'view.refresh': 'Refresh', 'view.clearFilters': 'Clear filters', 'view.refresh': 'Refresh', 'view.clearFilters': 'Clear filters',
'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…', '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)', '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', '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', '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…', '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', 'view.refresh': 'Rafraîchir', 'view.clearFilters': 'Effacer les filtres',
'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…', '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)', '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 laudio RX en mode CW)',
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest', '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', '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…', 'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…',
+10
View File
@@ -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 BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
export function CWDecoderRunning():Promise<boolean>;
export function ChatAvailable():Promise<boolean>; export function ChatAvailable():Promise<boolean>;
export function CheckForUpdate():Promise<main.UpdateInfo>; export function CheckForUpdate():Promise<main.UpdateInfo>;
@@ -368,6 +370,8 @@ export function GetCATSettings():Promise<main.CATSettings>;
export function GetCATState():Promise<cat.RigState>; export function GetCATState():Promise<cat.RigState>;
export function GetCWDecoderPitch():Promise<number>;
export function GetCatalogCodes():Promise<Array<string>>; export function GetCatalogCodes():Promise<Array<string>>;
export function GetChangelog():Promise<Array<main.ChangelogEntry>>; 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 SetCATMode(arg1:string):Promise<void>;
export function SetCWDecoderPitch(arg1:number):Promise<void>;
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>; export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
export function SetClublogMostWantedEnabled(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 SetUltrabeamDirection(arg1:number):Promise<void>;
export function StartCWDecoder():Promise<void>;
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):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 SwitchCATRig(arg1:number):Promise<void>;
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>; export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
+20
View File
@@ -126,6 +126,10 @@ export function BulkUpdateQSL(arg1, arg2) {
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2); return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
} }
export function CWDecoderRunning() {
return window['go']['main']['App']['CWDecoderRunning']();
}
export function ChatAvailable() { export function ChatAvailable() {
return window['go']['main']['App']['ChatAvailable'](); return window['go']['main']['App']['ChatAvailable']();
} }
@@ -690,6 +694,10 @@ export function GetCATState() {
return window['go']['main']['App']['GetCATState'](); return window['go']['main']['App']['GetCATState']();
} }
export function GetCWDecoderPitch() {
return window['go']['main']['App']['GetCWDecoderPitch']();
}
export function GetCatalogCodes() { export function GetCatalogCodes() {
return window['go']['main']['App']['GetCatalogCodes'](); return window['go']['main']['App']['GetCatalogCodes']();
} }
@@ -1670,6 +1678,10 @@ export function SetCATMode(arg1) {
return window['go']['main']['App']['SetCATMode'](arg1); return window['go']['main']['App']['SetCATMode'](arg1);
} }
export function SetCWDecoderPitch(arg1) {
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
}
export function SetClublogCtyEnabled(arg1) { export function SetClublogCtyEnabled(arg1) {
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1); return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
} }
@@ -1706,10 +1718,18 @@ export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1); return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
} }
export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder']();
}
export function StationSetRelay(arg1, arg2, arg3) { export function StationSetRelay(arg1, arg2, arg3) {
return window['go']['main']['App']['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) { export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1); return window['go']['main']['App']['SwitchCATRig'](arg1);
} }
+697
View File
@@ -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" 23 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
// ±56 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: 4001000 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
// peakcap — 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
// (619 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 24.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,
})
}
+326
View File
@@ -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:])
}
+30
View File
@@ -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)
}
}
}