diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index da5b248..237385a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -598,7 +598,7 @@ export default function App() { 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 offT = EventsOn('cw:text', (t: string) => setCwText((s) => (s + t).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?.(); }; @@ -1476,8 +1476,16 @@ export default function App() { function wkSendMacro(i: number) { const m = wkMacros[i]; if (!m) return; - if (wkAutoCallRef.current) runAutoCall(i); // loop this macro until a reply / stop - else wkSend(m.text); + // Auto-call only loops CQ-type macros. Sending any other macro (e.g. a + // report once someone answers) sends ONCE and cancels a running loop — + // otherwise a report would keep repeating. + const isCQ = (m.text || '').toUpperCase().includes('CQ'); + if (wkAutoCallRef.current && isCQ) { + runAutoCall(i); // loop this CQ until a reply is sent / Stop / ESC + } else { + stopAutoCall(); + wkSend(m.text); + } } wkSendMacroRef.current = wkSendMacro; function wkToggleAutoCall(on: boolean) { @@ -2046,7 +2054,9 @@ export default function App() { return; } const keyerLive = wkActiveRef.current; - if (keyerLive) WinkeyerStop().catch(() => {}); + // ESC aborts the current CW transmission AND the auto-call loop, so it + // won't resend after the gap — you must click a CQ macro to restart it. + if (keyerLive) { stopAutoCall(); WinkeyerStop().catch(() => {}); } if (!keyerLive || wkEscClearsRef.current) { resetEntry(); callsignRef.current?.focus(); @@ -3202,21 +3212,25 @@ export default function App() { {cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'} -
+ {/* Single-line rolling ticker — no scrollbar; newest text stays + pinned to the right, older text scrolls off the left. */} +
{cwText.trim() === '' ? ( listening… ) : ( - cwText.trim().split(/\s+/).map((tok, i) => ( - - )) +
+ {cwText.trim().split(/\s+/).map((tok, i) => ( + + ))} +
)}
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */} diff --git a/internal/cwdecode/cwdecode.go b/internal/cwdecode/cwdecode.go index 0687c52..6623d5b 100644 --- a/internal/cwdecode/cwdecode.go +++ b/internal/cwdecode/cwdecode.go @@ -1,48 +1,64 @@ // 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. +// of Goertzel tone detectors, a pitch LOCK that follows a single tone (so QRM +// at other pitches is ignored), an SNR-based key-down/key-up detector measured +// against the broadband noise floor (so QRN bursts that lift every bin are +// rejected), 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. +// tested with synthetic signals. As with every audio CW decoder, weak signals +// and very heavy QRM still degrade it; the lock + SNR gate trade a little +// sensitivity for far fewer false decodes. package cwdecode -import "math" +import ( + "math" + "sort" +) // 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) + 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. 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 + fs int + hop int // samples between 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 + mags []float64 // per-bin magnitude this hop + nbuf []float64 // scratch for the noise percentile - // Adaptive envelope (relative, so absolute gain is irrelevant). - peak, floor float64 + // Pitch lock + noise. + lockIdx int // index of the locked tone bin, -1 = unlocked + candIdx int // current argmax candidate while unlocked + candHops int // consecutive hops the candidate has been dominant + unlockHops int // consecutive low-SNR hops while locked + noise float64 + relockHops int // quiet hops before the lock is released + onSNR float64 // SNR to call key-down / to acquire a lock + offSNR float64 // SNR below which it's key-up + + // Keying / timing. 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 + wordEmitted bool - 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) + lastPitch float64 + lastRMS float64 statusEvery int sinceStatus int @@ -71,23 +87,29 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder { } 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 + hop: sampleRate / 250, // ~4 ms resolution + win: sampleRate / 62, // ~16 ms Goertzel window + dotHops: 15, // ~20 WPM seed + onSNR: 4.0, + offSNR: 2.5, + lockIdx: -1, + candIdx: -1, + statusEvery: 25, // ~10 Hz 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. + d.relockHops = int(0.8 * float64(d.fs) / float64(d.hop)) // release lock after ~0.8 s quiet + // Candidate CW tones: 250–1200 Hz every 25 Hz (covers most rigs' audio + // offset). The locked bin is the pitch; only its magnitude is decoded. 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))) } + d.mags = make([]float64, len(d.freqs)) + d.nbuf = make([]float64, len(d.freqs)) return d } @@ -95,7 +117,7 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder { func (d *Decoder) Reset() { d.ring = d.ring[:0] d.acc = 0 - d.peak, d.floor = 0, 0 + d.lockIdx, d.candIdx, d.candHops, d.unlockHops = -1, -1, 0, 0 d.state = false d.stateHops = 0 d.dotHops = 15 @@ -113,18 +135,18 @@ func (d *Decoder) Process(samples []int16) { d.acc++ if d.acc >= d.hop && len(d.ring) >= d.win { d.acc = 0 - mag, pitch := d.toneMag() - d.step(mag, pitch) + d.analyze() + d.step() } } } -// 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 +// analyze runs the Goertzel bank over the current window, estimates the noise +// floor, and maintains the pitch lock. +func (d *Decoder) analyze() { n := float64(len(d.ring)) var sumSq float64 + maxIdx, maxMag := 0, -1.0 for i, coeff := range d.coeffs { var s1, s2 float64 for _, x := range d.ring { @@ -132,57 +154,71 @@ func (d *Decoder) toneMag() (float64, float64) { s2 = s1 s1 = s0 } - power := s1*s1 + s2*s2 - coeff*s1*s2 - if power > best { - best = power - bestF = d.freqs[i] + m := math.Sqrt(math.Max(s1*s1+s2*s2-coeff*s1*s2, 0)) / n + d.mags[i] = m + if m > maxMag { + maxMag = m + maxIdx = 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 + d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4) + + // Noise floor = 40th percentile of the bins (robust to a few strong tones, + // so one or two QRM signals don't inflate it). + copy(d.nbuf, d.mags) + sort.Float64s(d.nbuf) + d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)] + eps := d.noise + 1e-9 + + if d.lockIdx < 0 { + // Acquire: lock once the same bin has been dominant for a few hops and + // is clearly above the noise. + if maxIdx == d.candIdx { + d.candHops++ + } else { + d.candIdx, d.candHops = maxIdx, 1 + } + if d.candHops >= 4 && maxMag/eps > d.onSNR { + d.lockIdx, d.unlockHops = maxIdx, 0 + } + } else { + // Hold the lock through key-up gaps; release only after a long quiet so + // we can retune to a new signal/pitch. + if d.mags[d.lockIdx]/eps < d.offSNR { + d.unlockHops++ + } else { + d.unlockHops = 0 + } + if d.unlockHops > d.relockHops { + d.lockIdx, d.candIdx, d.candHops = -1, -1, 0 + } + } + if d.lockIdx >= 0 { + d.lastPitch = d.freqs[d.lockIdx] + } else { + d.lastPitch = 0 + } } -// 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 +// step advances the keying detector + timing state machine by one hop. +func (d *Decoder) step() { + on := false + if d.lockIdx >= 0 { + snr := d.mags[d.lockIdx] / (d.noise + 1e-9) if d.state { - on = mag > offTh + on = snr > d.offSNR // hysteresis: stay keyed until it clearly drops } else { - on = mag > onTh + on = snr > d.onSNR } - 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 + d.spaceProgress() } } else { if d.state { @@ -191,24 +227,21 @@ func (d *Decoder) step(mag, pitch float64) { 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. +// dot-length estimate. Runs shorter than a third of a dot are rejected as +// clicks/noise. 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 { + if h > d.dotHops*2 { d.elem = append(d.elem, '-') d.adaptDot(h / 3) } else { diff --git a/internal/cwdecode/cwdecode_test.go b/internal/cwdecode/cwdecode_test.go index 460dcfd..e91f63a 100644 --- a/internal/cwdecode/cwdecode_test.go +++ b/internal/cwdecode/cwdecode_test.go @@ -17,6 +17,10 @@ func charToMorse() map[byte]string { // keyMessage synthesizes a clean keyed tone for msg at the given WPM/pitch. func keyMessage(msg string, fs, wpm int, pitch float64) []int16 { + return keyMessageAmp(msg, fs, wpm, pitch, 9000) +} + +func keyMessageAmp(msg string, fs, wpm int, pitch, amp float64) []int16 { dot := fs * 1200 / (wpm * 1000) // samples per dot c2m := charToMorse() var out []int16 @@ -25,7 +29,7 @@ func keyMessage(msg string, fs, wpm int, pitch float64) []int16 { tone := func(n int) { for i := 0; i < n; i++ { - out = append(out, int16(9000*math.Sin(phase))) + out = append(out, int16(amp*math.Sin(phase))) phase += dphi } } @@ -79,6 +83,41 @@ func TestDecodeCleanSignal(t *testing.T) { } } +func TestDecodeWithQRM(t *testing.T) { + const fs = 16000 + // Target at 700 Hz; a strong interfering keyed signal at 950 Hz, slightly + // quieter, sending different text. The pitch lock should hold on the target. + target := keyMessageAmp("PARIS PARIS PARIS", fs, 20, 700, 9000) + qrm := keyMessageAmp("BK DE QRZ QRZ TEST", fs, 26, 950, 6500) + mix := make([]int16, len(target)) + for i := range target { + v := int(target[i]) + if i < len(qrm) { + v += int(qrm[i]) + } + if v > 32767 { + v = 32767 + } else if v < -32768 { + v = -32768 + } + mix[i] = int16(v) + } + + var sb strings.Builder + d := New(fs, func(s string) { sb.WriteString(s) }, nil) + for i := 0; i < len(mix); i += 256 { + end := i + 256 + if end > len(mix) { + end = len(mix) + } + d.Process(mix[i:end]) + } + got := strings.ToUpper(sb.String()) + if !strings.Contains(got, "PARIS") { + t.Fatalf("with QRM, decoded %q, want it to contain PARIS", got) + } +} + func TestDecodeNumbersAndProsign(t *testing.T) { const fs = 16000 var sb strings.Builder