feat: cw decoder
This commit is contained in:
@@ -378,6 +378,8 @@ type App struct {
|
||||
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
|
||||
audioMgr *audio.Manager
|
||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||
cwMu sync.Mutex // guards the CW decoder lifecycle
|
||||
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
||||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||
pttMu sync.Mutex
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"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.
|
||||
|
||||
// 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)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
// Capture ended (stopped or errored) — clear state so a restart works.
|
||||
a.cwMu.Lock()
|
||||
if a.cwStop == stop {
|
||||
a.cwStop = nil
|
||||
}
|
||||
a.cwMu.Unlock()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopCWDecoder halts the CW decoder if running.
|
||||
func (a *App) StopCWDecoder() {
|
||||
a.cwMu.Lock()
|
||||
stop := a.cwStop
|
||||
a.cwStop = 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
|
||||
}
|
||||
+79
-2
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Database, Eraser, Hash, Loader2, Lock,
|
||||
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Mic, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||
StartCWDecoder, StopCWDecoder,
|
||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart,
|
||||
GetAwardDefs,
|
||||
GetUIPref,
|
||||
@@ -590,6 +591,27 @@ export default function App() {
|
||||
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
|
||||
|
||||
// === 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';
|
||||
useEffect(() => {
|
||||
const offT = EventsOn('cw:text', (t: string) => setCwText((s) => (s + t).slice(-2000)));
|
||||
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; });
|
||||
}
|
||||
|
||||
const [dvkEnabled, setDvkEnabled] = useState(false);
|
||||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||
@@ -1943,6 +1965,7 @@ export default function App() {
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' },
|
||||
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
|
||||
{ type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' },
|
||||
{ type: 'separator' },
|
||||
// Maintenance — bumped here while we only have one entry. Will move
|
||||
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
||||
@@ -1952,7 +1975,7 @@ export default function App() {
|
||||
{ name: 'help', label: 'Help', items: [
|
||||
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
|
||||
]},
|
||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled]);
|
||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled]);
|
||||
|
||||
function handleMenu(action: string) {
|
||||
switch (action) {
|
||||
@@ -1969,6 +1992,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.refreshCty': refreshCtyDat(); break;
|
||||
case 'tools.downloadRefs': downloadRefs(); break;
|
||||
case 'help.about': setShowAbout(true); break;
|
||||
@@ -2776,6 +2800,24 @@ export default function App() {
|
||||
<Zap className="size-4" />
|
||||
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-amber-500 animate-pulse" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCwDecoder}
|
||||
title={
|
||||
cwEnabled
|
||||
? (mode === 'CW' ? 'CW decoder — on (decoding) · click to disable' : 'CW decoder — on, idle until CW mode · click to disable')
|
||||
: 'CW decoder · click to enable (decodes RX audio in CW mode)'
|
||||
}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||
cwOn && cwStatus.active ? 'border-emerald-400 bg-emerald-100 text-emerald-800'
|
||||
: cwEnabled ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
||||
: '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-emerald-500 animate-pulse" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
|
||||
@@ -3148,6 +3190,41 @@ export default function App() {
|
||||
)}
|
||||
</div>{/* /entry + aside row */}
|
||||
|
||||
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
|
||||
{cwOn && (
|
||||
<div className="mx-2.5 mb-1 flex items-center gap-2 rounded-md border border-emerald-300/70 bg-emerald-50/60 px-2 py-1 text-xs">
|
||||
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-emerald-600' : 'text-muted-foreground')} />
|
||||
{/* Input-level meter — if this stays flat with a strong signal, the RX
|
||||
audio device is wrong/silent rather than a decode problem. */}
|
||||
<div className="shrink-0 w-12 h-1.5 rounded bg-muted overflow-hidden" title={`Audio level ${Math.round(cwStatus.level * 100)}%`}>
|
||||
<div className="h-full bg-emerald-500 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>
|
||||
<div className="flex-1 min-w-0 overflow-x-auto whitespace-nowrap font-mono leading-5">
|
||||
{cwText.trim() === '' ? (
|
||||
<span className="text-muted-foreground italic">listening…</span>
|
||||
) : (
|
||||
cwText.trim().split(/\s+/).map((tok, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="mr-1 rounded px-1 hover:bg-emerald-200/70"
|
||||
title="Use as callsign"
|
||||
onClick={() => onCallsignInput(tok, { force: true })}
|
||||
>
|
||||
{tok}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="shrink-0 text-muted-foreground hover:text-foreground" title="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)]',
|
||||
|
||||
Vendored
+6
@@ -41,6 +41,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 CheckForUpdate():Promise<main.UpdateInfo>;
|
||||
|
||||
export function ClearLookupCache():Promise<void>;
|
||||
@@ -469,6 +471,10 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||
|
||||
export function StartCWDecoder():Promise<void>;
|
||||
|
||||
export function StopCWDecoder():Promise<void>;
|
||||
|
||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||
|
||||
@@ -54,6 +54,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 CheckForUpdate() {
|
||||
return window['go']['main']['App']['CheckForUpdate']();
|
||||
}
|
||||
@@ -910,6 +914,14 @@ export function SetUltrabeamDirection(arg1) {
|
||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||
}
|
||||
|
||||
export function StartCWDecoder() {
|
||||
return window['go']['main']['App']['StartCWDecoder']();
|
||||
}
|
||||
|
||||
export function StopCWDecoder() {
|
||||
return window['go']['main']['App']['StopCWDecoder']();
|
||||
}
|
||||
|
||||
export function SwitchCATRig(arg1) {
|
||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package audio
|
||||
|
||||
// SampleRate is the fixed capture rate (mono 16-bit PCM). Exported so consumers
|
||||
// like the CW decoder can configure their DSP to match.
|
||||
const SampleRate = sampleRate
|
||||
|
||||
// StreamCapture captures from deviceID and calls onSamples with mono 16-bit PCM
|
||||
// frames (as int16) until stop closes. A thin wrapper over the internal capture
|
||||
// loop for live consumers (the CW decoder) that want samples, not raw bytes.
|
||||
func StreamCapture(deviceID string, stop <-chan struct{}, onSamples func([]int16)) error {
|
||||
return captureStream(deviceID, stop, func(chunk []byte) {
|
||||
if len(chunk) == 0 {
|
||||
return
|
||||
}
|
||||
onSamples(bytesToInt16(chunk))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
|
||||
// mono PCM samples into decoded text. The pipeline is the classic one — a bank
|
||||
// of Goertzel tone detectors (auto-picking the dominant pitch), an adaptive
|
||||
// envelope/threshold to recover key-down/key-up, an adaptive dot-length (WPM)
|
||||
// estimate, and a timing state machine that maps marks/spaces to Morse and
|
||||
// then to characters.
|
||||
//
|
||||
// It is deliberately self-contained and dependency-free so it can be unit
|
||||
// tested with synthetic signals. Robustness on weak/QRM/QSB signals is limited
|
||||
// (as with every audio CW decoder); it does well on clean signals.
|
||||
package cwdecode
|
||||
|
||||
import "math"
|
||||
|
||||
// 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
|
||||
Level float64 `json:"level"` // 0..1 rough signal strength (SNR proxy)
|
||||
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.
|
||||
type Decoder struct {
|
||||
fs int
|
||||
hop int // samples between envelope updates
|
||||
win int // Goertzel window length
|
||||
freqs []float64
|
||||
coeffs []float64 // precomputed 2*cos(w) per freq
|
||||
|
||||
ring []float64 // last win samples
|
||||
acc int // samples since last hop
|
||||
|
||||
// Adaptive envelope (relative, so absolute gain is irrelevant).
|
||||
peak, floor float64
|
||||
state bool // true = mark (key down)
|
||||
stateHops int
|
||||
dotHops float64 // adaptive dot length, in hops
|
||||
|
||||
elem []byte // current "." / "-" run for the in-progress character
|
||||
charEmitted bool // current space already flushed a character
|
||||
wordEmitted bool // current space already flushed a word gap
|
||||
lastPitch float64
|
||||
lastRMS float64 // 0..1 input level of the current window (for the UI meter)
|
||||
|
||||
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',
|
||||
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
|
||||
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
|
||||
}
|
||||
|
||||
// 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 / 250, // ~4 ms envelope resolution
|
||||
win: sampleRate / 62, // ~16 ms Goertzel window
|
||||
dotHops: 15, // ~20 WPM seed (15 hops * 4 ms = 60 ms)
|
||||
statusEvery: 25, // ~10 Hz status
|
||||
onChar: onChar,
|
||||
onStatus: onStatus,
|
||||
}
|
||||
if d.hop < 1 {
|
||||
d.hop = 1
|
||||
}
|
||||
// Candidate CW tones: 250–1200 Hz every 25 Hz (wide enough for most rigs'
|
||||
// audio offset). The dominant bin is the pitch (auto), and its magnitude
|
||||
// drives the envelope.
|
||||
for f := 250.0; f <= 1200.0; f += 25 {
|
||||
d.freqs = append(d.freqs, f)
|
||||
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Reset clears decode state (e.g. when the user re-arms the decoder).
|
||||
func (d *Decoder) Reset() {
|
||||
d.ring = d.ring[:0]
|
||||
d.acc = 0
|
||||
d.peak, d.floor = 0, 0
|
||||
d.state = false
|
||||
d.stateHops = 0
|
||||
d.dotHops = 15
|
||||
d.elem = d.elem[:0]
|
||||
d.charEmitted, d.wordEmitted = false, false
|
||||
}
|
||||
|
||||
// Process feeds a block of mono samples through the decoder.
|
||||
func (d *Decoder) Process(samples []int16) {
|
||||
for _, s := range samples {
|
||||
d.ring = append(d.ring, float64(s))
|
||||
if len(d.ring) > d.win {
|
||||
d.ring = d.ring[len(d.ring)-d.win:]
|
||||
}
|
||||
d.acc++
|
||||
if d.acc >= d.hop && len(d.ring) >= d.win {
|
||||
d.acc = 0
|
||||
mag, pitch := d.toneMag()
|
||||
d.step(mag, pitch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toneMag runs the Goertzel bank over the current window and returns the
|
||||
// strongest bin's magnitude and its frequency (the auto-detected pitch).
|
||||
func (d *Decoder) toneMag() (float64, float64) {
|
||||
best, bestF := 0.0, d.lastPitch
|
||||
n := float64(len(d.ring))
|
||||
var sumSq float64
|
||||
for i, coeff := range d.coeffs {
|
||||
var s1, s2 float64
|
||||
for _, x := range d.ring {
|
||||
s0 := x + coeff*s1 - s2
|
||||
s2 = s1
|
||||
s1 = s0
|
||||
}
|
||||
power := s1*s1 + s2*s2 - coeff*s1*s2
|
||||
if power > best {
|
||||
best = power
|
||||
bestF = d.freqs[i]
|
||||
}
|
||||
}
|
||||
for _, x := range d.ring {
|
||||
sumSq += x * x
|
||||
}
|
||||
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4) // ×4 so quiet audio is visible
|
||||
// Normalise by window length so the magnitude scale is rate-independent.
|
||||
return math.Sqrt(math.Max(best, 0)) / n, bestF
|
||||
}
|
||||
|
||||
// step advances the envelope follower + timing state machine by one hop.
|
||||
func (d *Decoder) step(mag, pitch float64) {
|
||||
// Envelope: fast attack / slow release for the peak, fast drop / slow rise
|
||||
// for the noise floor. Tracks the signal even through QSB.
|
||||
if mag > d.peak {
|
||||
d.peak += (mag - d.peak) * 0.4
|
||||
} else {
|
||||
d.peak += (mag - d.peak) * 0.02
|
||||
}
|
||||
if mag < d.floor {
|
||||
d.floor += (mag - d.floor) * 0.4
|
||||
} else {
|
||||
d.floor += (mag - d.floor) * 0.01
|
||||
}
|
||||
|
||||
span := d.peak - d.floor
|
||||
// Hysteresis thresholds; require a minimum SNR span to call anything a tone.
|
||||
on := d.state
|
||||
if span > d.floor*0.3+1e-9 {
|
||||
onTh := d.floor + 0.55*span
|
||||
offTh := d.floor + 0.35*span
|
||||
if d.state {
|
||||
on = mag > offTh
|
||||
} else {
|
||||
on = mag > onTh
|
||||
}
|
||||
if on {
|
||||
d.lastPitch = pitch
|
||||
}
|
||||
} else {
|
||||
on = false
|
||||
}
|
||||
|
||||
if on == d.state {
|
||||
d.stateHops++
|
||||
if !d.state {
|
||||
d.spaceProgress() // flush char/word as the gap grows
|
||||
}
|
||||
} else {
|
||||
if d.state {
|
||||
d.endMark(d.stateHops)
|
||||
}
|
||||
d.state = on
|
||||
d.stateHops = 1
|
||||
if on {
|
||||
// A new mark starts → the previous space is over; re-arm flushing.
|
||||
d.charEmitted, d.wordEmitted = false, false
|
||||
}
|
||||
}
|
||||
|
||||
d.emitStatus(on)
|
||||
}
|
||||
|
||||
// endMark classifies a finished key-down run as a dot or dash and adapts the
|
||||
// dot-length estimate.
|
||||
func (d *Decoder) endMark(hops int) {
|
||||
h := float64(hops)
|
||||
// Reject impulse noise far shorter than a dot.
|
||||
if h < d.dotHops*0.35 {
|
||||
return
|
||||
}
|
||||
dash := h > d.dotHops*2
|
||||
if dash {
|
||||
d.elem = append(d.elem, '-')
|
||||
d.adaptDot(h / 3)
|
||||
} else {
|
||||
d.elem = append(d.elem, '.')
|
||||
d.adaptDot(h)
|
||||
}
|
||||
}
|
||||
|
||||
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
|
||||
// to ~5–100 WPM).
|
||||
func (d *Decoder) adaptDot(obs float64) {
|
||||
d.dotHops = d.dotHops*0.7 + obs*0.3
|
||||
if d.dotHops < 3 {
|
||||
d.dotHops = 3
|
||||
}
|
||||
if d.dotHops > 60 {
|
||||
d.dotHops = 60
|
||||
}
|
||||
}
|
||||
|
||||
// spaceProgress flushes the current character once the gap exceeds a character
|
||||
// gap, and a word space once it exceeds a word gap.
|
||||
func (d *Decoder) spaceProgress() {
|
||||
g := float64(d.stateHops)
|
||||
if !d.charEmitted && g > d.dotHops*2 {
|
||||
d.flushChar()
|
||||
d.charEmitted = true
|
||||
}
|
||||
if !d.wordEmitted && g > d.dotHops*5 {
|
||||
if d.onChar != nil {
|
||||
d.onChar(" ")
|
||||
}
|
||||
d.wordEmitted = true
|
||||
}
|
||||
}
|
||||
|
||||
// flushChar looks up the accumulated element string and emits the character.
|
||||
func (d *Decoder) flushChar() {
|
||||
if len(d.elem) == 0 {
|
||||
return
|
||||
}
|
||||
if c, ok := morse[string(d.elem)]; ok {
|
||||
if d.onChar != nil {
|
||||
d.onChar(string(c))
|
||||
}
|
||||
} else if d.onChar != nil {
|
||||
d.onChar("?")
|
||||
}
|
||||
d.elem = d.elem[:0]
|
||||
}
|
||||
|
||||
func (d *Decoder) emitStatus(on bool) {
|
||||
d.sinceStatus++
|
||||
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
|
||||
return
|
||||
}
|
||||
d.sinceStatus = 0
|
||||
hopMs := float64(d.hop) / float64(d.fs) * 1000
|
||||
wpm := 0
|
||||
if d.dotHops > 0 {
|
||||
wpm = int(math.Round(1200 / (d.dotHops * hopMs)))
|
||||
}
|
||||
d.onStatus(Status{WPM: wpm, Pitch: int(math.Round(d.lastPitch)), Level: d.lastRMS, Active: on})
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package cwdecode
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// reverse Morse map for the synthesizer.
|
||||
func charToMorse() map[byte]string {
|
||||
m := map[byte]string{}
|
||||
for code, ch := range morse {
|
||||
m[ch] = code
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// keyMessage synthesizes a clean keyed tone for msg at the given WPM/pitch.
|
||||
func keyMessage(msg string, fs, wpm int, pitch float64) []int16 {
|
||||
dot := fs * 1200 / (wpm * 1000) // samples per dot
|
||||
c2m := charToMorse()
|
||||
var out []int16
|
||||
phase := 0.0
|
||||
dphi := 2 * math.Pi * pitch / float64(fs)
|
||||
|
||||
tone := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, int16(9000*math.Sin(phase)))
|
||||
phase += dphi
|
||||
}
|
||||
}
|
||||
silence := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, 0)
|
||||
}
|
||||
}
|
||||
|
||||
silence(fs / 4) // 250 ms lead-in for AGC warmup
|
||||
for i := 0; i < len(msg); i++ {
|
||||
ch := msg[i]
|
||||
if ch == ' ' {
|
||||
silence(7 * dot)
|
||||
continue
|
||||
}
|
||||
code := c2m[ch]
|
||||
for j := 0; j < len(code); j++ {
|
||||
if code[j] == '.' {
|
||||
tone(dot)
|
||||
} else {
|
||||
tone(3 * dot)
|
||||
}
|
||||
silence(dot) // inter-element gap
|
||||
}
|
||||
silence(3 * dot) // inter-character gap (on top of the trailing element gap)
|
||||
}
|
||||
silence(fs / 4)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestDecodeCleanSignal(t *testing.T) {
|
||||
const fs = 16000
|
||||
var sb strings.Builder
|
||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||
|
||||
// Repeat so AGC warm-up only costs the first word.
|
||||
samples := keyMessage("PARIS PARIS PARIS", fs, 22, 700)
|
||||
// Feed in small chunks like the live capture would.
|
||||
for i := 0; i < len(samples); i += 256 {
|
||||
end := i + 256
|
||||
if end > len(samples) {
|
||||
end = len(samples)
|
||||
}
|
||||
d.Process(samples[i:end])
|
||||
}
|
||||
|
||||
got := strings.ToUpper(sb.String())
|
||||
if !strings.Contains(got, "PARIS") {
|
||||
t.Fatalf("decoded %q, want it to contain PARIS", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeNumbersAndProsign(t *testing.T) {
|
||||
const fs = 16000
|
||||
var sb strings.Builder
|
||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||
samples := keyMessage("TEST 599 TEST", fs, 18, 650)
|
||||
for i := 0; i < len(samples); i += 200 {
|
||||
end := i + 200
|
||||
if end > len(samples) {
|
||||
end = len(samples)
|
||||
}
|
||||
d.Process(samples[i:end])
|
||||
}
|
||||
got := strings.ToUpper(sb.String())
|
||||
if !strings.Contains(got, "599") {
|
||||
t.Fatalf("decoded %q, want it to contain 599", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user