diff --git a/app.go b/app.go index 7795956..9bad169 100644 --- a/app.go +++ b/app.go @@ -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 diff --git a/app_cw.go b/app_cw.go new file mode 100644 index 0000000..8a81a3b --- /dev/null +++ b/app_cw.go @@ -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 +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dfd43f6..da5b248 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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([]); const [dvkStat, setDvkStat] = useState({ 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() { {wkStatus.busy && } + + )) + )} + + + + )} + {/* ===== LOWER: tabbed table / cluster / band map ===== */} {compact ? null : <>
,arg2:string,arg3:string):Prom export function BulkUpdateQSL(arg1:Array,arg2:main.QSLBulkUpdate):Promise; +export function CWDecoderRunning():Promise; + export function CheckForUpdate():Promise; export function ClearLookupCache():Promise; @@ -469,6 +471,10 @@ export function SetUIPref(arg1:string,arg2:string):Promise; export function SetUltrabeamDirection(arg1:number):Promise; +export function StartCWDecoder():Promise; + +export function StopCWDecoder():Promise; + export function SwitchCATRig(arg1:number):Promise; export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 63d6ec3..4e37db5 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -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); } diff --git a/internal/audio/cwtap.go b/internal/audio/cwtap.go new file mode 100644 index 0000000..e1be512 --- /dev/null +++ b/internal/audio/cwtap.go @@ -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)) + }) +} diff --git a/internal/cwdecode/cwdecode.go b/internal/cwdecode/cwdecode.go new file mode 100644 index 0000000..0687c52 --- /dev/null +++ b/internal/cwdecode/cwdecode.go @@ -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}) +} diff --git a/internal/cwdecode/cwdecode_test.go b/internal/cwdecode/cwdecode_test.go new file mode 100644 index 0000000..460dcfd --- /dev/null +++ b/internal/cwdecode/cwdecode_test.go @@ -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) + } +}