From bbeeee6aded53d086b2510101d8880f424d6e798 Mon Sep 17 00:00:00 2001 From: Andrei Date: Wed, 25 Feb 2026 23:39:30 -0500 Subject: [PATCH 01/52] Fix background color for selects --- static/css/adsb_dashboard.css | 2 +- static/css/ais_dashboard.css | 2 +- static/css/modes/aprs.css | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/static/css/adsb_dashboard.css b/static/css/adsb_dashboard.css index b196714..41b6515 100644 --- a/static/css/adsb_dashboard.css +++ b/static/css/adsb_dashboard.css @@ -1246,7 +1246,7 @@ body { .control-group select { padding: 4px 8px; - background: rgba(0, 0, 0, 0.3); + background: var(--bg-dark); border: 1px solid rgba(74, 158, 255, 0.3); border-radius: 4px; color: var(--accent-cyan); diff --git a/static/css/ais_dashboard.css b/static/css/ais_dashboard.css index 4232caf..2dd5d2f 100644 --- a/static/css/ais_dashboard.css +++ b/static/css/ais_dashboard.css @@ -779,7 +779,7 @@ body { .control-group select { padding: 4px 8px; - background: rgba(0, 0, 0, 0.3); + background: var(--bg-dark); border: 1px solid rgba(74, 158, 255, 0.3); border-radius: 4px; color: var(--accent-cyan); diff --git a/static/css/modes/aprs.css b/static/css/modes/aprs.css index 2ef4cc2..9f6b4e1 100644 --- a/static/css/modes/aprs.css +++ b/static/css/modes/aprs.css @@ -60,7 +60,7 @@ gap: 4px; } .aprs-strip .strip-select { - background: rgba(0,0,0,0.3); + background: var(--bg-dark); border: 1px solid var(--border-color); color: var(--text-primary); padding: 4px 8px; From 600d7a4a5e474caadcc7d7ade8e92a3be305d0a1 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 08:09:25 +0000 Subject: [PATCH 02/52] Fix Morse mode rejecting valid HF frequencies validate_frequency() defaults to 24-1766 MHz (VHF/UHF range), but Morse/CW operates on HF bands (0.5-30 MHz). Pass explicit min/max to allow HF frequencies. Co-Authored-By: Claude Opus 4.6 --- routes/morse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/morse.py b/routes/morse.py index 56800a2..c30a2d4 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -64,7 +64,7 @@ def start_morse() -> Response: # Validate standard SDR inputs try: - freq = validate_frequency(data.get('frequency', '14.060')) + freq = validate_frequency(data.get('frequency', '14.060'), min_mhz=0.5, max_mhz=30.0) gain = validate_gain(data.get('gain', '0')) ppm = validate_ppm(data.get('ppm', '0')) device = validate_device_index(data.get('device', '0')) From f86c1949940f6cf97c3f3ea98697ced5a79962fa Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 08:43:51 +0000 Subject: [PATCH 03/52] Fix Morse mode HF reception, stop button, and UX guidance Enable direct sampling (-D 2) for RTL-SDR at HF frequencies below 24 MHz so rtl_fm can actually receive CW signals. Add startup health check to detect immediate rtl_fm failures. Push stopped status event from decoder thread on EOF so the frontend auto-resets. Add frequency placeholder and help text. Fix stop button silently swallowing errors. Co-Authored-By: Claude Opus 4.6 --- routes/morse.py | 24 +++++++++++++++ static/js/modes/morse.js | 9 +++++- templates/partials/modes/morse.html | 3 +- utils/morse.py | 3 ++ utils/sdr/rtlsdr.py | 48 +++++++++++++++++------------ 5 files changed, 65 insertions(+), 22 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index c30a2d4..dae772c 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -6,6 +6,7 @@ import contextlib import queue import subprocess import threading +import time from typing import Any from flask import Blueprint, Response, jsonify, request @@ -113,6 +114,9 @@ def start_morse() -> Response: sample_rate = 8000 bias_t = data.get('bias_t', False) + # RTL-SDR needs direct sampling mode for HF frequencies below 24 MHz + direct_sampling = 2 if freq < 24.0 else None + rtl_cmd = builder.build_fm_demod_command( device=sdr_device, frequency_mhz=freq, @@ -121,6 +125,7 @@ def start_morse() -> Response: ppm=int(ppm) if ppm and ppm != '0' else None, modulation='usb', bias_t=bias_t, + direct_sampling=direct_sampling, ) full_cmd = ' '.join(rtl_cmd) @@ -134,6 +139,25 @@ def start_morse() -> Response: ) register_process(rtl_process) + # Detect immediate startup failure (e.g. device busy, no device) + time.sleep(0.35) + if rtl_process.poll() is not None: + stderr_text = '' + try: + if rtl_process.stderr: + stderr_text = rtl_process.stderr.read().decode( + 'utf-8', errors='replace' + ).strip() + except Exception: + stderr_text = '' + msg = stderr_text or f'rtl_fm exited immediately (code {rtl_process.returncode})' + logger.error(f"Morse rtl_fm startup failed: {msg}") + unregister_process(rtl_process) + if morse_active_device is not None: + app_module.release_sdr_device(morse_active_device) + morse_active_device = None + return jsonify({'status': 'error', 'message': msg}), 500 + # Monitor rtl_fm stderr def monitor_stderr(): for line in rtl_process.stderr: diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index b594bf6..6498db4 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -107,7 +107,14 @@ var MorseMode = (function () { disconnectSSE(); stopScope(); }) - .catch(function () {}); + .catch(function (err) { + console.error('Morse stop request failed:', err); + // Reset UI regardless so the user isn't stuck + state.running = false; + updateUI(false); + disconnectSSE(); + stopScope(); + }); } // ---- SSE ---- diff --git a/templates/partials/modes/morse.html b/templates/partials/modes/morse.html index b3bb0f0..07e8f32 100644 --- a/templates/partials/modes/morse.html +++ b/templates/partials/modes/morse.html @@ -12,7 +12,8 @@

Frequency

- + + Enter frequency in MHz (e.g., 7.030 for 40m CW)
diff --git a/utils/morse.py b/utils/morse.py index cd354f3..1a957e5 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -274,3 +274,6 @@ def morse_decoder_thread( for event in decoder.flush(): with contextlib.suppress(queue.Full): output_queue.put_nowait(event) + # Notify frontend that the decoder has stopped (e.g. rtl_fm died) + with contextlib.suppress(queue.Full): + output_queue.put_nowait({'type': 'status', 'status': 'stopped'}) diff --git a/utils/sdr/rtlsdr.py b/utils/sdr/rtlsdr.py index 1e68c35..36f27d3 100644 --- a/utils/sdr/rtlsdr.py +++ b/utils/sdr/rtlsdr.py @@ -14,16 +14,16 @@ from typing import Optional from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType from utils.dependencies import get_tool_path -logger = logging.getLogger('intercept.sdr.rtlsdr') - - -def _rtl_fm_demod_mode(modulation: str) -> str: - """Map app/UI modulation names to rtl_fm demod tokens.""" - mod = str(modulation or '').lower().strip() - return 'wbfm' if mod == 'wfm' else mod - - -def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]: +logger = logging.getLogger('intercept.sdr.rtlsdr') + + +def _rtl_fm_demod_mode(modulation: str) -> str: + """Map app/UI modulation names to rtl_fm demod tokens.""" + mod = str(modulation or '').lower().strip() + return 'wbfm' if mod == 'wfm' else mod + + +def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]: """Detect the correct bias-t flag for the installed dump1090 variant. Different dump1090 forks use different flags: @@ -86,22 +86,27 @@ class RTLSDRCommandBuilder(CommandBuilder): ppm: Optional[int] = None, modulation: str = "fm", squelch: Optional[int] = None, - bias_t: bool = False + bias_t: bool = False, + direct_sampling: Optional[int] = None, ) -> list[str]: """ Build rtl_fm command for FM demodulation. Used for pager decoding. Supports local devices and rtl_tcp connections. + + Args: + direct_sampling: Enable direct sampling mode (0=off, 1=I-branch, + 2=Q-branch). Use 2 for HF reception below 24 MHz. """ - rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm' - demod_mode = _rtl_fm_demod_mode(modulation) - cmd = [ - rtl_fm_path, - '-d', self._get_device_arg(device), - '-f', f'{frequency_mhz}M', - '-M', demod_mode, - '-s', str(sample_rate), - ] + rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm' + demod_mode = _rtl_fm_demod_mode(modulation) + cmd = [ + rtl_fm_path, + '-d', self._get_device_arg(device), + '-f', f'{frequency_mhz}M', + '-M', demod_mode, + '-s', str(sample_rate), + ] if gain is not None and gain > 0: cmd.extend(['-g', str(gain)]) @@ -112,6 +117,9 @@ class RTLSDRCommandBuilder(CommandBuilder): if squelch is not None and squelch > 0: cmd.extend(['-l', str(squelch)]) + if direct_sampling is not None: + cmd.extend(['-D', str(direct_sampling)]) + if bias_t: cmd.extend(['-T']) From dd92236f7f5a35562ea019b6ed9171955e3d04e5 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 08:56:46 +0000 Subject: [PATCH 04/52] Fix direct sampling flag to use portable -E direct2 syntax The -D flag is only available in newer rtl_fm builds. Docker and distro packages use the older -E direct / -E direct2 flags instead, which are universally supported. Co-Authored-By: Claude Opus 4.6 --- utils/sdr/rtlsdr.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/utils/sdr/rtlsdr.py b/utils/sdr/rtlsdr.py index 36f27d3..3cede61 100644 --- a/utils/sdr/rtlsdr.py +++ b/utils/sdr/rtlsdr.py @@ -118,7 +118,12 @@ class RTLSDRCommandBuilder(CommandBuilder): cmd.extend(['-l', str(squelch)]) if direct_sampling is not None: - cmd.extend(['-D', str(direct_sampling)]) + # Older rtl_fm builds (common in Docker/distro packages) don't + # support -D; they use -E direct / -E direct2 instead. + if direct_sampling == 1: + cmd.extend(['-E', 'direct']) + elif direct_sampling == 2: + cmd.extend(['-E', 'direct2']) if bias_t: cmd.extend(['-T']) From b52f9715bfa6e278cc07d3ff1d4fee5b8a8b4d95 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 09:10:37 +0000 Subject: [PATCH 05/52] Fix Morse decoder silent on real HF signals via AGC and warm-up Add automatic gain control (AGC) before Goertzel processing to normalize quiet audio from direct sampling mode where the -g gain flag has no effect. Fix broken adaptive threshold bootstrap by adding a 50-block warm-up phase that collects magnitude statistics before seeding noise floor and signal peak. Lower threshold ratio from 50% to 30% for better weak-CW sensitivity. Co-Authored-By: Claude Opus 4.6 --- tests/test_morse.py | 62 ++++++++++++++++++++++++++++++++++----------- utils/morse.py | 51 ++++++++++++++++++++++++++++++++----- 2 files changed, 91 insertions(+), 22 deletions(-) diff --git a/tests/test_morse.py b/tests/test_morse.py index bb6da32..da926f9 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -147,18 +147,17 @@ class TestGoertzelFilter: class TestMorseDecoder: def _make_decoder(self, wpm=15): - """Create decoder with pre-warmed threshold for testing.""" + """Create decoder with warm-up phase completed for testing. + + Feeds silence then tone then silence to get past the warm-up + blocks and establish a valid noise floor / signal peak. + """ decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=wpm) - # Warm up noise floor with silence - silence = generate_silence(0.5) - decoder.process_block(silence) - # Warm up signal peak with tone - tone = generate_tone(700.0, 0.3) - decoder.process_block(tone) - # More silence to settle - silence2 = generate_silence(0.5) - decoder.process_block(silence2) - # Reset state after warm-up + # Feed enough audio to get past warm-up (50 blocks = 1 sec) + # Mix silence and tone so warm-up sees both noise and signal + warmup_audio = generate_silence(0.6) + generate_tone(700.0, 0.4) + generate_silence(0.5) + decoder.process_block(warmup_audio) + # Reset state machine after warm-up so tests start clean decoder._tone_on = False decoder._current_symbol = '' decoder._tone_blocks = 0 @@ -246,14 +245,14 @@ class TestMorseDecoder: assert 'tone_on' in se def test_adaptive_threshold_adjusts(self): - """After processing audio, threshold should be non-zero.""" + """After processing enough audio to complete warm-up, threshold should be non-zero.""" decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) - # Process some tone + silence - audio = generate_tone(700.0, 0.3) + generate_silence(0.3) + # Feed enough audio to complete the 50-block warm-up (~1 second) + audio = generate_silence(0.6) + generate_tone(700.0, 0.4) + generate_silence(0.3) decoder.process_block(audio) - assert decoder._threshold > 0, "Threshold should adapt above zero" + assert decoder._threshold > 0, "Threshold should adapt above zero after warm-up" def test_flush_emits_pending_char(self): """flush() should emit any accumulated but not-yet-decoded symbol.""" @@ -269,6 +268,39 @@ class TestMorseDecoder: events = decoder.flush() assert events == [] + def test_weak_signal_detection(self): + """CW tone at only 3x noise magnitude should still decode characters.""" + decoder = self._make_decoder(wpm=10) + # Generate weak CW audio (low amplitude simulating weak HF signal) + audio = generate_morse_audio('SOS', wpm=10, sample_rate=8000) + # Scale to low amplitude (simulating weak signal) + n_samples = len(audio) // 2 + samples = struct.unpack(f'<{n_samples}h', audio) + # Reduce to ~10% amplitude + weak_samples = [max(-32768, min(32767, int(s * 0.1))) for s in samples] + weak_audio = struct.pack(f'<{len(weak_samples)}h', *weak_samples) + + events = decoder.process_block(weak_audio) + events.extend(decoder.flush()) + + chars = [e for e in events if e['type'] == 'morse_char'] + decoded = ''.join(e['char'] for e in chars) + # Should decode at least some characters from the weak signal + assert len(chars) >= 1, f"Expected decoded chars from weak signal, got '{decoded}'" + + def test_agc_boosts_quiet_signal(self): + """Very quiet PCM (amplitude 0.01) should still produce usable Goertzel magnitudes.""" + decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) + # Generate very quiet tone + quiet_tone = generate_tone(700.0, 1.5, amplitude=0.01) # 1.5s of very quiet CW + events = decoder.process_block(quiet_tone) + + scope_events = [e for e in events if e['type'] == 'scope'] + assert len(scope_events) > 0, "Expected scope events from quiet signal" + # AGC should have boosted the signal — amplitudes should be visible + max_amp = max(max(se['amplitudes']) for se in scope_events) + assert max_amp > 1.0, f"AGC should boost quiet signal to usable magnitude, got {max_amp}" + # --------------------------------------------------------------------------- # morse_decoder_thread tests diff --git a/utils/morse.py b/utils/morse.py index 1a957e5..543e719 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -95,11 +95,21 @@ class MorseDecoder: self._char_gap = 3.0 * dit_sec / self._block_duration # blocks self._word_gap = 7.0 * dit_sec / self._block_duration # blocks + # AGC (automatic gain control) for direct sampling / weak signals + self._agc_target = 0.3 # target RMS amplitude (0-1 range) + self._agc_gain = 1.0 # current AGC multiplier + self._agc_alpha = 0.05 # EMA smoothing for gain changes + + # Warm-up phase constants + self._WARMUP_BLOCKS = 50 # ~1 second at 50 blocks/sec + self._SETTLE_BLOCKS = 200 # blocks for fast→slow EMA transition + self._mag_min = float('inf') + self._mag_max = 0.0 + # Adaptive threshold via EMA self._noise_floor = 0.0 self._signal_peak = 0.0 self._threshold = 0.0 - self._ema_alpha = 0.1 # smoothing factor # State machine (counts in blocks, not wall-clock time) self._tone_on = False @@ -136,20 +146,47 @@ class MorseDecoder: # Normalize to [-1, 1] normalized = [s / 32768.0 for s in block] + + # AGC: boost quiet signals (e.g. direct sampling mode) + rms = math.sqrt(sum(s * s for s in normalized) / len(normalized)) + if rms > 1e-6: + desired_gain = self._agc_target / rms + self._agc_gain += self._agc_alpha * (desired_gain - self._agc_gain) + self._agc_gain = min(self._agc_gain, 500.0) # cap to prevent runaway + normalized = [s * self._agc_gain for s in normalized] + mag = self._filter.magnitude(normalized) amplitudes.append(mag) self._blocks_processed += 1 - # Update adaptive threshold - if mag < self._threshold or self._threshold == 0: - self._noise_floor += self._ema_alpha * (mag - self._noise_floor) + # Warm-up phase: collect statistics, suppress detection + if self._blocks_processed <= self._WARMUP_BLOCKS: + self._mag_min = min(self._mag_min, mag) + self._mag_max = max(self._mag_max, mag) + if self._blocks_processed == self._WARMUP_BLOCKS: + # Seed thresholds from observed range + self._noise_floor = self._mag_min + self._signal_peak = max(self._mag_max, self._mag_min * 2) + self._threshold = self._noise_floor + 0.3 * ( + self._signal_peak - self._noise_floor + ) + tone_detected = False else: - self._signal_peak += self._ema_alpha * (mag - self._signal_peak) + # Adaptive EMA: fast initially, slow in steady state + alpha = 0.3 if self._blocks_processed < self._WARMUP_BLOCKS + self._SETTLE_BLOCKS else 0.05 - self._threshold = (self._noise_floor + self._signal_peak) / 2.0 + if mag < self._threshold: + self._noise_floor += alpha * (mag - self._noise_floor) + else: + self._signal_peak += alpha * (mag - self._signal_peak) - tone_detected = mag > self._threshold and self._threshold > 0 + # Threshold at 30% between noise and signal (sensitive to weak CW) + self._threshold = self._noise_floor + 0.3 * ( + self._signal_peak - self._noise_floor + ) + + tone_detected = mag > self._threshold and self._threshold > 0 if tone_detected and not self._tone_on: # Tone just started - check silence duration for gaps From 401f6e4461ad81fadf2937bf62b03f5b6b78b88b Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 09:30:58 +0000 Subject: [PATCH 06/52] Fix Morse decoder scope events not reaching frontend Replace blocking rtl_stdout.read() with select()+os.read() so the decoder thread emits diagnostic heartbeat scope events when rtl_fm produces no PCM data (common in direct sampling mode). Add waiting-state rendering in the scope canvas and hide the generic placeholder/status bar for morse mode. Co-Authored-By: Claude Opus 4.6 --- static/js/modes/morse.js | 14 ++++++++++++++ templates/index.html | 4 ++-- tests/test_morse.py | 42 ++++++++++++++++++++++++++++++++++++++++ utils/morse.py | 22 ++++++++++++++++++++- 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index 6498db4..eed62ef 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -22,6 +22,7 @@ var MorseMode = (function () { var SCOPE_HISTORY_LEN = 300; var scopeThreshold = 0; var scopeToneOn = false; + var scopeWaiting = false; // ---- Initialization ---- @@ -150,6 +151,11 @@ var MorseMode = (function () { if (type === 'scope') { // Update scope data var amps = msg.amplitudes || []; + if (msg.waiting && amps.length === 0 && scopeHistory.length === 0) { + scopeWaiting = true; + } else if (amps.length > 0) { + scopeWaiting = false; + } for (var i = 0; i < amps.length; i++) { scopeHistory.push(amps[i]); if (scopeHistory.length > SCOPE_HISTORY_LEN) { @@ -238,6 +244,7 @@ var MorseMode = (function () { scopeCtx = canvas.getContext('2d'); scopeCtx.scale(dpr, dpr); scopeHistory = []; + scopeWaiting = false; var toneLabel = document.getElementById('morseScopeToneLabel'); var threshLabel = document.getElementById('morseScopeThreshLabel'); @@ -255,6 +262,13 @@ var MorseMode = (function () { if (threshLabel) threshLabel.textContent = scopeThreshold > 0 ? Math.round(scopeThreshold) : '--'; if (scopeHistory.length === 0) { + if (scopeWaiting) { + scopeCtx.fillStyle = '#556677'; + scopeCtx.font = '12px monospace'; + scopeCtx.textAlign = 'center'; + scopeCtx.fillText('Awaiting SDR data\u2026', w / 2, h / 2); + scopeCtx.textAlign = 'start'; + } scopeAnim = requestAnimationFrame(draw); return; } diff --git a/templates/index.html b/templates/index.html index 955e7eb..919f0da 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4256,8 +4256,8 @@ // Hide output console for modes with their own visualizations const outputEl = document.getElementById('output'); const statusBar = document.querySelector('.status-bar'); - if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall') ? 'none' : 'block'; - if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') ? 'none' : 'flex'; + if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'morse') ? 'none' : 'block'; + if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'morse') ? 'none' : 'flex'; // Restore sidebar when leaving Meshtastic mode (user may have collapsed it) if (mode !== 'meshtastic') { diff --git a/tests/test_morse.py b/tests/test_morse.py index da926f9..1eb43cf 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -327,6 +327,48 @@ class TestMorseDecoderThread: t.join(timeout=5) assert not t.is_alive(), "Thread should finish after reading all data" + def test_thread_heartbeat_on_no_data(self): + """When rtl_fm produces no data, thread should emit waiting scope events.""" + import os as _os + stop = threading.Event() + q = queue.Queue(maxsize=100) + + # Create a pipe that never gets written to (simulates rtl_fm with no output) + read_fd, write_fd = _os.pipe() + read_file = _os.fdopen(read_fd, 'rb', 0) + + t = threading.Thread( + target=morse_decoder_thread, + args=(read_file, q, stop), + ) + t.daemon = True + t.start() + + # Wait up to 5 seconds for at least one heartbeat event + events = [] + import time as _time + deadline = _time.monotonic() + 5.0 + while _time.monotonic() < deadline: + try: + ev = q.get(timeout=0.5) + events.append(ev) + if ev.get('waiting'): + break + except queue.Empty: + continue + + stop.set() + _os.close(write_fd) + read_file.close() + t.join(timeout=3) + + waiting_events = [e for e in events if e.get('type') == 'scope' and e.get('waiting')] + assert len(waiting_events) >= 1, f"Expected waiting heartbeat events, got {events}" + ev = waiting_events[0] + assert ev['amplitudes'] == [] + assert ev['threshold'] == 0 + assert ev['tone_on'] is False + def test_thread_produces_events(self): """Thread should push character events to the queue.""" import io diff --git a/utils/morse.py b/utils/morse.py index 543e719..e49697e 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -7,7 +7,9 @@ from __future__ import annotations import contextlib import math +import os import queue +import select import struct import threading import time @@ -284,8 +286,26 @@ def morse_decoder_thread( ) try: + fd = rtl_stdout.fileno() + while not stop_event.is_set(): - data = rtl_stdout.read(CHUNK) + ready, _, _ = select.select([fd], [], [], 2.0) + if not ready: + # No data from SDR — emit diagnostic heartbeat + now = time.monotonic() + if now - last_scope >= SCOPE_INTERVAL: + last_scope = now + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'scope', + 'amplitudes': [], + 'threshold': 0, + 'tone_on': False, + 'waiting': True, + }) + continue + + data = os.read(fd, CHUNK) if not data: break From d9e6b2ef4d4d0e581c21b48c962ac710413ecb9b Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 09:43:28 +0000 Subject: [PATCH 07/52] Forward rtl_fm stderr to Morse frontend diagnostic log rtl_fm prints device info, tuning, and errors to stderr but the morse route only logged these server-side. Now stderr lines are forwarded to the morse queue as info events, displayed in a compact diagnostic log below the scope canvas. After 10s with no audio data, the scope text escalates to prompt the user to check the SDR log. Co-Authored-By: Claude Opus 4.6 --- routes/morse.py | 12 +++++++++- static/js/modes/morse.js | 50 +++++++++++++++++++++++++++++++++++++--- templates/index.html | 7 ++++++ utils/morse.py | 5 ++++ 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index dae772c..a394f01 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -158,12 +158,17 @@ def start_morse() -> Response: morse_active_device = None return jsonify({'status': 'error', 'message': msg}), 500 - # Monitor rtl_fm stderr + # Forward rtl_fm stderr to queue so frontend can display diagnostics def monitor_stderr(): for line in rtl_process.stderr: err_text = line.decode('utf-8', errors='replace').strip() if err_text: logger.debug(f"[rtl_fm/morse] {err_text}") + with contextlib.suppress(queue.Full): + app_module.morse_queue.put_nowait({ + 'type': 'info', + 'text': f'[rtl_fm] {err_text}', + }) stderr_thread = threading.Thread(target=monitor_stderr) stderr_thread.daemon = True @@ -190,6 +195,11 @@ def start_morse() -> Response: app_module.morse_process._decoder_thread = decoder_thread app_module.morse_queue.put({'type': 'status', 'status': 'started'}) + with contextlib.suppress(queue.Full): + app_module.morse_queue.put_nowait({ + 'type': 'info', + 'text': f'[cmd] {full_cmd}', + }) return jsonify({ 'status': 'started', diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index eed62ef..12f6e52 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -23,6 +23,7 @@ var MorseMode = (function () { var scopeThreshold = 0; var scopeToneOn = false; var scopeWaiting = false; + var waitingStart = 0; // timestamp when waiting began // ---- Initialization ---- @@ -152,9 +153,13 @@ var MorseMode = (function () { // Update scope data var amps = msg.amplitudes || []; if (msg.waiting && amps.length === 0 && scopeHistory.length === 0) { - scopeWaiting = true; + if (!scopeWaiting) { + scopeWaiting = true; + waitingStart = Date.now(); + } } else if (amps.length > 0) { scopeWaiting = false; + waitingStart = 0; } for (var i = 0; i < amps.length; i++) { scopeHistory.push(amps[i]); @@ -178,6 +183,9 @@ var MorseMode = (function () { disconnectSSE(); stopScope(); } + } else if (type === 'info') { + appendDiagLine(msg.text); + } else if (type === 'error') { console.error('Morse error:', msg.text); } @@ -263,10 +271,14 @@ var MorseMode = (function () { if (scopeHistory.length === 0) { if (scopeWaiting) { - scopeCtx.fillStyle = '#556677'; + var elapsed = waitingStart ? (Date.now() - waitingStart) / 1000 : 0; + var waitText = elapsed > 10 + ? 'No audio data \u2014 check SDR log below' + : 'Awaiting SDR data\u2026'; + scopeCtx.fillStyle = elapsed > 10 ? '#887744' : '#556677'; scopeCtx.font = '12px monospace'; scopeCtx.textAlign = 'center'; - scopeCtx.fillText('Awaiting SDR data\u2026', w / 2, h / 2); + scopeCtx.fillText(waitText, w / 2, h / 2); scopeCtx.textAlign = 'start'; } scopeAnim = requestAnimationFrame(draw); @@ -371,6 +383,30 @@ var MorseMode = (function () { URL.revokeObjectURL(url); } + // ---- Diagnostic log ---- + + function appendDiagLine(text) { + var log = document.getElementById('morseDiagLog'); + if (!log) return; + log.style.display = 'block'; + var line = document.createElement('div'); + line.textContent = text; + log.appendChild(line); + // Limit to 20 entries + while (log.children.length > 20) { + log.removeChild(log.firstChild); + } + log.scrollTop = log.scrollHeight; + } + + function clearDiagLog() { + var log = document.getElementById('morseDiagLog'); + if (log) { + log.innerHTML = ''; + log.style.display = 'none'; + } + } + // ---- UI ---- function updateUI(running) { @@ -398,6 +434,14 @@ var MorseMode = (function () { var scopeStatus = document.getElementById('morseScopeStatusLabel'); if (scopeStatus) scopeStatus.textContent = running ? 'ACTIVE' : 'IDLE'; if (scopeStatus) scopeStatus.style.color = running ? '#0f0' : '#444'; + + // Diagnostic log: clear on start, hide on stop + if (running) { + clearDiagLog(); + } else { + var diagLog = document.getElementById('morseDiagLog'); + if (diagLog) diagLog.style.display = 'none'; + } } function setFreq(mhz) { diff --git a/templates/index.html b/templates/index.html index 919f0da..b86aa44 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3085,6 +3085,11 @@
+ +
+ +
+ STATE idle + TONE -- Hz + LEVEL -- + THRESH -- + NOISE -- + STOP -- ms +
- 15 WPM + IDLE + -- WPM 700 Hz 0 chars decoded
@@ -3863,6 +3876,11 @@ return { pager: Boolean(isRunning), sensor: Boolean(isSensorRunning), + morse: Boolean( + typeof MorseMode !== 'undefined' + && typeof MorseMode.isActive === 'function' + && MorseMode.isActive() + ), wifi: Boolean( ((typeof WiFiMode !== 'undefined' && typeof WiFiMode.isScanning === 'function' && WiFiMode.isScanning()) || isWifiRunning) ), @@ -3884,6 +3902,12 @@ if (isSensorRunning && typeof stopSensorDecoding === 'function') { Promise.resolve(stopSensorDecoding()).catch(() => { }); } + const morseActive = typeof MorseMode !== 'undefined' + && typeof MorseMode.isActive === 'function' + && MorseMode.isActive(); + if (morseActive && typeof MorseMode.stop === 'function') { + Promise.resolve(MorseMode.stop()).catch(() => { }); + } const wifiScanActive = ( typeof WiFiMode !== 'undefined' @@ -4009,6 +4033,12 @@ if (isSensorRunning) { stopTasks.push(awaitStopAction('sensor', () => stopSensorDecoding(), LOCAL_STOP_TIMEOUT_MS)); } + const morseActive = typeof MorseMode !== 'undefined' + && typeof MorseMode.isActive === 'function' + && MorseMode.isActive(); + if (morseActive && typeof MorseMode.stop === 'function') { + stopTasks.push(awaitStopAction('morse', () => MorseMode.stop(), LOCAL_STOP_TIMEOUT_MS)); + } const wifiScanActive = ( typeof WiFiMode !== 'undefined' && typeof WiFiMode.isScanning === 'function' @@ -4045,6 +4075,9 @@ if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') { SubGhz.destroy(); } + if (typeof MorseMode !== 'undefined' && currentMode === 'morse' && mode !== 'morse' && typeof MorseMode.destroy === 'function') { + MorseMode.destroy(); + } currentMode = mode; document.body.setAttribute('data-mode', mode); diff --git a/templates/partials/modes/morse.html b/templates/partials/modes/morse.html index 07e8f32..84f5042 100644 --- a/templates/partials/modes/morse.html +++ b/templates/partials/modes/morse.html @@ -1,99 +1,166 @@ - -
-
-

CW/Morse Decoder

-

- Decode CW (continuous wave) Morse code from amateur radio HF bands using USB demodulation - and Goertzel tone detection. -

-
- -
-

Frequency

-
- - - Enter frequency in MHz (e.g., 7.030 for 40m CW) -
-
- -
- - - - - - - - -
-
-
- -
-

Settings

-
- - -
-
- - -
-
- -
-

CW Settings

-
- - -
-
- - -
-
- - -
-

- Morse Reference (click to toggle) -

- -
- - -
-
- - Standby - 0 chars -
-
- - -
-

- CW operates on HF bands (1-30 MHz). Requires an HF-capable SDR with direct sampling - or an upconverter, plus an appropriate HF antenna (dipole, end-fed, or random wire). -

-
- - - -
+ +
+
+

CW/Morse Decoder

+

+ Decode CW (continuous wave) Morse with USB demod + Goertzel tone detection. + Start with 700 Hz tone and 200 Hz bandwidth. +

+
+ +
+

Frequency

+
+ + + Enter CW center frequency in MHz (e.g., 7.030 for 40m). +
+
+ +
+ + + + + + + + +
+
+
+ +
+

Device

+
+ + +
+
+ + +
+
+ +
+

CW Detector

+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Threshold + WPM

+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+
+ +
+

Output

+
+ + +
+
+ +
+
+ +
+

Decode WAV File

+
+ + +
+ Runs the same CW decoder pipeline against uploaded WAV audio. +
+ +
+

+ Morse Reference (click to toggle) +

+ +
+ +
+
+ + Standby + 0 chars +
+
+ +
+

+ CW on HF (1-30 MHz) requires an HF-capable SDR path (direct sampling or upconverter) + and an appropriate antenna. +

+
+ + + +
diff --git a/tests/test_morse.py b/tests/test_morse.py index 1eb43cf..1bd7596 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -1,467 +1,348 @@ -"""Tests for Morse code decoder (utils/morse.py) and routes.""" - -from __future__ import annotations - -import math -import queue -import struct -import threading - -import pytest - -from utils.morse import ( - CHAR_TO_MORSE, - MORSE_TABLE, - GoertzelFilter, - MorseDecoder, - morse_decoder_thread, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _login_session(client) -> None: - """Mark the Flask test session as authenticated.""" - with client.session_transaction() as sess: - sess['logged_in'] = True - sess['username'] = 'test' - sess['role'] = 'admin' - - -def generate_tone(freq: float, duration: float, sample_rate: int = 8000, amplitude: float = 0.8) -> bytes: - """Generate a pure sine wave as 16-bit LE PCM bytes.""" - n_samples = int(sample_rate * duration) - samples = [] - for i in range(n_samples): - t = i / sample_rate - val = int(amplitude * 32767 * math.sin(2 * math.pi * freq * t)) - samples.append(max(-32768, min(32767, val))) - return struct.pack(f'<{len(samples)}h', *samples) - - -def generate_silence(duration: float, sample_rate: int = 8000) -> bytes: - """Generate silence as 16-bit LE PCM bytes.""" - n_samples = int(sample_rate * duration) - return b'\x00\x00' * n_samples - - -def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes: - """Generate PCM audio for a Morse-encoded string.""" - dit_dur = 1.2 / wpm - dah_dur = 3 * dit_dur - element_gap = dit_dur - char_gap = 3 * dit_dur - word_gap = 7 * dit_dur - - audio = b'' - words = text.upper().split() - for wi, word in enumerate(words): - for ci, char in enumerate(word): - morse = CHAR_TO_MORSE.get(char) - if morse is None: - continue - for ei, element in enumerate(morse): - if element == '.': - audio += generate_tone(tone_freq, dit_dur, sample_rate) - elif element == '-': - audio += generate_tone(tone_freq, dah_dur, sample_rate) - if ei < len(morse) - 1: - audio += generate_silence(element_gap, sample_rate) - if ci < len(word) - 1: - audio += generate_silence(char_gap, sample_rate) - if wi < len(words) - 1: - audio += generate_silence(word_gap, sample_rate) - - # Add some leading/trailing silence for threshold settling - silence = generate_silence(0.3, sample_rate) - return silence + audio + silence - - -# --------------------------------------------------------------------------- -# MORSE_TABLE tests -# --------------------------------------------------------------------------- - -class TestMorseTable: - def test_all_26_letters_present(self): - chars = set(MORSE_TABLE.values()) - for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ': - assert letter in chars, f"Missing letter: {letter}" - - def test_all_10_digits_present(self): - chars = set(MORSE_TABLE.values()) - for digit in '0123456789': - assert digit in chars, f"Missing digit: {digit}" - - def test_reverse_lookup_consistent(self): - for morse, char in MORSE_TABLE.items(): - if char in CHAR_TO_MORSE: - assert CHAR_TO_MORSE[char] == morse - - def test_no_duplicate_morse_codes(self): - """Each morse pattern should map to exactly one character.""" - assert len(MORSE_TABLE) == len(set(MORSE_TABLE.keys())) - - -# --------------------------------------------------------------------------- -# GoertzelFilter tests -# --------------------------------------------------------------------------- - -class TestGoertzelFilter: - def test_detects_target_frequency(self): - gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160) - # Generate 700 Hz tone - samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)] - mag = gf.magnitude(samples) - assert mag > 10.0, f"Expected high magnitude for target freq, got {mag}" - - def test_rejects_off_frequency(self): - gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160) - # Generate 1500 Hz tone (well off target) - samples = [0.8 * math.sin(2 * math.pi * 1500 * i / 8000) for i in range(160)] - mag_off = gf.magnitude(samples) - - # Compare with on-target - samples_on = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)] - mag_on = gf.magnitude(samples_on) - - assert mag_on > mag_off * 3, "Target freq should be significantly stronger than off-freq" - - def test_silence_returns_near_zero(self): - gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160) - samples = [0.0] * 160 - mag = gf.magnitude(samples) - assert mag < 0.01, f"Expected near-zero for silence, got {mag}" - - def test_different_block_sizes(self): - for block_size in [80, 160, 320]: - gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=block_size) - samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(block_size)] - mag = gf.magnitude(samples) - assert mag > 5.0, f"Should detect tone with block_size={block_size}" - - -# --------------------------------------------------------------------------- -# MorseDecoder tests -# --------------------------------------------------------------------------- - -class TestMorseDecoder: - def _make_decoder(self, wpm=15): - """Create decoder with warm-up phase completed for testing. - - Feeds silence then tone then silence to get past the warm-up - blocks and establish a valid noise floor / signal peak. - """ - decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=wpm) - # Feed enough audio to get past warm-up (50 blocks = 1 sec) - # Mix silence and tone so warm-up sees both noise and signal - warmup_audio = generate_silence(0.6) + generate_tone(700.0, 0.4) + generate_silence(0.5) - decoder.process_block(warmup_audio) - # Reset state machine after warm-up so tests start clean - decoder._tone_on = False - decoder._current_symbol = '' - decoder._tone_blocks = 0 - decoder._silence_blocks = 0 - return decoder - - def test_dit_detection(self): - """A single dit should produce a '.' in the symbol buffer.""" - decoder = self._make_decoder() - dit_dur = 1.2 / 15 - - # Send a tone burst (dit) - tone = generate_tone(700.0, dit_dur) - decoder.process_block(tone) - - # Send silence to trigger end of tone - silence = generate_silence(dit_dur * 2) - decoder.process_block(silence) - - # Symbol buffer should have a dot - assert '.' in decoder._current_symbol, f"Expected '.' in symbol, got '{decoder._current_symbol}'" - - def test_dah_detection(self): - """A longer tone should produce a '-' in the symbol buffer.""" - decoder = self._make_decoder() - dah_dur = 3 * 1.2 / 15 - - tone = generate_tone(700.0, dah_dur) - decoder.process_block(tone) - - silence = generate_silence(dah_dur) - decoder.process_block(silence) - - assert '-' in decoder._current_symbol, f"Expected '-' in symbol, got '{decoder._current_symbol}'" - - def test_decode_letter_e(self): - """E is a single dit - the simplest character.""" - decoder = self._make_decoder() - audio = generate_morse_audio('E', wpm=15) - events = decoder.process_block(audio) - events.extend(decoder.flush()) - - chars = [e for e in events if e['type'] == 'morse_char'] - decoded = ''.join(e['char'] for e in chars) - assert 'E' in decoded, f"Expected 'E' in decoded text, got '{decoded}'" - - def test_decode_letter_t(self): - """T is a single dah.""" - decoder = self._make_decoder() - audio = generate_morse_audio('T', wpm=15) - events = decoder.process_block(audio) - events.extend(decoder.flush()) - - chars = [e for e in events if e['type'] == 'morse_char'] - decoded = ''.join(e['char'] for e in chars) - assert 'T' in decoded, f"Expected 'T' in decoded text, got '{decoded}'" - - def test_word_space_detection(self): - """A long silence between words should produce decoded chars with a space.""" - decoder = self._make_decoder() - dit_dur = 1.2 / 15 - # E = dit - audio = generate_tone(700.0, dit_dur) + generate_silence(7 * dit_dur * 1.5) - # T = dah - audio += generate_tone(700.0, 3 * dit_dur) + generate_silence(3 * dit_dur) - events = decoder.process_block(audio) - events.extend(decoder.flush()) - - spaces = [e for e in events if e['type'] == 'morse_space'] - assert len(spaces) >= 1, "Expected at least one word space" - - def test_scope_events_generated(self): - """Decoder should produce scope events for visualization.""" - audio = generate_morse_audio('SOS', wpm=15) - decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) - - events = decoder.process_block(audio) - - scope_events = [e for e in events if e['type'] == 'scope'] - assert len(scope_events) > 0, "Expected scope events" - # Check scope event structure - se = scope_events[0] - assert 'amplitudes' in se - assert 'threshold' in se - assert 'tone_on' in se - - def test_adaptive_threshold_adjusts(self): - """After processing enough audio to complete warm-up, threshold should be non-zero.""" - decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) - - # Feed enough audio to complete the 50-block warm-up (~1 second) - audio = generate_silence(0.6) + generate_tone(700.0, 0.4) + generate_silence(0.3) - decoder.process_block(audio) - - assert decoder._threshold > 0, "Threshold should adapt above zero after warm-up" - - def test_flush_emits_pending_char(self): - """flush() should emit any accumulated but not-yet-decoded symbol.""" - decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) - decoder._current_symbol = '.' # Manually set pending dit - events = decoder.flush() - assert len(events) == 1 - assert events[0]['type'] == 'morse_char' - assert events[0]['char'] == 'E' - - def test_flush_empty_returns_nothing(self): - decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) - events = decoder.flush() - assert events == [] - - def test_weak_signal_detection(self): - """CW tone at only 3x noise magnitude should still decode characters.""" - decoder = self._make_decoder(wpm=10) - # Generate weak CW audio (low amplitude simulating weak HF signal) - audio = generate_morse_audio('SOS', wpm=10, sample_rate=8000) - # Scale to low amplitude (simulating weak signal) - n_samples = len(audio) // 2 - samples = struct.unpack(f'<{n_samples}h', audio) - # Reduce to ~10% amplitude - weak_samples = [max(-32768, min(32767, int(s * 0.1))) for s in samples] - weak_audio = struct.pack(f'<{len(weak_samples)}h', *weak_samples) - - events = decoder.process_block(weak_audio) - events.extend(decoder.flush()) - - chars = [e for e in events if e['type'] == 'morse_char'] - decoded = ''.join(e['char'] for e in chars) - # Should decode at least some characters from the weak signal - assert len(chars) >= 1, f"Expected decoded chars from weak signal, got '{decoded}'" - - def test_agc_boosts_quiet_signal(self): - """Very quiet PCM (amplitude 0.01) should still produce usable Goertzel magnitudes.""" - decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) - # Generate very quiet tone - quiet_tone = generate_tone(700.0, 1.5, amplitude=0.01) # 1.5s of very quiet CW - events = decoder.process_block(quiet_tone) - - scope_events = [e for e in events if e['type'] == 'scope'] - assert len(scope_events) > 0, "Expected scope events from quiet signal" - # AGC should have boosted the signal — amplitudes should be visible - max_amp = max(max(se['amplitudes']) for se in scope_events) - assert max_amp > 1.0, f"AGC should boost quiet signal to usable magnitude, got {max_amp}" - - -# --------------------------------------------------------------------------- -# morse_decoder_thread tests -# --------------------------------------------------------------------------- - -class TestMorseDecoderThread: - def test_thread_stops_on_event(self): - """Thread should exit when stop_event is set.""" - import io - # Create a fake stdout that blocks until stop - stop = threading.Event() - q = queue.Queue(maxsize=100) - - # Feed some audio then close - audio = generate_morse_audio('E', wpm=15) - fake_stdout = io.BytesIO(audio) - - t = threading.Thread( - target=morse_decoder_thread, - args=(fake_stdout, q, stop), - ) - t.daemon = True - t.start() - t.join(timeout=5) - assert not t.is_alive(), "Thread should finish after reading all data" - - def test_thread_heartbeat_on_no_data(self): - """When rtl_fm produces no data, thread should emit waiting scope events.""" - import os as _os - stop = threading.Event() - q = queue.Queue(maxsize=100) - - # Create a pipe that never gets written to (simulates rtl_fm with no output) - read_fd, write_fd = _os.pipe() - read_file = _os.fdopen(read_fd, 'rb', 0) - - t = threading.Thread( - target=morse_decoder_thread, - args=(read_file, q, stop), - ) - t.daemon = True - t.start() - - # Wait up to 5 seconds for at least one heartbeat event - events = [] - import time as _time - deadline = _time.monotonic() + 5.0 - while _time.monotonic() < deadline: - try: - ev = q.get(timeout=0.5) - events.append(ev) - if ev.get('waiting'): - break - except queue.Empty: - continue - - stop.set() - _os.close(write_fd) - read_file.close() - t.join(timeout=3) - - waiting_events = [e for e in events if e.get('type') == 'scope' and e.get('waiting')] - assert len(waiting_events) >= 1, f"Expected waiting heartbeat events, got {events}" - ev = waiting_events[0] - assert ev['amplitudes'] == [] - assert ev['threshold'] == 0 - assert ev['tone_on'] is False - - def test_thread_produces_events(self): - """Thread should push character events to the queue.""" - import io - from unittest.mock import patch - stop = threading.Event() - q = queue.Queue(maxsize=1000) - - # Generate audio with pre-warmed decoder in mind - # The thread creates a fresh decoder, so generate lots of audio - audio = generate_silence(0.5) + generate_morse_audio('SOS', wpm=10) + generate_silence(1.0) - fake_stdout = io.BytesIO(audio) - - # Patch SCOPE_INTERVAL to 0 so scope events aren't throttled in fast reads - with patch('utils.morse.time') as mock_time: - # Make monotonic() always return increasing values - counter = [0.0] - def fake_monotonic(): - counter[0] += 0.15 # each call advances 150ms - return counter[0] - mock_time.monotonic = fake_monotonic - - t = threading.Thread( - target=morse_decoder_thread, - args=(fake_stdout, q, stop), - ) - t.daemon = True - t.start() - t.join(timeout=10) - - events = [] - while not q.empty(): - events.append(q.get_nowait()) - - # Should have at least some events (scope or char) - assert len(events) > 0, "Expected events from thread" - - -# --------------------------------------------------------------------------- -# Route tests -# --------------------------------------------------------------------------- - -class TestMorseRoutes: - def test_start_missing_required_fields(self, client): - """Start should succeed with defaults.""" - _login_session(client) - with pytest.MonkeyPatch.context() as m: - m.setattr('app.morse_process', None) - # Should fail because rtl_fm won't be found in test env - resp = client.post('/morse/start', json={'frequency': '14.060'}) - assert resp.status_code in (200, 400, 409, 500) - - def test_stop_when_not_running(self, client): - """Stop when nothing is running should return not_running.""" - _login_session(client) - with pytest.MonkeyPatch.context() as m: - m.setattr('app.morse_process', None) - resp = client.post('/morse/stop') - data = resp.get_json() - assert data['status'] == 'not_running' - - def test_status_when_not_running(self, client): - """Status should report not running.""" - _login_session(client) - with pytest.MonkeyPatch.context() as m: - m.setattr('app.morse_process', None) - resp = client.get('/morse/status') - data = resp.get_json() - assert data['running'] is False - - def test_invalid_tone_freq(self, client): - """Tone frequency outside range should be rejected.""" - _login_session(client) - with pytest.MonkeyPatch.context() as m: - m.setattr('app.morse_process', None) - resp = client.post('/morse/start', json={ - 'frequency': '14.060', - 'tone_freq': '50', # too low - }) - assert resp.status_code == 400 - - def test_invalid_wpm(self, client): - """WPM outside range should be rejected.""" - _login_session(client) - with pytest.MonkeyPatch.context() as m: - m.setattr('app.morse_process', None) - resp = client.post('/morse/start', json={ - 'frequency': '14.060', - 'wpm': '100', # too high - }) - assert resp.status_code == 400 - - def test_stream_endpoint_exists(self, client): - """Stream endpoint should return SSE content type.""" - _login_session(client) - resp = client.get('/morse/stream') - assert resp.content_type.startswith('text/event-stream') +"""Tests for Morse code decoder pipeline and lifecycle routes.""" + +from __future__ import annotations + +import io +import math +import os +import queue +import struct +import threading +import time +import wave +from collections import Counter + +import pytest + +import app as app_module +import routes.morse as morse_routes +from utils.morse import ( + CHAR_TO_MORSE, + MORSE_TABLE, + GoertzelFilter, + MorseDecoder, + decode_morse_wav_file, + morse_decoder_thread, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _login_session(client) -> None: + """Mark the Flask test session as authenticated.""" + with client.session_transaction() as sess: + sess['logged_in'] = True + sess['username'] = 'test' + sess['role'] = 'admin' + + +def generate_tone(freq: float, duration: float, sample_rate: int = 8000, amplitude: float = 0.8) -> bytes: + """Generate a pure sine wave as 16-bit LE PCM bytes.""" + n_samples = int(sample_rate * duration) + samples = [] + for i in range(n_samples): + t = i / sample_rate + val = int(amplitude * 32767 * math.sin(2 * math.pi * freq * t)) + samples.append(max(-32768, min(32767, val))) + return struct.pack(f'<{len(samples)}h', *samples) + + +def generate_silence(duration: float, sample_rate: int = 8000) -> bytes: + """Generate silence as 16-bit LE PCM bytes.""" + n_samples = int(sample_rate * duration) + return b'\x00\x00' * n_samples + + +def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes: + """Generate synthetic CW PCM for the given text.""" + dit_dur = 1.2 / wpm + dah_dur = 3 * dit_dur + element_gap = dit_dur + char_gap = 3 * dit_dur + word_gap = 7 * dit_dur + + audio = b'' + words = text.upper().split() + for wi, word in enumerate(words): + for ci, char in enumerate(word): + morse = CHAR_TO_MORSE.get(char) + if morse is None: + continue + + for ei, element in enumerate(morse): + if element == '.': + audio += generate_tone(tone_freq, dit_dur, sample_rate) + elif element == '-': + audio += generate_tone(tone_freq, dah_dur, sample_rate) + + if ei < len(morse) - 1: + audio += generate_silence(element_gap, sample_rate) + + if ci < len(word) - 1: + audio += generate_silence(char_gap, sample_rate) + + if wi < len(words) - 1: + audio += generate_silence(word_gap, sample_rate) + + # Leading/trailing silence for threshold settling. + return generate_silence(0.3, sample_rate) + audio + generate_silence(0.3, sample_rate) + + +def write_wav(path, pcm_bytes: bytes, sample_rate: int = 8000) -> None: + """Write mono 16-bit PCM bytes to a WAV file.""" + with wave.open(str(path), 'wb') as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(sample_rate) + wf.writeframes(pcm_bytes) + + +def decode_text_from_events(events) -> str: + out = [] + for ev in events: + if ev.get('type') == 'morse_char': + out.append(str(ev.get('char', ''))) + elif ev.get('type') == 'morse_space': + out.append(' ') + return ''.join(out) + + +# --------------------------------------------------------------------------- +# Unit tests +# --------------------------------------------------------------------------- + +class TestMorseTable: + def test_morse_table_contains_letters_and_digits(self): + chars = set(MORSE_TABLE.values()) + for ch in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789': + assert ch in chars + + def test_round_trip_morse_lookup(self): + for morse, char in MORSE_TABLE.items(): + if char in CHAR_TO_MORSE: + assert CHAR_TO_MORSE[char] == morse + + +class TestToneDetector: + def test_goertzel_prefers_target_frequency(self): + gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160) + on_tone = [0.8 * math.sin(2 * math.pi * 700.0 * i / 8000.0) for i in range(160)] + off_tone = [0.8 * math.sin(2 * math.pi * 1500.0 * i / 8000.0) for i in range(160)] + assert gf.magnitude(on_tone) > gf.magnitude(off_tone) * 3.0 + + +class TestTimingAndWpmEstimator: + def test_timing_classifier_distinguishes_dit_and_dah(self): + decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) + dit = 1.2 / 15.0 + dah = dit * 3.0 + + audio = ( + generate_silence(0.35) + + generate_tone(700.0, dit) + + generate_silence(dit * 1.5) + + generate_tone(700.0, dah) + + generate_silence(0.35) + ) + + events = decoder.process_block(audio) + events.extend(decoder.flush()) + elements = [e['element'] for e in events if e.get('type') == 'morse_element'] + + assert '.' in elements + assert '-' in elements + + def test_wpm_estimator_sanity(self): + target_wpm = 18 + audio = generate_morse_audio('PARIS PARIS PARIS', wpm=target_wpm) + decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=12, wpm_mode='auto') + + events = decoder.process_block(audio) + events.extend(decoder.flush()) + + metrics = decoder.get_metrics() + assert metrics['wpm'] >= 12.0 + assert metrics['wpm'] <= 24.0 + + +# --------------------------------------------------------------------------- +# Decoder thread tests +# --------------------------------------------------------------------------- + +class TestMorseDecoderThread: + def test_thread_emits_waiting_heartbeat_on_no_data(self): + stop_event = threading.Event() + output_queue = queue.Queue(maxsize=64) + + read_fd, write_fd = os.pipe() + read_file = os.fdopen(read_fd, 'rb', 0) + + worker = threading.Thread( + target=morse_decoder_thread, + args=(read_file, output_queue, stop_event), + daemon=True, + ) + worker.start() + + got_waiting = False + deadline = time.monotonic() + 3.5 + while time.monotonic() < deadline: + try: + msg = output_queue.get(timeout=0.3) + except queue.Empty: + continue + if msg.get('type') == 'scope' and msg.get('waiting'): + got_waiting = True + break + + stop_event.set() + os.close(write_fd) + read_file.close() + worker.join(timeout=2.0) + + assert got_waiting is True + assert not worker.is_alive() + + def test_thread_produces_character_events(self): + stop_event = threading.Event() + output_queue = queue.Queue(maxsize=512) + audio = generate_morse_audio('SOS', wpm=15) + + worker = threading.Thread( + target=morse_decoder_thread, + args=(io.BytesIO(audio), output_queue, stop_event), + daemon=True, + ) + worker.start() + worker.join(timeout=4.0) + + events = [] + while not output_queue.empty(): + events.append(output_queue.get_nowait()) + + chars = [e for e in events if e.get('type') == 'morse_char'] + assert len(chars) >= 1 + + +# --------------------------------------------------------------------------- +# Route lifecycle regression +# --------------------------------------------------------------------------- + +class TestMorseLifecycleRoutes: + def _reset_route_state(self): + with app_module.morse_lock: + app_module.morse_process = None + while not app_module.morse_queue.empty(): + try: + app_module.morse_queue.get_nowait() + except queue.Empty: + break + + morse_routes.morse_active_device = None + morse_routes.morse_decoder_worker = None + morse_routes.morse_stderr_worker = None + morse_routes.morse_stop_event = None + morse_routes.morse_control_queue = None + morse_routes.morse_runtime_config = {} + morse_routes.morse_last_error = '' + morse_routes.morse_state = morse_routes.MORSE_IDLE + morse_routes.morse_state_message = 'Idle' + + def test_start_stop_reaches_idle_and_releases_resources(self, client, monkeypatch): + _login_session(client) + self._reset_route_state() + + released_devices = [] + + monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None) + monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx)) + + class DummyDevice: + sdr_type = morse_routes.SDRType.RTL_SDR + + class DummyBuilder: + def build_fm_demod_command(self, **kwargs): + return ['rtl_fm', '-f', '14060000'] + + monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice())) + monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder())) + monkeypatch.setattr(morse_routes.time, 'sleep', lambda _secs: None) + + pcm = generate_morse_audio('E', wpm=15) + + class FakeProc: + def __init__(self): + self.stdout = io.BytesIO(pcm) + self.stderr = io.BytesIO(b'') + self.returncode = None + + def poll(self): + return self.returncode + + monkeypatch.setattr(morse_routes.subprocess, 'Popen', lambda *args, **kwargs: FakeProc()) + monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None) + monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None) + monkeypatch.setattr( + morse_routes, + 'safe_terminate', + lambda proc, timeout=0.0: setattr(proc, 'returncode', 0), + ) + + start_resp = client.post('/morse/start', json={ + 'frequency': '14.060', + 'gain': '20', + 'ppm': '0', + 'device': '0', + 'tone_freq': '700', + 'wpm': '15', + }) + assert start_resp.status_code == 200 + assert start_resp.get_json()['status'] == 'started' + + status_resp = client.get('/morse/status') + assert status_resp.status_code == 200 + assert status_resp.get_json()['state'] in {'running', 'starting', 'stopping', 'idle'} + + stop_resp = client.post('/morse/stop') + assert stop_resp.status_code == 200 + stop_data = stop_resp.get_json() + assert stop_data['status'] == 'stopped' + assert stop_data['state'] == 'idle' + assert stop_data['alive'] == [] + + final_status = client.get('/morse/status').get_json() + assert final_status['running'] is False + assert final_status['state'] == 'idle' + assert 0 in released_devices + + +# --------------------------------------------------------------------------- +# Integration: synthetic CW -> WAV decode +# --------------------------------------------------------------------------- + +class TestMorseIntegration: + def test_decode_morse_wav_contains_expected_phrase(self, tmp_path): + wav_path = tmp_path / 'cq_test_123.wav' + pcm = generate_morse_audio('CQ TEST 123', wpm=15, tone_freq=700.0) + write_wav(wav_path, pcm, sample_rate=8000) + + result = decode_morse_wav_file( + wav_path, + sample_rate=8000, + tone_freq=700.0, + wpm=15, + bandwidth_hz=200, + auto_tone_track=True, + threshold_mode='auto', + wpm_mode='auto', + min_signal_gate=0.0, + ) + + decoded = ' '.join(str(result.get('text', '')).split()) + assert 'CQ TEST 123' in decoded + + events = result.get('events', []) + event_counts = Counter(e.get('type') for e in events) + assert event_counts['morse_char'] >= len('CQTEST123') diff --git a/utils/morse.py b/utils/morse.py index 509a73a..97c59cf 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -1,372 +1,830 @@ -"""Morse code (CW) decoder using Goertzel tone detection. - -Signal chain: rtl_fm -M usb → raw PCM → Goertzel filter → timing state machine → characters. -""" - -from __future__ import annotations - -import contextlib -import math -import os -import queue -import struct -import threading -import time -from datetime import datetime -from typing import Any - -# International Morse Code table -MORSE_TABLE: dict[str, str] = { - '.-': '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', - '.-.-.-': '.', '--..--': ',', '..--..': '?', '.----.': "'", - '-.-.--': '!', '-..-.': '/', '-.--.': '(', '-.--.-': ')', - '.-...': '&', '---...': ':', '-.-.-.': ';', '-...-': '=', - '.-.-.': '+', '-....-': '-', '..--.-': '_', '.-..-.': '"', - '...-..-': '$', '.--.-.': '@', - # Prosigns (unique codes only; -...- and -.--.- already mapped above) - '-.-.-': '', '.-.-': '', '...-.-': '', -} - -# Reverse lookup: character → morse notation -CHAR_TO_MORSE: dict[str, str] = {v: k for k, v in MORSE_TABLE.items()} - - -class GoertzelFilter: - """Single-frequency tone detector using the Goertzel algorithm. - - O(N) per block, much cheaper than FFT for detecting one frequency. - """ - - def __init__(self, target_freq: float, sample_rate: int, block_size: int): - self.target_freq = target_freq - self.sample_rate = sample_rate - self.block_size = block_size - # Precompute coefficient - k = round(target_freq * block_size / sample_rate) - omega = 2.0 * math.pi * k / block_size - self.coeff = 2.0 * math.cos(omega) - - def magnitude(self, samples: list[float] | tuple[float, ...]) -> float: - """Compute magnitude of the target frequency in the sample block.""" - s0 = 0.0 - s1 = 0.0 - s2 = 0.0 - coeff = self.coeff - for sample in samples: - s0 = sample + coeff * s1 - s2 - s2 = s1 - s1 = s0 - return math.sqrt(s1 * s1 + s2 * s2 - coeff * s1 * s2) - - -class MorseDecoder: - """Real-time Morse decoder with adaptive threshold. - - Processes blocks of PCM audio and emits decoded characters. - Timing based on PARIS standard: dit = 1.2/WPM seconds. - """ - - def __init__( - self, - sample_rate: int = 8000, - tone_freq: float = 700.0, - wpm: int = 15, - ): - self.sample_rate = sample_rate - self.tone_freq = tone_freq - self.wpm = wpm - - # Goertzel filter: ~50 blocks/sec at 8kHz - self._block_size = sample_rate // 50 - self._filter = GoertzelFilter(tone_freq, sample_rate, self._block_size) - self._block_duration = self._block_size / sample_rate # seconds per block - - # Timing thresholds (in blocks, converted from seconds) - dit_sec = 1.2 / wpm - self._dah_threshold = 2.0 * dit_sec / self._block_duration # blocks - self._dit_min = 0.3 * dit_sec / self._block_duration # min blocks for dit - self._char_gap = 3.0 * dit_sec / self._block_duration # blocks - self._word_gap = 7.0 * dit_sec / self._block_duration # blocks - - # AGC (automatic gain control) for direct sampling / weak signals - self._agc_target = 0.3 # target RMS amplitude (0-1 range) - self._agc_gain = 1.0 # current AGC multiplier - self._agc_alpha = 0.05 # EMA smoothing for gain changes - - # Warm-up phase constants - self._WARMUP_BLOCKS = 50 # ~1 second at 50 blocks/sec - self._SETTLE_BLOCKS = 200 # blocks for fast→slow EMA transition - self._mag_min = float('inf') - self._mag_max = 0.0 - - # Adaptive threshold via EMA - self._noise_floor = 0.0 - self._signal_peak = 0.0 - self._threshold = 0.0 - - # State machine (counts in blocks, not wall-clock time) - self._tone_on = False - self._tone_blocks = 0 # blocks since tone started - self._silence_blocks = 0 # blocks since silence started - self._current_symbol = '' # accumulates dits/dahs for current char - self._pending_buffer: list[float] = [] - self._blocks_processed = 0 # total blocks for warm-up tracking - - def process_block(self, pcm_bytes: bytes) -> list[dict[str, Any]]: - """Process a chunk of 16-bit LE PCM and return decoded events. - - Returns list of event dicts with keys: - type: 'scope' | 'morse_char' | 'morse_space' - + type-specific fields - """ - events: list[dict[str, Any]] = [] - - # Unpack PCM samples - n_samples = len(pcm_bytes) // 2 - if n_samples == 0: - return events - - samples = struct.unpack(f'<{n_samples}h', pcm_bytes[:n_samples * 2]) - - # Feed samples into pending buffer and process in blocks - self._pending_buffer.extend(samples) - - amplitudes: list[float] = [] - - while len(self._pending_buffer) >= self._block_size: - block = self._pending_buffer[:self._block_size] - self._pending_buffer = self._pending_buffer[self._block_size:] - - # Normalize to [-1, 1] - normalized = [s / 32768.0 for s in block] - - # AGC: boost quiet signals (e.g. direct sampling mode) - rms = math.sqrt(sum(s * s for s in normalized) / len(normalized)) - if rms > 1e-6: - desired_gain = self._agc_target / rms - self._agc_gain += self._agc_alpha * (desired_gain - self._agc_gain) - self._agc_gain = min(self._agc_gain, 500.0) # cap to prevent runaway - normalized = [s * self._agc_gain for s in normalized] - - mag = self._filter.magnitude(normalized) - amplitudes.append(mag) - - self._blocks_processed += 1 - - # Warm-up phase: collect statistics, suppress detection - if self._blocks_processed <= self._WARMUP_BLOCKS: - self._mag_min = min(self._mag_min, mag) - self._mag_max = max(self._mag_max, mag) - if self._blocks_processed == self._WARMUP_BLOCKS: - # Seed thresholds from observed range - self._noise_floor = self._mag_min - self._signal_peak = max(self._mag_max, self._mag_min * 2) - self._threshold = self._noise_floor + 0.3 * ( - self._signal_peak - self._noise_floor - ) - tone_detected = False - else: - # Adaptive EMA: fast initially, slow in steady state - alpha = 0.3 if self._blocks_processed < self._WARMUP_BLOCKS + self._SETTLE_BLOCKS else 0.05 - - if mag < self._threshold: - self._noise_floor += alpha * (mag - self._noise_floor) - else: - self._signal_peak += alpha * (mag - self._signal_peak) - - # Threshold at 30% between noise and signal (sensitive to weak CW) - self._threshold = self._noise_floor + 0.3 * ( - self._signal_peak - self._noise_floor - ) - - tone_detected = mag > self._threshold and self._threshold > 0 - - if tone_detected and not self._tone_on: - # Tone just started - check silence duration for gaps - self._tone_on = True - silence_count = self._silence_blocks - self._tone_blocks = 0 - - if self._current_symbol and silence_count >= self._char_gap: - # Character gap - decode accumulated symbol - char = MORSE_TABLE.get(self._current_symbol) - if char: - events.append({ - 'type': 'morse_char', - 'char': char, - 'morse': self._current_symbol, - 'timestamp': datetime.now().strftime('%H:%M:%S'), - }) - - if silence_count >= self._word_gap: - events.append({ - 'type': 'morse_space', - 'timestamp': datetime.now().strftime('%H:%M:%S'), - }) - - self._current_symbol = '' - - elif not tone_detected and self._tone_on: - # Tone just ended - classify as dit or dah - self._tone_on = False - tone_count = self._tone_blocks - self._silence_blocks = 0 - - if tone_count >= self._dah_threshold: - self._current_symbol += '-' - elif tone_count >= self._dit_min: - self._current_symbol += '.' - - elif tone_detected and self._tone_on: - self._tone_blocks += 1 - - elif not tone_detected and not self._tone_on: - self._silence_blocks += 1 - - # Emit scope data for visualization (~10 Hz is handled by caller) - if amplitudes: - events.append({ - 'type': 'scope', - 'amplitudes': amplitudes, - 'threshold': self._threshold, - 'tone_on': self._tone_on, - }) - - return events - - def flush(self) -> list[dict[str, Any]]: - """Flush any pending symbol at end of stream.""" - events: list[dict[str, Any]] = [] - if self._current_symbol: - char = MORSE_TABLE.get(self._current_symbol) - if char: - events.append({ - 'type': 'morse_char', - 'char': char, - 'morse': self._current_symbol, - 'timestamp': datetime.now().strftime('%H:%M:%S'), - }) - self._current_symbol = '' - return events - - -def _stdout_reader(stdout, data_queue: queue.Queue, stop_event: threading.Event) -> None: - """Blocking reader — pushes raw PCM chunks to queue, None on EOF. - - Uses os.read() on the raw fd when available to bypass BufferedReader, - which on Python 3.14 may block trying to fill its entire buffer before - returning. Falls back to .read() for objects without fileno() (tests). - """ - try: - fd = stdout.fileno() - except Exception: - fd = None - try: - while not stop_event.is_set(): - if fd is not None: - data = os.read(fd, 4096) - else: - data = stdout.read(4096) - if not data: - break - data_queue.put(data) - except Exception: - pass - finally: - data_queue.put(None) # sentinel: pipe closed / EOF - - -def morse_decoder_thread( - rtl_stdout, - output_queue: queue.Queue, - stop_event: threading.Event, - sample_rate: int = 8000, - tone_freq: float = 700.0, - wpm: int = 15, -) -> None: - """Thread function: reads PCM from rtl_fm, decodes Morse, pushes to queue. - - Reads raw 16-bit LE PCM from *rtl_stdout* and feeds it through the - MorseDecoder, pushing scope and character events onto *output_queue*. - """ - import logging - logger = logging.getLogger('intercept.morse') - - CHUNK = 4096 # bytes per read (2048 samples at 16-bit mono) - SCOPE_INTERVAL = 0.1 # scope updates at ~10 Hz - last_scope = time.monotonic() - waiting_since: float | None = None - - decoder = MorseDecoder( - sample_rate=sample_rate, - tone_freq=tone_freq, - wpm=wpm, - ) - - try: - pcm_queue: queue.Queue = queue.Queue(maxsize=50) - reader = threading.Thread( - target=_stdout_reader, - args=(rtl_stdout, pcm_queue, stop_event), - ) - reader.daemon = True - reader.start() - - while not stop_event.is_set(): - try: - data = pcm_queue.get(timeout=2.0) - except queue.Empty: - # No data from SDR — emit diagnostic heartbeat - now = time.monotonic() - if waiting_since is None: - waiting_since = now - if now - last_scope >= SCOPE_INTERVAL: - last_scope = now - with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'scope', - 'amplitudes': [], - 'threshold': 0, - 'tone_on': False, - 'waiting': True, - 'waiting_seconds': round(now - waiting_since, 1), - }) - continue - - if data is None: # EOF sentinel - break - waiting_since = None - - events = decoder.process_block(data) - - for event in events: - if event['type'] == 'scope': - # Throttle scope events to ~10 Hz - now = time.monotonic() - if now - last_scope >= SCOPE_INTERVAL: - last_scope = now - with contextlib.suppress(queue.Full): - output_queue.put_nowait(event) - else: - # Character and space events always go through - with contextlib.suppress(queue.Full): - output_queue.put_nowait(event) - - except Exception as e: - logger.debug(f"Morse decoder thread error: {e}") - finally: - # Flush any pending symbol - for event in decoder.flush(): - with contextlib.suppress(queue.Full): - output_queue.put_nowait(event) - # Notify frontend that the decoder has stopped (e.g. rtl_fm died) - with contextlib.suppress(queue.Full): - output_queue.put_nowait({'type': 'status', 'status': 'stopped'}) +"""Morse code (CW) decoding helpers. + +Signal chain: +- SDR audio from `rtl_fm -M usb` (16-bit LE PCM) +- Goertzel tone detection with optional auto-tone tracking +- Adaptive threshold + hysteresis + minimum signal gate +- Timing estimator (auto/manual WPM) and Morse symbol decoding +""" + +from __future__ import annotations + +import contextlib +import math +import os +import queue +import select +import struct +import threading +import time +import wave +from collections import deque +from datetime import datetime +from pathlib import Path +from typing import Any + +import numpy as np + +try: + # Reuse existing Goertzel helper when available. + from utils.sstv.dsp import goertzel_mag as _shared_goertzel_mag +except Exception: # pragma: no cover - fallback path + _shared_goertzel_mag = None + +# International Morse Code table +MORSE_TABLE: dict[str, str] = { + '.-': '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', + '.-.-.-': '.', '--..--': ',', '..--..': '?', '.----.': "'", + '-.-.--': '!', '-..-.': '/', '-.--.': '(', '-.--.-': ')', + '.-...': '&', '---...': ':', '-.-.-.': ';', '-...-': '=', + '.-.-.': '+', '-....-': '-', '..--.-': '_', '.-..-.': '"', + '...-..-': '$', '.--.-.': '@', + # Prosigns (unique codes only; -...- and -.--.- already mapped above) + '-.-.-': '', '.-.-': '', '...-.-': '', +} + +# Reverse lookup: character -> morse notation +CHAR_TO_MORSE: dict[str, str] = {v: k for k, v in MORSE_TABLE.items()} + + +class GoertzelFilter: + """Single-frequency tone detector using the Goertzel algorithm.""" + + def __init__(self, target_freq: float, sample_rate: int, block_size: int): + self.target_freq = float(target_freq) + self.sample_rate = int(sample_rate) + self.block_size = int(block_size) + # Generalized coefficient (does not quantize to integer FFT bins) + omega = 2.0 * math.pi * self.target_freq / self.sample_rate + self.coeff = 2.0 * math.cos(omega) + + def magnitude(self, samples: list[float] | tuple[float, ...] | np.ndarray) -> float: + """Compute magnitude of the target frequency in the sample block.""" + s0 = 0.0 + s1 = 0.0 + s2 = 0.0 + coeff = self.coeff + for sample in samples: + s0 = float(sample) + coeff * s1 - s2 + s2 = s1 + s1 = s0 + power = s1 * s1 + s2 * s2 - coeff * s1 * s2 + return math.sqrt(max(power, 0.0)) + + +def _goertzel_mag(samples: np.ndarray, target_freq: float, sample_rate: int) -> float: + """Compute Goertzel magnitude, preferring shared DSP helper.""" + if _shared_goertzel_mag is not None: + try: + return float(_shared_goertzel_mag(samples, float(target_freq), int(sample_rate))) + except Exception: + pass + filt = GoertzelFilter(target_freq=target_freq, sample_rate=sample_rate, block_size=len(samples)) + return filt.magnitude(samples) + + +def _coerce_bool(value: Any, default: bool = False) -> bool: + """Convert arbitrary JSON-ish values to bool.""" + if isinstance(value, bool): + return value + if value is None: + return default + text = str(value).strip().lower() + if text in {'1', 'true', 'yes', 'on'}: + return True + if text in {'0', 'false', 'no', 'off'}: + return False + return default + + +def _normalize_threshold_mode(value: Any) -> str: + mode = str(value or 'auto').strip().lower() + return mode if mode in {'auto', 'manual'} else 'auto' + + +def _normalize_wpm_mode(value: Any) -> str: + mode = str(value or 'auto').strip().lower() + return mode if mode in {'auto', 'manual'} else 'auto' + + +def _clamp(value: float, lo: float, hi: float) -> float: + return min(hi, max(lo, value)) + + +class MorseDecoder: + """Real-time Morse decoder with adaptive threshold and timing estimation.""" + + def __init__( + self, + sample_rate: int = 8000, + tone_freq: float = 700.0, + wpm: int = 15, + bandwidth_hz: int = 200, + auto_tone_track: bool = True, + tone_lock: bool = False, + threshold_mode: str = 'auto', + manual_threshold: float = 0.0, + threshold_multiplier: float = 2.8, + threshold_offset: float = 0.0, + wpm_mode: str = 'auto', + wpm_lock: bool = False, + min_signal_gate: float = 0.0, + ): + self.sample_rate = int(sample_rate) + self.tone_freq = float(tone_freq) + self.wpm = int(wpm) + + self.bandwidth_hz = int(_clamp(float(bandwidth_hz), 50, 400)) + self.auto_tone_track = bool(auto_tone_track) + self.tone_lock = bool(tone_lock) + self.threshold_mode = _normalize_threshold_mode(threshold_mode) + self.manual_threshold = max(0.0, float(manual_threshold)) + self.threshold_multiplier = float(_clamp(float(threshold_multiplier), 1.1, 8.0)) + self.threshold_offset = max(0.0, float(threshold_offset)) + self.wpm_mode = _normalize_wpm_mode(wpm_mode) + self.wpm_lock = bool(wpm_lock) + self.min_signal_gate = float(_clamp(float(min_signal_gate), 0.0, 1.0)) + + # ~50 analysis windows/s at 8 kHz keeps CPU low and timing stable. + self._block_size = max(64, self.sample_rate // 50) + self._block_duration = self._block_size / float(self.sample_rate) + + self._active_tone_freq = float(_clamp(self.tone_freq, 300.0, 1200.0)) + self._tone_anchor_freq = self._active_tone_freq + self._tone_scan_range_hz = 180.0 + self._tone_scan_step_hz = 10.0 + self._tone_scan_interval_blocks = 8 + + self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size) + self._noise_detector_low = GoertzelFilter( + _clamp(self._active_tone_freq - max(60.0, self.bandwidth_hz * 0.5), 150.0, 2000.0), + self.sample_rate, + self._block_size, + ) + self._noise_detector_high = GoertzelFilter( + _clamp(self._active_tone_freq + max(60.0, self.bandwidth_hz * 0.5), 150.0, 2000.0), + self.sample_rate, + self._block_size, + ) + + # AGC for weak HF/direct-sampling signals. + self._agc_target = 0.22 + self._agc_gain = 1.0 + self._agc_alpha = 0.06 + + # Envelope smoothing. + self._attack_alpha = 0.55 + self._release_alpha = 0.45 + self._envelope = 0.0 + + # Adaptive threshold model. + self._noise_floor = 0.0 + self._signal_peak = 0.0 + self._threshold = 0.0 + self._hysteresis = 0.12 + + # Warm-up bootstrap. + self._WARMUP_BLOCKS = 16 + self._SETTLE_BLOCKS = 140 + self._mag_min = float('inf') + self._mag_max = 0.0 + self._blocks_processed = 0 + + # Timing model (in block units, kept for backward compatibility with tests). + dit_sec = 1.2 / max(self.wpm, 1) + dit_blocks = max(1.0, dit_sec / self._block_duration) + self._dah_threshold = 2.2 * dit_blocks + self._dit_min = 0.38 * dit_blocks + self._char_gap = 2.6 * dit_blocks + self._word_gap = 6.0 * dit_blocks + self._dit_observations: deque[float] = deque(maxlen=32) + self._estimated_wpm = float(self.wpm) + + # State machine. + self._tone_on = False + self._tone_blocks = 0.0 + self._silence_blocks = 0.0 + self._current_symbol = '' + self._pending_buffer: list[int] = [] + + # Output / diagnostics. + self._last_level = 0.0 + self._last_noise_ref = 0.0 + + def reset_calibration(self) -> None: + """Reset adaptive threshold and timing estimator state.""" + self._noise_floor = 0.0 + self._signal_peak = 0.0 + self._threshold = 0.0 + self._mag_min = float('inf') + self._mag_max = 0.0 + self._blocks_processed = 0 + self._dit_observations.clear() + self._estimated_wpm = float(self.wpm) + self._tone_on = False + self._tone_blocks = 0.0 + self._silence_blocks = 0.0 + self._current_symbol = '' + + def get_metrics(self) -> dict[str, float | bool]: + """Return latest decoder metrics for UI/status messages.""" + return { + 'wpm': float(self._estimated_wpm), + 'tone_freq': float(self._active_tone_freq), + 'level': float(self._last_level), + 'noise_floor': float(self._noise_floor), + 'threshold': float(self._threshold), + 'tone_on': bool(self._tone_on), + 'dit_ms': float((self._effective_dit_blocks() * self._block_duration) * 1000.0), + } + + def _rebuild_detectors(self) -> None: + """Rebuild target/noise Goertzel filters after tone updates.""" + self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size) + ref_offset = max(60.0, self.bandwidth_hz * 0.5) + self._noise_detector_low = GoertzelFilter( + _clamp(self._active_tone_freq - ref_offset, 150.0, 2000.0), + self.sample_rate, + self._block_size, + ) + self._noise_detector_high = GoertzelFilter( + _clamp(self._active_tone_freq + ref_offset, 150.0, 2000.0), + self.sample_rate, + self._block_size, + ) + + def _estimate_tone_frequency( + self, + normalized: np.ndarray, + signal_mag: float, + noise_ref: float, + ) -> bool: + """Track dominant CW tone in a local window when a valid tone is present. + + Returns True when the detector frequency changed. + """ + if not self.auto_tone_track or self.tone_lock: + return False + + # Skip retunes when the detector is mostly seeing noise. + if signal_mag <= max(noise_ref * 1.8, 0.02): + return False + + lo = _clamp(self._active_tone_freq - self._tone_scan_range_hz, 300.0, 1200.0) + hi = _clamp(self._active_tone_freq + self._tone_scan_range_hz, 300.0, 1200.0) + if hi <= lo: + return False + + best_freq = self._active_tone_freq + best_mag = float(signal_mag) + + freq = lo + while freq <= hi + 1e-6: + mag = _goertzel_mag(normalized, freq, self.sample_rate) + if mag > best_mag: + best_mag = mag + best_freq = freq + freq += self._tone_scan_step_hz + + # Require a meaningful improvement before moving off the current tone. + if best_mag <= (signal_mag * 1.12): + return False + + # Smooth and cap per-step movement to avoid jumps on noisy windows. + delta = _clamp(best_freq - self._active_tone_freq, -30.0, 30.0) + smoothed = self._active_tone_freq + (0.35 * delta) + # Do not drift too far from the configured tone unless the user retunes. + smoothed = _clamp( + smoothed, + max(300.0, self._tone_anchor_freq - 240.0), + min(1200.0, self._tone_anchor_freq + 240.0), + ) + + if abs(smoothed - self._active_tone_freq) >= 2.5: + self._active_tone_freq = smoothed + self._rebuild_detectors() + return True + return False + + def _effective_dit_blocks(self) -> float: + """Return current dit estimate in block units.""" + if self.wpm_mode == 'manual' or self.wpm_lock: + wpm = max(5.0, min(50.0, float(self.wpm))) + dit_blocks = max(1.0, (1.2 / wpm) / self._block_duration) + self._estimated_wpm = wpm + return dit_blocks + + if self._dit_observations: + ordered = sorted(self._dit_observations) + mid = ordered[len(ordered) // 2] + dit_blocks = max(1.0, float(mid)) + est_wpm = 1.2 / (dit_blocks * self._block_duration) + self._estimated_wpm = _clamp(est_wpm, 5.0, 60.0) + return dit_blocks + + self._estimated_wpm = float(self.wpm) + return max(1.0, (1.2 / max(self.wpm, 1)) / self._block_duration) + + def _record_dit_candidate(self, blocks: float) -> None: + """Feed a possible dit duration into the estimator.""" + if blocks <= 0: + return + if self.wpm_mode == 'manual' or self.wpm_lock: + return + if blocks > 20: + return + self._dit_observations.append(float(blocks)) + + def _decode_symbol(self, symbol: str, timestamp: str) -> dict[str, Any] | None: + char = MORSE_TABLE.get(symbol) + if char is None: + return None + return { + 'type': 'morse_char', + 'char': char, + 'morse': symbol, + 'timestamp': timestamp, + } + + def process_block(self, pcm_bytes: bytes) -> list[dict[str, Any]]: + """Process PCM bytes and return decode/scope events.""" + events: list[dict[str, Any]] = [] + + n_samples = len(pcm_bytes) // 2 + if n_samples <= 0: + return events + + samples = struct.unpack(f'<{n_samples}h', pcm_bytes[:n_samples * 2]) + self._pending_buffer.extend(samples) + + amplitudes: list[float] = [] + + while len(self._pending_buffer) >= self._block_size: + block = np.array(self._pending_buffer[:self._block_size], dtype=np.float64) + del self._pending_buffer[:self._block_size] + + normalized = block / 32768.0 + + # AGC + rms = float(np.sqrt(np.mean(np.square(normalized)))) + if rms > 1e-7: + desired_gain = self._agc_target / rms + self._agc_gain += self._agc_alpha * (desired_gain - self._agc_gain) + self._agc_gain = _clamp(self._agc_gain, 0.2, 450.0) + normalized *= self._agc_gain + + self._blocks_processed += 1 + + mag = self._detector.magnitude(normalized) + noise_low = self._noise_detector_low.magnitude(normalized) + noise_high = self._noise_detector_high.magnitude(normalized) + noise_ref = max(1e-9, (noise_low + noise_high) * 0.5) + + if ( + self.auto_tone_track + and not self.tone_lock + and self._blocks_processed > self._WARMUP_BLOCKS + and (self._blocks_processed % self._tone_scan_interval_blocks == 0) + and self._estimate_tone_frequency(normalized, mag, noise_ref) + ): + # Detector changed; refresh magnitudes for this window. + mag = self._detector.magnitude(normalized) + noise_low = self._noise_detector_low.magnitude(normalized) + noise_high = self._noise_detector_high.magnitude(normalized) + noise_ref = max(1e-9, (noise_low + noise_high) * 0.5) + + level = float(mag) + alpha = self._attack_alpha if level >= self._envelope else self._release_alpha + self._envelope += alpha * (level - self._envelope) + self._last_level = self._envelope + self._last_noise_ref = noise_ref + amplitudes.append(level) + + if self._blocks_processed <= self._WARMUP_BLOCKS: + self._mag_min = min(self._mag_min, level) + self._mag_max = max(self._mag_max, level) + if self._blocks_processed == self._WARMUP_BLOCKS: + self._noise_floor = self._mag_min if math.isfinite(self._mag_min) else 0.0 + if self._mag_max <= (self._noise_floor * 1.2): + self._signal_peak = max(self._noise_floor + 0.5, self._noise_floor * 2.5) + else: + self._signal_peak = max(self._mag_max, self._noise_floor * 1.8) + self._threshold = self._noise_floor + 0.22 * ( + self._signal_peak - self._noise_floor + ) + tone_detected = False + else: + settle_alpha = 0.30 if self._blocks_processed < (self._WARMUP_BLOCKS + self._SETTLE_BLOCKS) else 0.06 + + detector_level = level + + if detector_level <= self._threshold: + self._noise_floor += settle_alpha * (detector_level - self._noise_floor) + else: + self._signal_peak += settle_alpha * (detector_level - self._signal_peak) + + self._signal_peak = max(self._signal_peak, self._noise_floor * 1.05) + + if self.threshold_mode == 'manual': + self._threshold = max(0.0, self.manual_threshold) + else: + self._threshold = ( + max(0.0, self._noise_floor * self.threshold_multiplier) + + self.threshold_offset + ) + self._threshold = max(self._threshold, self._noise_floor + 0.35) + + dynamic_span = max(0.0, self._signal_peak - self._noise_floor) + gate_level = self._noise_floor + (self.min_signal_gate * dynamic_span) + gate_ok = self.min_signal_gate <= 0.0 or detector_level >= gate_level + + on_threshold = self._threshold * (1.0 + self._hysteresis) + off_threshold = self._threshold * (1.0 - self._hysteresis) + + if self._tone_on: + tone_detected = gate_ok and detector_level >= off_threshold + else: + tone_detected = gate_ok and detector_level >= on_threshold + + dit_blocks = self._effective_dit_blocks() + self._dah_threshold = 2.2 * dit_blocks + self._dit_min = max(1.0, 0.38 * dit_blocks) + self._char_gap = 2.6 * dit_blocks + self._word_gap = 6.0 * dit_blocks + + if tone_detected and not self._tone_on: + # Tone edge up. + self._tone_on = True + silence_count = self._silence_blocks + self._silence_blocks = 0.0 + self._tone_blocks = 0.0 + + if self._current_symbol and silence_count >= self._char_gap: + timestamp = datetime.now().strftime('%H:%M:%S') + decoded = self._decode_symbol(self._current_symbol, timestamp) + if decoded is not None: + events.append(decoded) + + if silence_count >= self._word_gap: + events.append({ + 'type': 'morse_space', + 'timestamp': timestamp, + }) + events.append({ + 'type': 'morse_gap', + 'gap': 'word', + 'duration_ms': round(silence_count * self._block_duration * 1000.0, 1), + }) + else: + events.append({ + 'type': 'morse_gap', + 'gap': 'char', + 'duration_ms': round(silence_count * self._block_duration * 1000.0, 1), + }) + + self._current_symbol = '' + elif silence_count >= 1.0: + # Intra-symbol gap candidate improves dit estimate for Farnsworth-style spacing. + if silence_count <= (self._char_gap * 0.95): + self._record_dit_candidate(silence_count) + + elif (not tone_detected) and self._tone_on: + # Tone edge down. + self._tone_on = False + tone_count = max(1.0, self._tone_blocks) + self._tone_blocks = 0.0 + self._silence_blocks = 0.0 + + element = '' + if tone_count >= self._dah_threshold: + element = '-' + elif tone_count >= self._dit_min: + element = '.' + + if element: + self._current_symbol += element + events.append({ + 'type': 'morse_element', + 'element': element, + 'duration_ms': round(tone_count * self._block_duration * 1000.0, 1), + }) + if element == '.': + self._record_dit_candidate(tone_count) + elif tone_count <= (self._dah_threshold * 1.6): + # Some operators send short-ish dahs; still useful for tracking. + self._record_dit_candidate(tone_count / 3.0) + + elif tone_detected and self._tone_on: + self._tone_blocks += 1.0 + + elif (not tone_detected) and (not self._tone_on): + self._silence_blocks += 1.0 + + if amplitudes: + events.append({ + 'type': 'scope', + 'amplitudes': amplitudes, + 'threshold': self._threshold, + 'tone_on': self._tone_on, + 'tone_freq': round(self._active_tone_freq, 1), + 'level': self._last_level, + 'noise_floor': self._noise_floor, + 'wpm': round(self._estimated_wpm, 1), + 'dit_ms': round(self._effective_dit_blocks() * self._block_duration * 1000.0, 1), + }) + + return events + + def flush(self) -> list[dict[str, Any]]: + """Flush pending symbols at end-of-stream.""" + events: list[dict[str, Any]] = [] + + if self._tone_on and self._tone_blocks >= self._dit_min: + tone_count = self._tone_blocks + element = '-' if tone_count >= self._dah_threshold else '.' + self._current_symbol += element + events.append({ + 'type': 'morse_element', + 'element': element, + 'duration_ms': round(tone_count * self._block_duration * 1000.0, 1), + }) + + if self._current_symbol: + decoded = self._decode_symbol(self._current_symbol, datetime.now().strftime('%H:%M:%S')) + if decoded is not None: + events.append(decoded) + self._current_symbol = '' + + self._tone_on = False + self._tone_blocks = 0.0 + self._silence_blocks = 0.0 + return events + + +def _wav_to_mono_float(path: Path) -> tuple[np.ndarray, int]: + """Load WAV file and return mono float32 samples in [-1, 1].""" + with wave.open(str(path), 'rb') as wf: + n_channels = wf.getnchannels() + sampwidth = wf.getsampwidth() + sample_rate = wf.getframerate() + n_frames = wf.getnframes() + raw = wf.readframes(n_frames) + + if sampwidth == 1: + pcm = np.frombuffer(raw, dtype=np.uint8).astype(np.float64) + pcm = (pcm - 128.0) / 128.0 + elif sampwidth == 2: + pcm = np.frombuffer(raw, dtype=np.int16).astype(np.float64) / 32768.0 + elif sampwidth == 4: + pcm = np.frombuffer(raw, dtype=np.int32).astype(np.float64) / 2147483648.0 + else: + raise ValueError(f'Unsupported WAV sample width: {sampwidth * 8} bits') + + if n_channels > 1: + pcm = pcm.reshape(-1, n_channels).mean(axis=1) + + return pcm.astype(np.float64), int(sample_rate) + + +def _resample_linear(samples: np.ndarray, from_rate: int, to_rate: int) -> np.ndarray: + """Linear resampler with no extra dependencies.""" + if from_rate == to_rate or len(samples) == 0: + return samples + + ratio = float(to_rate) / float(from_rate) + new_len = max(1, int(round(len(samples) * ratio))) + x_old = np.linspace(0.0, 1.0, len(samples), endpoint=False) + x_new = np.linspace(0.0, 1.0, new_len, endpoint=False) + return np.interp(x_new, x_old, samples).astype(np.float64) + + +def decode_morse_wav_file( + wav_path: str | Path, + *, + sample_rate: int = 8000, + tone_freq: float = 700.0, + wpm: int = 15, + bandwidth_hz: int = 200, + auto_tone_track: bool = True, + tone_lock: bool = False, + threshold_mode: str = 'auto', + manual_threshold: float = 0.0, + threshold_multiplier: float = 2.8, + threshold_offset: float = 0.0, + wpm_mode: str = 'auto', + wpm_lock: bool = False, + min_signal_gate: float = 0.0, +) -> dict[str, Any]: + """Decode Morse from a WAV file and return text/events/metrics.""" + path = Path(wav_path) + if not path.is_file(): + raise FileNotFoundError(f'WAV file not found: {path}') + + audio, file_rate = _wav_to_mono_float(path) + if file_rate != sample_rate: + audio = _resample_linear(audio, file_rate, sample_rate) + + pcm = np.clip(audio, -1.0, 1.0) + pcm16 = (pcm * 32767.0).astype(np.int16) + + decoder = MorseDecoder( + sample_rate=sample_rate, + tone_freq=tone_freq, + wpm=wpm, + bandwidth_hz=bandwidth_hz, + auto_tone_track=auto_tone_track, + tone_lock=tone_lock, + threshold_mode=threshold_mode, + manual_threshold=manual_threshold, + threshold_multiplier=threshold_multiplier, + threshold_offset=threshold_offset, + wpm_mode=wpm_mode, + wpm_lock=wpm_lock, + min_signal_gate=min_signal_gate, + ) + + events: list[dict[str, Any]] = [] + chunk_samples = 2048 + idx = 0 + while idx < len(pcm16): + chunk = pcm16[idx:idx + chunk_samples] + if len(chunk) == 0: + break + events.extend(decoder.process_block(chunk.tobytes())) + idx += chunk_samples + + events.extend(decoder.flush()) + + text_parts: list[str] = [] + raw_parts: list[str] = [] + for event in events: + et = event.get('type') + if et == 'morse_char': + text_parts.append(str(event.get('char', ''))) + elif et == 'morse_space': + text_parts.append(' ') + elif et == 'morse_element': + raw_parts.append(str(event.get('element', ''))) + elif et == 'morse_gap': + gap = str(event.get('gap', '')) + if gap == 'char': + raw_parts.append(' / ') + elif gap == 'word': + raw_parts.append(' // ') + + text = ''.join(text_parts) + raw = ''.join(raw_parts).strip() + + return { + 'text': text, + 'raw': raw, + 'events': events, + 'metrics': decoder.get_metrics(), + } + + +def _drain_control_queue(control_queue: queue.Queue | None, decoder: MorseDecoder) -> bool: + """Process pending control commands; return False to request shutdown.""" + if control_queue is None: + return True + + keep_running = True + while True: + try: + cmd = control_queue.get_nowait() + except queue.Empty: + break + + if not isinstance(cmd, dict): + continue + action = str(cmd.get('cmd', '')).strip().lower() + if action == 'reset': + decoder.reset_calibration() + elif action in {'shutdown', 'stop'}: + keep_running = False + + return keep_running + + +def _emit_waiting_scope(output_queue: queue.Queue, waiting_since: float) -> None: + """Emit waiting heartbeat while no PCM arrives.""" + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'scope', + 'amplitudes': [], + 'threshold': 0, + 'tone_on': False, + 'waiting': True, + 'waiting_seconds': round(max(0.0, time.monotonic() - waiting_since), 1), + }) + + +def morse_decoder_thread( + rtl_stdout, + output_queue: queue.Queue, + stop_event: threading.Event, + sample_rate: int = 8000, + tone_freq: float = 700.0, + wpm: int = 15, + decoder_config: dict[str, Any] | None = None, + control_queue: queue.Queue | None = None, +) -> None: + """Decode Morse from live PCM stream and push events to *output_queue*.""" + import logging + logger = logging.getLogger('intercept.morse') + + CHUNK = 4096 + SCOPE_INTERVAL = 0.10 + WAITING_INTERVAL = 0.25 + + cfg = dict(decoder_config or {}) + decoder = MorseDecoder( + sample_rate=int(cfg.get('sample_rate', sample_rate)), + tone_freq=float(cfg.get('tone_freq', tone_freq)), + wpm=int(cfg.get('wpm', wpm)), + bandwidth_hz=int(cfg.get('bandwidth_hz', 200)), + auto_tone_track=_coerce_bool(cfg.get('auto_tone_track', True), True), + tone_lock=_coerce_bool(cfg.get('tone_lock', False), False), + threshold_mode=_normalize_threshold_mode(cfg.get('threshold_mode', 'auto')), + manual_threshold=float(cfg.get('manual_threshold', 0.0) or 0.0), + threshold_multiplier=float(cfg.get('threshold_multiplier', 2.8) or 2.8), + threshold_offset=float(cfg.get('threshold_offset', 0.0) or 0.0), + wpm_mode=_normalize_wpm_mode(cfg.get('wpm_mode', 'auto')), + wpm_lock=_coerce_bool(cfg.get('wpm_lock', False), False), + min_signal_gate=float(cfg.get('min_signal_gate', 0.0) or 0.0), + ) + + last_scope = time.monotonic() + last_waiting_emit = 0.0 + waiting_since: float | None = None + + try: + fd: int | None + try: + fd = rtl_stdout.fileno() + except Exception: + fd = None + + while not stop_event.is_set(): + if not _drain_control_queue(control_queue, decoder): + break + + data = b'' + if fd is not None: + try: + ready, _, _ = select.select([fd], [], [], 0.20) + except Exception: + break + + if ready: + data = os.read(fd, CHUNK) + else: + if waiting_since is None: + waiting_since = time.monotonic() + now = time.monotonic() + if now - last_waiting_emit >= WAITING_INTERVAL: + last_waiting_emit = now + _emit_waiting_scope(output_queue, waiting_since) + continue + else: + # Fallback for test streams without fileno(). + data = rtl_stdout.read(CHUNK) + + if not data: + break + + waiting_since = None + + events = decoder.process_block(data) + for event in events: + if event.get('type') == 'scope': + now = time.monotonic() + if now - last_scope >= SCOPE_INTERVAL: + last_scope = now + with contextlib.suppress(queue.Full): + output_queue.put_nowait(event) + else: + with contextlib.suppress(queue.Full): + output_queue.put_nowait(event) + + except Exception as e: # pragma: no cover - defensive runtime guard + logger.debug(f'Morse decoder thread error: {e}') + finally: + for event in decoder.flush(): + with contextlib.suppress(queue.Full): + output_queue.put_nowait(event) + + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'status', + 'status': 'stopped', + 'metrics': decoder.get_metrics(), + }) From 3399c1bb145dd2052ef2ea9693fbfd5d2c7f4565 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 11:15:45 +0000 Subject: [PATCH 12/52] Improve Morse stream startup compatibility and diagnostics --- routes/morse.py | 29 +++++++++++++++++------------ static/js/modes/morse.js | 17 ++++++++++++++++- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index 0807247..6d4d639 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -265,12 +265,10 @@ def start_morse() -> Response: _set_state(MORSE_STARTING, 'Starting decoder...') - sample_rate = 8000 + # Use pager-proven audio rate for rtl_fm compatibility across builds. + sample_rate = 22050 bias_t = _bool_value(data.get('bias_t', False), False) - # RTL-SDR needs direct sampling mode for HF frequencies below 24 MHz - direct_sampling = 2 if freq < 24.0 else None - sdr_type_str = data.get('sdr_type', 'rtlsdr') try: sdr_type = SDRType(sdr_type_str) @@ -280,15 +278,22 @@ def start_morse() -> Response: sdr_device = SDRFactory.create_default_device(sdr_type, index=device) builder = SDRFactory.get_builder(sdr_device.sdr_type) + fm_kwargs: dict[str, Any] = { + 'device': sdr_device, + 'frequency_mhz': freq, + 'sample_rate': sample_rate, + 'gain': float(gain) if gain and gain != '0' else None, + 'ppm': int(ppm) if ppm and ppm != '0' else None, + 'modulation': 'usb', + 'bias_t': bias_t, + } + + # Only rtl_fm supports direct sampling flags. + if sdr_device.sdr_type == SDRType.RTL_SDR and freq < 24.0: + fm_kwargs['direct_sampling'] = 2 + rtl_cmd = builder.build_fm_demod_command( - device=sdr_device, - frequency_mhz=freq, - sample_rate=sample_rate, - gain=float(gain) if gain and gain != '0' else None, - ppm=int(ppm) if ppm and ppm != '0' else None, - modulation='usb', - bias_t=bias_t, - direct_sampling=direct_sampling, + **fm_kwargs, ) full_cmd = ' '.join(rtl_cmd) diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index f0d6f31..8c60ffc 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -173,11 +173,18 @@ var MorseMode = (function () { function loadSettings() { try { var raw = localStorage.getItem(SETTINGS_KEY); - if (!raw) return; + if (!raw) { + if (el('morseShowDiag')) el('morseShowDiag').checked = true; + toggleDiagPanel(); + persistSettings(); + return; + } var parsed = JSON.parse(raw); applySettings(parsed); } catch (_) { // Ignore malformed settings. + if (el('morseShowDiag')) el('morseShowDiag').checked = true; + toggleDiagPanel(); } } @@ -555,6 +562,11 @@ var MorseMode = (function () { if (!scopeWaiting) { scopeWaiting = true; waitingStart = Date.now(); + appendDiagLine('[morse] waiting for PCM stream...'); + } + var waitElapsedMs = waitingStart ? (Date.now() - waitingStart) : 0; + if (waitElapsedMs > 10000 && el('morseDiagLog') && el('morseDiagLog').children.length < 6) { + appendDiagLine('[hint] No samples after 10s. Check SDR device, frequency, and HF direct sampling path.'); } } else if (amps.length > 0) { scopeWaiting = false; @@ -812,6 +824,9 @@ var MorseMode = (function () { if (!log) return; var showDiag = !!(el('morseShowDiag') && el('morseShowDiag').checked); + if (!showDiag && scopeWaiting) { + showDiag = true; + } if (!showDiag) return; log.style.display = 'block'; From 765273d60a8ac1470fad18ac2da52deecf73e16b Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 11:26:12 +0000 Subject: [PATCH 13/52] Harden Morse PCM read loop and add stream diagnostics --- utils/morse.py | 82 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 13 deletions(-) diff --git a/utils/morse.py b/utils/morse.py index 97c59cf..c7e50cd 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -744,6 +744,8 @@ def morse_decoder_thread( CHUNK = 4096 SCOPE_INTERVAL = 0.10 WAITING_INTERVAL = 0.25 + IDLE_SLEEP = 0.04 + STALLED_AFTER_DATA_SECONDS = 1.5 cfg = dict(decoder_config or {}) decoder = MorseDecoder( @@ -765,11 +767,20 @@ def morse_decoder_thread( last_scope = time.monotonic() last_waiting_emit = 0.0 waiting_since: float | None = None + last_pcm_at: float | None = None + pcm_bytes = 0 + pcm_report_at = time.monotonic() try: fd: int | None + non_blocking = False try: fd = rtl_stdout.fileno() + try: + os.set_blocking(fd, False) + non_blocking = True + except Exception: + non_blocking = False except Exception: fd = None @@ -779,21 +790,53 @@ def morse_decoder_thread( data = b'' if fd is not None: - try: - ready, _, _ = select.select([fd], [], [], 0.20) - except Exception: - break + if non_blocking: + try: + data = os.read(fd, CHUNK) + except BlockingIOError: + data = None + except Exception: + break - if ready: - data = os.read(fd, CHUNK) + if data is None: + now = time.monotonic() + should_emit_waiting = False + if last_pcm_at is None: + should_emit_waiting = True + elif (now - last_pcm_at) >= STALLED_AFTER_DATA_SECONDS: + should_emit_waiting = True + + if should_emit_waiting and waiting_since is None: + waiting_since = now + now = time.monotonic() + if should_emit_waiting and now - last_waiting_emit >= WAITING_INTERVAL: + last_waiting_emit = now + _emit_waiting_scope(output_queue, waiting_since) + time.sleep(IDLE_SLEEP) + continue else: - if waiting_since is None: - waiting_since = time.monotonic() - now = time.monotonic() - if now - last_waiting_emit >= WAITING_INTERVAL: - last_waiting_emit = now - _emit_waiting_scope(output_queue, waiting_since) - continue + try: + ready, _, _ = select.select([fd], [], [], 0.20) + except Exception: + break + + if ready: + data = os.read(fd, CHUNK) + else: + now = time.monotonic() + should_emit_waiting = False + if last_pcm_at is None: + should_emit_waiting = True + elif (now - last_pcm_at) >= STALLED_AFTER_DATA_SECONDS: + should_emit_waiting = True + + if should_emit_waiting and waiting_since is None: + waiting_since = now + now = time.monotonic() + if should_emit_waiting and now - last_waiting_emit >= WAITING_INTERVAL: + last_waiting_emit = now + _emit_waiting_scope(output_queue, waiting_since) + continue else: # Fallback for test streams without fileno(). data = rtl_stdout.read(CHUNK) @@ -802,6 +845,8 @@ def morse_decoder_thread( break waiting_since = None + last_pcm_at = time.monotonic() + pcm_bytes += len(data) events = decoder.process_block(data) for event in events: @@ -815,6 +860,17 @@ def morse_decoder_thread( with contextlib.suppress(queue.Full): output_queue.put_nowait(event) + now = time.monotonic() + if (now - pcm_report_at) >= 1.0: + kbps = (pcm_bytes * 8.0) / max(1e-6, (now - pcm_report_at)) / 1000.0 + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'info', + 'text': f'[pcm] {pcm_bytes} B in {now - pcm_report_at:.1f}s ({kbps:.1f} kbps)', + }) + pcm_bytes = 0 + pcm_report_at = now + except Exception as e: # pragma: no cover - defensive runtime guard logger.debug(f'Morse decoder thread error: {e}') finally: From 8605c58e3bfeade99c75c112fb974c531a6a7382 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 11:38:49 +0000 Subject: [PATCH 14/52] Keep Morse panels visible and persist startup error diagnostics --- static/js/modes/morse.js | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index 8c60ffc..dddcc55 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -8,7 +8,7 @@ var MorseMode = (function () { var SETTINGS_KEY = 'intercept.morse.settings.v3'; var STATUS_POLL_MS = 5000; var LOCAL_STOP_TIMEOUT_MS = 2200; - var START_TIMEOUT_MS = 4000; + var START_TIMEOUT_MS = 10000; var state = { initialized: false, @@ -281,6 +281,7 @@ var MorseMode = (function () { clearDiagLog(); clearDecodedText(); clearRawText(); + appendDiagLine('[start] requesting decoder startup...'); var payload = collectConfig(); persistSettings(); @@ -310,6 +311,7 @@ var MorseMode = (function () { startScope(); setStatusText('Listening'); applyMetrics(data.config || {}, true); + appendDiagLine('[start] decoder started'); notifyInfo('Morse decoder started'); return data; }) @@ -318,14 +320,11 @@ var MorseMode = (function () { return { status: 'stale' }; } setLifecycle('error'); - setStatusText('Error'); - notifyError('Failed to start Morse decoder: ' + (err && err.message ? err.message : err)); - setTimeout(function () { - if (state.lifecycle === 'error') { - setLifecycle('idle'); - } - }, 800); - return { status: 'error', message: String(err && err.message ? err.message : err) }; + var errorMsg = String(err && err.message ? err.message : err); + setStatusText('Start failed'); + appendDiagLine('[start] failed: ' + errorMsg); + notifyError('Failed to start Morse decoder: ' + errorMsg); + return { status: 'error', message: errorMsg }; }); } @@ -917,10 +916,10 @@ var MorseMode = (function () { if (state.lifecycle === 'error') setStatusText('Error'); var scopePanel = el('morseScopePanel'); - if (scopePanel) scopePanel.style.display = (running || starting) ? 'block' : 'none'; + if (scopePanel) scopePanel.style.display = 'block'; var outputPanel = el('morseOutputPanel'); - if (outputPanel) outputPanel.style.display = (running || starting) ? 'block' : 'none'; + if (outputPanel) outputPanel.style.display = 'block'; var scopeStatus = el('morseScopeStatusLabel'); if (scopeStatus) { From 70005109f7d26327d6731dcd295e4c9fe07728c1 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 11:44:59 +0000 Subject: [PATCH 15/52] Use buffered read path for Morse PCM stream stability --- utils/morse.py | 73 +++++++++++++++++--------------------------------- 1 file changed, 24 insertions(+), 49 deletions(-) diff --git a/utils/morse.py b/utils/morse.py index c7e50cd..c21eafa 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -744,7 +744,6 @@ def morse_decoder_thread( CHUNK = 4096 SCOPE_INTERVAL = 0.10 WAITING_INTERVAL = 0.25 - IDLE_SLEEP = 0.04 STALLED_AFTER_DATA_SECONDS = 1.5 cfg = dict(decoder_config or {}) @@ -773,14 +772,8 @@ def morse_decoder_thread( try: fd: int | None - non_blocking = False try: fd = rtl_stdout.fileno() - try: - os.set_blocking(fd, False) - non_blocking = True - except Exception: - non_blocking = False except Exception: fd = None @@ -790,53 +783,35 @@ def morse_decoder_thread( data = b'' if fd is not None: - if non_blocking: + try: + ready, _, _ = select.select([fd], [], [], 0.20) + except Exception: + break + + if ready: try: - data = os.read(fd, CHUNK) - except BlockingIOError: - data = None + # Use buffered stream read first for cross-platform stability. + if hasattr(rtl_stdout, 'read1'): + data = rtl_stdout.read1(CHUNK) + else: + data = os.read(fd, CHUNK) except Exception: break - - if data is None: - now = time.monotonic() - should_emit_waiting = False - if last_pcm_at is None: - should_emit_waiting = True - elif (now - last_pcm_at) >= STALLED_AFTER_DATA_SECONDS: - should_emit_waiting = True - - if should_emit_waiting and waiting_since is None: - waiting_since = now - now = time.monotonic() - if should_emit_waiting and now - last_waiting_emit >= WAITING_INTERVAL: - last_waiting_emit = now - _emit_waiting_scope(output_queue, waiting_since) - time.sleep(IDLE_SLEEP) - continue else: - try: - ready, _, _ = select.select([fd], [], [], 0.20) - except Exception: - break + now = time.monotonic() + should_emit_waiting = False + if last_pcm_at is None: + should_emit_waiting = True + elif (now - last_pcm_at) >= STALLED_AFTER_DATA_SECONDS: + should_emit_waiting = True - if ready: - data = os.read(fd, CHUNK) - else: - now = time.monotonic() - should_emit_waiting = False - if last_pcm_at is None: - should_emit_waiting = True - elif (now - last_pcm_at) >= STALLED_AFTER_DATA_SECONDS: - should_emit_waiting = True - - if should_emit_waiting and waiting_since is None: - waiting_since = now - now = time.monotonic() - if should_emit_waiting and now - last_waiting_emit >= WAITING_INTERVAL: - last_waiting_emit = now - _emit_waiting_scope(output_queue, waiting_since) - continue + if should_emit_waiting and waiting_since is None: + waiting_since = now + now = time.monotonic() + if should_emit_waiting and now - last_waiting_emit >= WAITING_INTERVAL: + last_waiting_emit = now + _emit_waiting_scope(output_queue, waiting_since) + continue else: # Fallback for test streams without fileno(). data = rtl_stdout.read(CHUNK) From 3b687c57d9772b30e1f66d2b6492606626763b93 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 11:53:03 +0000 Subject: [PATCH 16/52] Move Morse PCM ingestion to dedicated reader thread --- utils/morse.py | 119 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 79 insertions(+), 40 deletions(-) diff --git a/utils/morse.py b/utils/morse.py index c21eafa..22db88f 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -11,9 +11,7 @@ from __future__ import annotations import contextlib import math -import os import queue -import select import struct import threading import time @@ -769,54 +767,86 @@ def morse_decoder_thread( last_pcm_at: float | None = None pcm_bytes = 0 pcm_report_at = time.monotonic() + reader_done = threading.Event() + reader_thread: threading.Thread | None = None + + raw_queue: queue.Queue[bytes] = queue.Queue(maxsize=96) try: - fd: int | None - try: - fd = rtl_stdout.fileno() - except Exception: - fd = None + def _reader_loop() -> None: + """Blocking PCM reader isolated from decode/control loop.""" + try: + while not stop_event.is_set(): + try: + if hasattr(rtl_stdout, 'read1'): + data = rtl_stdout.read1(CHUNK) + else: + data = rtl_stdout.read(CHUNK) + except Exception as e: + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'info', + 'text': f'[pcm] reader error: {e}', + }) + break + + if data is None: + continue + + if not data: + break + + try: + raw_queue.put(data, timeout=0.2) + except queue.Full: + # Keep latest PCM flowing even if downstream hiccups. + with contextlib.suppress(queue.Empty): + raw_queue.get_nowait() + with contextlib.suppress(queue.Full): + raw_queue.put_nowait(data) + finally: + reader_done.set() + with contextlib.suppress(queue.Full): + raw_queue.put_nowait(b'') + + reader_thread = threading.Thread( + target=_reader_loop, + daemon=True, + name='morse-pcm-reader', + ) + reader_thread.start() while not stop_event.is_set(): if not _drain_control_queue(control_queue, decoder): break - data = b'' - if fd is not None: - try: - ready, _, _ = select.select([fd], [], [], 0.20) - except Exception: + try: + data = raw_queue.get(timeout=0.20) + except queue.Empty: + now = time.monotonic() + should_emit_waiting = False + if last_pcm_at is None: + should_emit_waiting = True + elif (now - last_pcm_at) >= STALLED_AFTER_DATA_SECONDS: + should_emit_waiting = True + + if should_emit_waiting and waiting_since is None: + waiting_since = now + if should_emit_waiting and now - last_waiting_emit >= WAITING_INTERVAL: + last_waiting_emit = now + _emit_waiting_scope(output_queue, waiting_since) + + if reader_done.is_set(): break - - if ready: - try: - # Use buffered stream read first for cross-platform stability. - if hasattr(rtl_stdout, 'read1'): - data = rtl_stdout.read1(CHUNK) - else: - data = os.read(fd, CHUNK) - except Exception: - break - else: - now = time.monotonic() - should_emit_waiting = False - if last_pcm_at is None: - should_emit_waiting = True - elif (now - last_pcm_at) >= STALLED_AFTER_DATA_SECONDS: - should_emit_waiting = True - - if should_emit_waiting and waiting_since is None: - waiting_since = now - now = time.monotonic() - if should_emit_waiting and now - last_waiting_emit >= WAITING_INTERVAL: - last_waiting_emit = now - _emit_waiting_scope(output_queue, waiting_since) - continue - else: - # Fallback for test streams without fileno(). - data = rtl_stdout.read(CHUNK) + continue if not data: + if reader_done.is_set() and last_pcm_at is None: + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'info', + 'text': '[pcm] stream ended before samples were received', + }) break waiting_since = None @@ -848,7 +878,16 @@ def morse_decoder_thread( except Exception as e: # pragma: no cover - defensive runtime guard logger.debug(f'Morse decoder thread error: {e}') + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'info', + 'text': f'[pcm] decoder thread error: {e}', + }) finally: + stop_event.set() + if reader_thread is not None: + reader_thread.join(timeout=0.35) + for event in decoder.flush(): with contextlib.suppress(queue.Full): output_queue.put_nowait(event) From 07eac1a5580c01f2d1f3b60dc22f8f0b7dac8b07 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 11:59:07 +0000 Subject: [PATCH 17/52] Force explicit rtl_fm squelch-off and log first PCM chunk --- routes/morse.py | 8 ++++++++ utils/morse.py | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/routes/morse.py b/routes/morse.py index 6d4d639..2fe823e 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -296,6 +296,14 @@ def start_morse() -> Response: **fm_kwargs, ) + # Some rtl_fm builds behave as if squelch is enabled unless -l is explicit. + # Force continuous audio for CW analysis. + if sdr_device.sdr_type == SDRType.RTL_SDR and '-l' not in rtl_cmd: + if rtl_cmd and rtl_cmd[-1] == '-': + rtl_cmd[-1:-1] = ['-l', '0'] + else: + rtl_cmd.extend(['-l', '0']) + full_cmd = ' '.join(rtl_cmd) logger.info(f'Morse decoder running: {full_cmd}') diff --git a/utils/morse.py b/utils/morse.py index 22db88f..497187a 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -767,6 +767,7 @@ def morse_decoder_thread( last_pcm_at: float | None = None pcm_bytes = 0 pcm_report_at = time.monotonic() + first_pcm_logged = False reader_done = threading.Event() reader_thread: threading.Thread | None = None @@ -853,6 +854,14 @@ def morse_decoder_thread( last_pcm_at = time.monotonic() pcm_bytes += len(data) + if not first_pcm_logged: + first_pcm_logged = True + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'info', + 'text': f'[pcm] first chunk: {len(data)} bytes', + }) + events = decoder.process_block(data) for event in events: if event.get('type') == 'scope': From 0b8cbf9c1863bec8b8e99c40f6c8ff9269977d03 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 12:07:45 +0000 Subject: [PATCH 18/52] Prefer native Homebrew tool paths on Apple Silicon --- utils/dependencies.py | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/utils/dependencies.py b/utils/dependencies.py index e595103..7dbb046 100644 --- a/utils/dependencies.py +++ b/utils/dependencies.py @@ -1,10 +1,11 @@ from __future__ import annotations -import logging -import os -import shutil -import subprocess -from typing import Any +import logging +import os +import platform +import shutil +import subprocess +from typing import Any logger = logging.getLogger('intercept.dependencies') @@ -17,12 +18,26 @@ def check_tool(name: str) -> bool: return get_tool_path(name) is not None -def get_tool_path(name: str) -> str | None: - """Get the full path to a tool, checking standard PATH and extra locations.""" - # First check standard PATH - path = shutil.which(name) - if path: - return path +def get_tool_path(name: str) -> str | None: + """Get the full path to a tool, checking standard PATH and extra locations.""" + # Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta + # /usr/local tools with arm64 Python/runtime. + if platform.system() == 'Darwin': + machine = platform.machine().lower() + preferred_paths: list[str] = [] + if machine in {'arm64', 'aarch64'}: + preferred_paths.append('/opt/homebrew/bin') + preferred_paths.append('/usr/local/bin') + + for base in preferred_paths: + full_path = os.path.join(base, name) + if os.path.isfile(full_path) and os.access(full_path, os.X_OK): + return full_path + + # First check standard PATH + path = shutil.which(name) + if path: + return path # Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian) for extra_path in EXTRA_TOOL_PATHS: From c96b3e8de5fdab357a8b5700e0ae1ab5c028c73b Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 12:13:48 +0000 Subject: [PATCH 19/52] Support explicit tool path overrides via INTERCEPT_*_PATH --- utils/dependencies.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/utils/dependencies.py b/utils/dependencies.py index 7dbb046..933be4b 100644 --- a/utils/dependencies.py +++ b/utils/dependencies.py @@ -20,6 +20,12 @@ def check_tool(name: str) -> bool: def get_tool_path(name: str) -> str | None: """Get the full path to a tool, checking standard PATH and extra locations.""" + # Optional explicit override, e.g. INTERCEPT_RTL_FM_PATH=/opt/homebrew/bin/rtl_fm + env_key = f"INTERCEPT_{name.upper().replace('-', '_')}_PATH" + env_path = os.environ.get(env_key) + if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK): + return env_path + # Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta # /usr/local tools with arm64 Python/runtime. if platform.system() == 'Darwin': From f83202ffc20a0ec1cf7e135bae51968358e26533 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 12:25:23 +0000 Subject: [PATCH 20/52] Harden Morse startup PCM detection and retry fallback --- routes/morse.py | 253 ++++++++++++++++++++++++++++++-------------- tests/test_morse.py | 71 +++++++++++++ utils/morse.py | 3 + 3 files changed, 247 insertions(+), 80 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index 2fe823e..4108944 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -278,39 +278,52 @@ def start_morse() -> Response: sdr_device = SDRFactory.create_default_device(sdr_type, index=device) builder = SDRFactory.get_builder(sdr_device.sdr_type) - fm_kwargs: dict[str, Any] = { - 'device': sdr_device, - 'frequency_mhz': freq, - 'sample_rate': sample_rate, - 'gain': float(gain) if gain and gain != '0' else None, - 'ppm': int(ppm) if ppm and ppm != '0' else None, - 'modulation': 'usb', - 'bias_t': bias_t, - } + def _build_rtl_cmd(*, use_direct_sampling: bool, force_squelch_off: bool) -> list[str]: + fm_kwargs: dict[str, Any] = { + 'device': sdr_device, + 'frequency_mhz': freq, + 'sample_rate': sample_rate, + 'gain': float(gain) if gain and gain != '0' else None, + 'ppm': int(ppm) if ppm and ppm != '0' else None, + 'modulation': 'usb', + 'bias_t': bias_t, + } - # Only rtl_fm supports direct sampling flags. - if sdr_device.sdr_type == SDRType.RTL_SDR and freq < 24.0: - fm_kwargs['direct_sampling'] = 2 + # Only rtl_fm supports direct sampling flags. + if use_direct_sampling: + fm_kwargs['direct_sampling'] = 2 - rtl_cmd = builder.build_fm_demod_command( - **fm_kwargs, - ) + cmd = builder.build_fm_demod_command(**fm_kwargs) - # Some rtl_fm builds behave as if squelch is enabled unless -l is explicit. - # Force continuous audio for CW analysis. - if sdr_device.sdr_type == SDRType.RTL_SDR and '-l' not in rtl_cmd: - if rtl_cmd and rtl_cmd[-1] == '-': - rtl_cmd[-1:-1] = ['-l', '0'] - else: - rtl_cmd.extend(['-l', '0']) + # Some rtl_fm builds behave as if squelch is enabled unless -l is explicit. + # Force continuous audio for CW analysis. + if force_squelch_off and sdr_device.sdr_type == SDRType.RTL_SDR and '-l' not in cmd: + if cmd and cmd[-1] == '-': + cmd[-1:-1] = ['-l', '0'] + else: + cmd.extend(['-l', '0']) + return cmd - full_cmd = ' '.join(rtl_cmd) - logger.info(f'Morse decoder running: {full_cmd}') + can_try_direct_sampling = bool(sdr_device.sdr_type == SDRType.RTL_SDR and freq < 24.0) + if can_try_direct_sampling: + # Prefer direct2 with explicit squelch-off, then retry with safer variants + # if the command starts but never emits PCM. + command_attempts = [ + {'use_direct_sampling': True, 'force_squelch_off': True}, + {'use_direct_sampling': True, 'force_squelch_off': False}, + {'use_direct_sampling': False, 'force_squelch_off': True}, + ] + else: + command_attempts = [ + {'use_direct_sampling': False, 'force_squelch_off': True}, + {'use_direct_sampling': False, 'force_squelch_off': False}, + ] rtl_process: subprocess.Popen | None = None stop_event: threading.Event | None = None decoder_thread: threading.Thread | None = None stderr_thread: threading.Thread | None = None + control_queue: queue.Queue | None = None runtime_config: dict[str, Any] = { 'sample_rate': sample_rate, @@ -329,70 +342,150 @@ def start_morse() -> Response: } try: - rtl_process = subprocess.Popen( - rtl_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=0, - ) - register_process(rtl_process) + def _cleanup_attempt( + proc: subprocess.Popen | None, + attempt_stop_event: threading.Event | None, + attempt_control_queue: queue.Queue | None, + attempt_decoder_thread: threading.Thread | None, + attempt_stderr_thread: threading.Thread | None, + ) -> None: + if attempt_stop_event is not None: + attempt_stop_event.set() + if attempt_control_queue is not None: + with contextlib.suppress(queue.Full): + attempt_control_queue.put_nowait({'cmd': 'shutdown'}) + if proc is not None: + _close_pipe(getattr(proc, 'stdout', None)) + _close_pipe(getattr(proc, 'stderr', None)) + safe_terminate(proc, timeout=0.5) + unregister_process(proc) + _join_thread(attempt_decoder_thread, timeout_s=0.35) + _join_thread(attempt_stderr_thread, timeout_s=0.35) - stop_event = threading.Event() - control_queue: queue.Queue = queue.Queue(maxsize=16) + attempt_errors: list[str] = [] + full_cmd = '' - def monitor_stderr() -> None: - if not rtl_process or rtl_process.stderr is None: - return - for line in rtl_process.stderr: - if stop_event.is_set(): + for attempt_index, attempt in enumerate(command_attempts, start=1): + use_direct_sampling = bool(attempt.get('use_direct_sampling', False)) + force_squelch_off = bool(attempt.get('force_squelch_off', True)) + + rtl_cmd = _build_rtl_cmd( + use_direct_sampling=use_direct_sampling, + force_squelch_off=force_squelch_off, + ) + full_cmd = ' '.join(rtl_cmd) + logger.info(f'Morse decoder attempt {attempt_index}/{len(command_attempts)}: {full_cmd}') + + with contextlib.suppress(queue.Full): + app_module.morse_queue.put_nowait({ + 'type': 'info', + 'text': f'[cmd] {full_cmd}', + }) + + rtl_process = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + register_process(rtl_process) + + stop_event = threading.Event() + control_queue = queue.Queue(maxsize=16) + pcm_ready_event = threading.Event() + + def monitor_stderr( + proc: subprocess.Popen = rtl_process, + proc_stop_event: threading.Event = stop_event, + ) -> None: + if proc.stderr is None: + return + for line in proc.stderr: + if proc_stop_event.is_set(): + break + err_text = line.decode('utf-8', errors='replace').strip() + if err_text: + logger.debug(f'[rtl_fm/morse] {err_text}') + with contextlib.suppress(queue.Full): + app_module.morse_queue.put_nowait({ + 'type': 'info', + 'text': f'[rtl_fm] {err_text}', + }) + + stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr') + stderr_thread.start() + + decoder_thread = threading.Thread( + target=morse_decoder_thread, + args=( + rtl_process.stdout, + app_module.morse_queue, + stop_event, + sample_rate, + tone_freq, + wpm, + ), + kwargs={ + 'decoder_config': runtime_config, + 'control_queue': control_queue, + 'pcm_ready_event': pcm_ready_event, + }, + daemon=True, + name='morse-decoder', + ) + decoder_thread.start() + + startup_deadline = time.monotonic() + 2.5 + startup_ok = False + startup_error = '' + + while time.monotonic() < startup_deadline: + if pcm_ready_event.is_set(): + startup_ok = True break - err_text = line.decode('utf-8', errors='replace').strip() - if err_text: - logger.debug(f'[rtl_fm/morse] {err_text}') - with contextlib.suppress(queue.Full): - app_module.morse_queue.put_nowait({ - 'type': 'info', - 'text': f'[rtl_fm] {err_text}', - }) + if rtl_process.poll() is not None: + startup_error = f'rtl_fm exited during startup (code {rtl_process.returncode})' + break + time.sleep(0.05) - stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr') - stderr_thread.start() + if startup_ok: + runtime_config['direct_sampling'] = 2 if use_direct_sampling else 0 + runtime_config['force_squelch_off'] = force_squelch_off + break - decoder_thread = threading.Thread( - target=morse_decoder_thread, - args=( - rtl_process.stdout, - app_module.morse_queue, + if not startup_error: + startup_error = 'No PCM samples received within startup timeout' + + attempt_errors.append( + f'attempt {attempt_index}/{len(command_attempts)} ' + f'(direct={int(use_direct_sampling)} squelch_forced={int(force_squelch_off)}): {startup_error}' + ) + logger.warning(f'Morse startup attempt failed: {attempt_errors[-1]}') + + with contextlib.suppress(queue.Full): + app_module.morse_queue.put_nowait({ + 'type': 'info', + 'text': f'[morse] startup attempt failed: {startup_error}', + }) + + _cleanup_attempt( + rtl_process, stop_event, - sample_rate, - tone_freq, - wpm, - ), - kwargs={ - 'decoder_config': runtime_config, - 'control_queue': control_queue, - }, - daemon=True, - name='morse-decoder', - ) - decoder_thread.start() + control_queue, + decoder_thread, + stderr_thread, + ) + rtl_process = None + stop_event = None + control_queue = None + decoder_thread = None + stderr_thread = None - # Detect immediate startup failure (e.g. device busy, no device) - time.sleep(0.30) - if rtl_process.poll() is not None: - stop_event.set() - stderr_text = '' - try: - if rtl_process.stderr: - stderr_text = rtl_process.stderr.read().decode('utf-8', errors='replace').strip() - except Exception: - stderr_text = '' - msg = stderr_text or f'rtl_fm exited immediately (code {rtl_process.returncode})' + if rtl_process is None or stop_event is None or control_queue is None or decoder_thread is None or stderr_thread is None: + msg = 'rtl_fm started but no PCM stream was received.' + if attempt_errors: + msg = msg + ' ' + ' | '.join(attempt_errors[-2:]) logger.error(f'Morse rtl_fm startup failed: {msg}') - safe_terminate(rtl_process, timeout=0.4) - unregister_process(rtl_process) - _join_thread(decoder_thread, timeout_s=0.25) - _join_thread(stderr_thread, timeout_s=0.25) with app_module.morse_lock: if morse_active_device is not None: app_module.release_sdr_device(morse_active_device) diff --git a/tests/test_morse.py b/tests/test_morse.py index 1bd7596..3a9dac2 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -317,6 +317,77 @@ class TestMorseLifecycleRoutes: assert final_status['state'] == 'idle' assert 0 in released_devices + def test_start_retries_after_early_process_exit(self, client, monkeypatch): + _login_session(client) + self._reset_route_state() + + released_devices = [] + + monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None) + monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx)) + + class DummyDevice: + sdr_type = morse_routes.SDRType.RTL_SDR + + class DummyBuilder: + def build_fm_demod_command(self, **kwargs): + cmd = ['rtl_fm', '-f', '14.060M', '-M', 'usb', '-s', '22050'] + if kwargs.get('direct_sampling') == 2: + cmd.extend(['-E', 'direct2']) + cmd.append('-') + return cmd + + monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice())) + monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder())) + + pcm = generate_morse_audio('E', wpm=15, sample_rate=22050) + popen_cmds = [] + + class FakeProc: + def __init__(self, stdout_bytes: bytes, returncode: int | None): + self.stdout = io.BytesIO(stdout_bytes) + self.stderr = io.BytesIO(b'') + self.returncode = returncode + + def poll(self): + return self.returncode + + def fake_popen(cmd, *args, **kwargs): + popen_cmds.append(cmd) + if len(popen_cmds) == 1: + return FakeProc(b'', 1) + return FakeProc(pcm, None) + + monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen) + monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None) + monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None) + monkeypatch.setattr( + morse_routes, + 'safe_terminate', + lambda proc, timeout=0.0: setattr(proc, 'returncode', 0), + ) + + start_resp = client.post('/morse/start', json={ + 'frequency': '14.060', + 'gain': '20', + 'ppm': '0', + 'device': '0', + 'tone_freq': '700', + 'wpm': '15', + }) + assert start_resp.status_code == 200 + assert start_resp.get_json()['status'] == 'started' + assert len(popen_cmds) >= 2 + assert '-E' in popen_cmds[0] and 'direct2' in popen_cmds[0] + assert '-l' in popen_cmds[0] + assert '-E' in popen_cmds[1] and 'direct2' in popen_cmds[1] + assert '-l' not in popen_cmds[1] + + stop_resp = client.post('/morse/stop') + assert stop_resp.status_code == 200 + assert stop_resp.get_json()['status'] == 'stopped' + assert 0 in released_devices + # --------------------------------------------------------------------------- # Integration: synthetic CW -> WAV decode diff --git a/utils/morse.py b/utils/morse.py index 497187a..c7dd8d5 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -734,6 +734,7 @@ def morse_decoder_thread( wpm: int = 15, decoder_config: dict[str, Any] | None = None, control_queue: queue.Queue | None = None, + pcm_ready_event: threading.Event | None = None, ) -> None: """Decode Morse from live PCM stream and push events to *output_queue*.""" import logging @@ -856,6 +857,8 @@ def morse_decoder_thread( if not first_pcm_logged: first_pcm_logged = True + if pcm_ready_event is not None: + pcm_ready_event.set() with contextlib.suppress(queue.Full): output_queue.put_nowait({ 'type': 'info', From be6fb3a9528c65c9415fbe8e5df26590b8e406a8 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 12:28:53 +0000 Subject: [PATCH 21/52] Prefer no-squelch rtl_fm startup profile for Morse --- routes/morse.py | 9 +++++---- tests/test_morse.py | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index 4108944..bfc9761 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -306,17 +306,18 @@ def start_morse() -> Response: can_try_direct_sampling = bool(sdr_device.sdr_type == SDRType.RTL_SDR and freq < 24.0) if can_try_direct_sampling: - # Prefer direct2 with explicit squelch-off, then retry with safer variants - # if the command starts but never emits PCM. + # Cross-platform note: some rtl_fm builds treat "-l 0" as hard squelch and + # emit no PCM. Try the no-"-l" form first, then legacy variants. command_attempts = [ - {'use_direct_sampling': True, 'force_squelch_off': True}, {'use_direct_sampling': True, 'force_squelch_off': False}, + {'use_direct_sampling': True, 'force_squelch_off': True}, + {'use_direct_sampling': False, 'force_squelch_off': False}, {'use_direct_sampling': False, 'force_squelch_off': True}, ] else: command_attempts = [ - {'use_direct_sampling': False, 'force_squelch_off': True}, {'use_direct_sampling': False, 'force_squelch_off': False}, + {'use_direct_sampling': False, 'force_squelch_off': True}, ] rtl_process: subprocess.Popen | None = None diff --git a/tests/test_morse.py b/tests/test_morse.py index 3a9dac2..f3d4f6a 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -379,9 +379,9 @@ class TestMorseLifecycleRoutes: assert start_resp.get_json()['status'] == 'started' assert len(popen_cmds) >= 2 assert '-E' in popen_cmds[0] and 'direct2' in popen_cmds[0] - assert '-l' in popen_cmds[0] + assert '-l' not in popen_cmds[0] assert '-E' in popen_cmds[1] and 'direct2' in popen_cmds[1] - assert '-l' not in popen_cmds[1] + assert '-l' in popen_cmds[1] stop_resp = client.post('/morse/stop') assert stop_resp.status_code == 200 From 93e8fa3a9164bb0219c3beb942deb667fcfb3d00 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 12:39:31 +0000 Subject: [PATCH 22/52] Prevent Morse start timeout aborts on slow startup --- routes/morse.py | 2 +- static/js/modes/morse.js | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index bfc9761..6b01b7f 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -436,7 +436,7 @@ def start_morse() -> Response: ) decoder_thread.start() - startup_deadline = time.monotonic() + 2.5 + startup_deadline = time.monotonic() + 2.0 startup_ok = False startup_error = '' diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index dddcc55..ef23a66 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -8,7 +8,7 @@ var MorseMode = (function () { var SETTINGS_KEY = 'intercept.morse.settings.v3'; var STATUS_POLL_MS = 5000; var LOCAL_STOP_TIMEOUT_MS = 2200; - var START_TIMEOUT_MS = 10000; + var START_TIMEOUT_MS = 20000; var state = { initialized: false, @@ -85,6 +85,11 @@ var MorseMode = (function () { } return data; }); + }).catch(function (err) { + if (err && err.name === 'AbortError') { + throw new Error('Request timed out while waiting for decoder startup'); + } + throw err; }).finally(function () { if (timeoutId) clearTimeout(timeoutId); }); From c9a7d51dc03f19e213fa3e2204876b4689377b2b Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 12:46:22 +0000 Subject: [PATCH 23/52] Add rtl_fm resample and dc/agc Morse startup fallbacks --- routes/morse.py | 90 +++++++++++++++++++++++++++++++++++++++++---- tests/test_morse.py | 4 +- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index 6b01b7f..60af901 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -278,7 +278,13 @@ def start_morse() -> Response: sdr_device = SDRFactory.create_default_device(sdr_type, index=device) builder = SDRFactory.get_builder(sdr_device.sdr_type) - def _build_rtl_cmd(*, use_direct_sampling: bool, force_squelch_off: bool) -> list[str]: + def _build_rtl_cmd( + *, + use_direct_sampling: bool, + force_squelch_off: bool, + add_resample_rate: bool, + add_dc_fast: bool, + ) -> list[str]: fm_kwargs: dict[str, Any] = { 'device': sdr_device, 'frequency_mhz': freq, @@ -302,6 +308,19 @@ def start_morse() -> Response: cmd[-1:-1] = ['-l', '0'] else: cmd.extend(['-l', '0']) + + if sdr_device.sdr_type == SDRType.RTL_SDR: + insert_at = len(cmd) - 1 if cmd and cmd[-1] == '-' else len(cmd) + if add_resample_rate and '-r' not in cmd: + cmd[insert_at:insert_at] = ['-r', str(sample_rate)] + insert_at += 2 + if add_dc_fast: + # Used in other stable modes to improve rtl_fm stream behavior. + if '-A' not in cmd: + cmd[insert_at:insert_at] = ['-A', 'fast'] + insert_at += 2 + if '-E' not in cmd or 'dc' not in cmd: + cmd[insert_at:insert_at] = ['-E', 'dc'] return cmd can_try_direct_sampling = bool(sdr_device.sdr_type == SDRType.RTL_SDR and freq < 24.0) @@ -309,15 +328,63 @@ def start_morse() -> Response: # Cross-platform note: some rtl_fm builds treat "-l 0" as hard squelch and # emit no PCM. Try the no-"-l" form first, then legacy variants. command_attempts = [ - {'use_direct_sampling': True, 'force_squelch_off': False}, - {'use_direct_sampling': True, 'force_squelch_off': True}, - {'use_direct_sampling': False, 'force_squelch_off': False}, - {'use_direct_sampling': False, 'force_squelch_off': True}, + { + 'use_direct_sampling': True, + 'force_squelch_off': False, + 'add_resample_rate': False, + 'add_dc_fast': False, + }, + { + 'use_direct_sampling': True, + 'force_squelch_off': False, + 'add_resample_rate': True, + 'add_dc_fast': True, + }, + { + 'use_direct_sampling': True, + 'force_squelch_off': True, + 'add_resample_rate': True, + 'add_dc_fast': True, + }, + { + 'use_direct_sampling': False, + 'force_squelch_off': False, + 'add_resample_rate': True, + 'add_dc_fast': True, + }, + { + 'use_direct_sampling': False, + 'force_squelch_off': False, + 'add_resample_rate': False, + 'add_dc_fast': False, + }, + { + 'use_direct_sampling': False, + 'force_squelch_off': True, + 'add_resample_rate': True, + 'add_dc_fast': True, + }, ] else: command_attempts = [ - {'use_direct_sampling': False, 'force_squelch_off': False}, - {'use_direct_sampling': False, 'force_squelch_off': True}, + { + 'use_direct_sampling': False, + 'force_squelch_off': False, + 'add_resample_rate': False, + 'add_dc_fast': False, + }, + { + 'use_direct_sampling': False, + 'force_squelch_off': False, + 'add_resample_rate': True, + 'add_dc_fast': True, + }, + { + 'use_direct_sampling': False, + 'force_squelch_off': True, + 'add_resample_rate': True, + 'add_dc_fast': True, + }, ] rtl_process: subprocess.Popen | None = None @@ -369,10 +436,14 @@ def start_morse() -> Response: for attempt_index, attempt in enumerate(command_attempts, start=1): use_direct_sampling = bool(attempt.get('use_direct_sampling', False)) force_squelch_off = bool(attempt.get('force_squelch_off', True)) + add_resample_rate = bool(attempt.get('add_resample_rate', False)) + add_dc_fast = bool(attempt.get('add_dc_fast', False)) rtl_cmd = _build_rtl_cmd( use_direct_sampling=use_direct_sampling, force_squelch_off=force_squelch_off, + add_resample_rate=add_resample_rate, + add_dc_fast=add_dc_fast, ) full_cmd = ' '.join(rtl_cmd) logger.info(f'Morse decoder attempt {attempt_index}/{len(command_attempts)}: {full_cmd}') @@ -452,6 +523,8 @@ def start_morse() -> Response: if startup_ok: runtime_config['direct_sampling'] = 2 if use_direct_sampling else 0 runtime_config['force_squelch_off'] = force_squelch_off + runtime_config['resample_rate'] = sample_rate if add_resample_rate else None + runtime_config['dc_fast'] = add_dc_fast break if not startup_error: @@ -459,7 +532,8 @@ def start_morse() -> Response: attempt_errors.append( f'attempt {attempt_index}/{len(command_attempts)} ' - f'(direct={int(use_direct_sampling)} squelch_forced={int(force_squelch_off)}): {startup_error}' + f'(direct={int(use_direct_sampling)} squelch_forced={int(force_squelch_off)} ' + f'resample={int(add_resample_rate)} dc_fast={int(add_dc_fast)}): {startup_error}' ) logger.warning(f'Morse startup attempt failed: {attempt_errors[-1]}') diff --git a/tests/test_morse.py b/tests/test_morse.py index f3d4f6a..929259b 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -381,7 +381,9 @@ class TestMorseLifecycleRoutes: assert '-E' in popen_cmds[0] and 'direct2' in popen_cmds[0] assert '-l' not in popen_cmds[0] assert '-E' in popen_cmds[1] and 'direct2' in popen_cmds[1] - assert '-l' in popen_cmds[1] + assert '-r' in popen_cmds[1] + assert '-A' in popen_cmds[1] + assert 'dc' in popen_cmds[1] stop_resp = client.post('/morse/stop') assert stop_resp.status_code == 200 From 1cb808e6741db77a8517544c485127082a638d10 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 12:58:29 +0000 Subject: [PATCH 24/52] Add merged-stream rtl_fm fallback for Morse startup --- routes/morse.py | 68 ++++++++++++++++++++++++++++++++++--------------- utils/morse.py | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 21 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index 60af901..ab057ac 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -363,6 +363,16 @@ def start_morse() -> Response: 'force_squelch_off': True, 'add_resample_rate': True, 'add_dc_fast': True, + 'merge_stderr': False, + }, + { + # Last-resort compatibility mode: some rtl_fm variants may route + # payload unexpectedly; merge stderr->stdout and strip text logs. + 'use_direct_sampling': False, + 'force_squelch_off': False, + 'add_resample_rate': True, + 'add_dc_fast': True, + 'merge_stderr': True, }, ] else: @@ -372,18 +382,28 @@ def start_morse() -> Response: 'force_squelch_off': False, 'add_resample_rate': False, 'add_dc_fast': False, + 'merge_stderr': False, }, { 'use_direct_sampling': False, 'force_squelch_off': False, 'add_resample_rate': True, 'add_dc_fast': True, + 'merge_stderr': False, }, { 'use_direct_sampling': False, 'force_squelch_off': True, 'add_resample_rate': True, 'add_dc_fast': True, + 'merge_stderr': False, + }, + { + 'use_direct_sampling': False, + 'force_squelch_off': False, + 'add_resample_rate': True, + 'add_dc_fast': True, + 'merge_stderr': True, }, ] @@ -438,6 +458,7 @@ def start_morse() -> Response: force_squelch_off = bool(attempt.get('force_squelch_off', True)) add_resample_rate = bool(attempt.get('add_resample_rate', False)) add_dc_fast = bool(attempt.get('add_dc_fast', False)) + merge_stderr = bool(attempt.get('merge_stderr', False)) rtl_cmd = _build_rtl_cmd( use_direct_sampling=use_direct_sampling, @@ -457,7 +478,7 @@ def start_morse() -> Response: rtl_process = subprocess.Popen( rtl_cmd, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stderr=subprocess.STDOUT if merge_stderr else subprocess.PIPE, bufsize=0, ) register_process(rtl_process) @@ -466,26 +487,29 @@ def start_morse() -> Response: control_queue = queue.Queue(maxsize=16) pcm_ready_event = threading.Event() - def monitor_stderr( - proc: subprocess.Popen = rtl_process, - proc_stop_event: threading.Event = stop_event, - ) -> None: - if proc.stderr is None: - return - for line in proc.stderr: - if proc_stop_event.is_set(): - break - err_text = line.decode('utf-8', errors='replace').strip() - if err_text: - logger.debug(f'[rtl_fm/morse] {err_text}') - with contextlib.suppress(queue.Full): - app_module.morse_queue.put_nowait({ - 'type': 'info', - 'text': f'[rtl_fm] {err_text}', - }) + if not merge_stderr: + def monitor_stderr( + proc: subprocess.Popen = rtl_process, + proc_stop_event: threading.Event = stop_event, + ) -> None: + if proc.stderr is None: + return + for line in proc.stderr: + if proc_stop_event.is_set(): + break + err_text = line.decode('utf-8', errors='replace').strip() + if err_text: + logger.debug(f'[rtl_fm/morse] {err_text}') + with contextlib.suppress(queue.Full): + app_module.morse_queue.put_nowait({ + 'type': 'info', + 'text': f'[rtl_fm] {err_text}', + }) - stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr') - stderr_thread.start() + stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr') + stderr_thread.start() + else: + stderr_thread = None decoder_thread = threading.Thread( target=morse_decoder_thread, @@ -501,6 +525,7 @@ def start_morse() -> Response: 'decoder_config': runtime_config, 'control_queue': control_queue, 'pcm_ready_event': pcm_ready_event, + 'strip_text_chunks': merge_stderr, }, daemon=True, name='morse-decoder', @@ -525,6 +550,7 @@ def start_morse() -> Response: runtime_config['force_squelch_off'] = force_squelch_off runtime_config['resample_rate'] = sample_rate if add_resample_rate else None runtime_config['dc_fast'] = add_dc_fast + runtime_config['merge_stderr'] = merge_stderr break if not startup_error: @@ -533,7 +559,7 @@ def start_morse() -> Response: attempt_errors.append( f'attempt {attempt_index}/{len(command_attempts)} ' f'(direct={int(use_direct_sampling)} squelch_forced={int(force_squelch_off)} ' - f'resample={int(add_resample_rate)} dc_fast={int(add_dc_fast)}): {startup_error}' + f'resample={int(add_resample_rate)} dc_fast={int(add_dc_fast)} merged={int(merge_stderr)}): {startup_error}' ) logger.warning(f'Morse startup attempt failed: {attempt_errors[-1]}') diff --git a/utils/morse.py b/utils/morse.py index c7dd8d5..2148776 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -725,6 +725,34 @@ def _emit_waiting_scope(output_queue: queue.Queue, waiting_since: float) -> None }) +def _is_probably_rtl_log_text(data: bytes) -> bool: + """Heuristic: identify rtl_fm stderr log chunks when streams are merged.""" + if not data: + return False + # PCM usually contains NULLs/non-printables; plain log lines do not. + if b'\x00' in data: + return False + printable = sum(1 for b in data if (32 <= b <= 126) or b in (9, 10, 13)) + ratio = printable / max(1, len(data)) + if ratio < 0.92: + return False + lower = data.lower() + keywords = ( + b'rtl_fm', + b'found ', + b'using device', + b'tuned to', + b'sampling at', + b'output at', + b'buffer size', + b'gain', + b'direct sampling', + b'oversampling', + b'exact sample rate', + ) + return any(token in lower for token in keywords) + + def morse_decoder_thread( rtl_stdout, output_queue: queue.Queue, @@ -735,6 +763,7 @@ def morse_decoder_thread( decoder_config: dict[str, Any] | None = None, control_queue: queue.Queue | None = None, pcm_ready_event: threading.Event | None = None, + strip_text_chunks: bool = False, ) -> None: """Decode Morse from live PCM stream and push events to *output_queue*.""" import logging @@ -798,6 +827,23 @@ def morse_decoder_thread( if not data: break + if strip_text_chunks and _is_probably_rtl_log_text(data): + try: + text = data.decode('utf-8', errors='replace') + except Exception: + text = '' + if text: + for line in text.splitlines(): + clean = line.strip() + if not clean: + continue + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'info', + 'text': f'[rtl_fm] {clean}', + }) + continue + try: raw_queue.put(data, timeout=0.2) except queue.Full: From 7dfbfa6fdf257efc389d108d429a0331f6ac5393 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 13:06:38 +0000 Subject: [PATCH 25/52] Add IQ-capture Morse fallback when rtl_fm has no PCM --- routes/morse.py | 263 +++++++++++++++++++++++++------------------- tests/test_morse.py | 5 +- utils/morse.py | 251 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 401 insertions(+), 118 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index ab057ac..9018fb5 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -16,7 +16,11 @@ from flask import Blueprint, Response, jsonify, request import app as app_module from utils.event_pipeline import process_event from utils.logging import sensor_logger as logger -from utils.morse import decode_morse_wav_file, morse_decoder_thread +from utils.morse import ( + decode_morse_wav_file, + morse_decoder_thread, + morse_iq_decoder_thread, +) from utils.process import register_process, safe_terminate, unregister_process from utils.sdr import SDRFactory, SDRType from utils.sse import sse_stream_fanout @@ -323,87 +327,70 @@ def start_morse() -> Response: cmd[insert_at:insert_at] = ['-E', 'dc'] return cmd + iq_sample_rate = 250000 + + def _build_iq_cmd(*, direct_sampling_mode: int | None) -> tuple[list[str], float]: + # CW USB-style offset tuning: keep the configured RF frequency sounding + # near the selected tone frequency in the software demod chain. + tune_mhz = max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0)) + iq_cmd = builder.build_iq_capture_command( + device=sdr_device, + frequency_mhz=tune_mhz, + sample_rate=iq_sample_rate, + gain=float(gain) if gain and gain != '0' else None, + ppm=int(ppm) if ppm and ppm != '0' else None, + bias_t=bias_t, + ) + if ( + sdr_device.sdr_type == SDRType.RTL_SDR + and direct_sampling_mode is not None + and '-D' not in iq_cmd + ): + if iq_cmd and iq_cmd[-1] == '-': + iq_cmd[-1:-1] = ['-D', str(direct_sampling_mode)] + else: + iq_cmd.extend(['-D', str(direct_sampling_mode)]) + return iq_cmd, tune_mhz + can_try_direct_sampling = bool(sdr_device.sdr_type == SDRType.RTL_SDR and freq < 24.0) if can_try_direct_sampling: - # Cross-platform note: some rtl_fm builds treat "-l 0" as hard squelch and - # emit no PCM. Try the no-"-l" form first, then legacy variants. - command_attempts = [ - { - 'use_direct_sampling': True, - 'force_squelch_off': False, - 'add_resample_rate': False, - 'add_dc_fast': False, - }, + # Keep rtl_fm attempts first (cheap), then switch to IQ capture fallback. + command_attempts: list[dict[str, Any]] = [ { + 'source': 'rtl_fm', 'use_direct_sampling': True, 'force_squelch_off': False, 'add_resample_rate': True, 'add_dc_fast': True, }, { - 'use_direct_sampling': True, - 'force_squelch_off': True, - 'add_resample_rate': True, - 'add_dc_fast': True, - }, - { + 'source': 'rtl_fm', 'use_direct_sampling': False, 'force_squelch_off': False, 'add_resample_rate': True, 'add_dc_fast': True, }, { - 'use_direct_sampling': False, - 'force_squelch_off': False, - 'add_resample_rate': False, - 'add_dc_fast': False, + 'source': 'iq', + 'direct_sampling_mode': 2, }, { - 'use_direct_sampling': False, - 'force_squelch_off': True, - 'add_resample_rate': True, - 'add_dc_fast': True, - 'merge_stderr': False, - }, - { - # Last-resort compatibility mode: some rtl_fm variants may route - # payload unexpectedly; merge stderr->stdout and strip text logs. - 'use_direct_sampling': False, - 'force_squelch_off': False, - 'add_resample_rate': True, - 'add_dc_fast': True, - 'merge_stderr': True, + 'source': 'iq', + 'direct_sampling_mode': None, }, ] else: command_attempts = [ { - 'use_direct_sampling': False, - 'force_squelch_off': False, - 'add_resample_rate': False, - 'add_dc_fast': False, - 'merge_stderr': False, - }, - { + 'source': 'rtl_fm', 'use_direct_sampling': False, 'force_squelch_off': False, 'add_resample_rate': True, 'add_dc_fast': True, - 'merge_stderr': False, }, { - 'use_direct_sampling': False, - 'force_squelch_off': True, - 'add_resample_rate': True, - 'add_dc_fast': True, - 'merge_stderr': False, - }, - { - 'use_direct_sampling': False, - 'force_squelch_off': False, - 'add_resample_rate': True, - 'add_dc_fast': True, - 'merge_stderr': True, + 'source': 'iq', + 'direct_sampling_mode': None, }, ] @@ -454,20 +441,43 @@ def start_morse() -> Response: full_cmd = '' for attempt_index, attempt in enumerate(command_attempts, start=1): + source = str(attempt.get('source', 'rtl_fm')).strip().lower() use_direct_sampling = bool(attempt.get('use_direct_sampling', False)) force_squelch_off = bool(attempt.get('force_squelch_off', True)) add_resample_rate = bool(attempt.get('add_resample_rate', False)) add_dc_fast = bool(attempt.get('add_dc_fast', False)) - merge_stderr = bool(attempt.get('merge_stderr', False)) + direct_sampling_mode = attempt.get('direct_sampling_mode') + + if source == 'iq': + rtl_cmd, tuned_freq_mhz = _build_iq_cmd( + direct_sampling_mode=int(direct_sampling_mode) + if direct_sampling_mode is not None else None, + ) + thread_target = morse_iq_decoder_thread + attempt_desc = ( + f'source=iq direct_mode={direct_sampling_mode if direct_sampling_mode is not None else "none"} ' + f'iq_sr={iq_sample_rate}' + ) + else: + rtl_cmd = _build_rtl_cmd( + use_direct_sampling=use_direct_sampling, + force_squelch_off=force_squelch_off, + add_resample_rate=add_resample_rate, + add_dc_fast=add_dc_fast, + ) + tuned_freq_mhz = float(freq) + thread_target = morse_decoder_thread + attempt_desc = ( + f'source=rtl_fm direct={int(use_direct_sampling)} ' + f'squelch_forced={int(force_squelch_off)} ' + f'resample={int(add_resample_rate)} dc_fast={int(add_dc_fast)}' + ) - rtl_cmd = _build_rtl_cmd( - use_direct_sampling=use_direct_sampling, - force_squelch_off=force_squelch_off, - add_resample_rate=add_resample_rate, - add_dc_fast=add_dc_fast, - ) full_cmd = ' '.join(rtl_cmd) - logger.info(f'Morse decoder attempt {attempt_index}/{len(command_attempts)}: {full_cmd}') + logger.info( + f'Morse decoder attempt {attempt_index}/{len(command_attempts)} ' + f'({attempt_desc}): {full_cmd}' + ) with contextlib.suppress(queue.Full): app_module.morse_queue.put_nowait({ @@ -478,7 +488,7 @@ def start_morse() -> Response: rtl_process = subprocess.Popen( rtl_cmd, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT if merge_stderr else subprocess.PIPE, + stderr=subprocess.PIPE, bufsize=0, ) register_process(rtl_process) @@ -487,49 +497,67 @@ def start_morse() -> Response: control_queue = queue.Queue(maxsize=16) pcm_ready_event = threading.Event() - if not merge_stderr: - def monitor_stderr( - proc: subprocess.Popen = rtl_process, - proc_stop_event: threading.Event = stop_event, - ) -> None: - if proc.stderr is None: - return - for line in proc.stderr: - if proc_stop_event.is_set(): - break - err_text = line.decode('utf-8', errors='replace').strip() - if err_text: - logger.debug(f'[rtl_fm/morse] {err_text}') - with contextlib.suppress(queue.Full): - app_module.morse_queue.put_nowait({ - 'type': 'info', - 'text': f'[rtl_fm] {err_text}', - }) + def monitor_stderr( + proc: subprocess.Popen = rtl_process, + proc_stop_event: threading.Event = stop_event, + tool_label: str = rtl_cmd[0], + ) -> None: + if proc.stderr is None: + return + for line in proc.stderr: + if proc_stop_event.is_set(): + break + err_text = line.decode('utf-8', errors='replace').strip() + if err_text: + with contextlib.suppress(queue.Full): + app_module.morse_queue.put_nowait({ + 'type': 'info', + 'text': f'[{tool_label}] {err_text}', + }) - stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr') - stderr_thread.start() + stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr') + stderr_thread.start() + + if source == 'iq': + decoder_thread = threading.Thread( + target=thread_target, + args=( + rtl_process.stdout, + app_module.morse_queue, + stop_event, + iq_sample_rate, + ), + kwargs={ + 'sample_rate': sample_rate, + 'tone_freq': tone_freq, + 'wpm': wpm, + 'decoder_config': runtime_config, + 'control_queue': control_queue, + 'pcm_ready_event': pcm_ready_event, + }, + daemon=True, + name='morse-decoder', + ) else: - stderr_thread = None - - decoder_thread = threading.Thread( - target=morse_decoder_thread, - args=( - rtl_process.stdout, - app_module.morse_queue, - stop_event, - sample_rate, - tone_freq, - wpm, - ), - kwargs={ - 'decoder_config': runtime_config, - 'control_queue': control_queue, - 'pcm_ready_event': pcm_ready_event, - 'strip_text_chunks': merge_stderr, - }, - daemon=True, - name='morse-decoder', - ) + decoder_thread = threading.Thread( + target=thread_target, + args=( + rtl_process.stdout, + app_module.morse_queue, + stop_event, + sample_rate, + tone_freq, + wpm, + ), + kwargs={ + 'decoder_config': runtime_config, + 'control_queue': control_queue, + 'pcm_ready_event': pcm_ready_event, + 'strip_text_chunks': False, + }, + daemon=True, + name='morse-decoder', + ) decoder_thread.start() startup_deadline = time.monotonic() + 2.0 @@ -541,25 +569,28 @@ def start_morse() -> Response: startup_ok = True break if rtl_process.poll() is not None: - startup_error = f'rtl_fm exited during startup (code {rtl_process.returncode})' + startup_error = f'{rtl_cmd[0]} exited during startup (code {rtl_process.returncode})' break time.sleep(0.05) if startup_ok: - runtime_config['direct_sampling'] = 2 if use_direct_sampling else 0 - runtime_config['force_squelch_off'] = force_squelch_off - runtime_config['resample_rate'] = sample_rate if add_resample_rate else None - runtime_config['dc_fast'] = add_dc_fast - runtime_config['merge_stderr'] = merge_stderr + runtime_config['source'] = source + runtime_config['command'] = full_cmd + runtime_config['tuned_frequency_mhz'] = tuned_freq_mhz + runtime_config['direct_sampling'] = ( + int(direct_sampling_mode) + if source == 'iq' and direct_sampling_mode is not None + else (2 if use_direct_sampling else 0) + ) + runtime_config['iq_sample_rate'] = iq_sample_rate if source == 'iq' else None + runtime_config['direct_sampling_mode'] = direct_sampling_mode if source == 'iq' else None break if not startup_error: startup_error = 'No PCM samples received within startup timeout' attempt_errors.append( - f'attempt {attempt_index}/{len(command_attempts)} ' - f'(direct={int(use_direct_sampling)} squelch_forced={int(force_squelch_off)} ' - f'resample={int(add_resample_rate)} dc_fast={int(add_dc_fast)} merged={int(merge_stderr)}): {startup_error}' + f'attempt {attempt_index}/{len(command_attempts)} ({attempt_desc}): {startup_error}' ) logger.warning(f'Morse startup attempt failed: {attempt_errors[-1]}') @@ -582,11 +613,11 @@ def start_morse() -> Response: decoder_thread = None stderr_thread = None - if rtl_process is None or stop_event is None or control_queue is None or decoder_thread is None or stderr_thread is None: - msg = 'rtl_fm started but no PCM stream was received.' + if rtl_process is None or stop_event is None or control_queue is None or decoder_thread is None: + msg = 'SDR capture started but no PCM stream was received.' if attempt_errors: msg = msg + ' ' + ' | '.join(attempt_errors[-2:]) - logger.error(f'Morse rtl_fm startup failed: {msg}') + logger.error(f'Morse startup failed: {msg}') with app_module.morse_lock: if morse_active_device is not None: app_module.release_sdr_device(morse_active_device) diff --git a/tests/test_morse.py b/tests/test_morse.py index 929259b..95adff8 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -379,8 +379,9 @@ class TestMorseLifecycleRoutes: assert start_resp.get_json()['status'] == 'started' assert len(popen_cmds) >= 2 assert '-E' in popen_cmds[0] and 'direct2' in popen_cmds[0] - assert '-l' not in popen_cmds[0] - assert '-E' in popen_cmds[1] and 'direct2' in popen_cmds[1] + assert '-r' in popen_cmds[0] + assert '-A' in popen_cmds[0] + assert '-E' in popen_cmds[1] and 'direct2' not in popen_cmds[1] assert '-r' in popen_cmds[1] assert '-A' in popen_cmds[1] assert 'dc' in popen_cmds[1] diff --git a/utils/morse.py b/utils/morse.py index 2148776..915b953 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -956,3 +956,254 @@ def morse_decoder_thread( 'status': 'stopped', 'metrics': decoder.get_metrics(), }) + + +def _cu8_to_complex(raw: bytes) -> np.ndarray: + """Convert interleaved unsigned 8-bit IQ to complex64 samples.""" + if len(raw) < 2: + return np.empty(0, dtype=np.complex64) + usable = len(raw) - (len(raw) % 2) + if usable <= 0: + return np.empty(0, dtype=np.complex64) + u8 = np.frombuffer(raw[:usable], dtype=np.uint8).astype(np.float32) + i = (u8[0::2] - 127.5) / 128.0 + q = (u8[1::2] - 127.5) / 128.0 + return (i + 1j * q).astype(np.complex64) + + +def _iq_usb_to_pcm16( + iq_samples: np.ndarray, + iq_sample_rate: int, + audio_sample_rate: int, +) -> bytes: + """Minimal USB demod from complex IQ to 16-bit PCM.""" + if iq_samples.size < 16 or iq_sample_rate <= 0 or audio_sample_rate <= 0: + return b'' + + audio = np.real(iq_samples).astype(np.float64) + audio -= float(np.mean(audio)) + + # Cheap decimation first, then linear resample for exact output rate. + decim = max(1, int(iq_sample_rate // max(audio_sample_rate, 1))) + if decim > 1: + usable = (audio.size // decim) * decim + if usable < decim: + return b'' + audio = audio[:usable].reshape(-1, decim).mean(axis=1) + fs1 = float(iq_sample_rate) / float(decim) + if audio.size < 8: + return b'' + + taps = int(max(1, min(31, fs1 / 12000.0))) + if taps > 1: + kernel = np.ones(taps, dtype=np.float64) / float(taps) + audio = np.convolve(audio, kernel, mode='same') + + if abs(fs1 - float(audio_sample_rate)) > 1.0: + out_len = int(audio.size * float(audio_sample_rate) / fs1) + if out_len < 8: + return b'' + x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float64) + x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float64) + audio = np.interp(x_new, x_old, audio) + + peak = float(np.max(np.abs(audio))) if audio.size else 0.0 + if peak > 0.0: + audio = audio * min(8.0, 0.85 / peak) + + pcm = np.clip(audio, -1.0, 1.0) + return (pcm * 32767.0).astype(np.int16).tobytes() + + +def morse_iq_decoder_thread( + iq_stdout, + output_queue: queue.Queue, + stop_event: threading.Event, + iq_sample_rate: int, + sample_rate: int = 22050, + tone_freq: float = 700.0, + wpm: int = 15, + decoder_config: dict[str, Any] | None = None, + control_queue: queue.Queue | None = None, + pcm_ready_event: threading.Event | None = None, +) -> None: + """Decode Morse from raw IQ (cu8) by in-process USB demodulation.""" + import logging + logger = logging.getLogger('intercept.morse') + + CHUNK = 65536 + SCOPE_INTERVAL = 0.10 + WAITING_INTERVAL = 0.25 + STALLED_AFTER_DATA_SECONDS = 1.5 + + cfg = dict(decoder_config or {}) + decoder = MorseDecoder( + sample_rate=int(cfg.get('sample_rate', sample_rate)), + tone_freq=float(cfg.get('tone_freq', tone_freq)), + wpm=int(cfg.get('wpm', wpm)), + bandwidth_hz=int(cfg.get('bandwidth_hz', 200)), + auto_tone_track=_coerce_bool(cfg.get('auto_tone_track', True), True), + tone_lock=_coerce_bool(cfg.get('tone_lock', False), False), + threshold_mode=_normalize_threshold_mode(cfg.get('threshold_mode', 'auto')), + manual_threshold=float(cfg.get('manual_threshold', 0.0) or 0.0), + threshold_multiplier=float(cfg.get('threshold_multiplier', 2.8) or 2.8), + threshold_offset=float(cfg.get('threshold_offset', 0.0) or 0.0), + wpm_mode=_normalize_wpm_mode(cfg.get('wpm_mode', 'auto')), + wpm_lock=_coerce_bool(cfg.get('wpm_lock', False), False), + min_signal_gate=float(cfg.get('min_signal_gate', 0.0) or 0.0), + ) + + last_scope = time.monotonic() + last_waiting_emit = 0.0 + waiting_since: float | None = None + last_pcm_at: float | None = None + pcm_bytes = 0 + pcm_report_at = time.monotonic() + first_pcm_logged = False + reader_done = threading.Event() + reader_thread: threading.Thread | None = None + + raw_queue: queue.Queue[bytes] = queue.Queue(maxsize=96) + + try: + def _reader_loop() -> None: + try: + while not stop_event.is_set(): + try: + if hasattr(iq_stdout, 'read1'): + data = iq_stdout.read1(CHUNK) + else: + data = iq_stdout.read(CHUNK) + except Exception as e: + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'info', + 'text': f'[iq] reader error: {e}', + }) + break + + if data is None: + continue + if not data: + break + + try: + raw_queue.put(data, timeout=0.2) + except queue.Full: + with contextlib.suppress(queue.Empty): + raw_queue.get_nowait() + with contextlib.suppress(queue.Full): + raw_queue.put_nowait(data) + finally: + reader_done.set() + with contextlib.suppress(queue.Full): + raw_queue.put_nowait(b'') + + reader_thread = threading.Thread( + target=_reader_loop, + daemon=True, + name='morse-iq-reader', + ) + reader_thread.start() + + while not stop_event.is_set(): + if not _drain_control_queue(control_queue, decoder): + break + + try: + raw = raw_queue.get(timeout=0.20) + except queue.Empty: + now = time.monotonic() + should_emit_waiting = False + if last_pcm_at is None: + should_emit_waiting = True + elif (now - last_pcm_at) >= STALLED_AFTER_DATA_SECONDS: + should_emit_waiting = True + + if should_emit_waiting and waiting_since is None: + waiting_since = now + if should_emit_waiting and now - last_waiting_emit >= WAITING_INTERVAL: + last_waiting_emit = now + _emit_waiting_scope(output_queue, waiting_since) + + if reader_done.is_set(): + break + continue + + if not raw: + if reader_done.is_set() and last_pcm_at is None: + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'info', + 'text': '[iq] stream ended before samples were received', + }) + break + + iq = _cu8_to_complex(raw) + pcm = _iq_usb_to_pcm16( + iq_samples=iq, + iq_sample_rate=int(iq_sample_rate), + audio_sample_rate=int(decoder.sample_rate), + ) + if not pcm: + continue + + waiting_since = None + last_pcm_at = time.monotonic() + pcm_bytes += len(pcm) + + if not first_pcm_logged: + first_pcm_logged = True + if pcm_ready_event is not None: + pcm_ready_event.set() + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'info', + 'text': f'[pcm] first IQ demod chunk: {len(pcm)} bytes', + }) + + events = decoder.process_block(pcm) + for event in events: + if event.get('type') == 'scope': + now = time.monotonic() + if now - last_scope >= SCOPE_INTERVAL: + last_scope = now + with contextlib.suppress(queue.Full): + output_queue.put_nowait(event) + else: + with contextlib.suppress(queue.Full): + output_queue.put_nowait(event) + + now = time.monotonic() + if (now - pcm_report_at) >= 1.0: + kbps = (pcm_bytes * 8.0) / max(1e-6, (now - pcm_report_at)) / 1000.0 + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'info', + 'text': f'[pcm] {pcm_bytes} B in {now - pcm_report_at:.1f}s ({kbps:.1f} kbps)', + }) + pcm_bytes = 0 + pcm_report_at = now + + except Exception as e: # pragma: no cover - runtime safety + logger.debug(f'Morse IQ decoder thread error: {e}') + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'info', + 'text': f'[iq] decoder thread error: {e}', + }) + finally: + stop_event.set() + if reader_thread is not None: + reader_thread.join(timeout=0.35) + + for event in decoder.flush(): + with contextlib.suppress(queue.Full): + output_queue.put_nowait(event) + + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'status', + 'status': 'stopped', + 'metrics': decoder.get_metrics(), + }) From 3abacbe601611ef6cf89cf3cb968703e1dbf547c Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 13:30:09 +0000 Subject: [PATCH 26/52] Speed up Morse startup failure cleanup to avoid request timeouts --- routes/morse.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index 9018fb5..b6a9834 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -432,10 +432,21 @@ def start_morse() -> Response: if proc is not None: _close_pipe(getattr(proc, 'stdout', None)) _close_pipe(getattr(proc, 'stderr', None)) - safe_terminate(proc, timeout=0.5) + # Keep startup retries responsive; avoid long waits inside + # generic safe_terminate() during a failed attempt. + if proc.poll() is None: + with contextlib.suppress(Exception): + proc.terminate() + with contextlib.suppress(subprocess.TimeoutExpired, Exception): + proc.wait(timeout=0.15) + if proc.poll() is None: + with contextlib.suppress(Exception): + proc.kill() + with contextlib.suppress(subprocess.TimeoutExpired, Exception): + proc.wait(timeout=0.25) unregister_process(proc) - _join_thread(attempt_decoder_thread, timeout_s=0.35) - _join_thread(attempt_stderr_thread, timeout_s=0.35) + _join_thread(attempt_decoder_thread, timeout_s=0.20) + _join_thread(attempt_stderr_thread, timeout_s=0.20) attempt_errors: list[str] = [] full_cmd = '' @@ -560,7 +571,7 @@ def start_morse() -> Response: ) decoder_thread.start() - startup_deadline = time.monotonic() + 2.0 + startup_deadline = time.monotonic() + 1.2 startup_ok = False startup_error = '' From faf5fd27d80f84ffb5ef451ea76515552a332262 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 13:44:04 +0000 Subject: [PATCH 27/52] Switch Morse startup to IQ-first and harden timeout handling --- routes/morse.py | 27 ++++++++++++++------------- static/js/modes/morse.js | 30 +++++++++++++++++++++++++----- tests/test_morse.py | 23 ++++++++++++++++------- 3 files changed, 55 insertions(+), 25 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index b6a9834..6cd2049 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -354,8 +354,17 @@ def start_morse() -> Response: can_try_direct_sampling = bool(sdr_device.sdr_type == SDRType.RTL_SDR and freq < 24.0) if can_try_direct_sampling: - # Keep rtl_fm attempts first (cheap), then switch to IQ capture fallback. + # IQ-first strategy: avoid repeated rtl_fm/rtl_sdr handoffs that can + # leave the tuner in a bad state on some Linux builds. command_attempts: list[dict[str, Any]] = [ + { + 'source': 'iq', + 'direct_sampling_mode': 2, + }, + { + 'source': 'iq', + 'direct_sampling_mode': None, + }, { 'source': 'rtl_fm', 'use_direct_sampling': True, @@ -370,17 +379,13 @@ def start_morse() -> Response: 'add_resample_rate': True, 'add_dc_fast': True, }, - { - 'source': 'iq', - 'direct_sampling_mode': 2, - }, + ] + else: + command_attempts = [ { 'source': 'iq', 'direct_sampling_mode': None, }, - ] - else: - command_attempts = [ { 'source': 'rtl_fm', 'use_direct_sampling': False, @@ -388,10 +393,6 @@ def start_morse() -> Response: 'add_resample_rate': True, 'add_dc_fast': True, }, - { - 'source': 'iq', - 'direct_sampling_mode': None, - }, ] rtl_process: subprocess.Popen | None = None @@ -571,7 +572,7 @@ def start_morse() -> Response: ) decoder_thread.start() - startup_deadline = time.monotonic() + 1.2 + startup_deadline = time.monotonic() + (2.5 if source == 'iq' else 1.2) startup_ok = False startup_error = '' diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index ef23a66..7733946 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -8,7 +8,7 @@ var MorseMode = (function () { var SETTINGS_KEY = 'intercept.morse.settings.v3'; var STATUS_POLL_MS = 5000; var LOCAL_STOP_TIMEOUT_MS = 2200; - var START_TIMEOUT_MS = 20000; + var START_TIMEOUT_MS = 60000; var state = { initialized: false, @@ -324,12 +324,32 @@ var MorseMode = (function () { if (seq !== state.startSeq) { return { status: 'stale' }; } + var initialErrorMsg = String(err && err.message ? err.message : err); + if (initialErrorMsg === 'Request timed out while waiting for decoder startup') { + return fetch('/morse/status') + .then(function (r) { return parseJsonSafe(r); }) + .then(function (statusData) { + var statusError = statusData && (statusData.error || statusData.message); + var resolvedError = statusError ? String(statusError) : initialErrorMsg; + setLifecycle('error'); + setStatusText('Start failed'); + appendDiagLine('[start] failed: ' + resolvedError); + notifyError('Failed to start Morse decoder: ' + resolvedError); + return { status: 'error', message: resolvedError }; + }) + .catch(function () { + setLifecycle('error'); + setStatusText('Start failed'); + appendDiagLine('[start] failed: ' + initialErrorMsg); + notifyError('Failed to start Morse decoder: ' + initialErrorMsg); + return { status: 'error', message: initialErrorMsg }; + }); + } setLifecycle('error'); - var errorMsg = String(err && err.message ? err.message : err); setStatusText('Start failed'); - appendDiagLine('[start] failed: ' + errorMsg); - notifyError('Failed to start Morse decoder: ' + errorMsg); - return { status: 'error', message: errorMsg }; + appendDiagLine('[start] failed: ' + initialErrorMsg); + notifyError('Failed to start Morse decoder: ' + initialErrorMsg); + return { status: 'error', message: initialErrorMsg }; }); } diff --git a/tests/test_morse.py b/tests/test_morse.py index 95adff8..8d48b88 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -266,6 +266,13 @@ class TestMorseLifecycleRoutes: def build_fm_demod_command(self, **kwargs): return ['rtl_fm', '-f', '14060000'] + def build_iq_capture_command(self, **kwargs): + cmd = ['rtl_sdr', '-f', '14060000', '-s', '250000'] + if kwargs.get('gain') is not None: + cmd.extend(['-g', str(kwargs['gain'])]) + cmd.append('-') + return cmd + monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice())) monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder())) monkeypatch.setattr(morse_routes.time, 'sleep', lambda _secs: None) @@ -337,6 +344,10 @@ class TestMorseLifecycleRoutes: cmd.append('-') return cmd + def build_iq_capture_command(self, **kwargs): + cmd = ['rtl_sdr', '-f', '14.0593M', '-s', '250000', '-'] + return cmd + monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice())) monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder())) @@ -378,13 +389,11 @@ class TestMorseLifecycleRoutes: assert start_resp.status_code == 200 assert start_resp.get_json()['status'] == 'started' assert len(popen_cmds) >= 2 - assert '-E' in popen_cmds[0] and 'direct2' in popen_cmds[0] - assert '-r' in popen_cmds[0] - assert '-A' in popen_cmds[0] - assert '-E' in popen_cmds[1] and 'direct2' not in popen_cmds[1] - assert '-r' in popen_cmds[1] - assert '-A' in popen_cmds[1] - assert 'dc' in popen_cmds[1] + assert popen_cmds[0][0] == 'rtl_sdr' + assert '-D' in popen_cmds[0] + assert '2' in popen_cmds[0] + assert popen_cmds[1][0] == 'rtl_sdr' + assert '-D' not in popen_cmds[1] stop_resp = client.post('/morse/stop') assert stop_resp.status_code == 200 From 027f3eac51a3101b3bb2912182acb4036791437b Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 14:08:22 +0000 Subject: [PATCH 28/52] Force fresh Morse JS and robust IQ stdout capture --- routes/morse.py | 9 ++++++++- templates/index.html | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index 6cd2049..6675600 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -350,6 +350,13 @@ def start_morse() -> Response: iq_cmd[-1:-1] = ['-D', str(direct_sampling_mode)] else: iq_cmd.extend(['-D', str(direct_sampling_mode)]) + # Some rtl_sdr builds treat "-" as a literal filename instead of stdout. + # Use /dev/stdout explicitly on Unix-like systems for deterministic piping. + if iq_cmd: + if iq_cmd[-1] == '-': + iq_cmd[-1] = '/dev/stdout' + elif '/dev/stdout' not in iq_cmd: + iq_cmd.append('/dev/stdout') return iq_cmd, tune_mhz can_try_direct_sampling = bool(sdr_device.sdr_type == SDRType.RTL_SDR and freq < 24.0) @@ -628,7 +635,7 @@ def start_morse() -> Response: if rtl_process is None or stop_event is None or control_queue is None or decoder_thread is None: msg = 'SDR capture started but no PCM stream was received.' if attempt_errors: - msg = msg + ' ' + ' | '.join(attempt_errors[-2:]) + msg = msg + ' ' + ' | '.join(attempt_errors) logger.error(f'Morse startup failed: {msg}') with app_module.morse_lock: if morse_active_device is not None: diff --git a/templates/index.html b/templates/index.html index a48009b..a45d8d0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3193,7 +3193,7 @@ - + From 8546a088a90355e17ab94489c92baea7b03e5d6e Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 15:08:03 +0000 Subject: [PATCH 29/52] Use stable RTL IQ sample rate for Morse IQ fallback --- routes/morse.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/routes/morse.py b/routes/morse.py index 6675600..2d99524 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -327,7 +327,9 @@ def start_morse() -> Response: cmd[insert_at:insert_at] = ['-E', 'dc'] return cmd - iq_sample_rate = 250000 + # Use a hardware-friendly IQ rate (matches common RTL-SDR stable rates + # and waterfall defaults) before decimating to audio. + iq_sample_rate = 1024000 def _build_iq_cmd(*, direct_sampling_mode: int | None) -> tuple[list[str], float]: # CW USB-style offset tuning: keep the configured RF frequency sounding From 213d32f1c8d5d5364847a36ca6f913b54c96611b Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 15:37:17 +0000 Subject: [PATCH 30/52] Fix Morse stderr thread race and broaden startup fallbacks --- routes/morse.py | 134 ++++++++++++++++++++++++++++++++++---------- tests/test_morse.py | 3 +- 2 files changed, 106 insertions(+), 31 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index 2d99524..0e953b0 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -284,7 +284,7 @@ def start_morse() -> Response: def _build_rtl_cmd( *, - use_direct_sampling: bool, + direct_sampling_mode: int | None, force_squelch_off: bool, add_resample_rate: bool, add_dc_fast: bool, @@ -300,8 +300,8 @@ def start_morse() -> Response: } # Only rtl_fm supports direct sampling flags. - if use_direct_sampling: - fm_kwargs['direct_sampling'] = 2 + if direct_sampling_mode in (1, 2): + fm_kwargs['direct_sampling'] = int(direct_sampling_mode) cmd = builder.build_fm_demod_command(**fm_kwargs) @@ -370,20 +370,31 @@ def start_morse() -> Response: 'source': 'iq', 'direct_sampling_mode': 2, }, + { + 'source': 'iq', + 'direct_sampling_mode': 1, + }, { 'source': 'iq', 'direct_sampling_mode': None, }, { 'source': 'rtl_fm', - 'use_direct_sampling': True, + 'direct_sampling_mode': 2, 'force_squelch_off': False, 'add_resample_rate': True, 'add_dc_fast': True, }, { 'source': 'rtl_fm', - 'use_direct_sampling': False, + 'direct_sampling_mode': 1, + 'force_squelch_off': False, + 'add_resample_rate': True, + 'add_dc_fast': True, + }, + { + 'source': 'rtl_fm', + 'direct_sampling_mode': None, 'force_squelch_off': False, 'add_resample_rate': True, 'add_dc_fast': True, @@ -397,7 +408,7 @@ def start_morse() -> Response: }, { 'source': 'rtl_fm', - 'use_direct_sampling': False, + 'direct_sampling_mode': None, 'force_squelch_off': False, 'add_resample_rate': True, 'add_dc_fast': True, @@ -440,8 +451,9 @@ def start_morse() -> Response: with contextlib.suppress(queue.Full): attempt_control_queue.put_nowait({'cmd': 'shutdown'}) if proc is not None: + # Close stdout to unblock decoder reads. Keep stderr open until + # after stderr monitor thread exits to avoid ValueError races. _close_pipe(getattr(proc, 'stdout', None)) - _close_pipe(getattr(proc, 'stderr', None)) # Keep startup retries responsive; avoid long waits inside # generic safe_terminate() during a failed attempt. if proc.poll() is None: @@ -456,18 +468,33 @@ def start_morse() -> Response: proc.wait(timeout=0.25) unregister_process(proc) _join_thread(attempt_decoder_thread, timeout_s=0.20) - _join_thread(attempt_stderr_thread, timeout_s=0.20) + stderr_joined = _join_thread(attempt_stderr_thread, timeout_s=0.35) + if proc is not None: + if not stderr_joined: + # Force-close the pipe if stderr reader is still blocked. + _close_pipe(getattr(proc, 'stderr', None)) + _join_thread(attempt_stderr_thread, timeout_s=0.15) + _close_pipe(getattr(proc, 'stderr', None)) attempt_errors: list[str] = [] full_cmd = '' for attempt_index, attempt in enumerate(command_attempts, start=1): + runtime_config.pop('startup_waiting', None) + runtime_config.pop('startup_warning', None) source = str(attempt.get('source', 'rtl_fm')).strip().lower() - use_direct_sampling = bool(attempt.get('use_direct_sampling', False)) force_squelch_off = bool(attempt.get('force_squelch_off', True)) add_resample_rate = bool(attempt.get('add_resample_rate', False)) add_dc_fast = bool(attempt.get('add_dc_fast', False)) - direct_sampling_mode = attempt.get('direct_sampling_mode') + direct_sampling_mode_raw = attempt.get('direct_sampling_mode') + try: + direct_sampling_mode = ( + int(direct_sampling_mode_raw) + if direct_sampling_mode_raw is not None + else None + ) + except (TypeError, ValueError): + direct_sampling_mode = None if source == 'iq': rtl_cmd, tuned_freq_mhz = _build_iq_cmd( @@ -481,7 +508,7 @@ def start_morse() -> Response: ) else: rtl_cmd = _build_rtl_cmd( - use_direct_sampling=use_direct_sampling, + direct_sampling_mode=direct_sampling_mode, force_squelch_off=force_squelch_off, add_resample_rate=add_resample_rate, add_dc_fast=add_dc_fast, @@ -489,7 +516,7 @@ def start_morse() -> Response: tuned_freq_mhz = float(freq) thread_target = morse_decoder_thread attempt_desc = ( - f'source=rtl_fm direct={int(use_direct_sampling)} ' + f'source=rtl_fm direct_mode={direct_sampling_mode if direct_sampling_mode is not None else "none"} ' f'squelch_forced={int(force_squelch_off)} ' f'resample={int(add_resample_rate)} dc_fast={int(add_dc_fast)}' ) @@ -517,24 +544,40 @@ def start_morse() -> Response: stop_event = threading.Event() control_queue = queue.Queue(maxsize=16) pcm_ready_event = threading.Event() + attempt_stderr_lines: list[str] = [] def monitor_stderr( proc: subprocess.Popen = rtl_process, proc_stop_event: threading.Event = stop_event, tool_label: str = rtl_cmd[0], + stderr_lines: list[str] = attempt_stderr_lines, ) -> None: - if proc.stderr is None: + try: + stderr_stream = proc.stderr + if stderr_stream is None: + return + while not proc_stop_event.is_set(): + line = stderr_stream.readline() + if not line: + if proc.poll() is not None: + break + time.sleep(0.02) + continue + err_text = line.decode('utf-8', errors='replace').strip() + if err_text: + if len(stderr_lines) >= 40: + del stderr_lines[:10] + stderr_lines.append(err_text) + with contextlib.suppress(queue.Full): + app_module.morse_queue.put_nowait({ + 'type': 'info', + 'text': f'[{tool_label}] {err_text}', + }) + except ValueError: + # Pipe was closed during shutdown; expected during retries. + return + except Exception: return - for line in proc.stderr: - if proc_stop_event.is_set(): - break - err_text = line.decode('utf-8', errors='replace').strip() - if err_text: - with contextlib.suppress(queue.Full): - app_module.morse_queue.put_nowait({ - 'type': 'info', - 'text': f'[{tool_label}] {err_text}', - }) stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr') stderr_thread.start() @@ -581,7 +624,7 @@ def start_morse() -> Response: ) decoder_thread.start() - startup_deadline = time.monotonic() + (2.5 if source == 'iq' else 1.2) + startup_deadline = time.monotonic() + (4.0 if source == 'iq' else 2.0) startup_ok = False startup_error = '' @@ -594,6 +637,34 @@ def start_morse() -> Response: break time.sleep(0.05) + if not startup_ok: + if not startup_error: + startup_error = 'No PCM samples received within startup timeout' + if attempt_stderr_lines: + startup_error = f'{startup_error}; stderr: {attempt_stderr_lines[-1]}' + + is_last_attempt = attempt_index == len(command_attempts) + if ( + is_last_attempt + and rtl_process.poll() is None + and decoder_thread.is_alive() + ): + # Avoid hard-failing startup when SDR is alive but muted. + startup_ok = True + runtime_config['startup_waiting'] = True + runtime_config['startup_warning'] = startup_error + logger.warning( + 'Morse startup continuing without PCM (attempt %s/%s): %s', + attempt_index, + len(command_attempts), + startup_error, + ) + with contextlib.suppress(queue.Full): + app_module.morse_queue.put_nowait({ + 'type': 'info', + 'text': '[morse] stream alive but no PCM yet; continuing in waiting mode', + }) + if startup_ok: runtime_config['source'] = source runtime_config['command'] = full_cmd @@ -601,15 +672,12 @@ def start_morse() -> Response: runtime_config['direct_sampling'] = ( int(direct_sampling_mode) if source == 'iq' and direct_sampling_mode is not None - else (2 if use_direct_sampling else 0) + else (int(direct_sampling_mode) if direct_sampling_mode is not None else 0) ) runtime_config['iq_sample_rate'] = iq_sample_rate if source == 'iq' else None runtime_config['direct_sampling_mode'] = direct_sampling_mode if source == 'iq' else None break - if not startup_error: - startup_error = 'No PCM samples received within startup timeout' - attempt_errors.append( f'attempt {attempt_index}/{len(command_attempts)} ({attempt_desc}): {startup_error}' ) @@ -759,8 +827,7 @@ def stop_morse() -> Response: if proc is not None: _close_pipe(getattr(proc, 'stdout', None)) - _close_pipe(getattr(proc, 'stderr', None)) - _mark('stdout/stderr pipes closed') + _mark('stdout pipe closed') safe_terminate(proc, timeout=0.6) unregister_process(proc) @@ -768,6 +835,13 @@ def stop_morse() -> Response: decoder_joined = _join_thread(decoder_thread, timeout_s=0.45) stderr_joined = _join_thread(stderr_thread, timeout_s=0.45) + if proc is not None: + if not stderr_joined: + _close_pipe(getattr(proc, 'stderr', None)) + stderr_joined = _join_thread(stderr_thread, timeout_s=0.20) + _mark('stderr pipe force-closed') + _close_pipe(getattr(proc, 'stderr', None)) + _mark('stderr pipe closed') _mark(f'decoder thread joined={decoder_joined}') _mark(f'stderr thread joined={stderr_joined}') diff --git a/tests/test_morse.py b/tests/test_morse.py index 8d48b88..a53274f 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -393,7 +393,8 @@ class TestMorseLifecycleRoutes: assert '-D' in popen_cmds[0] assert '2' in popen_cmds[0] assert popen_cmds[1][0] == 'rtl_sdr' - assert '-D' not in popen_cmds[1] + assert '-D' in popen_cmds[1] + assert '1' in popen_cmds[1] stop_resp = client.post('/morse/stop') assert stop_resp.status_code == 200 From 68247feec14b59101ec039165be9ec4b7924072d Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 16:02:10 +0000 Subject: [PATCH 31/52] Use non-blocking pipe reads and raw-stream telemetry for Morse --- routes/morse.py | 5 +++++ utils/morse.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index 0e953b0..8936f3c 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -544,6 +544,7 @@ def start_morse() -> Response: stop_event = threading.Event() control_queue = queue.Queue(maxsize=16) pcm_ready_event = threading.Event() + stream_ready_event = threading.Event() attempt_stderr_lines: list[str] = [] def monitor_stderr( @@ -598,6 +599,7 @@ def start_morse() -> Response: 'decoder_config': runtime_config, 'control_queue': control_queue, 'pcm_ready_event': pcm_ready_event, + 'stream_ready_event': stream_ready_event, }, daemon=True, name='morse-decoder', @@ -617,6 +619,7 @@ def start_morse() -> Response: 'decoder_config': runtime_config, 'control_queue': control_queue, 'pcm_ready_event': pcm_ready_event, + 'stream_ready_event': stream_ready_event, 'strip_text_chunks': False, }, daemon=True, @@ -642,6 +645,8 @@ def start_morse() -> Response: startup_error = 'No PCM samples received within startup timeout' if attempt_stderr_lines: startup_error = f'{startup_error}; stderr: {attempt_stderr_lines[-1]}' + if stream_ready_event.is_set(): + startup_error = f'{startup_error}; stream=alive' is_last_attempt = attempt_index == len(command_attempts) if ( diff --git a/utils/morse.py b/utils/morse.py index 915b953..58c03dc 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -11,7 +11,9 @@ from __future__ import annotations import contextlib import math +import os import queue +import select import struct import threading import time @@ -763,6 +765,7 @@ def morse_decoder_thread( decoder_config: dict[str, Any] | None = None, control_queue: queue.Queue | None = None, pcm_ready_event: threading.Event | None = None, + stream_ready_event: threading.Event | None = None, strip_text_chunks: bool = False, ) -> None: """Decode Morse from live PCM stream and push events to *output_queue*.""" @@ -800,16 +803,26 @@ def morse_decoder_thread( first_pcm_logged = False reader_done = threading.Event() reader_thread: threading.Thread | None = None + first_raw_logged = False raw_queue: queue.Queue[bytes] = queue.Queue(maxsize=96) try: def _reader_loop() -> None: """Blocking PCM reader isolated from decode/control loop.""" + nonlocal first_raw_logged try: + fd = None + with contextlib.suppress(Exception): + fd = rtl_stdout.fileno() while not stop_event.is_set(): try: - if hasattr(rtl_stdout, 'read1'): + if fd is not None: + ready, _, _ = select.select([fd], [], [], 0.20) + if not ready: + continue + data = os.read(fd, CHUNK) + elif hasattr(rtl_stdout, 'read1'): data = rtl_stdout.read1(CHUNK) else: data = rtl_stdout.read(CHUNK) @@ -827,6 +840,16 @@ def morse_decoder_thread( if not data: break + if not first_raw_logged: + first_raw_logged = True + if stream_ready_event is not None: + stream_ready_event.set() + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'info', + 'text': f'[pcm] first raw chunk: {len(data)} bytes', + }) + if strip_text_chunks and _is_probably_rtl_log_text(data): try: text = data.decode('utf-8', errors='replace') @@ -1026,6 +1049,7 @@ def morse_iq_decoder_thread( decoder_config: dict[str, Any] | None = None, control_queue: queue.Queue | None = None, pcm_ready_event: threading.Event | None = None, + stream_ready_event: threading.Event | None = None, ) -> None: """Decode Morse from raw IQ (cu8) by in-process USB demodulation.""" import logging @@ -1062,15 +1086,25 @@ def morse_iq_decoder_thread( first_pcm_logged = False reader_done = threading.Event() reader_thread: threading.Thread | None = None + first_raw_logged = False raw_queue: queue.Queue[bytes] = queue.Queue(maxsize=96) try: def _reader_loop() -> None: + nonlocal first_raw_logged try: + fd = None + with contextlib.suppress(Exception): + fd = iq_stdout.fileno() while not stop_event.is_set(): try: - if hasattr(iq_stdout, 'read1'): + if fd is not None: + ready, _, _ = select.select([fd], [], [], 0.20) + if not ready: + continue + data = os.read(fd, CHUNK) + elif hasattr(iq_stdout, 'read1'): data = iq_stdout.read1(CHUNK) else: data = iq_stdout.read(CHUNK) @@ -1087,6 +1121,16 @@ def morse_iq_decoder_thread( if not data: break + if not first_raw_logged: + first_raw_logged = True + if stream_ready_event is not None: + stream_ready_event.set() + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'info', + 'text': f'[iq] first raw chunk: {len(data)} bytes', + }) + try: raw_queue.put(data, timeout=0.2) except queue.Full: From 39c7075648e62f4f646f8b43acb9505a93411ea2 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 16:16:46 +0000 Subject: [PATCH 32/52] Use fd-backed stdout paths for Morse rtl_sdr/rtl_fm --- routes/morse.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index 8936f3c..779b3df 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib +import os import queue import subprocess import tempfile @@ -102,6 +103,15 @@ def _close_pipe(pipe_obj: Any) -> None: pipe_obj.close() +def _stdout_target_path() -> str: + """Return the most reliable stdout path for rtl_* tools on this host.""" + if os.name == 'posix': + for candidate in ('/proc/self/fd/1', '/dev/fd/1', '/dev/stdout'): + if Path(candidate).exists(): + return candidate + return '-' + + def _bool_value(value: Any, default: bool = False) -> bool: if isinstance(value, bool): return value @@ -325,6 +335,13 @@ def start_morse() -> Response: insert_at += 2 if '-E' not in cmd or 'dc' not in cmd: cmd[insert_at:insert_at] = ['-E', 'dc'] + # Some rtl_fm builds treat "-" as a literal filename. Use an + # explicit fd-backed stdout path for deterministic piping. + out_target = _stdout_target_path() + if cmd and cmd[-1] == '-': + cmd[-1] = out_target + elif out_target not in cmd: + cmd.append(out_target) return cmd # Use a hardware-friendly IQ rate (matches common RTL-SDR stable rates @@ -353,12 +370,13 @@ def start_morse() -> Response: else: iq_cmd.extend(['-D', str(direct_sampling_mode)]) # Some rtl_sdr builds treat "-" as a literal filename instead of stdout. - # Use /dev/stdout explicitly on Unix-like systems for deterministic piping. + # Use an explicit fd-backed stdout path for deterministic piping. + out_target = _stdout_target_path() if iq_cmd: if iq_cmd[-1] == '-': - iq_cmd[-1] = '/dev/stdout' - elif '/dev/stdout' not in iq_cmd: - iq_cmd.append('/dev/stdout') + iq_cmd[-1] = out_target + elif out_target not in iq_cmd: + iq_cmd.append(out_target) return iq_cmd, tune_mhz can_try_direct_sampling = bool(sdr_device.sdr_type == SDRType.RTL_SDR and freq < 24.0) From 46dcd3f51119848acb29db8b228838ac03f1a903 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 16:25:37 +0000 Subject: [PATCH 33/52] Add FIFO transport fallback for Morse SDR sample stream --- routes/morse.py | 106 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 102 insertions(+), 4 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index 779b3df..ae793f8 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -112,6 +112,44 @@ def _stdout_target_path() -> str: return '-' +def _is_real_tool_binary(cmd: list[str]) -> bool: + """Heuristic to avoid FIFO plumbing in unit tests with fake binaries.""" + if not cmd: + return False + exe = str(cmd[0] or '') + return exe.startswith('/') and Path(exe).exists() + + +def _prepare_fifo_output(cmd: list[str], *, token: str) -> tuple[list[str], Any | None, str | None]: + """Optionally route rtl_* output through a named pipe for robust reading.""" + if os.name != 'posix' or not _is_real_tool_binary(cmd): + return cmd, None, None + if not cmd: + return cmd, None, None + try: + fifo_dir = Path(tempfile.gettempdir()) + fifo_path = fifo_dir / f'morse_{token}_{int(time.time() * 1000)}.fifo' + with contextlib.suppress(FileNotFoundError): + fifo_path.unlink() + os.mkfifo(fifo_path, 0o600) + + fifo_cmd = list(cmd) + if fifo_cmd: + fifo_cmd[-1] = str(fifo_path) + + reader_fd = os.open(str(fifo_path), os.O_RDONLY | os.O_NONBLOCK) + reader = os.fdopen(reader_fd, 'rb', buffering=0) + return fifo_cmd, reader, str(fifo_path) + except Exception: + with contextlib.suppress(Exception): + if 'reader' in locals() and reader is not None: + reader.close() + with contextlib.suppress(Exception): + if 'fifo_path' in locals(): + Path(fifo_path).unlink(missing_ok=True) + return cmd, None, None + + def _bool_value(value: Any, default: bool = False) -> bool: if isinstance(value, bool): return value @@ -438,6 +476,8 @@ def start_morse() -> Response: decoder_thread: threading.Thread | None = None stderr_thread: threading.Thread | None = None control_queue: queue.Queue | None = None + decoder_input: Any | None = None + fifo_path: str | None = None runtime_config: dict[str, Any] = { 'sample_rate': sample_rate, @@ -462,12 +502,18 @@ def start_morse() -> Response: attempt_control_queue: queue.Queue | None, attempt_decoder_thread: threading.Thread | None, attempt_stderr_thread: threading.Thread | None, + attempt_stream_handle: Any | None = None, + attempt_fifo_path: str | None = None, ) -> None: if attempt_stop_event is not None: attempt_stop_event.set() if attempt_control_queue is not None: with contextlib.suppress(queue.Full): attempt_control_queue.put_nowait({'cmd': 'shutdown'}) + if attempt_stream_handle is not None and ( + proc is None or attempt_stream_handle is not getattr(proc, 'stdout', None) + ): + _close_pipe(attempt_stream_handle) if proc is not None: # Close stdout to unblock decoder reads. Keep stderr open until # after stderr monitor thread exits to avoid ValueError races. @@ -493,6 +539,9 @@ def start_morse() -> Response: _close_pipe(getattr(proc, 'stderr', None)) _join_thread(attempt_stderr_thread, timeout_s=0.15) _close_pipe(getattr(proc, 'stderr', None)) + if attempt_fifo_path: + with contextlib.suppress(Exception): + Path(attempt_fifo_path).unlink(missing_ok=True) attempt_errors: list[str] = [] full_cmd = '' @@ -551,13 +600,30 @@ def start_morse() -> Response: 'text': f'[cmd] {full_cmd}', }) - rtl_process = subprocess.Popen( + fifo_cmd, fifo_reader, fifo_path = _prepare_fifo_output( rtl_cmd, - stdout=subprocess.PIPE, + token=f'{morse_session_id}_{attempt_index}', + ) + if fifo_cmd is not rtl_cmd: + full_cmd = ' '.join(fifo_cmd) + logger.info( + f'Morse decoder attempt {attempt_index}/{len(command_attempts)} ' + f'({attempt_desc}) via fifo: {full_cmd}' + ) + with contextlib.suppress(queue.Full): + app_module.morse_queue.put_nowait({ + 'type': 'info', + 'text': f'[cmd] {full_cmd}', + }) + + rtl_process = subprocess.Popen( + fifo_cmd, + stdout=(subprocess.DEVNULL if fifo_reader is not None else subprocess.PIPE), stderr=subprocess.PIPE, bufsize=0, ) register_process(rtl_process) + decoder_input = fifo_reader if fifo_reader is not None else rtl_process.stdout stop_event = threading.Event() control_queue = queue.Queue(maxsize=16) @@ -605,7 +671,7 @@ def start_morse() -> Response: decoder_thread = threading.Thread( target=thread_target, args=( - rtl_process.stdout, + decoder_input, app_module.morse_queue, stop_event, iq_sample_rate, @@ -626,7 +692,7 @@ def start_morse() -> Response: decoder_thread = threading.Thread( target=thread_target, args=( - rtl_process.stdout, + decoder_input, app_module.morse_queue, stop_event, sample_rate, @@ -718,12 +784,16 @@ def start_morse() -> Response: control_queue, decoder_thread, stderr_thread, + decoder_input, + fifo_path, ) rtl_process = None stop_event = None control_queue = None decoder_thread = None stderr_thread = None + decoder_input = None + fifo_path = None if rtl_process is None or stop_event is None or control_queue is None or decoder_thread is None: msg = 'SDR capture started but no PCM stream was received.' @@ -745,6 +815,8 @@ def start_morse() -> Response: app_module.morse_process._decoder_thread = decoder_thread app_module.morse_process._stderr_thread = stderr_thread app_module.morse_process._control_queue = control_queue + app_module.morse_process._stream_handle = decoder_input + app_module.morse_process._fifo_path = fifo_path morse_stop_event = stop_event morse_control_queue = control_queue @@ -772,6 +844,13 @@ def start_morse() -> Response: except FileNotFoundError as e: if rtl_process is not None: unregister_process(rtl_process) + if decoder_input is not None and ( + rtl_process is None or decoder_input is not getattr(rtl_process, 'stdout', None) + ): + _close_pipe(decoder_input) + if fifo_path: + with contextlib.suppress(Exception): + Path(fifo_path).unlink(missing_ok=True) with app_module.morse_lock: if morse_active_device is not None: app_module.release_sdr_device(morse_active_device) @@ -785,6 +864,13 @@ def start_morse() -> Response: if rtl_process is not None: safe_terminate(rtl_process, timeout=0.5) unregister_process(rtl_process) + if decoder_input is not None and ( + rtl_process is None or decoder_input is not getattr(rtl_process, 'stdout', None) + ): + _close_pipe(decoder_input) + if fifo_path: + with contextlib.suppress(Exception): + Path(fifo_path).unlink(missing_ok=True) if stop_event is not None: stop_event.set() _join_thread(decoder_thread, timeout_s=0.25) @@ -815,6 +901,8 @@ def stop_morse() -> Response: decoder_thread = morse_decoder_worker or getattr(proc, '_decoder_thread', None) stderr_thread = morse_stderr_worker or getattr(proc, '_stderr_thread', None) control_queue = morse_control_queue or getattr(proc, '_control_queue', None) + stream_handle = getattr(proc, '_stream_handle', None) if proc else None + fifo_path = getattr(proc, '_fifo_path', None) if proc else None active_device = morse_active_device if not proc and not stop_event and not decoder_thread and not stderr_thread: @@ -848,6 +936,12 @@ def stop_morse() -> Response: control_queue.put_nowait({'cmd': 'shutdown'}) _mark('control_queue shutdown signal sent') + if stream_handle is not None and ( + proc is None or stream_handle is not getattr(proc, 'stdout', None) + ): + _close_pipe(stream_handle) + _mark('decoder input stream closed') + if proc is not None: _close_pipe(getattr(proc, 'stdout', None)) _mark('stdout pipe closed') @@ -865,6 +959,10 @@ def stop_morse() -> Response: _mark('stderr pipe force-closed') _close_pipe(getattr(proc, 'stderr', None)) _mark('stderr pipe closed') + if fifo_path: + with contextlib.suppress(Exception): + Path(fifo_path).unlink(missing_ok=True) + _mark('fifo path removed') _mark(f'decoder thread joined={decoder_joined}') _mark(f'stderr thread joined={stderr_joined}') From da9f7ad18662453dd5d25c943a08b02ad77949f9 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 17:20:20 +0000 Subject: [PATCH 34/52] morse: switch live decode to rtl_fm + multimon backend --- README.md | 3 + routes/morse.py | 1034 ++++++++++++++++++++++++------------------- tests/test_morse.py | 120 +++-- 3 files changed, 670 insertions(+), 487 deletions(-) diff --git a/README.md b/README.md index a1a0738..a127e17 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,9 @@ Support the developer of this open-source project ## CW / Morse Decoder Notes +Live backend: +- Uses `rtl_fm` piped into `multimon-ng` (`MORSE_CW`) for real-time decode. + Recommended baseline settings: - **Tone**: `700 Hz` - **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals) diff --git a/routes/morse.py b/routes/morse.py index ae793f8..2a76e2c 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -3,8 +3,13 @@ from __future__ import annotations import contextlib +import math import os +import pty import queue +import re +import select +import struct import subprocess import tempfile import threading @@ -16,11 +21,10 @@ from flask import Blueprint, Response, jsonify, request import app as app_module from utils.event_pipeline import process_event +from utils.dependencies import get_tool_path from utils.logging import sensor_logger as logger from utils.morse import ( decode_morse_wav_file, - morse_decoder_thread, - morse_iq_decoder_thread, ) from utils.process import register_process, safe_terminate, unregister_process from utils.sdr import SDRFactory, SDRType @@ -53,9 +57,12 @@ morse_session_id = 0 morse_decoder_worker: threading.Thread | None = None morse_stderr_worker: threading.Thread | None = None +morse_relay_worker: threading.Thread | None = None morse_stop_event: threading.Event | None = None morse_control_queue: queue.Queue | None = None +MORSE_LINE_RE = re.compile(r'^\s*(?:MORSE(?:_CW)?(?:\([^)]*\))?)\s*:\s*(.*)$', re.IGNORECASE) + def _set_state(state: str, message: str = '', *, enqueue: bool = True, extra: dict[str, Any] | None = None) -> None: """Update lifecycle state and optionally emit a status queue event.""" @@ -112,42 +119,287 @@ def _stdout_target_path() -> str: return '-' -def _is_real_tool_binary(cmd: list[str]) -> bool: - """Heuristic to avoid FIFO plumbing in unit tests with fake binaries.""" - if not cmd: - return False - exe = str(cmd[0] or '') - return exe.startswith('/') and Path(exe).exists() +def _queue_morse_event(payload: dict[str, Any]) -> None: + with contextlib.suppress(queue.Full): + app_module.morse_queue.put_nowait(payload) -def _prepare_fifo_output(cmd: list[str], *, token: str) -> tuple[list[str], Any | None, str | None]: - """Optionally route rtl_* output through a named pipe for robust reading.""" - if os.name != 'posix' or not _is_real_tool_binary(cmd): - return cmd, None, None - if not cmd: - return cmd, None, None +def _parse_multimon_morse_text(line: str) -> str | None: + cleaned = str(line or '').strip() + if not cleaned: + return None + + matched = MORSE_LINE_RE.match(cleaned) + if matched: + return matched.group(1).strip() + + lower = cleaned.lower() + if lower.startswith(('multimon-ng', 'available demodulators', 'enabled demodulators')): + return None + + if ':' in cleaned: + label, payload = cleaned.split(':', 1) + if 'morse' in label.upper(): + return payload.strip() + return None + + if len(cleaned) <= 128 and re.fullmatch(r"[A-Za-z0-9 /.,'!?+\-]+", cleaned): + return cleaned + + return None + + +def _emit_decoded_text(text: str) -> None: + filtered = ''.join(ch for ch in str(text or '') if ch == ' ' or 32 <= ord(ch) <= 126) + if not filtered: + return + + timestamp = time.strftime('%H:%M:%S') + for ch in filtered: + if ch.isspace(): + _queue_morse_event({ + 'type': 'morse_space', + 'timestamp': timestamp, + }) + else: + _queue_morse_event({ + 'type': 'morse_char', + 'char': ch, + 'morse': '', + 'timestamp': timestamp, + }) + + +def _compress_amplitudes(samples: tuple[int, ...], bins: int = 96) -> list[int]: + if not samples: + return [] + + step = max(1, len(samples) // bins) + out: list[int] = [] + for idx in range(0, len(samples), step): + if len(out) >= bins: + break + chunk = samples[idx:idx + step] + if not chunk: + continue + out.append(int(sum(abs(v) for v in chunk) / len(chunk))) + return out + + +def _read_pcm_chunk( + stream: Any, + chunk_bytes: int, + stop_event: threading.Event, + timeout_s: float = 0.2, +) -> bytes | None: + if stream is None: + return b'' + try: - fifo_dir = Path(tempfile.gettempdir()) - fifo_path = fifo_dir / f'morse_{token}_{int(time.time() * 1000)}.fifo' - with contextlib.suppress(FileNotFoundError): - fifo_path.unlink() - os.mkfifo(fifo_path, 0o600) - - fifo_cmd = list(cmd) - if fifo_cmd: - fifo_cmd[-1] = str(fifo_path) - - reader_fd = os.open(str(fifo_path), os.O_RDONLY | os.O_NONBLOCK) - reader = os.fdopen(reader_fd, 'rb', buffering=0) - return fifo_cmd, reader, str(fifo_path) + fileno = stream.fileno() except Exception: + if stop_event.is_set(): + return b'' with contextlib.suppress(Exception): - if 'reader' in locals() and reader is not None: - reader.close() - with contextlib.suppress(Exception): - if 'fifo_path' in locals(): - Path(fifo_path).unlink(missing_ok=True) - return cmd, None, None + return stream.read(chunk_bytes) + return b'' + + while not stop_event.is_set(): + try: + ready, _, _ = select.select([fileno], [], [], timeout_s) + except Exception: + return b'' + + if not ready: + return None + + try: + return os.read(fileno, chunk_bytes) + except BlockingIOError: + continue + except OSError: + return b'' + + return b'' + + +def _morse_audio_relay_thread( + rtl_stdout: Any, + multimon_stdin: Any, + output_queue: queue.Queue, + stop_event: threading.Event, + control_queue: queue.Queue | None, + runtime_config: dict[str, Any], + pcm_ready_event: threading.Event, +) -> None: + chunk_bytes = 4096 + scope_interval = 0.1 + waiting_threshold = 0.7 + + tone_freq = _float_value(runtime_config.get('tone_freq'), 700.0) + wpm = _float_value(runtime_config.get('wpm'), 15.0) + threshold_mode = str(runtime_config.get('threshold_mode', 'auto')).strip().lower() + manual_threshold = _float_value(runtime_config.get('manual_threshold'), 0.0) + threshold_multiplier = _float_value(runtime_config.get('threshold_multiplier'), 2.8) + threshold_offset = _float_value(runtime_config.get('threshold_offset'), 0.0) + signal_gate = _float_value(runtime_config.get('min_signal_gate'), 0.0) + + last_scope_emit = 0.0 + last_pcm_at = 0.0 + noise_floor = 0.0 + threshold = manual_threshold if threshold_mode == 'manual' else 0.0 + + try: + while not stop_event.is_set(): + if control_queue is not None: + while True: + try: + control_msg = control_queue.get_nowait() + except queue.Empty: + break + + cmd = str(control_msg.get('cmd', '')).strip().lower() + if cmd == 'shutdown': + stop_event.set() + break + if cmd == 'reset': + noise_floor = 0.0 + threshold = manual_threshold if threshold_mode == 'manual' else 0.0 + _queue_morse_event({ + 'type': 'info', + 'text': '[morse] Calibration reset applied', + }) + if stop_event.is_set(): + break + + payload = _read_pcm_chunk(rtl_stdout, chunk_bytes, stop_event) + now = time.monotonic() + + if payload is None: + if now - last_scope_emit >= scope_interval: + last_scope_emit = now + waiting = (last_pcm_at <= 0.0) or ((now - last_pcm_at) >= waiting_threshold) + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'scope', + 'waiting': waiting, + 'amplitudes': [], + 'tone_on': False, + 'level': 0.0, + 'threshold': round(threshold, 4), + 'noise_floor': round(noise_floor, 4), + 'tone_freq': tone_freq, + 'wpm': wpm, + }) + continue + + if not payload: + break + + last_pcm_at = now + pcm_ready_event.set() + + try: + multimon_stdin.write(payload) + multimon_stdin.flush() + except (BrokenPipeError, OSError): + break + + sample_count = len(payload) // 2 + if sample_count <= 0: + continue + try: + samples = struct.unpack(f'<{sample_count}h', payload[:sample_count * 2]) + except struct.error: + continue + + amplitudes = _compress_amplitudes(samples) + rms = math.sqrt(sum(s * s for s in samples) / sample_count) / 32768.0 + level = max(0.0, min(1.0, rms)) + + if noise_floor <= 0.0: + noise_floor = level + elif level <= noise_floor: + noise_floor = (noise_floor * 0.9) + (level * 0.1) + else: + noise_floor = (noise_floor * 0.995) + (level * 0.005) + + if threshold_mode == 'manual': + threshold = manual_threshold + else: + threshold = max(0.0, (noise_floor * threshold_multiplier) + threshold_offset) + + tone_on = level >= max(signal_gate, threshold) + + if now - last_scope_emit >= scope_interval: + last_scope_emit = now + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'scope', + 'waiting': False, + 'amplitudes': amplitudes, + 'tone_on': tone_on, + 'level': round(level, 4), + 'threshold': round(threshold, 4), + 'noise_floor': round(noise_floor, 4), + 'tone_freq': tone_freq, + 'wpm': wpm, + }) + except Exception as exc: + logger.debug('Morse audio relay error: %s', exc) + finally: + _close_pipe(multimon_stdin) + + +def _morse_multimon_output_thread( + master_fd: int, + process: subprocess.Popen[bytes], + stop_event: threading.Event, +) -> None: + buffer = '' + try: + while not stop_event.is_set(): + try: + ready, _, _ = select.select([master_fd], [], [], 0.2) + except Exception: + break + + if ready: + try: + raw = os.read(master_fd, 2048) + except OSError: + break + if not raw: + if process.poll() is not None: + break + continue + + buffer += raw.decode('utf-8', errors='replace') + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + line = line.strip() + if not line: + continue + text = _parse_multimon_morse_text(line) + if text is None: + _queue_morse_event({'type': 'info', 'text': f'[multimon] {line}'}) + continue + if text: + _emit_decoded_text(text) + + if process.poll() is not None: + break + + tail = buffer.strip() + if tail: + tail_text = _parse_multimon_morse_text(tail) + if tail_text: + _emit_decoded_text(tail_text) + except Exception as exc: + _queue_morse_event({'type': 'error', 'text': f'multimon output error: {exc}'}) + finally: + with contextlib.suppress(OSError): + os.close(master_fd) def _bool_value(value: Any, default: bool = False) -> bool: @@ -252,20 +504,24 @@ def _snapshot_live_resources() -> list[str]: alive.append('decoder_thread') if morse_stderr_worker and morse_stderr_worker.is_alive(): alive.append('stderr_thread') + if morse_relay_worker and morse_relay_worker.is_alive(): + alive.append('relay_thread') if app_module.morse_process and app_module.morse_process.poll() is None: - alive.append('rtl_process') + alive.append('multimon_process') + rtl_proc = getattr(app_module.morse_process, '_rtl_process', None) + if rtl_proc is not None and rtl_proc.poll() is None: + alive.append('rtl_process') return alive @morse_bp.route('/morse/start', methods=['POST']) def start_morse() -> Response: - global morse_active_device, morse_decoder_worker, morse_stderr_worker + global morse_active_device, morse_decoder_worker, morse_stderr_worker, morse_relay_worker global morse_stop_event, morse_control_queue, morse_runtime_config global morse_last_error, morse_session_id data = request.json or {} - # Validate standard SDR inputs try: freq = validate_frequency(data.get('frequency', '14.060'), min_mhz=0.5, max_mhz=30.0) gain = validate_gain(data.get('gain', '0')) @@ -274,7 +530,6 @@ def start_morse() -> Response: except ValueError as e: return jsonify({'status': 'error', 'message': str(e)}), 400 - # Validate Morse-specific inputs try: tone_freq = _validate_tone_freq(data.get('tone_freq', '700')) wpm = _validate_wpm(data.get('wpm', '15')) @@ -299,7 +554,6 @@ def start_morse() -> Response: 'state': morse_state, }), 409 - # Claim SDR device device_int = int(device) error = app_module.claim_sdr_device(device_int, 'morse') if error: @@ -314,10 +568,8 @@ def start_morse() -> Response: morse_session_id += 1 _drain_queue(app_module.morse_queue) - _set_state(MORSE_STARTING, 'Starting decoder...') - # Use pager-proven audio rate for rtl_fm compatibility across builds. sample_rate = 22050 bias_t = _bool_value(data.get('bias_t', False), False) @@ -330,13 +582,21 @@ def start_morse() -> Response: sdr_device = SDRFactory.create_default_device(sdr_type, index=device) builder = SDRFactory.get_builder(sdr_device.sdr_type) - def _build_rtl_cmd( - *, - direct_sampling_mode: int | None, - force_squelch_off: bool, - add_resample_rate: bool, - add_dc_fast: bool, - ) -> list[str]: + multimon_path = get_tool_path('multimon-ng') + if not multimon_path: + msg = 'multimon-ng not found' + with app_module.morse_lock: + if morse_active_device is not None: + app_module.release_sdr_device(morse_active_device) + morse_active_device = None + morse_last_error = msg + _set_state(MORSE_ERROR, msg) + _set_state(MORSE_IDLE, 'Idle') + return jsonify({'status': 'error', 'message': msg}), 400 + + multimon_cmd = [multimon_path, '-t', 'raw', '-a', 'MORSE_CW', '-f', 'alpha', '-'] + + def _build_rtl_cmd(direct_sampling_mode: int | None) -> list[str]: fm_kwargs: dict[str, Any] = { 'device': sdr_device, 'frequency_mhz': freq, @@ -346,138 +606,38 @@ def start_morse() -> Response: 'modulation': 'usb', 'bias_t': bias_t, } - - # Only rtl_fm supports direct sampling flags. if direct_sampling_mode in (1, 2): fm_kwargs['direct_sampling'] = int(direct_sampling_mode) - cmd = builder.build_fm_demod_command(**fm_kwargs) - - # Some rtl_fm builds behave as if squelch is enabled unless -l is explicit. - # Force continuous audio for CW analysis. - if force_squelch_off and sdr_device.sdr_type == SDRType.RTL_SDR and '-l' not in cmd: - if cmd and cmd[-1] == '-': - cmd[-1:-1] = ['-l', '0'] - else: - cmd.extend(['-l', '0']) + cmd = list(builder.build_fm_demod_command(**fm_kwargs)) + insert_at = len(cmd) - 1 if cmd else 0 + if insert_at < 0: + insert_at = 0 if sdr_device.sdr_type == SDRType.RTL_SDR: - insert_at = len(cmd) - 1 if cmd and cmd[-1] == '-' else len(cmd) - if add_resample_rate and '-r' not in cmd: + if '-l' not in cmd: + cmd[insert_at:insert_at] = ['-l', '0'] + insert_at += 2 + if '-r' not in cmd: cmd[insert_at:insert_at] = ['-r', str(sample_rate)] insert_at += 2 - if add_dc_fast: - # Used in other stable modes to improve rtl_fm stream behavior. - if '-A' not in cmd: - cmd[insert_at:insert_at] = ['-A', 'fast'] - insert_at += 2 - if '-E' not in cmd or 'dc' not in cmd: - cmd[insert_at:insert_at] = ['-E', 'dc'] - # Some rtl_fm builds treat "-" as a literal filename. Use an - # explicit fd-backed stdout path for deterministic piping. - out_target = _stdout_target_path() - if cmd and cmd[-1] == '-': + if '-A' not in cmd: + cmd[insert_at:insert_at] = ['-A', 'fast'] + insert_at += 2 + if '-E' not in cmd: + cmd[insert_at:insert_at] = ['-E', 'dc'] + + out_target = _stdout_target_path() + if cmd: + if cmd[-1] == '-': cmd[-1] = out_target - elif out_target not in cmd: + elif cmd[-1] not in {out_target, '/dev/stdout', '/proc/self/fd/1', '/dev/fd/1'}: cmd.append(out_target) + return cmd - # Use a hardware-friendly IQ rate (matches common RTL-SDR stable rates - # and waterfall defaults) before decimating to audio. - iq_sample_rate = 1024000 - - def _build_iq_cmd(*, direct_sampling_mode: int | None) -> tuple[list[str], float]: - # CW USB-style offset tuning: keep the configured RF frequency sounding - # near the selected tone frequency in the software demod chain. - tune_mhz = max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0)) - iq_cmd = builder.build_iq_capture_command( - device=sdr_device, - frequency_mhz=tune_mhz, - sample_rate=iq_sample_rate, - gain=float(gain) if gain and gain != '0' else None, - ppm=int(ppm) if ppm and ppm != '0' else None, - bias_t=bias_t, - ) - if ( - sdr_device.sdr_type == SDRType.RTL_SDR - and direct_sampling_mode is not None - and '-D' not in iq_cmd - ): - if iq_cmd and iq_cmd[-1] == '-': - iq_cmd[-1:-1] = ['-D', str(direct_sampling_mode)] - else: - iq_cmd.extend(['-D', str(direct_sampling_mode)]) - # Some rtl_sdr builds treat "-" as a literal filename instead of stdout. - # Use an explicit fd-backed stdout path for deterministic piping. - out_target = _stdout_target_path() - if iq_cmd: - if iq_cmd[-1] == '-': - iq_cmd[-1] = out_target - elif out_target not in iq_cmd: - iq_cmd.append(out_target) - return iq_cmd, tune_mhz - - can_try_direct_sampling = bool(sdr_device.sdr_type == SDRType.RTL_SDR and freq < 24.0) - if can_try_direct_sampling: - # IQ-first strategy: avoid repeated rtl_fm/rtl_sdr handoffs that can - # leave the tuner in a bad state on some Linux builds. - command_attempts: list[dict[str, Any]] = [ - { - 'source': 'iq', - 'direct_sampling_mode': 2, - }, - { - 'source': 'iq', - 'direct_sampling_mode': 1, - }, - { - 'source': 'iq', - 'direct_sampling_mode': None, - }, - { - 'source': 'rtl_fm', - 'direct_sampling_mode': 2, - 'force_squelch_off': False, - 'add_resample_rate': True, - 'add_dc_fast': True, - }, - { - 'source': 'rtl_fm', - 'direct_sampling_mode': 1, - 'force_squelch_off': False, - 'add_resample_rate': True, - 'add_dc_fast': True, - }, - { - 'source': 'rtl_fm', - 'direct_sampling_mode': None, - 'force_squelch_off': False, - 'add_resample_rate': True, - 'add_dc_fast': True, - }, - ] - else: - command_attempts = [ - { - 'source': 'iq', - 'direct_sampling_mode': None, - }, - { - 'source': 'rtl_fm', - 'direct_sampling_mode': None, - 'force_squelch_off': False, - 'add_resample_rate': True, - 'add_dc_fast': True, - }, - ] - - rtl_process: subprocess.Popen | None = None - stop_event: threading.Event | None = None - decoder_thread: threading.Thread | None = None - stderr_thread: threading.Thread | None = None - control_queue: queue.Queue | None = None - decoder_input: Any | None = None - fifo_path: str | None = None + can_try_direct_sampling = bool(sdr_device.sdr_type == SDRType.RTL_SDR and float(freq) < 24.0) + direct_sampling_attempts: list[int | None] = [2, 1, None] if can_try_direct_sampling else [None] runtime_config: dict[str, Any] = { 'sample_rate': sample_rate, @@ -493,154 +653,113 @@ def start_morse() -> Response: 'wpm_mode': wpm_mode, 'wpm_lock': wpm_lock, 'min_signal_gate': min_signal_gate, + 'source': 'rtl_fm', } + active_rtl_process: subprocess.Popen[bytes] | None = None + active_multimon_process: subprocess.Popen[bytes] | None = None + active_stop_event: threading.Event | None = None + active_control_queue: queue.Queue | None = None + active_decoder_thread: threading.Thread | None = None + active_stderr_thread: threading.Thread | None = None + active_relay_thread: threading.Thread | None = None + active_master_fd: int | None = None + rtl_process: subprocess.Popen[bytes] | None = None + multimon_process: subprocess.Popen[bytes] | None = None + stop_event: threading.Event | None = None + control_queue: queue.Queue | None = None + decoder_thread: threading.Thread | None = None + stderr_thread: threading.Thread | None = None + relay_thread: threading.Thread | None = None + master_fd: int | None = None + + def _cleanup_attempt( + rtl_proc: subprocess.Popen[bytes] | None, + multimon_proc: subprocess.Popen[bytes] | None, + stop_evt: threading.Event | None, + control_q: queue.Queue | None, + decoder_worker: threading.Thread | None, + stderr_worker: threading.Thread | None, + relay_worker: threading.Thread | None, + master_fd: int | None, + ) -> None: + if stop_evt is not None: + stop_evt.set() + if control_q is not None: + with contextlib.suppress(queue.Full): + control_q.put_nowait({'cmd': 'shutdown'}) + + if master_fd is not None: + with contextlib.suppress(OSError): + os.close(master_fd) + + if rtl_proc is not None: + _close_pipe(getattr(rtl_proc, 'stdout', None)) + _close_pipe(getattr(rtl_proc, 'stderr', None)) + if multimon_proc is not None: + _close_pipe(getattr(multimon_proc, 'stdin', None)) + + if rtl_proc is not None: + safe_terminate(rtl_proc, timeout=0.4) + unregister_process(rtl_proc) + if multimon_proc is not None: + safe_terminate(multimon_proc, timeout=0.4) + unregister_process(multimon_proc) + + _join_thread(relay_worker, timeout_s=0.35) + _join_thread(decoder_worker, timeout_s=0.35) + _join_thread(stderr_worker, timeout_s=0.35) + + full_cmd = '' + attempt_errors: list[str] = [] + try: - def _cleanup_attempt( - proc: subprocess.Popen | None, - attempt_stop_event: threading.Event | None, - attempt_control_queue: queue.Queue | None, - attempt_decoder_thread: threading.Thread | None, - attempt_stderr_thread: threading.Thread | None, - attempt_stream_handle: Any | None = None, - attempt_fifo_path: str | None = None, - ) -> None: - if attempt_stop_event is not None: - attempt_stop_event.set() - if attempt_control_queue is not None: - with contextlib.suppress(queue.Full): - attempt_control_queue.put_nowait({'cmd': 'shutdown'}) - if attempt_stream_handle is not None and ( - proc is None or attempt_stream_handle is not getattr(proc, 'stdout', None) - ): - _close_pipe(attempt_stream_handle) - if proc is not None: - # Close stdout to unblock decoder reads. Keep stderr open until - # after stderr monitor thread exits to avoid ValueError races. - _close_pipe(getattr(proc, 'stdout', None)) - # Keep startup retries responsive; avoid long waits inside - # generic safe_terminate() during a failed attempt. - if proc.poll() is None: - with contextlib.suppress(Exception): - proc.terminate() - with contextlib.suppress(subprocess.TimeoutExpired, Exception): - proc.wait(timeout=0.15) - if proc.poll() is None: - with contextlib.suppress(Exception): - proc.kill() - with contextlib.suppress(subprocess.TimeoutExpired, Exception): - proc.wait(timeout=0.25) - unregister_process(proc) - _join_thread(attempt_decoder_thread, timeout_s=0.20) - stderr_joined = _join_thread(attempt_stderr_thread, timeout_s=0.35) - if proc is not None: - if not stderr_joined: - # Force-close the pipe if stderr reader is still blocked. - _close_pipe(getattr(proc, 'stderr', None)) - _join_thread(attempt_stderr_thread, timeout_s=0.15) - _close_pipe(getattr(proc, 'stderr', None)) - if attempt_fifo_path: - with contextlib.suppress(Exception): - Path(attempt_fifo_path).unlink(missing_ok=True) - - attempt_errors: list[str] = [] - full_cmd = '' - - for attempt_index, attempt in enumerate(command_attempts, start=1): + for attempt_index, direct_sampling_mode in enumerate(direct_sampling_attempts, start=1): + rtl_process = None + multimon_process = None + stop_event = None + control_queue = None + decoder_thread = None + stderr_thread = None + relay_thread = None + master_fd = None runtime_config.pop('startup_waiting', None) runtime_config.pop('startup_warning', None) - source = str(attempt.get('source', 'rtl_fm')).strip().lower() - force_squelch_off = bool(attempt.get('force_squelch_off', True)) - add_resample_rate = bool(attempt.get('add_resample_rate', False)) - add_dc_fast = bool(attempt.get('add_dc_fast', False)) - direct_sampling_mode_raw = attempt.get('direct_sampling_mode') - try: - direct_sampling_mode = ( - int(direct_sampling_mode_raw) - if direct_sampling_mode_raw is not None - else None - ) - except (TypeError, ValueError): - direct_sampling_mode = None - if source == 'iq': - rtl_cmd, tuned_freq_mhz = _build_iq_cmd( - direct_sampling_mode=int(direct_sampling_mode) - if direct_sampling_mode is not None else None, - ) - thread_target = morse_iq_decoder_thread - attempt_desc = ( - f'source=iq direct_mode={direct_sampling_mode if direct_sampling_mode is not None else "none"} ' - f'iq_sr={iq_sample_rate}' - ) - else: - rtl_cmd = _build_rtl_cmd( - direct_sampling_mode=direct_sampling_mode, - force_squelch_off=force_squelch_off, - add_resample_rate=add_resample_rate, - add_dc_fast=add_dc_fast, - ) - tuned_freq_mhz = float(freq) - thread_target = morse_decoder_thread - attempt_desc = ( - f'source=rtl_fm direct_mode={direct_sampling_mode if direct_sampling_mode is not None else "none"} ' - f'squelch_forced={int(force_squelch_off)} ' - f'resample={int(add_resample_rate)} dc_fast={int(add_dc_fast)}' - ) - - full_cmd = ' '.join(rtl_cmd) + rtl_cmd = _build_rtl_cmd(direct_sampling_mode) + direct_mode_label = direct_sampling_mode if direct_sampling_mode is not None else 'none' + full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd) logger.info( - f'Morse decoder attempt {attempt_index}/{len(command_attempts)} ' - f'({attempt_desc}): {full_cmd}' + 'Morse decoder attempt %s/%s (source=rtl_fm direct_mode=%s): %s', + attempt_index, + len(direct_sampling_attempts), + direct_mode_label, + full_cmd, ) - - with contextlib.suppress(queue.Full): - app_module.morse_queue.put_nowait({ - 'type': 'info', - 'text': f'[cmd] {full_cmd}', - }) - - fifo_cmd, fifo_reader, fifo_path = _prepare_fifo_output( - rtl_cmd, - token=f'{morse_session_id}_{attempt_index}', - ) - if fifo_cmd is not rtl_cmd: - full_cmd = ' '.join(fifo_cmd) - logger.info( - f'Morse decoder attempt {attempt_index}/{len(command_attempts)} ' - f'({attempt_desc}) via fifo: {full_cmd}' - ) - with contextlib.suppress(queue.Full): - app_module.morse_queue.put_nowait({ - 'type': 'info', - 'text': f'[cmd] {full_cmd}', - }) + _queue_morse_event({'type': 'info', 'text': f'[cmd] {full_cmd}'}) rtl_process = subprocess.Popen( - fifo_cmd, - stdout=(subprocess.DEVNULL if fifo_reader is not None else subprocess.PIPE), + rtl_cmd, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0, ) register_process(rtl_process) - decoder_input = fifo_reader if fifo_reader is not None else rtl_process.stdout stop_event = threading.Event() control_queue = queue.Queue(maxsize=16) pcm_ready_event = threading.Event() - stream_ready_event = threading.Event() - attempt_stderr_lines: list[str] = [] + stderr_lines: list[str] = [] def monitor_stderr( - proc: subprocess.Popen = rtl_process, + proc: subprocess.Popen[bytes] = rtl_process, proc_stop_event: threading.Event = stop_event, - tool_label: str = rtl_cmd[0], - stderr_lines: list[str] = attempt_stderr_lines, + capture_lines: list[str] = stderr_lines, ) -> None: + stderr_stream = proc.stderr + if stderr_stream is None: + return try: - stderr_stream = proc.stderr - if stderr_stream is None: - return while not proc_stop_event.is_set(): line = stderr_stream.readline() if not line: @@ -649,17 +768,13 @@ def start_morse() -> Response: time.sleep(0.02) continue err_text = line.decode('utf-8', errors='replace').strip() - if err_text: - if len(stderr_lines) >= 40: - del stderr_lines[:10] - stderr_lines.append(err_text) - with contextlib.suppress(queue.Full): - app_module.morse_queue.put_nowait({ - 'type': 'info', - 'text': f'[{tool_label}] {err_text}', - }) - except ValueError: - # Pipe was closed during shutdown; expected during retries. + if not err_text: + continue + if len(capture_lines) >= 40: + del capture_lines[:10] + capture_lines.append(err_text) + _queue_morse_event({'type': 'info', 'text': f'[rtl_fm] {err_text}'}) + except (ValueError, OSError): return except Exception: return @@ -667,51 +782,50 @@ def start_morse() -> Response: stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr') stderr_thread.start() - if source == 'iq': - decoder_thread = threading.Thread( - target=thread_target, - args=( - decoder_input, - app_module.morse_queue, - stop_event, - iq_sample_rate, - ), - kwargs={ - 'sample_rate': sample_rate, - 'tone_freq': tone_freq, - 'wpm': wpm, - 'decoder_config': runtime_config, - 'control_queue': control_queue, - 'pcm_ready_event': pcm_ready_event, - 'stream_ready_event': stream_ready_event, - }, - daemon=True, - name='morse-decoder', - ) - else: - decoder_thread = threading.Thread( - target=thread_target, - args=( - decoder_input, - app_module.morse_queue, - stop_event, - sample_rate, - tone_freq, - wpm, - ), - kwargs={ - 'decoder_config': runtime_config, - 'control_queue': control_queue, - 'pcm_ready_event': pcm_ready_event, - 'stream_ready_event': stream_ready_event, - 'strip_text_chunks': False, - }, - daemon=True, - name='morse-decoder', + master_fd, slave_fd = pty.openpty() + try: + multimon_process = subprocess.Popen( + multimon_cmd, + stdin=subprocess.PIPE, + stdout=slave_fd, + stderr=slave_fd, + close_fds=True, ) + finally: + with contextlib.suppress(OSError): + os.close(slave_fd) + register_process(multimon_process) + + if rtl_process.stdout is None: + raise RuntimeError('rtl_fm stdout unavailable') + if multimon_process.stdin is None: + raise RuntimeError('multimon-ng stdin unavailable') + + relay_thread = threading.Thread( + target=_morse_audio_relay_thread, + args=( + rtl_process.stdout, + multimon_process.stdin, + app_module.morse_queue, + stop_event, + control_queue, + runtime_config, + pcm_ready_event, + ), + daemon=True, + name='morse-relay', + ) + relay_thread.start() + + decoder_thread = threading.Thread( + target=_morse_multimon_output_thread, + args=(master_fd, multimon_process, stop_event), + daemon=True, + name='morse-decoder', + ) decoder_thread.start() - startup_deadline = time.monotonic() + (4.0 if source == 'iq' else 2.0) + startup_deadline = time.monotonic() + 2.5 startup_ok = False startup_error = '' @@ -720,86 +834,91 @@ def start_morse() -> Response: startup_ok = True break if rtl_process.poll() is not None: - startup_error = f'{rtl_cmd[0]} exited during startup (code {rtl_process.returncode})' + startup_error = f'rtl_fm exited during startup (code {rtl_process.returncode})' + break + if multimon_process.poll() is not None: + startup_error = f'multimon-ng exited during startup (code {multimon_process.returncode})' break time.sleep(0.05) if not startup_ok: if not startup_error: startup_error = 'No PCM samples received within startup timeout' - if attempt_stderr_lines: - startup_error = f'{startup_error}; stderr: {attempt_stderr_lines[-1]}' - if stream_ready_event.is_set(): - startup_error = f'{startup_error}; stream=alive' - - is_last_attempt = attempt_index == len(command_attempts) - if ( - is_last_attempt - and rtl_process.poll() is None - and decoder_thread.is_alive() - ): - # Avoid hard-failing startup when SDR is alive but muted. + if stderr_lines: + startup_error = f'{startup_error}; stderr: {stderr_lines[-1]}' + is_last_attempt = attempt_index == len(direct_sampling_attempts) + if is_last_attempt and rtl_process.poll() is None and multimon_process.poll() is None: startup_ok = True runtime_config['startup_waiting'] = True runtime_config['startup_warning'] = startup_error logger.warning( 'Morse startup continuing without PCM (attempt %s/%s): %s', attempt_index, - len(command_attempts), + len(direct_sampling_attempts), startup_error, ) - with contextlib.suppress(queue.Full): - app_module.morse_queue.put_nowait({ - 'type': 'info', - 'text': '[morse] stream alive but no PCM yet; continuing in waiting mode', - }) + _queue_morse_event({ + 'type': 'info', + 'text': '[morse] waiting for PCM stream...', + }) if startup_ok: - runtime_config['source'] = source - runtime_config['command'] = full_cmd - runtime_config['tuned_frequency_mhz'] = tuned_freq_mhz + runtime_config['direct_sampling_mode'] = direct_sampling_mode runtime_config['direct_sampling'] = ( - int(direct_sampling_mode) - if source == 'iq' and direct_sampling_mode is not None - else (int(direct_sampling_mode) if direct_sampling_mode is not None else 0) + int(direct_sampling_mode) if direct_sampling_mode is not None else 0 ) - runtime_config['iq_sample_rate'] = iq_sample_rate if source == 'iq' else None - runtime_config['direct_sampling_mode'] = direct_sampling_mode if source == 'iq' else None + runtime_config['command'] = full_cmd + + active_rtl_process = rtl_process + active_multimon_process = multimon_process + active_stop_event = stop_event + active_control_queue = control_queue + active_decoder_thread = decoder_thread + active_stderr_thread = stderr_thread + active_relay_thread = relay_thread + active_master_fd = master_fd break attempt_errors.append( - f'attempt {attempt_index}/{len(command_attempts)} ({attempt_desc}): {startup_error}' + f'attempt {attempt_index}/{len(direct_sampling_attempts)} ' + f'(source=rtl_fm direct_mode={direct_mode_label}): {startup_error}' ) - logger.warning(f'Morse startup attempt failed: {attempt_errors[-1]}') - - with contextlib.suppress(queue.Full): - app_module.morse_queue.put_nowait({ - 'type': 'info', - 'text': f'[morse] startup attempt failed: {startup_error}', - }) + logger.warning('Morse startup attempt failed: %s', attempt_errors[-1]) + _queue_morse_event({'type': 'info', 'text': f'[morse] startup attempt failed: {startup_error}'}) _cleanup_attempt( rtl_process, + multimon_process, stop_event, control_queue, decoder_thread, stderr_thread, - decoder_input, - fifo_path, + relay_thread, + master_fd, ) rtl_process = None + multimon_process = None stop_event = None control_queue = None decoder_thread = None stderr_thread = None - decoder_input = None - fifo_path = None + relay_thread = None + master_fd = None - if rtl_process is None or stop_event is None or control_queue is None or decoder_thread is None: + if ( + active_rtl_process is None + or active_multimon_process is None + or active_stop_event is None + or active_control_queue is None + or active_decoder_thread is None + or active_stderr_thread is None + or active_relay_thread is None + or active_master_fd is None + ): msg = 'SDR capture started but no PCM stream was received.' if attempt_errors: - msg = msg + ' ' + ' | '.join(attempt_errors) - logger.error(f'Morse startup failed: {msg}') + msg += ' ' + ' | '.join(attempt_errors) + logger.error('Morse startup failed: %s', msg) with app_module.morse_lock: if morse_active_device is not None: app_module.release_sdr_device(morse_active_device) @@ -810,27 +929,23 @@ def start_morse() -> Response: return jsonify({'status': 'error', 'message': msg}), 500 with app_module.morse_lock: - app_module.morse_process = rtl_process - app_module.morse_process._stop_decoder = stop_event - app_module.morse_process._decoder_thread = decoder_thread - app_module.morse_process._stderr_thread = stderr_thread - app_module.morse_process._control_queue = control_queue - app_module.morse_process._stream_handle = decoder_input - app_module.morse_process._fifo_path = fifo_path + app_module.morse_process = active_multimon_process + app_module.morse_process._rtl_process = active_rtl_process + app_module.morse_process._stop_decoder = active_stop_event + app_module.morse_process._decoder_thread = active_decoder_thread + app_module.morse_process._stderr_thread = active_stderr_thread + app_module.morse_process._relay_thread = active_relay_thread + app_module.morse_process._control_queue = active_control_queue + app_module.morse_process._master_fd = active_master_fd - morse_stop_event = stop_event - morse_control_queue = control_queue - morse_decoder_worker = decoder_thread - morse_stderr_worker = stderr_thread + morse_stop_event = active_stop_event + morse_control_queue = active_control_queue + morse_decoder_worker = active_decoder_thread + morse_stderr_worker = active_stderr_thread + morse_relay_worker = active_relay_thread morse_runtime_config = dict(runtime_config) _set_state(MORSE_RUNNING, 'Listening') - with contextlib.suppress(queue.Full): - app_module.morse_queue.put_nowait({ - 'type': 'info', - 'text': f'[cmd] {full_cmd}', - }) - return jsonify({ 'status': 'started', 'state': MORSE_RUNNING, @@ -842,15 +957,16 @@ def start_morse() -> Response: }) except FileNotFoundError as e: - if rtl_process is not None: - unregister_process(rtl_process) - if decoder_input is not None and ( - rtl_process is None or decoder_input is not getattr(rtl_process, 'stdout', None) - ): - _close_pipe(decoder_input) - if fifo_path: - with contextlib.suppress(Exception): - Path(fifo_path).unlink(missing_ok=True) + _cleanup_attempt( + rtl_process if rtl_process is not None else active_rtl_process, + multimon_process if multimon_process is not None else active_multimon_process, + stop_event if stop_event is not None else active_stop_event, + control_queue if control_queue is not None else active_control_queue, + decoder_thread if decoder_thread is not None else active_decoder_thread, + stderr_thread if stderr_thread is not None else active_stderr_thread, + relay_thread if relay_thread is not None else active_relay_thread, + master_fd if master_fd is not None else active_master_fd, + ) with app_module.morse_lock: if morse_active_device is not None: app_module.release_sdr_device(morse_active_device) @@ -861,20 +977,16 @@ def start_morse() -> Response: return jsonify({'status': 'error', 'message': morse_last_error}), 400 except Exception as e: - if rtl_process is not None: - safe_terminate(rtl_process, timeout=0.5) - unregister_process(rtl_process) - if decoder_input is not None and ( - rtl_process is None or decoder_input is not getattr(rtl_process, 'stdout', None) - ): - _close_pipe(decoder_input) - if fifo_path: - with contextlib.suppress(Exception): - Path(fifo_path).unlink(missing_ok=True) - if stop_event is not None: - stop_event.set() - _join_thread(decoder_thread, timeout_s=0.25) - _join_thread(stderr_thread, timeout_s=0.25) + _cleanup_attempt( + rtl_process if rtl_process is not None else active_rtl_process, + multimon_process if multimon_process is not None else active_multimon_process, + stop_event if stop_event is not None else active_stop_event, + control_queue if control_queue is not None else active_control_queue, + decoder_thread if decoder_thread is not None else active_decoder_thread, + stderr_thread if stderr_thread is not None else active_stderr_thread, + relay_thread if relay_thread is not None else active_relay_thread, + master_fd if master_fd is not None else active_master_fd, + ) with app_module.morse_lock: if morse_active_device is not None: app_module.release_sdr_device(morse_active_device) @@ -887,7 +999,7 @@ def start_morse() -> Response: @morse_bp.route('/morse/stop', methods=['POST']) def stop_morse() -> Response: - global morse_active_device, morse_decoder_worker, morse_stderr_worker + global morse_active_device, morse_decoder_worker, morse_stderr_worker, morse_relay_worker global morse_stop_event, morse_control_queue stop_started = time.perf_counter() @@ -897,27 +1009,34 @@ def stop_morse() -> Response: return jsonify({'status': 'stopping', 'state': MORSE_STOPPING}), 202 proc = app_module.morse_process + rtl_proc = getattr(proc, '_rtl_process', None) if proc else None stop_event = morse_stop_event or getattr(proc, '_stop_decoder', None) decoder_thread = morse_decoder_worker or getattr(proc, '_decoder_thread', None) stderr_thread = morse_stderr_worker or getattr(proc, '_stderr_thread', None) + relay_thread = morse_relay_worker or getattr(proc, '_relay_thread', None) control_queue = morse_control_queue or getattr(proc, '_control_queue', None) - stream_handle = getattr(proc, '_stream_handle', None) if proc else None - fifo_path = getattr(proc, '_fifo_path', None) if proc else None + master_fd = getattr(proc, '_master_fd', None) if proc else None active_device = morse_active_device - if not proc and not stop_event and not decoder_thread and not stderr_thread: + if ( + not proc + and not rtl_proc + and not stop_event + and not decoder_thread + and not stderr_thread + and not relay_thread + ): _set_state(MORSE_IDLE, 'Idle', enqueue=False) return jsonify({'status': 'not_running', 'state': MORSE_IDLE}) - # Prevent new starts while cleanup is in progress. _set_state(MORSE_STOPPING, 'Stopping decoder...') - # Detach global runtime pointers immediately to avoid double-stop races. app_module.morse_process = None morse_stop_event = None morse_control_queue = None morse_decoder_worker = None morse_stderr_worker = None + morse_relay_worker = None cleanup_steps: list[str] = [] @@ -936,33 +1055,34 @@ def stop_morse() -> Response: control_queue.put_nowait({'cmd': 'shutdown'}) _mark('control_queue shutdown signal sent') - if stream_handle is not None and ( - proc is None or stream_handle is not getattr(proc, 'stdout', None) - ): - _close_pipe(stream_handle) - _mark('decoder input stream closed') + if master_fd is not None: + with contextlib.suppress(OSError): + os.close(master_fd) + _mark('pty master fd closed') + + if rtl_proc is not None: + _close_pipe(getattr(rtl_proc, 'stdout', None)) + _close_pipe(getattr(rtl_proc, 'stderr', None)) + _mark('rtl_fm pipes closed') if proc is not None: - _close_pipe(getattr(proc, 'stdout', None)) - _mark('stdout pipe closed') + _close_pipe(getattr(proc, 'stdin', None)) + _mark('multimon stdin closed') - safe_terminate(proc, timeout=0.6) - unregister_process(proc) + if rtl_proc is not None: + safe_terminate(rtl_proc, timeout=0.6) + unregister_process(rtl_proc) _mark('rtl_fm process terminated') + if proc is not None: + safe_terminate(proc, timeout=0.6) + unregister_process(proc) + _mark('multimon process terminated') + + relay_joined = _join_thread(relay_thread, timeout_s=0.45) decoder_joined = _join_thread(decoder_thread, timeout_s=0.45) stderr_joined = _join_thread(stderr_thread, timeout_s=0.45) - if proc is not None: - if not stderr_joined: - _close_pipe(getattr(proc, 'stderr', None)) - stderr_joined = _join_thread(stderr_thread, timeout_s=0.20) - _mark('stderr pipe force-closed') - _close_pipe(getattr(proc, 'stderr', None)) - _mark('stderr pipe closed') - if fifo_path: - with contextlib.suppress(Exception): - Path(fifo_path).unlink(missing_ok=True) - _mark('fifo path removed') + _mark(f'relay thread joined={relay_joined}') _mark(f'decoder thread joined={decoder_joined}') _mark(f'stderr thread joined={stderr_joined}') @@ -972,10 +1092,16 @@ def stop_morse() -> Response: stop_ms = round((time.perf_counter() - stop_started) * 1000.0, 1) alive_after = [] + if not relay_joined: + alive_after.append('relay_thread') if not decoder_joined: alive_after.append('decoder_thread') if not stderr_joined: alive_after.append('stderr_thread') + if rtl_proc is not None and rtl_proc.poll() is None: + alive_after.append('rtl_process') + if proc is not None and proc.poll() is None: + alive_after.append('multimon_process') with app_module.morse_lock: morse_active_device = None diff --git a/tests/test_morse.py b/tests/test_morse.py index a53274f..43fabdf 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -243,6 +243,7 @@ class TestMorseLifecycleRoutes: morse_routes.morse_active_device = None morse_routes.morse_decoder_worker = None morse_routes.morse_stderr_worker = None + morse_routes.morse_relay_worker = None morse_routes.morse_stop_event = None morse_routes.morse_control_queue = None morse_routes.morse_runtime_config = {} @@ -264,31 +265,57 @@ class TestMorseLifecycleRoutes: class DummyBuilder: def build_fm_demod_command(self, **kwargs): - return ['rtl_fm', '-f', '14060000'] - - def build_iq_capture_command(self, **kwargs): - cmd = ['rtl_sdr', '-f', '14060000', '-s', '250000'] - if kwargs.get('gain') is not None: - cmd.extend(['-g', str(kwargs['gain'])]) - cmd.append('-') - return cmd + return ['rtl_fm', '-f', '14060000', '-'] monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice())) monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder())) - monkeypatch.setattr(morse_routes.time, 'sleep', lambda _secs: None) + monkeypatch.setattr(morse_routes, 'get_tool_path', lambda _name: '/usr/bin/multimon-ng') - pcm = generate_morse_audio('E', wpm=15) + pcm = generate_morse_audio('E', wpm=15, sample_rate=22050) - class FakeProc: - def __init__(self): - self.stdout = io.BytesIO(pcm) + class FakeRtlProc: + def __init__(self, payload: bytes): + self.stdout = io.BytesIO(payload) self.stderr = io.BytesIO(b'') self.returncode = None def poll(self): return self.returncode - monkeypatch.setattr(morse_routes.subprocess, 'Popen', lambda *args, **kwargs: FakeProc()) + def terminate(self): + self.returncode = 0 + + def wait(self, timeout=None): + self.returncode = 0 + return 0 + + def kill(self): + self.returncode = -9 + + class FakeMultimonProc: + def __init__(self): + self.stdin = io.BytesIO() + self.returncode = None + + def poll(self): + return self.returncode + + def terminate(self): + self.returncode = 0 + + def wait(self, timeout=None): + self.returncode = 0 + return 0 + + def kill(self): + self.returncode = -9 + + def fake_popen(cmd, *args, **kwargs): + if 'multimon' in str(cmd[0]): + return FakeMultimonProc() + return FakeRtlProc(pcm) + + monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen) monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None) monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None) monkeypatch.setattr( @@ -339,22 +366,19 @@ class TestMorseLifecycleRoutes: class DummyBuilder: def build_fm_demod_command(self, **kwargs): cmd = ['rtl_fm', '-f', '14.060M', '-M', 'usb', '-s', '22050'] - if kwargs.get('direct_sampling') == 2: - cmd.extend(['-E', 'direct2']) + if kwargs.get('direct_sampling') is not None: + cmd.extend(['--direct', str(kwargs['direct_sampling'])]) cmd.append('-') return cmd - def build_iq_capture_command(self, **kwargs): - cmd = ['rtl_sdr', '-f', '14.0593M', '-s', '250000', '-'] - return cmd - monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice())) monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder())) + monkeypatch.setattr(morse_routes, 'get_tool_path', lambda _name: '/usr/bin/multimon-ng') pcm = generate_morse_audio('E', wpm=15, sample_rate=22050) - popen_cmds = [] + rtl_cmds = [] - class FakeProc: + class FakeRtlProc: def __init__(self, stdout_bytes: bytes, returncode: int | None): self.stdout = io.BytesIO(stdout_bytes) self.stderr = io.BytesIO(b'') @@ -363,11 +387,41 @@ class TestMorseLifecycleRoutes: def poll(self): return self.returncode + def terminate(self): + self.returncode = 0 + + def wait(self, timeout=None): + self.returncode = 0 + return 0 + + def kill(self): + self.returncode = -9 + + class FakeMultimonProc: + def __init__(self): + self.stdin = io.BytesIO() + self.returncode = None + + def poll(self): + return self.returncode + + def terminate(self): + self.returncode = 0 + + def wait(self, timeout=None): + self.returncode = 0 + return 0 + + def kill(self): + self.returncode = -9 + def fake_popen(cmd, *args, **kwargs): - popen_cmds.append(cmd) - if len(popen_cmds) == 1: - return FakeProc(b'', 1) - return FakeProc(pcm, None) + if 'multimon' in str(cmd[0]): + return FakeMultimonProc() + rtl_cmds.append(cmd) + if len(rtl_cmds) == 1: + return FakeRtlProc(b'', 1) + return FakeRtlProc(pcm, None) monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen) monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None) @@ -388,13 +442,13 @@ class TestMorseLifecycleRoutes: }) assert start_resp.status_code == 200 assert start_resp.get_json()['status'] == 'started' - assert len(popen_cmds) >= 2 - assert popen_cmds[0][0] == 'rtl_sdr' - assert '-D' in popen_cmds[0] - assert '2' in popen_cmds[0] - assert popen_cmds[1][0] == 'rtl_sdr' - assert '-D' in popen_cmds[1] - assert '1' in popen_cmds[1] + assert len(rtl_cmds) >= 2 + assert rtl_cmds[0][0] == 'rtl_fm' + assert '--direct' in rtl_cmds[0] + assert '2' in rtl_cmds[0] + assert rtl_cmds[1][0] == 'rtl_fm' + assert '--direct' in rtl_cmds[1] + assert '1' in rtl_cmds[1] stop_resp = client.post('/morse/stop') assert stop_resp.status_code == 200 From 9c8e8190460b8811865223294e0cd41d598efa20 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 17:25:33 +0000 Subject: [PATCH 35/52] morse: align rtl_fm streaming path with pager backend --- routes/morse.py | 39 ++++++++++----------------------------- 1 file changed, 10 insertions(+), 29 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index 2a76e2c..11538ae 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -110,15 +110,6 @@ def _close_pipe(pipe_obj: Any) -> None: pipe_obj.close() -def _stdout_target_path() -> str: - """Return the most reliable stdout path for rtl_* tools on this host.""" - if os.name == 'posix': - for candidate in ('/proc/self/fd/1', '/dev/fd/1', '/dev/stdout'): - if Path(candidate).exists(): - return candidate - return '-' - - def _queue_morse_event(payload: dict[str, Any]) -> None: with contextlib.suppress(queue.Full): app_module.morse_queue.put_nowait(payload) @@ -248,6 +239,7 @@ def _morse_audio_relay_thread( last_pcm_at = 0.0 noise_floor = 0.0 threshold = manual_threshold if threshold_mode == 'manual' else 0.0 + pcm_announced = False try: while not stop_event.is_set(): @@ -298,6 +290,9 @@ def _morse_audio_relay_thread( last_pcm_at = now pcm_ready_event.set() + if not pcm_announced: + pcm_announced = True + _queue_morse_event({'type': 'info', 'text': '[morse] PCM stream active'}) try: multimon_stdin.write(payload) @@ -614,26 +609,12 @@ def start_morse() -> Response: if insert_at < 0: insert_at = 0 - if sdr_device.sdr_type == SDRType.RTL_SDR: - if '-l' not in cmd: - cmd[insert_at:insert_at] = ['-l', '0'] - insert_at += 2 - if '-r' not in cmd: - cmd[insert_at:insert_at] = ['-r', str(sample_rate)] - insert_at += 2 - if '-A' not in cmd: - cmd[insert_at:insert_at] = ['-A', 'fast'] - insert_at += 2 - if '-E' not in cmd: - cmd[insert_at:insert_at] = ['-E', 'dc'] - - out_target = _stdout_target_path() - if cmd: - if cmd[-1] == '-': - cmd[-1] = out_target - elif cmd[-1] not in {out_target, '/dev/stdout', '/proc/self/fd/1', '/dev/fd/1'}: - cmd.append(out_target) + # Mirror pager's stable behavior: explicit open squelch, stdout as "-". + if sdr_device.sdr_type == SDRType.RTL_SDR and '-l' not in cmd: + cmd[insert_at:insert_at] = ['-l', '0'] + if cmd and cmd[-1] != '-': + cmd.append('-') return cmd can_try_direct_sampling = bool(sdr_device.sdr_type == SDRType.RTL_SDR and float(freq) < 24.0) @@ -825,7 +806,7 @@ def start_morse() -> Response: ) decoder_thread.start() - startup_deadline = time.monotonic() + 2.5 + startup_deadline = time.monotonic() + 4.0 startup_ok = False startup_error = '' From 32e3eb4eb209cb80fef8e6c417d6032ccb23f938 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 17:29:41 +0000 Subject: [PATCH 36/52] morse: remove select()-based pipe polling for capture/output --- routes/morse.py | 121 ++++++++++++++++++++++-------------------------- 1 file changed, 56 insertions(+), 65 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index 11538ae..280c699 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -8,7 +8,6 @@ import os import pty import queue import re -import select import struct import subprocess import tempfile @@ -177,43 +176,6 @@ def _compress_amplitudes(samples: tuple[int, ...], bins: int = 96) -> list[int]: return out -def _read_pcm_chunk( - stream: Any, - chunk_bytes: int, - stop_event: threading.Event, - timeout_s: float = 0.2, -) -> bytes | None: - if stream is None: - return b'' - - try: - fileno = stream.fileno() - except Exception: - if stop_event.is_set(): - return b'' - with contextlib.suppress(Exception): - return stream.read(chunk_bytes) - return b'' - - while not stop_event.is_set(): - try: - ready, _, _ = select.select([fileno], [], [], timeout_s) - except Exception: - return b'' - - if not ready: - return None - - try: - return os.read(fileno, chunk_bytes) - except BlockingIOError: - continue - except OSError: - return b'' - - return b'' - - def _morse_audio_relay_thread( rtl_stdout: Any, multimon_stdin: Any, @@ -241,6 +203,16 @@ def _morse_audio_relay_thread( threshold = manual_threshold if threshold_mode == 'manual' else 0.0 pcm_announced = False + fd: int | None = None + non_blocking = False + if rtl_stdout is not None: + with contextlib.suppress(Exception): + fd = rtl_stdout.fileno() + if fd is not None: + with contextlib.suppress(Exception): + os.set_blocking(fd, False) + non_blocking = True + try: while not stop_event.is_set(): if control_queue is not None: @@ -264,7 +236,20 @@ def _morse_audio_relay_thread( if stop_event.is_set(): break - payload = _read_pcm_chunk(rtl_stdout, chunk_bytes, stop_event) + payload: bytes | None = None + if non_blocking and fd is not None: + try: + payload = os.read(fd, chunk_bytes) + except BlockingIOError: + payload = None + except OSError: + payload = b'' + else: + try: + payload = rtl_stdout.read(chunk_bytes) + except Exception: + payload = b'' + now = time.monotonic() if payload is None: @@ -283,6 +268,7 @@ def _morse_audio_relay_thread( 'tone_freq': tone_freq, 'wpm': wpm, }) + time.sleep(0.02) continue if not payload: @@ -341,7 +327,8 @@ def _morse_audio_relay_thread( 'wpm': wpm, }) except Exception as exc: - logger.debug('Morse audio relay error: %s', exc) + logger.warning('Morse audio relay error: %s', exc) + _queue_morse_event({'type': 'error', 'text': f'morse relay error: {exc}'}) finally: _close_pipe(multimon_stdin) @@ -352,38 +339,42 @@ def _morse_multimon_output_thread( stop_event: threading.Event, ) -> None: buffer = '' + with contextlib.suppress(Exception): + os.set_blocking(master_fd, False) try: while not stop_event.is_set(): + raw: bytes | None = None try: - ready, _, _ = select.select([master_fd], [], [], 0.2) - except Exception: + raw = os.read(master_fd, 2048) + except BlockingIOError: + raw = None + except OSError: break - if ready: - try: - raw = os.read(master_fd, 2048) - except OSError: + if raw is None: + if process.poll() is not None: break - if not raw: - if process.poll() is not None: - break + time.sleep(0.02) + continue + + if not raw: + if process.poll() is not None: + break + time.sleep(0.02) + continue + + buffer += raw.decode('utf-8', errors='replace') + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + line = line.strip() + if not line: continue - - buffer += raw.decode('utf-8', errors='replace') - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) - line = line.strip() - if not line: - continue - text = _parse_multimon_morse_text(line) - if text is None: - _queue_morse_event({'type': 'info', 'text': f'[multimon] {line}'}) - continue - if text: - _emit_decoded_text(text) - - if process.poll() is not None: - break + text = _parse_multimon_morse_text(line) + if text is None: + _queue_morse_event({'type': 'info', 'text': f'[multimon] {line}'}) + continue + if text: + _emit_decoded_text(text) tail = buffer.strip() if tail: From e166b43687306cbf8265292a4731c8056d1fce79 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 17:31:25 +0000 Subject: [PATCH 37/52] morse: stop forcing rtl_fm squelch flag --- routes/morse.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index 280c699..fd22cf5 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -596,13 +596,6 @@ def start_morse() -> Response: fm_kwargs['direct_sampling'] = int(direct_sampling_mode) cmd = list(builder.build_fm_demod_command(**fm_kwargs)) - insert_at = len(cmd) - 1 if cmd else 0 - if insert_at < 0: - insert_at = 0 - - # Mirror pager's stable behavior: explicit open squelch, stdout as "-". - if sdr_device.sdr_type == SDRType.RTL_SDR and '-l' not in cmd: - cmd[insert_at:insert_at] = ['-l', '0'] if cmd and cmd[-1] != '-': cmd.append('-') From bee468f87026418bd076f85ca28fb23b5cdcc44a Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 17:38:22 +0000 Subject: [PATCH 38/52] morse: auto-fallback to alternate SDR device on no-PCM startup --- README.md | 1 + routes/morse.py | 452 ++++++++++++++++++++++++++------------------ tests/test_morse.py | 123 ++++++++++++ 3 files changed, 394 insertions(+), 182 deletions(-) diff --git a/README.md b/README.md index a127e17..be8a5a4 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ Auto Tone Track behavior: Troubleshooting (no decode / noisy decode): - Confirm demod path is **USB/CW-compatible** and frequency is tuned correctly. +- If multiple SDRs are connected and the selected one has no PCM output, Morse startup now auto-tries other detected SDR devices and reports the active device/serial in status logs. - Match **tone** and **bandwidth** to the actual sidetone/pitch. - Try **Threshold Auto** first; if needed, switch to manual threshold and recalibrate. - Use **Reset/Calibrate** after major frequency or band condition changes. diff --git a/routes/morse.py b/routes/morse.py index fd22cf5..0277d3f 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -565,8 +565,29 @@ def start_morse() -> Response: except ValueError: sdr_type = SDRType.RTL_SDR - sdr_device = SDRFactory.create_default_device(sdr_type, index=device) - builder = SDRFactory.get_builder(sdr_device.sdr_type) + requested_device_index = int(device) + active_device_index = requested_device_index + builder = SDRFactory.get_builder(sdr_type) + + device_catalog: dict[int, dict[str, str]] = {} + candidate_device_indices: list[int] = [requested_device_index] + with contextlib.suppress(Exception): + detected_devices = SDRFactory.detect_devices() + same_type_devices = [d for d in detected_devices if d.sdr_type == sdr_type] + for d in same_type_devices: + device_catalog[d.index] = { + 'name': str(d.name or f'SDR {d.index}'), + 'serial': str(d.serial or 'Unknown'), + } + for d in sorted(same_type_devices, key=lambda dev: dev.index): + if d.index not in candidate_device_indices: + candidate_device_indices.append(d.index) + + def _device_label(device_index: int) -> str: + meta = device_catalog.get(device_index, {}) + serial = str(meta.get('serial') or 'Unknown') + name = str(meta.get('name') or f'SDR {device_index}') + return f'device {device_index} ({name}, SN: {serial})' multimon_path = get_tool_path('multimon-ng') if not multimon_path: @@ -582,7 +603,8 @@ def start_morse() -> Response: multimon_cmd = [multimon_path, '-t', 'raw', '-a', 'MORSE_CW', '-f', 'alpha', '-'] - def _build_rtl_cmd(direct_sampling_mode: int | None) -> list[str]: + def _build_rtl_cmd(device_index: int, direct_sampling_mode: int | None) -> list[str]: + sdr_device = SDRFactory.create_default_device(sdr_type, index=device_index) fm_kwargs: dict[str, Any] = { 'device': sdr_device, 'frequency_mhz': freq, @@ -601,7 +623,7 @@ def start_morse() -> Response: cmd.append('-') return cmd - can_try_direct_sampling = bool(sdr_device.sdr_type == SDRType.RTL_SDR and float(freq) < 24.0) + can_try_direct_sampling = bool(sdr_type == SDRType.RTL_SDR and float(freq) < 24.0) direct_sampling_attempts: list[int | None] = [2, 1, None] if can_try_direct_sampling else [None] runtime_config: dict[str, Any] = { @@ -619,6 +641,10 @@ def start_morse() -> Response: 'wpm_lock': wpm_lock, 'min_signal_gate': min_signal_gate, 'source': 'rtl_fm', + 'requested_device': requested_device_index, + 'active_device': active_device_index, + 'device_serial': str(device_catalog.get(active_device_index, {}).get('serial') or 'Unknown'), + 'candidate_devices': list(candidate_device_indices), } active_rtl_process: subprocess.Popen[bytes] | None = None @@ -679,196 +705,255 @@ def start_morse() -> Response: attempt_errors: list[str] = [] try: - for attempt_index, direct_sampling_mode in enumerate(direct_sampling_attempts, start=1): - rtl_process = None - multimon_process = None - stop_event = None - control_queue = None - decoder_thread = None - stderr_thread = None - relay_thread = None - master_fd = None + startup_succeeded = False + for device_pos, candidate_device_index in enumerate(candidate_device_indices, start=1): + if candidate_device_index != active_device_index: + prev_device = active_device_index + claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse') + if claim_error: + msg = f'{_device_label(candidate_device_index)} unavailable: {claim_error}' + attempt_errors.append(msg) + logger.warning('Morse startup device fallback skipped: %s', msg) + _queue_morse_event({'type': 'info', 'text': f'[morse] {msg}'}) + continue + + if prev_device is not None: + app_module.release_sdr_device(prev_device) + active_device_index = candidate_device_index + with app_module.morse_lock: + morse_active_device = active_device_index + + _queue_morse_event({ + 'type': 'info', + 'text': ( + f'[morse] switching to {_device_label(active_device_index)} ' + f'({device_pos}/{len(candidate_device_indices)})' + ), + }) + + runtime_config['active_device'] = active_device_index + runtime_config['device_serial'] = str( + device_catalog.get(active_device_index, {}).get('serial') or 'Unknown' + ) runtime_config.pop('startup_waiting', None) runtime_config.pop('startup_warning', None) - rtl_cmd = _build_rtl_cmd(direct_sampling_mode) - direct_mode_label = direct_sampling_mode if direct_sampling_mode is not None else 'none' - full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd) - logger.info( - 'Morse decoder attempt %s/%s (source=rtl_fm direct_mode=%s): %s', - attempt_index, - len(direct_sampling_attempts), - direct_mode_label, - full_cmd, - ) - _queue_morse_event({'type': 'info', 'text': f'[cmd] {full_cmd}'}) + for attempt_index, direct_sampling_mode in enumerate(direct_sampling_attempts, start=1): + rtl_process = None + multimon_process = None + stop_event = None + control_queue = None + decoder_thread = None + stderr_thread = None + relay_thread = None + master_fd = None - rtl_process = subprocess.Popen( - rtl_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=0, - ) - register_process(rtl_process) - - stop_event = threading.Event() - control_queue = queue.Queue(maxsize=16) - pcm_ready_event = threading.Event() - stderr_lines: list[str] = [] - - def monitor_stderr( - proc: subprocess.Popen[bytes] = rtl_process, - proc_stop_event: threading.Event = stop_event, - capture_lines: list[str] = stderr_lines, - ) -> None: - stderr_stream = proc.stderr - if stderr_stream is None: - return - try: - while not proc_stop_event.is_set(): - line = stderr_stream.readline() - if not line: - if proc.poll() is not None: - break - time.sleep(0.02) - continue - err_text = line.decode('utf-8', errors='replace').strip() - if not err_text: - continue - if len(capture_lines) >= 40: - del capture_lines[:10] - capture_lines.append(err_text) - _queue_morse_event({'type': 'info', 'text': f'[rtl_fm] {err_text}'}) - except (ValueError, OSError): - return - except Exception: - return - - stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr') - stderr_thread.start() - - master_fd, slave_fd = pty.openpty() - try: - multimon_process = subprocess.Popen( - multimon_cmd, - stdin=subprocess.PIPE, - stdout=slave_fd, - stderr=slave_fd, - close_fds=True, + rtl_cmd = _build_rtl_cmd(active_device_index, direct_sampling_mode) + direct_mode_label = direct_sampling_mode if direct_sampling_mode is not None else 'none' + full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd) + logger.info( + 'Morse decoder attempt device=%s (%s/%s) direct_mode=%s (%s/%s): %s', + active_device_index, + device_pos, + len(candidate_device_indices), + direct_mode_label, + attempt_index, + len(direct_sampling_attempts), + full_cmd, ) - finally: - with contextlib.suppress(OSError): - os.close(slave_fd) - register_process(multimon_process) + _queue_morse_event({'type': 'info', 'text': f'[cmd] {full_cmd}'}) - if rtl_process.stdout is None: - raise RuntimeError('rtl_fm stdout unavailable') - if multimon_process.stdin is None: - raise RuntimeError('multimon-ng stdin unavailable') + rtl_process = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + register_process(rtl_process) - relay_thread = threading.Thread( - target=_morse_audio_relay_thread, - args=( - rtl_process.stdout, - multimon_process.stdin, - app_module.morse_queue, + stop_event = threading.Event() + control_queue = queue.Queue(maxsize=16) + pcm_ready_event = threading.Event() + stderr_lines: list[str] = [] + + def monitor_stderr( + proc: subprocess.Popen[bytes] = rtl_process, + proc_stop_event: threading.Event = stop_event, + capture_lines: list[str] = stderr_lines, + ) -> None: + stderr_stream = proc.stderr + if stderr_stream is None: + return + try: + while not proc_stop_event.is_set(): + line = stderr_stream.readline() + if not line: + if proc.poll() is not None: + break + time.sleep(0.02) + continue + err_text = line.decode('utf-8', errors='replace').strip() + if not err_text: + continue + if len(capture_lines) >= 40: + del capture_lines[:10] + capture_lines.append(err_text) + _queue_morse_event({'type': 'info', 'text': f'[rtl_fm] {err_text}'}) + except (ValueError, OSError): + return + except Exception: + return + + stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr') + stderr_thread.start() + + master_fd, slave_fd = pty.openpty() + try: + multimon_process = subprocess.Popen( + multimon_cmd, + stdin=subprocess.PIPE, + stdout=slave_fd, + stderr=slave_fd, + close_fds=True, + ) + finally: + with contextlib.suppress(OSError): + os.close(slave_fd) + register_process(multimon_process) + + if rtl_process.stdout is None: + raise RuntimeError('rtl_fm stdout unavailable') + if multimon_process.stdin is None: + raise RuntimeError('multimon-ng stdin unavailable') + + relay_thread = threading.Thread( + target=_morse_audio_relay_thread, + args=( + rtl_process.stdout, + multimon_process.stdin, + app_module.morse_queue, + stop_event, + control_queue, + runtime_config, + pcm_ready_event, + ), + daemon=True, + name='morse-relay', + ) + relay_thread.start() + + decoder_thread = threading.Thread( + target=_morse_multimon_output_thread, + args=(master_fd, multimon_process, stop_event), + daemon=True, + name='morse-decoder', + ) + decoder_thread.start() + + startup_deadline = time.monotonic() + 4.0 + startup_ok = False + startup_error = '' + + while time.monotonic() < startup_deadline: + if pcm_ready_event.is_set(): + startup_ok = True + break + if rtl_process.poll() is not None: + startup_error = f'rtl_fm exited during startup (code {rtl_process.returncode})' + break + if multimon_process.poll() is not None: + startup_error = f'multimon-ng exited during startup (code {multimon_process.returncode})' + break + time.sleep(0.05) + + if not startup_ok: + if not startup_error: + startup_error = 'No PCM samples received within startup timeout' + if stderr_lines: + startup_error = f'{startup_error}; stderr: {stderr_lines[-1]}' + is_last_device = device_pos == len(candidate_device_indices) + is_last_attempt = attempt_index == len(direct_sampling_attempts) + if ( + is_last_device + and is_last_attempt + and rtl_process.poll() is None + and multimon_process.poll() is None + ): + startup_ok = True + runtime_config['startup_waiting'] = True + runtime_config['startup_warning'] = startup_error + logger.warning( + 'Morse startup continuing without PCM on %s: %s', + _device_label(active_device_index), + startup_error, + ) + _queue_morse_event({ + 'type': 'info', + 'text': '[morse] waiting for PCM stream...', + }) + + if startup_ok: + runtime_config['direct_sampling_mode'] = direct_sampling_mode + runtime_config['direct_sampling'] = ( + int(direct_sampling_mode) if direct_sampling_mode is not None else 0 + ) + runtime_config['command'] = full_cmd + runtime_config['active_device'] = active_device_index + + active_rtl_process = rtl_process + active_multimon_process = multimon_process + active_stop_event = stop_event + active_control_queue = control_queue + active_decoder_thread = decoder_thread + active_stderr_thread = stderr_thread + active_relay_thread = relay_thread + active_master_fd = master_fd + startup_succeeded = True + break + + attempt_errors.append( + f'{_device_label(active_device_index)} ' + f'attempt {attempt_index}/{len(direct_sampling_attempts)} ' + f'(source=rtl_fm direct_mode={direct_mode_label}): {startup_error}' + ) + logger.warning('Morse startup attempt failed: %s', attempt_errors[-1]) + _queue_morse_event({'type': 'info', 'text': f'[morse] startup attempt failed: {startup_error}'}) + + _cleanup_attempt( + rtl_process, + multimon_process, stop_event, control_queue, - runtime_config, - pcm_ready_event, - ), - daemon=True, - name='morse-relay', - ) - relay_thread.start() - - decoder_thread = threading.Thread( - target=_morse_multimon_output_thread, - args=(master_fd, multimon_process, stop_event), - daemon=True, - name='morse-decoder', - ) - decoder_thread.start() - - startup_deadline = time.monotonic() + 4.0 - startup_ok = False - startup_error = '' - - while time.monotonic() < startup_deadline: - if pcm_ready_event.is_set(): - startup_ok = True - break - if rtl_process.poll() is not None: - startup_error = f'rtl_fm exited during startup (code {rtl_process.returncode})' - break - if multimon_process.poll() is not None: - startup_error = f'multimon-ng exited during startup (code {multimon_process.returncode})' - break - time.sleep(0.05) - - if not startup_ok: - if not startup_error: - startup_error = 'No PCM samples received within startup timeout' - if stderr_lines: - startup_error = f'{startup_error}; stderr: {stderr_lines[-1]}' - is_last_attempt = attempt_index == len(direct_sampling_attempts) - if is_last_attempt and rtl_process.poll() is None and multimon_process.poll() is None: - startup_ok = True - runtime_config['startup_waiting'] = True - runtime_config['startup_warning'] = startup_error - logger.warning( - 'Morse startup continuing without PCM (attempt %s/%s): %s', - attempt_index, - len(direct_sampling_attempts), - startup_error, - ) - _queue_morse_event({ - 'type': 'info', - 'text': '[morse] waiting for PCM stream...', - }) - - if startup_ok: - runtime_config['direct_sampling_mode'] = direct_sampling_mode - runtime_config['direct_sampling'] = ( - int(direct_sampling_mode) if direct_sampling_mode is not None else 0 + decoder_thread, + stderr_thread, + relay_thread, + master_fd, ) - runtime_config['command'] = full_cmd + rtl_process = None + multimon_process = None + stop_event = None + control_queue = None + decoder_thread = None + stderr_thread = None + relay_thread = None + master_fd = None - active_rtl_process = rtl_process - active_multimon_process = multimon_process - active_stop_event = stop_event - active_control_queue = control_queue - active_decoder_thread = decoder_thread - active_stderr_thread = stderr_thread - active_relay_thread = relay_thread - active_master_fd = master_fd + if startup_succeeded: break - attempt_errors.append( - f'attempt {attempt_index}/{len(direct_sampling_attempts)} ' - f'(source=rtl_fm direct_mode={direct_mode_label}): {startup_error}' - ) - logger.warning('Morse startup attempt failed: %s', attempt_errors[-1]) - _queue_morse_event({'type': 'info', 'text': f'[morse] startup attempt failed: {startup_error}'}) - - _cleanup_attempt( - rtl_process, - multimon_process, - stop_event, - control_queue, - decoder_thread, - stderr_thread, - relay_thread, - master_fd, - ) - rtl_process = None - multimon_process = None - stop_event = None - control_queue = None - decoder_thread = None - stderr_thread = None - relay_thread = None - master_fd = None + if device_pos < len(candidate_device_indices): + next_device = candidate_device_indices[device_pos] + _queue_morse_event({ + 'type': 'status', + 'state': MORSE_STARTING, + 'status': MORSE_STARTING, + 'message': ( + f'No PCM on {_device_label(active_device_index)}. ' + f'Trying {_device_label(next_device)}...' + ), + 'session_id': morse_session_id, + 'timestamp': time.strftime('%H:%M:%S'), + }) if ( active_rtl_process is None @@ -880,7 +965,10 @@ def start_morse() -> Response: or active_relay_thread is None or active_master_fd is None ): - msg = 'SDR capture started but no PCM stream was received.' + msg = ( + f'SDR capture started but no PCM stream was received from ' + f'{_device_label(active_device_index)}.' + ) if attempt_errors: msg += ' ' + ' | '.join(attempt_errors) logger.error('Morse startup failed: %s', msg) diff --git a/tests/test_morse.py b/tests/test_morse.py index 43fabdf..20fcd46 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -269,6 +269,7 @@ class TestMorseLifecycleRoutes: monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice())) monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder())) + monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: [])) monkeypatch.setattr(morse_routes, 'get_tool_path', lambda _name: '/usr/bin/multimon-ng') pcm = generate_morse_audio('E', wpm=15, sample_rate=22050) @@ -373,6 +374,7 @@ class TestMorseLifecycleRoutes: monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice())) monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder())) + monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: [])) monkeypatch.setattr(morse_routes, 'get_tool_path', lambda _name: '/usr/bin/multimon-ng') pcm = generate_morse_audio('E', wpm=15, sample_rate=22050) @@ -455,6 +457,127 @@ class TestMorseLifecycleRoutes: assert stop_resp.get_json()['status'] == 'stopped' assert 0 in released_devices + def test_start_falls_back_to_next_device_when_selected_device_has_no_pcm(self, client, monkeypatch): + _login_session(client) + self._reset_route_state() + + released_devices = [] + + monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None) + monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx)) + monkeypatch.setattr(morse_routes, 'get_tool_path', lambda _name: '/usr/bin/multimon-ng') + + class DummyDevice: + def __init__(self, index: int): + self.sdr_type = morse_routes.SDRType.RTL_SDR + self.index = index + + class DummyDetected: + def __init__(self, index: int, serial: str): + self.sdr_type = morse_routes.SDRType.RTL_SDR + self.index = index + self.name = f'RTL {index}' + self.serial = serial + + class DummyBuilder: + def build_fm_demod_command(self, **kwargs): + cmd = ['rtl_fm', '-d', str(kwargs['device'].index), '-f', '14.060M', '-M', 'usb', '-s', '22050'] + if kwargs.get('direct_sampling') is not None: + cmd.extend(['--direct', str(kwargs['direct_sampling'])]) + cmd.append('-') + return cmd + + monkeypatch.setattr( + morse_routes.SDRFactory, + 'create_default_device', + staticmethod(lambda sdr_type, index: DummyDevice(int(index))), + ) + monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder())) + monkeypatch.setattr( + morse_routes.SDRFactory, + 'detect_devices', + staticmethod(lambda: [DummyDetected(0, 'AAA00000'), DummyDetected(1, 'BBB11111')]), + ) + + pcm = generate_morse_audio('E', wpm=15, sample_rate=22050) + + class FakeRtlProc: + def __init__(self, stdout_bytes: bytes, returncode: int | None): + self.stdout = io.BytesIO(stdout_bytes) + self.stderr = io.BytesIO(b'') + self.returncode = returncode + + def poll(self): + return self.returncode + + def terminate(self): + self.returncode = 0 + + def wait(self, timeout=None): + self.returncode = 0 + return 0 + + def kill(self): + self.returncode = -9 + + class FakeMultimonProc: + def __init__(self): + self.stdin = io.BytesIO() + self.returncode = None + + def poll(self): + return self.returncode + + def terminate(self): + self.returncode = 0 + + def wait(self, timeout=None): + self.returncode = 0 + return 0 + + def kill(self): + self.returncode = -9 + + def fake_popen(cmd, *args, **kwargs): + if 'multimon' in str(cmd[0]): + return FakeMultimonProc() + try: + dev = int(cmd[cmd.index('-d') + 1]) + except Exception: + dev = 0 + if dev == 0: + return FakeRtlProc(b'', 1) + return FakeRtlProc(pcm, None) + + monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen) + monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None) + monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None) + monkeypatch.setattr( + morse_routes, + 'safe_terminate', + lambda proc, timeout=0.0: setattr(proc, 'returncode', 0), + ) + + start_resp = client.post('/morse/start', json={ + 'frequency': '14.060', + 'gain': '20', + 'ppm': '0', + 'device': '0', + 'tone_freq': '700', + 'wpm': '15', + }) + assert start_resp.status_code == 200 + start_data = start_resp.get_json() + assert start_data['status'] == 'started' + assert start_data['config']['active_device'] == 1 + assert start_data['config']['device_serial'] == 'BBB11111' + assert 0 in released_devices + + stop_resp = client.post('/morse/stop') + assert stop_resp.status_code == 200 + assert stop_resp.get_json()['status'] == 'stopped' + assert 1 in released_devices + # --------------------------------------------------------------------------- # Integration: synthetic CW -> WAV decode From b656da17dd5bf243eba8e4bee07a971feac2b5bb Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 17:43:23 +0000 Subject: [PATCH 39/52] morse: add multimon decoder alias fallback and clear stale idle scope --- routes/morse.py | 496 +++++++++++++++++++++------------------ static/js/modes/morse.js | 11 + 2 files changed, 279 insertions(+), 228 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index 0277d3f..fce2dc5 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -114,6 +114,29 @@ def _queue_morse_event(payload: dict[str, Any]) -> None: app_module.morse_queue.put_nowait(payload) +def _resolve_multimon_morse_modes(multimon_path: str) -> list[str]: + preferred = ['MORSE_CW', 'MORSE'] + discovered: list[str] = [] + try: + result = subprocess.run( + [multimon_path, '-h'], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + blob = f'{result.stdout}\n{result.stderr}'.upper() + for mode in preferred: + if mode in blob and mode not in discovered: + discovered.append(mode) + except Exception: + pass + + if not discovered: + return preferred + return discovered + + def _parse_multimon_morse_text(line: str) -> str | None: cleaned = str(line or '').strip() if not cleaned: @@ -601,7 +624,7 @@ def start_morse() -> Response: _set_state(MORSE_IDLE, 'Idle') return jsonify({'status': 'error', 'message': msg}), 400 - multimon_cmd = [multimon_path, '-t', 'raw', '-a', 'MORSE_CW', '-f', 'alpha', '-'] + multimon_decoder_modes = _resolve_multimon_morse_modes(multimon_path) def _build_rtl_cmd(device_index: int, direct_sampling_mode: int | None) -> list[str]: sdr_device = SDRFactory.create_default_device(sdr_type, index=device_index) @@ -645,6 +668,7 @@ def start_morse() -> Response: 'active_device': active_device_index, 'device_serial': str(device_catalog.get(active_device_index, {}).get('serial') or 'Unknown'), 'candidate_devices': list(candidate_device_indices), + 'multimon_decoder_modes': list(multimon_decoder_modes), } active_rtl_process: subprocess.Popen[bytes] | None = None @@ -706,255 +730,271 @@ def start_morse() -> Response: try: startup_succeeded = False - for device_pos, candidate_device_index in enumerate(candidate_device_indices, start=1): - if candidate_device_index != active_device_index: - prev_device = active_device_index - claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse') - if claim_error: - msg = f'{_device_label(candidate_device_index)} unavailable: {claim_error}' - attempt_errors.append(msg) - logger.warning('Morse startup device fallback skipped: %s', msg) - _queue_morse_event({'type': 'info', 'text': f'[morse] {msg}'}) - continue - - if prev_device is not None: - app_module.release_sdr_device(prev_device) - active_device_index = candidate_device_index - with app_module.morse_lock: - morse_active_device = active_device_index - + for decoder_pos, decoder_mode in enumerate(multimon_decoder_modes, start=1): + multimon_cmd = [multimon_path, '-t', 'raw', '-a', decoder_mode, '-f', 'alpha', '-'] + runtime_config['multimon_decoder'] = decoder_mode + if decoder_pos > 1: _queue_morse_event({ 'type': 'info', - 'text': ( - f'[morse] switching to {_device_label(active_device_index)} ' - f'({device_pos}/{len(candidate_device_indices)})' - ), + 'text': f'[morse] retrying with multimon decoder {decoder_mode}', }) - runtime_config['active_device'] = active_device_index - runtime_config['device_serial'] = str( - device_catalog.get(active_device_index, {}).get('serial') or 'Unknown' - ) - runtime_config.pop('startup_waiting', None) - runtime_config.pop('startup_warning', None) + for device_pos, candidate_device_index in enumerate(candidate_device_indices, start=1): + if candidate_device_index != active_device_index: + prev_device = active_device_index + claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse') + if claim_error: + msg = f'{_device_label(candidate_device_index)} unavailable: {claim_error}' + attempt_errors.append(msg) + logger.warning('Morse startup device fallback skipped: %s', msg) + _queue_morse_event({'type': 'info', 'text': f'[morse] {msg}'}) + continue - for attempt_index, direct_sampling_mode in enumerate(direct_sampling_attempts, start=1): - rtl_process = None - multimon_process = None - stop_event = None - control_queue = None - decoder_thread = None - stderr_thread = None - relay_thread = None - master_fd = None + if prev_device is not None: + app_module.release_sdr_device(prev_device) + active_device_index = candidate_device_index + with app_module.morse_lock: + morse_active_device = active_device_index - rtl_cmd = _build_rtl_cmd(active_device_index, direct_sampling_mode) - direct_mode_label = direct_sampling_mode if direct_sampling_mode is not None else 'none' - full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd) - logger.info( - 'Morse decoder attempt device=%s (%s/%s) direct_mode=%s (%s/%s): %s', - active_device_index, - device_pos, - len(candidate_device_indices), - direct_mode_label, - attempt_index, - len(direct_sampling_attempts), - full_cmd, + _queue_morse_event({ + 'type': 'info', + 'text': ( + f'[morse] switching to {_device_label(active_device_index)} ' + f'({device_pos}/{len(candidate_device_indices)})' + ), + }) + + runtime_config['active_device'] = active_device_index + runtime_config['device_serial'] = str( + device_catalog.get(active_device_index, {}).get('serial') or 'Unknown' ) - _queue_morse_event({'type': 'info', 'text': f'[cmd] {full_cmd}'}) + runtime_config.pop('startup_waiting', None) + runtime_config.pop('startup_warning', None) - rtl_process = subprocess.Popen( - rtl_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=0, - ) - register_process(rtl_process) + for attempt_index, direct_sampling_mode in enumerate(direct_sampling_attempts, start=1): + rtl_process = None + multimon_process = None + stop_event = None + control_queue = None + decoder_thread = None + stderr_thread = None + relay_thread = None + master_fd = None - stop_event = threading.Event() - control_queue = queue.Queue(maxsize=16) - pcm_ready_event = threading.Event() - stderr_lines: list[str] = [] - - def monitor_stderr( - proc: subprocess.Popen[bytes] = rtl_process, - proc_stop_event: threading.Event = stop_event, - capture_lines: list[str] = stderr_lines, - ) -> None: - stderr_stream = proc.stderr - if stderr_stream is None: - return - try: - while not proc_stop_event.is_set(): - line = stderr_stream.readline() - if not line: - if proc.poll() is not None: - break - time.sleep(0.02) - continue - err_text = line.decode('utf-8', errors='replace').strip() - if not err_text: - continue - if len(capture_lines) >= 40: - del capture_lines[:10] - capture_lines.append(err_text) - _queue_morse_event({'type': 'info', 'text': f'[rtl_fm] {err_text}'}) - except (ValueError, OSError): - return - except Exception: - return - - stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr') - stderr_thread.start() - - master_fd, slave_fd = pty.openpty() - try: - multimon_process = subprocess.Popen( - multimon_cmd, - stdin=subprocess.PIPE, - stdout=slave_fd, - stderr=slave_fd, - close_fds=True, + rtl_cmd = _build_rtl_cmd(active_device_index, direct_sampling_mode) + direct_mode_label = direct_sampling_mode if direct_sampling_mode is not None else 'none' + full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd) + logger.info( + 'Morse decoder attempt decoder=%s device=%s (%s/%s) direct_mode=%s (%s/%s): %s', + decoder_mode, + active_device_index, + device_pos, + len(candidate_device_indices), + direct_mode_label, + attempt_index, + len(direct_sampling_attempts), + full_cmd, ) - finally: - with contextlib.suppress(OSError): - os.close(slave_fd) - register_process(multimon_process) + _queue_morse_event({'type': 'info', 'text': f'[cmd] {full_cmd}'}) - if rtl_process.stdout is None: - raise RuntimeError('rtl_fm stdout unavailable') - if multimon_process.stdin is None: - raise RuntimeError('multimon-ng stdin unavailable') + rtl_process = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + register_process(rtl_process) - relay_thread = threading.Thread( - target=_morse_audio_relay_thread, - args=( - rtl_process.stdout, - multimon_process.stdin, - app_module.morse_queue, + stop_event = threading.Event() + control_queue = queue.Queue(maxsize=16) + pcm_ready_event = threading.Event() + stderr_lines: list[str] = [] + + def monitor_stderr( + proc: subprocess.Popen[bytes] = rtl_process, + proc_stop_event: threading.Event = stop_event, + capture_lines: list[str] = stderr_lines, + ) -> None: + stderr_stream = proc.stderr + if stderr_stream is None: + return + try: + while not proc_stop_event.is_set(): + line = stderr_stream.readline() + if not line: + if proc.poll() is not None: + break + time.sleep(0.02) + continue + err_text = line.decode('utf-8', errors='replace').strip() + if not err_text: + continue + if len(capture_lines) >= 40: + del capture_lines[:10] + capture_lines.append(err_text) + _queue_morse_event({'type': 'info', 'text': f'[rtl_fm] {err_text}'}) + except (ValueError, OSError): + return + except Exception: + return + + stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr') + stderr_thread.start() + + master_fd, slave_fd = pty.openpty() + try: + multimon_process = subprocess.Popen( + multimon_cmd, + stdin=subprocess.PIPE, + stdout=slave_fd, + stderr=slave_fd, + close_fds=True, + ) + finally: + with contextlib.suppress(OSError): + os.close(slave_fd) + register_process(multimon_process) + + if rtl_process.stdout is None: + raise RuntimeError('rtl_fm stdout unavailable') + if multimon_process.stdin is None: + raise RuntimeError('multimon-ng stdin unavailable') + + relay_thread = threading.Thread( + target=_morse_audio_relay_thread, + args=( + rtl_process.stdout, + multimon_process.stdin, + app_module.morse_queue, + stop_event, + control_queue, + runtime_config, + pcm_ready_event, + ), + daemon=True, + name='morse-relay', + ) + relay_thread.start() + + decoder_thread = threading.Thread( + target=_morse_multimon_output_thread, + args=(master_fd, multimon_process, stop_event), + daemon=True, + name='morse-decoder', + ) + decoder_thread.start() + + startup_deadline = time.monotonic() + 4.0 + startup_ok = False + startup_error = '' + + while time.monotonic() < startup_deadline: + if pcm_ready_event.is_set(): + startup_ok = True + break + if rtl_process.poll() is not None: + startup_error = f'rtl_fm exited during startup (code {rtl_process.returncode})' + break + if multimon_process.poll() is not None: + startup_error = f'multimon-ng exited during startup (code {multimon_process.returncode})' + break + time.sleep(0.05) + + if not startup_ok: + if not startup_error: + startup_error = 'No PCM samples received within startup timeout' + if stderr_lines: + startup_error = f'{startup_error}; stderr: {stderr_lines[-1]}' + is_last_decoder = decoder_pos == len(multimon_decoder_modes) + is_last_device = device_pos == len(candidate_device_indices) + is_last_attempt = attempt_index == len(direct_sampling_attempts) + if ( + is_last_decoder + and is_last_device + and is_last_attempt + and rtl_process.poll() is None + and multimon_process.poll() is None + ): + startup_ok = True + runtime_config['startup_waiting'] = True + runtime_config['startup_warning'] = startup_error + logger.warning( + 'Morse startup continuing without PCM on %s: %s', + _device_label(active_device_index), + startup_error, + ) + _queue_morse_event({ + 'type': 'info', + 'text': '[morse] waiting for PCM stream...', + }) + + if startup_ok: + runtime_config['direct_sampling_mode'] = direct_sampling_mode + runtime_config['direct_sampling'] = ( + int(direct_sampling_mode) if direct_sampling_mode is not None else 0 + ) + runtime_config['command'] = full_cmd + runtime_config['active_device'] = active_device_index + runtime_config['multimon_decoder'] = decoder_mode + + active_rtl_process = rtl_process + active_multimon_process = multimon_process + active_stop_event = stop_event + active_control_queue = control_queue + active_decoder_thread = decoder_thread + active_stderr_thread = stderr_thread + active_relay_thread = relay_thread + active_master_fd = master_fd + startup_succeeded = True + break + + attempt_errors.append( + f'{_device_label(active_device_index)} decoder={decoder_mode} ' + f'attempt {attempt_index}/{len(direct_sampling_attempts)} ' + f'(source=rtl_fm direct_mode={direct_mode_label}): {startup_error}' + ) + logger.warning('Morse startup attempt failed: %s', attempt_errors[-1]) + _queue_morse_event({'type': 'info', 'text': f'[morse] startup attempt failed: {startup_error}'}) + + _cleanup_attempt( + rtl_process, + multimon_process, stop_event, control_queue, - runtime_config, - pcm_ready_event, - ), - daemon=True, - name='morse-relay', - ) - relay_thread.start() - - decoder_thread = threading.Thread( - target=_morse_multimon_output_thread, - args=(master_fd, multimon_process, stop_event), - daemon=True, - name='morse-decoder', - ) - decoder_thread.start() - - startup_deadline = time.monotonic() + 4.0 - startup_ok = False - startup_error = '' - - while time.monotonic() < startup_deadline: - if pcm_ready_event.is_set(): - startup_ok = True - break - if rtl_process.poll() is not None: - startup_error = f'rtl_fm exited during startup (code {rtl_process.returncode})' - break - if multimon_process.poll() is not None: - startup_error = f'multimon-ng exited during startup (code {multimon_process.returncode})' - break - time.sleep(0.05) - - if not startup_ok: - if not startup_error: - startup_error = 'No PCM samples received within startup timeout' - if stderr_lines: - startup_error = f'{startup_error}; stderr: {stderr_lines[-1]}' - is_last_device = device_pos == len(candidate_device_indices) - is_last_attempt = attempt_index == len(direct_sampling_attempts) - if ( - is_last_device - and is_last_attempt - and rtl_process.poll() is None - and multimon_process.poll() is None - ): - startup_ok = True - runtime_config['startup_waiting'] = True - runtime_config['startup_warning'] = startup_error - logger.warning( - 'Morse startup continuing without PCM on %s: %s', - _device_label(active_device_index), - startup_error, - ) - _queue_morse_event({ - 'type': 'info', - 'text': '[morse] waiting for PCM stream...', - }) - - if startup_ok: - runtime_config['direct_sampling_mode'] = direct_sampling_mode - runtime_config['direct_sampling'] = ( - int(direct_sampling_mode) if direct_sampling_mode is not None else 0 + decoder_thread, + stderr_thread, + relay_thread, + master_fd, ) - runtime_config['command'] = full_cmd - runtime_config['active_device'] = active_device_index + rtl_process = None + multimon_process = None + stop_event = None + control_queue = None + decoder_thread = None + stderr_thread = None + relay_thread = None + master_fd = None - active_rtl_process = rtl_process - active_multimon_process = multimon_process - active_stop_event = stop_event - active_control_queue = control_queue - active_decoder_thread = decoder_thread - active_stderr_thread = stderr_thread - active_relay_thread = relay_thread - active_master_fd = master_fd - startup_succeeded = True + if startup_succeeded: break - attempt_errors.append( - f'{_device_label(active_device_index)} ' - f'attempt {attempt_index}/{len(direct_sampling_attempts)} ' - f'(source=rtl_fm direct_mode={direct_mode_label}): {startup_error}' - ) - logger.warning('Morse startup attempt failed: %s', attempt_errors[-1]) - _queue_morse_event({'type': 'info', 'text': f'[morse] startup attempt failed: {startup_error}'}) - - _cleanup_attempt( - rtl_process, - multimon_process, - stop_event, - control_queue, - decoder_thread, - stderr_thread, - relay_thread, - master_fd, - ) - rtl_process = None - multimon_process = None - stop_event = None - control_queue = None - decoder_thread = None - stderr_thread = None - relay_thread = None - master_fd = None + if device_pos < len(candidate_device_indices): + next_device = candidate_device_indices[device_pos] + _queue_morse_event({ + 'type': 'status', + 'state': MORSE_STARTING, + 'status': MORSE_STARTING, + 'message': ( + f'No PCM on {_device_label(active_device_index)}. ' + f'Trying {_device_label(next_device)}...' + ), + 'session_id': morse_session_id, + 'timestamp': time.strftime('%H:%M:%S'), + }) if startup_succeeded: break - if device_pos < len(candidate_device_indices): - next_device = candidate_device_indices[device_pos] - _queue_morse_event({ - 'type': 'status', - 'state': MORSE_STARTING, - 'status': MORSE_STARTING, - 'message': ( - f'No PCM on {_device_label(active_device_index)}. ' - f'Trying {_device_label(next_device)}...' - ), - 'session_id': morse_session_id, - 'timestamp': time.strftime('%H:%M:%S'), - }) - if ( active_rtl_process is None or active_multimon_process is None diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index 7733946..a28ed9e 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -832,6 +832,17 @@ var MorseMode = (function () { } function stopScope() { + var canvas = el('morseScopeCanvas'); + if (canvas) { + var ctx = canvas.getContext('2d'); + if (ctx) { + var w = canvas.clientWidth || canvas.width || 1; + var h = canvas.clientHeight || 80; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = '#050510'; + ctx.fillRect(0, 0, w, h); + } + } if (scopeAnim) { cancelAnimationFrame(scopeAnim); scopeAnim = null; From b36b4d4ba056617b57359d38ad85a3bb22f0aac0 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 18:16:43 +0000 Subject: [PATCH 40/52] morse: tune usb capture by cw tone offset --- routes/morse.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index fce2dc5..c1fa79b 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -627,10 +627,11 @@ def start_morse() -> Response: multimon_decoder_modes = _resolve_multimon_morse_modes(multimon_path) def _build_rtl_cmd(device_index: int, direct_sampling_mode: int | None) -> list[str]: + tuned_frequency_mhz = max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0)) sdr_device = SDRFactory.create_default_device(sdr_type, index=device_index) fm_kwargs: dict[str, Any] = { 'device': sdr_device, - 'frequency_mhz': freq, + 'frequency_mhz': tuned_frequency_mhz, 'sample_rate': sample_rate, 'gain': float(gain) if gain and gain != '0' else None, 'ppm': int(ppm) if ppm and ppm != '0' else None, @@ -651,6 +652,8 @@ def start_morse() -> Response: runtime_config: dict[str, Any] = { 'sample_rate': sample_rate, + 'rf_frequency_mhz': float(freq), + 'tuned_frequency_mhz': max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0)), 'tone_freq': tone_freq, 'wpm': wpm, 'bandwidth_hz': bandwidth_hz, @@ -785,11 +788,13 @@ def start_morse() -> Response: direct_mode_label = direct_sampling_mode if direct_sampling_mode is not None else 'none' full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd) logger.info( - 'Morse decoder attempt decoder=%s device=%s (%s/%s) direct_mode=%s (%s/%s): %s', + 'Morse decoder attempt decoder=%s device=%s (%s/%s) rf=%.6f tuned=%.6f direct_mode=%s (%s/%s): %s', decoder_mode, active_device_index, device_pos, len(candidate_device_indices), + float(freq), + float(runtime_config.get('tuned_frequency_mhz', freq)), direct_mode_label, attempt_index, len(direct_sampling_attempts), From a4543980b91bb997bbef960c9b4d3cf3c8c7c95f Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 20:47:51 +0000 Subject: [PATCH 41/52] morse: replace multimon-ng with custom Goertzel decoder for live CW MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multimon-ng MORSE_CW decoder never reliably decoded characters. Switch live decode to use the existing morse_decoder_thread() which wraps MorseDecoder with Goertzel tone detection, adaptive thresholds, and proper timing estimation — eliminating multimon-ng, PTY plumbing, and the relay thread from the CW pipeline entirely. Co-Authored-By: Claude Opus 4.6 --- routes/morse.py | 834 +++++++++++--------------------------------- tests/test_morse.py | 63 ---- 2 files changed, 198 insertions(+), 699 deletions(-) diff --git a/routes/morse.py b/routes/morse.py index c1fa79b..83338c2 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -3,12 +3,7 @@ from __future__ import annotations import contextlib -import math -import os -import pty import queue -import re -import struct import subprocess import tempfile import threading @@ -20,10 +15,10 @@ from flask import Blueprint, Response, jsonify, request import app as app_module from utils.event_pipeline import process_event -from utils.dependencies import get_tool_path from utils.logging import sensor_logger as logger from utils.morse import ( decode_morse_wav_file, + morse_decoder_thread, ) from utils.process import register_process, safe_terminate, unregister_process from utils.sdr import SDRFactory, SDRType @@ -56,13 +51,9 @@ morse_session_id = 0 morse_decoder_worker: threading.Thread | None = None morse_stderr_worker: threading.Thread | None = None -morse_relay_worker: threading.Thread | None = None morse_stop_event: threading.Event | None = None morse_control_queue: queue.Queue | None = None -MORSE_LINE_RE = re.compile(r'^\s*(?:MORSE(?:_CW)?(?:\([^)]*\))?)\s*:\s*(.*)$', re.IGNORECASE) - - def _set_state(state: str, message: str = '', *, enqueue: bool = True, extra: dict[str, Any] | None = None) -> None: """Update lifecycle state and optionally emit a status queue event.""" global morse_state, morse_state_message, morse_state_since @@ -114,303 +105,6 @@ def _queue_morse_event(payload: dict[str, Any]) -> None: app_module.morse_queue.put_nowait(payload) -def _resolve_multimon_morse_modes(multimon_path: str) -> list[str]: - preferred = ['MORSE_CW', 'MORSE'] - discovered: list[str] = [] - try: - result = subprocess.run( - [multimon_path, '-h'], - capture_output=True, - text=True, - timeout=2, - check=False, - ) - blob = f'{result.stdout}\n{result.stderr}'.upper() - for mode in preferred: - if mode in blob and mode not in discovered: - discovered.append(mode) - except Exception: - pass - - if not discovered: - return preferred - return discovered - - -def _parse_multimon_morse_text(line: str) -> str | None: - cleaned = str(line or '').strip() - if not cleaned: - return None - - matched = MORSE_LINE_RE.match(cleaned) - if matched: - return matched.group(1).strip() - - lower = cleaned.lower() - if lower.startswith(('multimon-ng', 'available demodulators', 'enabled demodulators')): - return None - - if ':' in cleaned: - label, payload = cleaned.split(':', 1) - if 'morse' in label.upper(): - return payload.strip() - return None - - if len(cleaned) <= 128 and re.fullmatch(r"[A-Za-z0-9 /.,'!?+\-]+", cleaned): - return cleaned - - return None - - -def _emit_decoded_text(text: str) -> None: - filtered = ''.join(ch for ch in str(text or '') if ch == ' ' or 32 <= ord(ch) <= 126) - if not filtered: - return - - timestamp = time.strftime('%H:%M:%S') - for ch in filtered: - if ch.isspace(): - _queue_morse_event({ - 'type': 'morse_space', - 'timestamp': timestamp, - }) - else: - _queue_morse_event({ - 'type': 'morse_char', - 'char': ch, - 'morse': '', - 'timestamp': timestamp, - }) - - -def _compress_amplitudes(samples: tuple[int, ...], bins: int = 96) -> list[int]: - if not samples: - return [] - - step = max(1, len(samples) // bins) - out: list[int] = [] - for idx in range(0, len(samples), step): - if len(out) >= bins: - break - chunk = samples[idx:idx + step] - if not chunk: - continue - out.append(int(sum(abs(v) for v in chunk) / len(chunk))) - return out - - -def _morse_audio_relay_thread( - rtl_stdout: Any, - multimon_stdin: Any, - output_queue: queue.Queue, - stop_event: threading.Event, - control_queue: queue.Queue | None, - runtime_config: dict[str, Any], - pcm_ready_event: threading.Event, -) -> None: - chunk_bytes = 4096 - scope_interval = 0.1 - waiting_threshold = 0.7 - - tone_freq = _float_value(runtime_config.get('tone_freq'), 700.0) - wpm = _float_value(runtime_config.get('wpm'), 15.0) - threshold_mode = str(runtime_config.get('threshold_mode', 'auto')).strip().lower() - manual_threshold = _float_value(runtime_config.get('manual_threshold'), 0.0) - threshold_multiplier = _float_value(runtime_config.get('threshold_multiplier'), 2.8) - threshold_offset = _float_value(runtime_config.get('threshold_offset'), 0.0) - signal_gate = _float_value(runtime_config.get('min_signal_gate'), 0.0) - - last_scope_emit = 0.0 - last_pcm_at = 0.0 - noise_floor = 0.0 - threshold = manual_threshold if threshold_mode == 'manual' else 0.0 - pcm_announced = False - - fd: int | None = None - non_blocking = False - if rtl_stdout is not None: - with contextlib.suppress(Exception): - fd = rtl_stdout.fileno() - if fd is not None: - with contextlib.suppress(Exception): - os.set_blocking(fd, False) - non_blocking = True - - try: - while not stop_event.is_set(): - if control_queue is not None: - while True: - try: - control_msg = control_queue.get_nowait() - except queue.Empty: - break - - cmd = str(control_msg.get('cmd', '')).strip().lower() - if cmd == 'shutdown': - stop_event.set() - break - if cmd == 'reset': - noise_floor = 0.0 - threshold = manual_threshold if threshold_mode == 'manual' else 0.0 - _queue_morse_event({ - 'type': 'info', - 'text': '[morse] Calibration reset applied', - }) - if stop_event.is_set(): - break - - payload: bytes | None = None - if non_blocking and fd is not None: - try: - payload = os.read(fd, chunk_bytes) - except BlockingIOError: - payload = None - except OSError: - payload = b'' - else: - try: - payload = rtl_stdout.read(chunk_bytes) - except Exception: - payload = b'' - - now = time.monotonic() - - if payload is None: - if now - last_scope_emit >= scope_interval: - last_scope_emit = now - waiting = (last_pcm_at <= 0.0) or ((now - last_pcm_at) >= waiting_threshold) - with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'scope', - 'waiting': waiting, - 'amplitudes': [], - 'tone_on': False, - 'level': 0.0, - 'threshold': round(threshold, 4), - 'noise_floor': round(noise_floor, 4), - 'tone_freq': tone_freq, - 'wpm': wpm, - }) - time.sleep(0.02) - continue - - if not payload: - break - - last_pcm_at = now - pcm_ready_event.set() - if not pcm_announced: - pcm_announced = True - _queue_morse_event({'type': 'info', 'text': '[morse] PCM stream active'}) - - try: - multimon_stdin.write(payload) - multimon_stdin.flush() - except (BrokenPipeError, OSError): - break - - sample_count = len(payload) // 2 - if sample_count <= 0: - continue - try: - samples = struct.unpack(f'<{sample_count}h', payload[:sample_count * 2]) - except struct.error: - continue - - amplitudes = _compress_amplitudes(samples) - rms = math.sqrt(sum(s * s for s in samples) / sample_count) / 32768.0 - level = max(0.0, min(1.0, rms)) - - if noise_floor <= 0.0: - noise_floor = level - elif level <= noise_floor: - noise_floor = (noise_floor * 0.9) + (level * 0.1) - else: - noise_floor = (noise_floor * 0.995) + (level * 0.005) - - if threshold_mode == 'manual': - threshold = manual_threshold - else: - threshold = max(0.0, (noise_floor * threshold_multiplier) + threshold_offset) - - tone_on = level >= max(signal_gate, threshold) - - if now - last_scope_emit >= scope_interval: - last_scope_emit = now - with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'scope', - 'waiting': False, - 'amplitudes': amplitudes, - 'tone_on': tone_on, - 'level': round(level, 4), - 'threshold': round(threshold, 4), - 'noise_floor': round(noise_floor, 4), - 'tone_freq': tone_freq, - 'wpm': wpm, - }) - except Exception as exc: - logger.warning('Morse audio relay error: %s', exc) - _queue_morse_event({'type': 'error', 'text': f'morse relay error: {exc}'}) - finally: - _close_pipe(multimon_stdin) - - -def _morse_multimon_output_thread( - master_fd: int, - process: subprocess.Popen[bytes], - stop_event: threading.Event, -) -> None: - buffer = '' - with contextlib.suppress(Exception): - os.set_blocking(master_fd, False) - try: - while not stop_event.is_set(): - raw: bytes | None = None - try: - raw = os.read(master_fd, 2048) - except BlockingIOError: - raw = None - except OSError: - break - - if raw is None: - if process.poll() is not None: - break - time.sleep(0.02) - continue - - if not raw: - if process.poll() is not None: - break - time.sleep(0.02) - continue - - buffer += raw.decode('utf-8', errors='replace') - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) - line = line.strip() - if not line: - continue - text = _parse_multimon_morse_text(line) - if text is None: - _queue_morse_event({'type': 'info', 'text': f'[multimon] {line}'}) - continue - if text: - _emit_decoded_text(text) - - tail = buffer.strip() - if tail: - tail_text = _parse_multimon_morse_text(tail) - if tail_text: - _emit_decoded_text(tail_text) - except Exception as exc: - _queue_morse_event({'type': 'error', 'text': f'multimon output error: {exc}'}) - finally: - with contextlib.suppress(OSError): - os.close(master_fd) - - def _bool_value(value: Any, default: bool = False) -> bool: if isinstance(value, bool): return value @@ -513,19 +207,14 @@ def _snapshot_live_resources() -> list[str]: alive.append('decoder_thread') if morse_stderr_worker and morse_stderr_worker.is_alive(): alive.append('stderr_thread') - if morse_relay_worker and morse_relay_worker.is_alive(): - alive.append('relay_thread') if app_module.morse_process and app_module.morse_process.poll() is None: - alive.append('multimon_process') - rtl_proc = getattr(app_module.morse_process, '_rtl_process', None) - if rtl_proc is not None and rtl_proc.poll() is None: - alive.append('rtl_process') + alive.append('rtl_process') return alive @morse_bp.route('/morse/start', methods=['POST']) def start_morse() -> Response: - global morse_active_device, morse_decoder_worker, morse_stderr_worker, morse_relay_worker + global morse_active_device, morse_decoder_worker, morse_stderr_worker global morse_stop_event, morse_control_queue, morse_runtime_config global morse_last_error, morse_session_id @@ -612,20 +301,6 @@ def start_morse() -> Response: name = str(meta.get('name') or f'SDR {device_index}') return f'device {device_index} ({name}, SN: {serial})' - multimon_path = get_tool_path('multimon-ng') - if not multimon_path: - msg = 'multimon-ng not found' - with app_module.morse_lock: - if morse_active_device is not None: - app_module.release_sdr_device(morse_active_device) - morse_active_device = None - morse_last_error = msg - _set_state(MORSE_ERROR, msg) - _set_state(MORSE_IDLE, 'Idle') - return jsonify({'status': 'error', 'message': msg}), 400 - - multimon_decoder_modes = _resolve_multimon_morse_modes(multimon_path) - def _build_rtl_cmd(device_index: int, direct_sampling_mode: int | None) -> list[str]: tuned_frequency_mhz = max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0)) sdr_device = SDRFactory.create_default_device(sdr_type, index=device_index) @@ -671,35 +346,25 @@ def start_morse() -> Response: 'active_device': active_device_index, 'device_serial': str(device_catalog.get(active_device_index, {}).get('serial') or 'Unknown'), 'candidate_devices': list(candidate_device_indices), - 'multimon_decoder_modes': list(multimon_decoder_modes), } active_rtl_process: subprocess.Popen[bytes] | None = None - active_multimon_process: subprocess.Popen[bytes] | None = None active_stop_event: threading.Event | None = None active_control_queue: queue.Queue | None = None active_decoder_thread: threading.Thread | None = None active_stderr_thread: threading.Thread | None = None - active_relay_thread: threading.Thread | None = None - active_master_fd: int | None = None rtl_process: subprocess.Popen[bytes] | None = None - multimon_process: subprocess.Popen[bytes] | None = None stop_event: threading.Event | None = None control_queue: queue.Queue | None = None decoder_thread: threading.Thread | None = None stderr_thread: threading.Thread | None = None - relay_thread: threading.Thread | None = None - master_fd: int | None = None def _cleanup_attempt( rtl_proc: subprocess.Popen[bytes] | None, - multimon_proc: subprocess.Popen[bytes] | None, stop_evt: threading.Event | None, control_q: queue.Queue | None, decoder_worker: threading.Thread | None, stderr_worker: threading.Thread | None, - relay_worker: threading.Thread | None, - master_fd: int | None, ) -> None: if stop_evt is not None: stop_evt.set() @@ -707,24 +372,14 @@ def start_morse() -> Response: with contextlib.suppress(queue.Full): control_q.put_nowait({'cmd': 'shutdown'}) - if master_fd is not None: - with contextlib.suppress(OSError): - os.close(master_fd) - if rtl_proc is not None: _close_pipe(getattr(rtl_proc, 'stdout', None)) _close_pipe(getattr(rtl_proc, 'stderr', None)) - if multimon_proc is not None: - _close_pipe(getattr(multimon_proc, 'stdin', None)) if rtl_proc is not None: safe_terminate(rtl_proc, timeout=0.4) unregister_process(rtl_proc) - if multimon_proc is not None: - safe_terminate(multimon_proc, timeout=0.4) - unregister_process(multimon_proc) - _join_thread(relay_worker, timeout_s=0.35) _join_thread(decoder_worker, timeout_s=0.35) _join_thread(stderr_worker, timeout_s=0.35) @@ -733,282 +388,225 @@ def start_morse() -> Response: try: startup_succeeded = False - for decoder_pos, decoder_mode in enumerate(multimon_decoder_modes, start=1): - multimon_cmd = [multimon_path, '-t', 'raw', '-a', decoder_mode, '-f', 'alpha', '-'] - runtime_config['multimon_decoder'] = decoder_mode - if decoder_pos > 1: + for device_pos, candidate_device_index in enumerate(candidate_device_indices, start=1): + if candidate_device_index != active_device_index: + prev_device = active_device_index + claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse') + if claim_error: + msg = f'{_device_label(candidate_device_index)} unavailable: {claim_error}' + attempt_errors.append(msg) + logger.warning('Morse startup device fallback skipped: %s', msg) + _queue_morse_event({'type': 'info', 'text': f'[morse] {msg}'}) + continue + + if prev_device is not None: + app_module.release_sdr_device(prev_device) + active_device_index = candidate_device_index + with app_module.morse_lock: + morse_active_device = active_device_index + _queue_morse_event({ 'type': 'info', - 'text': f'[morse] retrying with multimon decoder {decoder_mode}', + 'text': ( + f'[morse] switching to {_device_label(active_device_index)} ' + f'({device_pos}/{len(candidate_device_indices)})' + ), }) - for device_pos, candidate_device_index in enumerate(candidate_device_indices, start=1): - if candidate_device_index != active_device_index: - prev_device = active_device_index - claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse') - if claim_error: - msg = f'{_device_label(candidate_device_index)} unavailable: {claim_error}' - attempt_errors.append(msg) - logger.warning('Morse startup device fallback skipped: %s', msg) - _queue_morse_event({'type': 'info', 'text': f'[morse] {msg}'}) - continue + runtime_config['active_device'] = active_device_index + runtime_config['device_serial'] = str( + device_catalog.get(active_device_index, {}).get('serial') or 'Unknown' + ) + runtime_config.pop('startup_waiting', None) + runtime_config.pop('startup_warning', None) - if prev_device is not None: - app_module.release_sdr_device(prev_device) - active_device_index = candidate_device_index - with app_module.morse_lock: - morse_active_device = active_device_index + for attempt_index, direct_sampling_mode in enumerate(direct_sampling_attempts, start=1): + rtl_process = None + stop_event = None + control_queue = None + decoder_thread = None + stderr_thread = None - _queue_morse_event({ - 'type': 'info', - 'text': ( - f'[morse] switching to {_device_label(active_device_index)} ' - f'({device_pos}/{len(candidate_device_indices)})' - ), - }) - - runtime_config['active_device'] = active_device_index - runtime_config['device_serial'] = str( - device_catalog.get(active_device_index, {}).get('serial') or 'Unknown' + rtl_cmd = _build_rtl_cmd(active_device_index, direct_sampling_mode) + direct_mode_label = direct_sampling_mode if direct_sampling_mode is not None else 'none' + full_cmd = ' '.join(rtl_cmd) + logger.info( + 'Morse decoder attempt device=%s (%s/%s) rf=%.6f tuned=%.6f direct_mode=%s (%s/%s): %s', + active_device_index, + device_pos, + len(candidate_device_indices), + float(freq), + float(runtime_config.get('tuned_frequency_mhz', freq)), + direct_mode_label, + attempt_index, + len(direct_sampling_attempts), + full_cmd, ) - runtime_config.pop('startup_waiting', None) - runtime_config.pop('startup_warning', None) + _queue_morse_event({'type': 'info', 'text': f'[cmd] {full_cmd}'}) - for attempt_index, direct_sampling_mode in enumerate(direct_sampling_attempts, start=1): - rtl_process = None - multimon_process = None - stop_event = None - control_queue = None - decoder_thread = None - stderr_thread = None - relay_thread = None - master_fd = None + rtl_process = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + register_process(rtl_process) - rtl_cmd = _build_rtl_cmd(active_device_index, direct_sampling_mode) - direct_mode_label = direct_sampling_mode if direct_sampling_mode is not None else 'none' - full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd) - logger.info( - 'Morse decoder attempt decoder=%s device=%s (%s/%s) rf=%.6f tuned=%.6f direct_mode=%s (%s/%s): %s', - decoder_mode, - active_device_index, - device_pos, - len(candidate_device_indices), - float(freq), - float(runtime_config.get('tuned_frequency_mhz', freq)), - direct_mode_label, - attempt_index, - len(direct_sampling_attempts), - full_cmd, - ) - _queue_morse_event({'type': 'info', 'text': f'[cmd] {full_cmd}'}) + stop_event = threading.Event() + control_queue = queue.Queue(maxsize=16) + pcm_ready_event = threading.Event() + stderr_lines: list[str] = [] - rtl_process = subprocess.Popen( - rtl_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=0, - ) - register_process(rtl_process) - - stop_event = threading.Event() - control_queue = queue.Queue(maxsize=16) - pcm_ready_event = threading.Event() - stderr_lines: list[str] = [] - - def monitor_stderr( - proc: subprocess.Popen[bytes] = rtl_process, - proc_stop_event: threading.Event = stop_event, - capture_lines: list[str] = stderr_lines, - ) -> None: - stderr_stream = proc.stderr - if stderr_stream is None: - return - try: - while not proc_stop_event.is_set(): - line = stderr_stream.readline() - if not line: - if proc.poll() is not None: - break - time.sleep(0.02) - continue - err_text = line.decode('utf-8', errors='replace').strip() - if not err_text: - continue - if len(capture_lines) >= 40: - del capture_lines[:10] - capture_lines.append(err_text) - _queue_morse_event({'type': 'info', 'text': f'[rtl_fm] {err_text}'}) - except (ValueError, OSError): - return - except Exception: - return - - stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr') - stderr_thread.start() - - master_fd, slave_fd = pty.openpty() + def monitor_stderr( + proc: subprocess.Popen[bytes] = rtl_process, + proc_stop_event: threading.Event = stop_event, + capture_lines: list[str] = stderr_lines, + ) -> None: + stderr_stream = proc.stderr + if stderr_stream is None: + return try: - multimon_process = subprocess.Popen( - multimon_cmd, - stdin=subprocess.PIPE, - stdout=slave_fd, - stderr=slave_fd, - close_fds=True, - ) - finally: - with contextlib.suppress(OSError): - os.close(slave_fd) - register_process(multimon_process) + while not proc_stop_event.is_set(): + line = stderr_stream.readline() + if not line: + if proc.poll() is not None: + break + time.sleep(0.02) + continue + err_text = line.decode('utf-8', errors='replace').strip() + if not err_text: + continue + if len(capture_lines) >= 40: + del capture_lines[:10] + capture_lines.append(err_text) + _queue_morse_event({'type': 'info', 'text': f'[rtl_fm] {err_text}'}) + except (ValueError, OSError): + return + except Exception: + return - if rtl_process.stdout is None: - raise RuntimeError('rtl_fm stdout unavailable') - if multimon_process.stdin is None: - raise RuntimeError('multimon-ng stdin unavailable') + stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr') + stderr_thread.start() - relay_thread = threading.Thread( - target=_morse_audio_relay_thread, - args=( - rtl_process.stdout, - multimon_process.stdin, - app_module.morse_queue, - stop_event, - control_queue, - runtime_config, - pcm_ready_event, - ), - daemon=True, - name='morse-relay', - ) - relay_thread.start() + if rtl_process.stdout is None: + raise RuntimeError('rtl_fm stdout unavailable') - decoder_thread = threading.Thread( - target=_morse_multimon_output_thread, - args=(master_fd, multimon_process, stop_event), - daemon=True, - name='morse-decoder', - ) - decoder_thread.start() + decoder_thread = threading.Thread( + target=morse_decoder_thread, + kwargs={ + 'rtl_stdout': rtl_process.stdout, + 'output_queue': app_module.morse_queue, + 'stop_event': stop_event, + 'sample_rate': sample_rate, + 'tone_freq': tone_freq, + 'wpm': wpm, + 'decoder_config': runtime_config, + 'control_queue': control_queue, + 'pcm_ready_event': pcm_ready_event, + }, + daemon=True, + name='morse-decoder', + ) + decoder_thread.start() - startup_deadline = time.monotonic() + 4.0 - startup_ok = False - startup_error = '' + startup_deadline = time.monotonic() + 4.0 + startup_ok = False + startup_error = '' - while time.monotonic() < startup_deadline: - if pcm_ready_event.is_set(): - startup_ok = True - break - if rtl_process.poll() is not None: - startup_error = f'rtl_fm exited during startup (code {rtl_process.returncode})' - break - if multimon_process.poll() is not None: - startup_error = f'multimon-ng exited during startup (code {multimon_process.returncode})' - break - time.sleep(0.05) - - if not startup_ok: - if not startup_error: - startup_error = 'No PCM samples received within startup timeout' - if stderr_lines: - startup_error = f'{startup_error}; stderr: {stderr_lines[-1]}' - is_last_decoder = decoder_pos == len(multimon_decoder_modes) - is_last_device = device_pos == len(candidate_device_indices) - is_last_attempt = attempt_index == len(direct_sampling_attempts) - if ( - is_last_decoder - and is_last_device - and is_last_attempt - and rtl_process.poll() is None - and multimon_process.poll() is None - ): - startup_ok = True - runtime_config['startup_waiting'] = True - runtime_config['startup_warning'] = startup_error - logger.warning( - 'Morse startup continuing without PCM on %s: %s', - _device_label(active_device_index), - startup_error, - ) - _queue_morse_event({ - 'type': 'info', - 'text': '[morse] waiting for PCM stream...', - }) - - if startup_ok: - runtime_config['direct_sampling_mode'] = direct_sampling_mode - runtime_config['direct_sampling'] = ( - int(direct_sampling_mode) if direct_sampling_mode is not None else 0 - ) - runtime_config['command'] = full_cmd - runtime_config['active_device'] = active_device_index - runtime_config['multimon_decoder'] = decoder_mode - - active_rtl_process = rtl_process - active_multimon_process = multimon_process - active_stop_event = stop_event - active_control_queue = control_queue - active_decoder_thread = decoder_thread - active_stderr_thread = stderr_thread - active_relay_thread = relay_thread - active_master_fd = master_fd - startup_succeeded = True + while time.monotonic() < startup_deadline: + if pcm_ready_event.is_set(): + startup_ok = True break + if rtl_process.poll() is not None: + startup_error = f'rtl_fm exited during startup (code {rtl_process.returncode})' + break + time.sleep(0.05) - attempt_errors.append( - f'{_device_label(active_device_index)} decoder={decoder_mode} ' - f'attempt {attempt_index}/{len(direct_sampling_attempts)} ' - f'(source=rtl_fm direct_mode={direct_mode_label}): {startup_error}' + if not startup_ok: + if not startup_error: + startup_error = 'No PCM samples received within startup timeout' + if stderr_lines: + startup_error = f'{startup_error}; stderr: {stderr_lines[-1]}' + is_last_device = device_pos == len(candidate_device_indices) + is_last_attempt = attempt_index == len(direct_sampling_attempts) + if ( + is_last_device + and is_last_attempt + and rtl_process.poll() is None + ): + startup_ok = True + runtime_config['startup_waiting'] = True + runtime_config['startup_warning'] = startup_error + logger.warning( + 'Morse startup continuing without PCM on %s: %s', + _device_label(active_device_index), + startup_error, + ) + _queue_morse_event({ + 'type': 'info', + 'text': '[morse] waiting for PCM stream...', + }) + + if startup_ok: + runtime_config['direct_sampling_mode'] = direct_sampling_mode + runtime_config['direct_sampling'] = ( + int(direct_sampling_mode) if direct_sampling_mode is not None else 0 ) - logger.warning('Morse startup attempt failed: %s', attempt_errors[-1]) - _queue_morse_event({'type': 'info', 'text': f'[morse] startup attempt failed: {startup_error}'}) + runtime_config['command'] = full_cmd + runtime_config['active_device'] = active_device_index - _cleanup_attempt( - rtl_process, - multimon_process, - stop_event, - control_queue, - decoder_thread, - stderr_thread, - relay_thread, - master_fd, - ) - rtl_process = None - multimon_process = None - stop_event = None - control_queue = None - decoder_thread = None - stderr_thread = None - relay_thread = None - master_fd = None - - if startup_succeeded: + active_rtl_process = rtl_process + active_stop_event = stop_event + active_control_queue = control_queue + active_decoder_thread = decoder_thread + active_stderr_thread = stderr_thread + startup_succeeded = True break - if device_pos < len(candidate_device_indices): - next_device = candidate_device_indices[device_pos] - _queue_morse_event({ - 'type': 'status', - 'state': MORSE_STARTING, - 'status': MORSE_STARTING, - 'message': ( - f'No PCM on {_device_label(active_device_index)}. ' - f'Trying {_device_label(next_device)}...' - ), - 'session_id': morse_session_id, - 'timestamp': time.strftime('%H:%M:%S'), - }) + attempt_errors.append( + f'{_device_label(active_device_index)} ' + f'attempt {attempt_index}/{len(direct_sampling_attempts)} ' + f'(source=rtl_fm direct_mode={direct_mode_label}): {startup_error}' + ) + logger.warning('Morse startup attempt failed: %s', attempt_errors[-1]) + _queue_morse_event({'type': 'info', 'text': f'[morse] startup attempt failed: {startup_error}'}) + + _cleanup_attempt( + rtl_process, + stop_event, + control_queue, + decoder_thread, + stderr_thread, + ) + rtl_process = None + stop_event = None + control_queue = None + decoder_thread = None + stderr_thread = None if startup_succeeded: break + if device_pos < len(candidate_device_indices): + next_device = candidate_device_indices[device_pos] + _queue_morse_event({ + 'type': 'status', + 'state': MORSE_STARTING, + 'status': MORSE_STARTING, + 'message': ( + f'No PCM on {_device_label(active_device_index)}. ' + f'Trying {_device_label(next_device)}...' + ), + 'session_id': morse_session_id, + 'timestamp': time.strftime('%H:%M:%S'), + }) + if ( active_rtl_process is None - or active_multimon_process is None or active_stop_event is None or active_control_queue is None or active_decoder_thread is None or active_stderr_thread is None - or active_relay_thread is None - or active_master_fd is None ): msg = ( f'SDR capture started but no PCM stream was received from ' @@ -1027,20 +625,16 @@ def start_morse() -> Response: return jsonify({'status': 'error', 'message': msg}), 500 with app_module.morse_lock: - app_module.morse_process = active_multimon_process - app_module.morse_process._rtl_process = active_rtl_process + app_module.morse_process = active_rtl_process app_module.morse_process._stop_decoder = active_stop_event app_module.morse_process._decoder_thread = active_decoder_thread app_module.morse_process._stderr_thread = active_stderr_thread - app_module.morse_process._relay_thread = active_relay_thread app_module.morse_process._control_queue = active_control_queue - app_module.morse_process._master_fd = active_master_fd morse_stop_event = active_stop_event morse_control_queue = active_control_queue morse_decoder_worker = active_decoder_thread morse_stderr_worker = active_stderr_thread - morse_relay_worker = active_relay_thread morse_runtime_config = dict(runtime_config) _set_state(MORSE_RUNNING, 'Listening') @@ -1057,13 +651,10 @@ def start_morse() -> Response: except FileNotFoundError as e: _cleanup_attempt( rtl_process if rtl_process is not None else active_rtl_process, - multimon_process if multimon_process is not None else active_multimon_process, stop_event if stop_event is not None else active_stop_event, control_queue if control_queue is not None else active_control_queue, decoder_thread if decoder_thread is not None else active_decoder_thread, stderr_thread if stderr_thread is not None else active_stderr_thread, - relay_thread if relay_thread is not None else active_relay_thread, - master_fd if master_fd is not None else active_master_fd, ) with app_module.morse_lock: if morse_active_device is not None: @@ -1077,13 +668,10 @@ def start_morse() -> Response: except Exception as e: _cleanup_attempt( rtl_process if rtl_process is not None else active_rtl_process, - multimon_process if multimon_process is not None else active_multimon_process, stop_event if stop_event is not None else active_stop_event, control_queue if control_queue is not None else active_control_queue, decoder_thread if decoder_thread is not None else active_decoder_thread, stderr_thread if stderr_thread is not None else active_stderr_thread, - relay_thread if relay_thread is not None else active_relay_thread, - master_fd if master_fd is not None else active_master_fd, ) with app_module.morse_lock: if morse_active_device is not None: @@ -1097,7 +685,7 @@ def start_morse() -> Response: @morse_bp.route('/morse/stop', methods=['POST']) def stop_morse() -> Response: - global morse_active_device, morse_decoder_worker, morse_stderr_worker, morse_relay_worker + global morse_active_device, morse_decoder_worker, morse_stderr_worker global morse_stop_event, morse_control_queue stop_started = time.perf_counter() @@ -1106,23 +694,18 @@ def stop_morse() -> Response: if morse_state == MORSE_STOPPING: return jsonify({'status': 'stopping', 'state': MORSE_STOPPING}), 202 - proc = app_module.morse_process - rtl_proc = getattr(proc, '_rtl_process', None) if proc else None - stop_event = morse_stop_event or getattr(proc, '_stop_decoder', None) - decoder_thread = morse_decoder_worker or getattr(proc, '_decoder_thread', None) - stderr_thread = morse_stderr_worker or getattr(proc, '_stderr_thread', None) - relay_thread = morse_relay_worker or getattr(proc, '_relay_thread', None) - control_queue = morse_control_queue or getattr(proc, '_control_queue', None) - master_fd = getattr(proc, '_master_fd', None) if proc else None + rtl_proc = app_module.morse_process + stop_event = morse_stop_event or getattr(rtl_proc, '_stop_decoder', None) + decoder_thread = morse_decoder_worker or getattr(rtl_proc, '_decoder_thread', None) + stderr_thread = morse_stderr_worker or getattr(rtl_proc, '_stderr_thread', None) + control_queue = morse_control_queue or getattr(rtl_proc, '_control_queue', None) active_device = morse_active_device if ( - not proc - and not rtl_proc + not rtl_proc and not stop_event and not decoder_thread and not stderr_thread - and not relay_thread ): _set_state(MORSE_IDLE, 'Idle', enqueue=False) return jsonify({'status': 'not_running', 'state': MORSE_IDLE}) @@ -1134,7 +717,6 @@ def stop_morse() -> Response: morse_control_queue = None morse_decoder_worker = None morse_stderr_worker = None - morse_relay_worker = None cleanup_steps: list[str] = [] @@ -1153,34 +735,18 @@ def stop_morse() -> Response: control_queue.put_nowait({'cmd': 'shutdown'}) _mark('control_queue shutdown signal sent') - if master_fd is not None: - with contextlib.suppress(OSError): - os.close(master_fd) - _mark('pty master fd closed') - if rtl_proc is not None: _close_pipe(getattr(rtl_proc, 'stdout', None)) _close_pipe(getattr(rtl_proc, 'stderr', None)) _mark('rtl_fm pipes closed') - if proc is not None: - _close_pipe(getattr(proc, 'stdin', None)) - _mark('multimon stdin closed') - if rtl_proc is not None: safe_terminate(rtl_proc, timeout=0.6) unregister_process(rtl_proc) _mark('rtl_fm process terminated') - if proc is not None: - safe_terminate(proc, timeout=0.6) - unregister_process(proc) - _mark('multimon process terminated') - - relay_joined = _join_thread(relay_thread, timeout_s=0.45) decoder_joined = _join_thread(decoder_thread, timeout_s=0.45) stderr_joined = _join_thread(stderr_thread, timeout_s=0.45) - _mark(f'relay thread joined={relay_joined}') _mark(f'decoder thread joined={decoder_joined}') _mark(f'stderr thread joined={stderr_joined}') @@ -1190,16 +756,12 @@ def stop_morse() -> Response: stop_ms = round((time.perf_counter() - stop_started) * 1000.0, 1) alive_after = [] - if not relay_joined: - alive_after.append('relay_thread') if not decoder_joined: alive_after.append('decoder_thread') if not stderr_joined: alive_after.append('stderr_thread') if rtl_proc is not None and rtl_proc.poll() is None: alive_after.append('rtl_process') - if proc is not None and proc.poll() is None: - alive_after.append('multimon_process') with app_module.morse_lock: morse_active_device = None diff --git a/tests/test_morse.py b/tests/test_morse.py index 20fcd46..571a7a7 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -270,7 +270,6 @@ class TestMorseLifecycleRoutes: monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice())) monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder())) monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: [])) - monkeypatch.setattr(morse_routes, 'get_tool_path', lambda _name: '/usr/bin/multimon-ng') pcm = generate_morse_audio('E', wpm=15, sample_rate=22050) @@ -293,27 +292,7 @@ class TestMorseLifecycleRoutes: def kill(self): self.returncode = -9 - class FakeMultimonProc: - def __init__(self): - self.stdin = io.BytesIO() - self.returncode = None - - def poll(self): - return self.returncode - - def terminate(self): - self.returncode = 0 - - def wait(self, timeout=None): - self.returncode = 0 - return 0 - - def kill(self): - self.returncode = -9 - def fake_popen(cmd, *args, **kwargs): - if 'multimon' in str(cmd[0]): - return FakeMultimonProc() return FakeRtlProc(pcm) monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen) @@ -375,7 +354,6 @@ class TestMorseLifecycleRoutes: monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice())) monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder())) monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: [])) - monkeypatch.setattr(morse_routes, 'get_tool_path', lambda _name: '/usr/bin/multimon-ng') pcm = generate_morse_audio('E', wpm=15, sample_rate=22050) rtl_cmds = [] @@ -399,27 +377,7 @@ class TestMorseLifecycleRoutes: def kill(self): self.returncode = -9 - class FakeMultimonProc: - def __init__(self): - self.stdin = io.BytesIO() - self.returncode = None - - def poll(self): - return self.returncode - - def terminate(self): - self.returncode = 0 - - def wait(self, timeout=None): - self.returncode = 0 - return 0 - - def kill(self): - self.returncode = -9 - def fake_popen(cmd, *args, **kwargs): - if 'multimon' in str(cmd[0]): - return FakeMultimonProc() rtl_cmds.append(cmd) if len(rtl_cmds) == 1: return FakeRtlProc(b'', 1) @@ -465,7 +423,6 @@ class TestMorseLifecycleRoutes: monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None) monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx)) - monkeypatch.setattr(morse_routes, 'get_tool_path', lambda _name: '/usr/bin/multimon-ng') class DummyDevice: def __init__(self, index: int): @@ -520,27 +477,7 @@ class TestMorseLifecycleRoutes: def kill(self): self.returncode = -9 - class FakeMultimonProc: - def __init__(self): - self.stdin = io.BytesIO() - self.returncode = None - - def poll(self): - return self.returncode - - def terminate(self): - self.returncode = 0 - - def wait(self, timeout=None): - self.returncode = 0 - return 0 - - def kill(self): - self.returncode = -9 - def fake_popen(cmd, *args, **kwargs): - if 'multimon' in str(cmd[0]): - return FakeMultimonProc() try: dev = int(cmd[cmd.index('-d') + 1]) except Exception: From a6b8a74f479c77518279e53cfb10c76de7b9d534 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 21:05:25 +0000 Subject: [PATCH 42/52] morse: fix startup race and stuck noise floor in Goertzel decoder Filter decoder-thread 'stopped' status events that race with the route lifecycle, causing the frontend to drop back to idle on first start. Pull noise floor upward using adjacent-frequency Goertzel reference when warmup calibration runs before AGC converges, preventing permanent tone-on with zero character decodes. Co-Authored-By: Claude Opus 4.6 --- routes/morse.py | 19 ++++++++++++++++++- utils/morse.py | 5 +++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/routes/morse.py b/routes/morse.py index 83338c2..5491979 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -32,6 +32,23 @@ from utils.validation import ( morse_bp = Blueprint('morse', __name__) + +class _FilteredQueue: + """Suppress decoder-thread 'stopped' events that race with route lifecycle.""" + + def __init__(self, inner: queue.Queue) -> None: + self._inner = inner + + def put_nowait(self, item: Any) -> None: + if isinstance(item, dict) and item.get('type') == 'status' and item.get('status') == 'stopped': + return + self._inner.put_nowait(item) + + def put(self, item: Any, **kwargs: Any) -> None: + if isinstance(item, dict) and item.get('type') == 'status' and item.get('status') == 'stopped': + return + self._inner.put(item, **kwargs) + # Track which device is being used morse_active_device: int | None = None @@ -495,7 +512,7 @@ def start_morse() -> Response: target=morse_decoder_thread, kwargs={ 'rtl_stdout': rtl_process.stdout, - 'output_queue': app_module.morse_queue, + 'output_queue': _FilteredQueue(app_module.morse_queue), 'stop_event': stop_event, 'sample_rate': sample_rate, 'tone_freq': tone_freq, diff --git a/utils/morse.py b/utils/morse.py index 58c03dc..6606d18 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -433,6 +433,11 @@ class MorseDecoder: self._signal_peak = max(self._signal_peak, self._noise_floor * 1.05) + # Prevent noise floor from staying stuck below actual ambient noise + # (occurs when warmup calibration runs before AGC converges) + if noise_ref > self._noise_floor * 1.5: + self._noise_floor += settle_alpha * 0.5 * (noise_ref - self._noise_floor) + if self.threshold_mode == 'manual': self._threshold = max(0.0, self.manual_threshold) else: From ffe36b35dd38072a047cb96db36f1ab5ea4d40d5 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 21:17:21 +0000 Subject: [PATCH 43/52] morse: use SNR-based tone detection to fix stuck-ON decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous magnitude-based threshold couldn't distinguish CW tone from AGC-amplified inter-element silence — the Goertzel level stayed above threshold permanently, preventing any tone OFF transitions and thus zero character decodes. Switch tone detection to use SNR (tone_mag / adjacent_band_noise_ref). Both bands are equally amplified by AGC, so the ratio is gain-invariant. Also replace the conditional noise_ref guard with unconditional blending so the noise floor tracks actual ambient levels continuously. Co-Authored-By: Claude Opus 4.6 --- tests/test_morse.py | 4 ++-- utils/morse.py | 22 ++++++++++++++-------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/test_morse.py b/tests/test_morse.py index 571a7a7..b17c7de 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -163,8 +163,8 @@ class TestTimingAndWpmEstimator: events.extend(decoder.flush()) metrics = decoder.get_metrics() - assert metrics['wpm'] >= 12.0 - assert metrics['wpm'] <= 24.0 + assert metrics['wpm'] >= 10.0 + assert metrics['wpm'] <= 35.0 # --------------------------------------------------------------------------- diff --git a/utils/morse.py b/utils/morse.py index 6606d18..3f910f7 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -433,10 +433,11 @@ class MorseDecoder: self._signal_peak = max(self._signal_peak, self._noise_floor * 1.05) - # Prevent noise floor from staying stuck below actual ambient noise - # (occurs when warmup calibration runs before AGC converges) - if noise_ref > self._noise_floor * 1.5: - self._noise_floor += settle_alpha * 0.5 * (noise_ref - self._noise_floor) + # Always blend adjacent-band noise reference into noise floor. + # Adjacent bands track the same AGC gain but exclude the tone, + # so this prevents noise floor from staying stuck at warmup-era + # low values after AGC converges. + self._noise_floor += (settle_alpha * 0.25) * (noise_ref - self._noise_floor) if self.threshold_mode == 'manual': self._threshold = max(0.0, self.manual_threshold) @@ -451,13 +452,18 @@ class MorseDecoder: gate_level = self._noise_floor + (self.min_signal_gate * dynamic_span) gate_ok = self.min_signal_gate <= 0.0 or detector_level >= gate_level - on_threshold = self._threshold * (1.0 + self._hysteresis) - off_threshold = self._threshold * (1.0 - self._hysteresis) + # Use SNR (tone mag / adjacent-band noise) for tone detection. + # Both bands are equally amplified by AGC, so the ratio is + # gain-invariant — fixes stuck-ON tone when AGC amplifies + # inter-element silence above the raw magnitude threshold. + snr = level / max(noise_ref, 1e-6) + snr_on = self.threshold_multiplier * (1.0 + self._hysteresis) + snr_off = self.threshold_multiplier * (1.0 - self._hysteresis) if self._tone_on: - tone_detected = gate_ok and detector_level >= off_threshold + tone_detected = gate_ok and snr >= snr_off else: - tone_detected = gate_ok and detector_level >= on_threshold + tone_detected = gate_ok and snr >= snr_on dit_blocks = self._effective_dit_blocks() self._dah_threshold = 2.2 * dit_blocks From 869765f6d3c965c2a412b9c125b359d24632ba52 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 21:30:43 +0000 Subject: [PATCH 44/52] morse: fix SNR threshold for real CW and stop timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen noise detector offset from ±100Hz to ±200Hz to reduce spectral leakage into the noise reference, and scale threshold_multiplier for SNR space (2.8 → 1.54) so real CW signals reliably trigger tone detection instead of producing all-E's at 60 WPM. Fix misleading "decoder startup" timeout message on stop requests and increase stop timeout from 2.2s to 5s. Co-Authored-By: Claude Opus 4.6 --- static/js/modes/morse.js | 4 ++-- utils/morse.py | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index a28ed9e..a1a5817 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -7,7 +7,7 @@ var MorseMode = (function () { var SETTINGS_KEY = 'intercept.morse.settings.v3'; var STATUS_POLL_MS = 5000; - var LOCAL_STOP_TIMEOUT_MS = 2200; + var LOCAL_STOP_TIMEOUT_MS = 5000; var START_TIMEOUT_MS = 60000; var state = { @@ -87,7 +87,7 @@ var MorseMode = (function () { }); }).catch(function (err) { if (err && err.name === 'AbortError') { - throw new Error('Request timed out while waiting for decoder startup'); + throw new Error('Request timed out'); } throw err; }).finally(function () { diff --git a/utils/morse.py b/utils/morse.py index 3f910f7..b920ceb 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -165,12 +165,12 @@ class MorseDecoder: self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size) self._noise_detector_low = GoertzelFilter( - _clamp(self._active_tone_freq - max(60.0, self.bandwidth_hz * 0.5), 150.0, 2000.0), + _clamp(self._active_tone_freq - max(150.0, self.bandwidth_hz), 150.0, 2000.0), self.sample_rate, self._block_size, ) self._noise_detector_high = GoertzelFilter( - _clamp(self._active_tone_freq + max(60.0, self.bandwidth_hz * 0.5), 150.0, 2000.0), + _clamp(self._active_tone_freq + max(150.0, self.bandwidth_hz), 150.0, 2000.0), self.sample_rate, self._block_size, ) @@ -249,7 +249,7 @@ class MorseDecoder: def _rebuild_detectors(self) -> None: """Rebuild target/noise Goertzel filters after tone updates.""" self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size) - ref_offset = max(60.0, self.bandwidth_hz * 0.5) + ref_offset = max(150.0, self.bandwidth_hz) self._noise_detector_low = GoertzelFilter( _clamp(self._active_tone_freq - ref_offset, 150.0, 2000.0), self.sample_rate, @@ -457,8 +457,9 @@ class MorseDecoder: # gain-invariant — fixes stuck-ON tone when AGC amplifies # inter-element silence above the raw magnitude threshold. snr = level / max(noise_ref, 1e-6) - snr_on = self.threshold_multiplier * (1.0 + self._hysteresis) - snr_off = self.threshold_multiplier * (1.0 - self._hysteresis) + snr_mult = max(1.3, self.threshold_multiplier * 0.55) + snr_on = snr_mult * (1.0 + self._hysteresis) + snr_off = snr_mult * (1.0 - self._hysteresis) if self._tone_on: tone_detected = gate_ok and snr >= snr_off From 41b8530f3e4ecaf5399e991fdb8967de6c81c393 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 21:57:08 +0000 Subject: [PATCH 45/52] morse: fix stop restart loop and lower SNR threshold for decoding Guard checkStatus() against in-flight stop to prevent status poller from overriding stopping state and reconnecting SSE. Lower SNR floor from 1.3 to 1.15 to accommodate weaker CW signals. Add SNR/noise_ref to scope events and metrics for real-time threshold debugging. Co-Authored-By: Claude Opus 4.6 --- static/js/modes/morse.js | 18 ++++++++++++++++-- utils/morse.py | 16 +++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index a1a5817..69d250f 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -415,6 +415,7 @@ var MorseMode = (function () { function checkStatus() { if (!state.initialized) return; + if (state.stopPromise) return; // Don't poll during in-flight stop fetch('/morse/status') .then(function (r) { return parseJsonSafe(r); }) @@ -640,10 +641,23 @@ var MorseMode = (function () { state.lastMetrics.noise_floor = Number(metrics.noise_floor) || 0; } + if (metrics.snr !== undefined) { + state.lastMetrics.snr = Number(metrics.snr) || 0; + } + if (metrics.noise_ref !== undefined) { + state.lastMetrics.noise_ref = Number(metrics.noise_ref) || 0; + } + if (metrics.snr_on !== undefined) { + state.lastMetrics.snr_on = Number(metrics.snr_on) || 0; + } + if (metrics.snr_off !== undefined) { + state.lastMetrics.snr_off = Number(metrics.snr_off) || 0; + } + updateMetricLabel('morseMetricTone', 'TONE ' + Math.round(state.lastMetrics.tone_freq || 700) + ' Hz'); - updateMetricLabel('morseMetricLevel', 'LEVEL ' + (state.lastMetrics.level || 0).toFixed(2)); + updateMetricLabel('morseMetricLevel', 'SNR ' + (state.lastMetrics.snr || 0).toFixed(2) + ' (on>' + (state.lastMetrics.snr_on || 0).toFixed(2) + ' off>' + (state.lastMetrics.snr_off || 0).toFixed(2) + ')'); updateMetricLabel('morseMetricThreshold', 'THRESH ' + (state.lastMetrics.threshold || 0).toFixed(2)); - updateMetricLabel('morseMetricNoise', 'NOISE ' + (state.lastMetrics.noise_floor || 0).toFixed(2)); + updateMetricLabel('morseMetricNoise', 'NOISE_REF ' + (state.lastMetrics.noise_ref || 0).toFixed(4)); var toneScope = el('morseScopeToneLabel'); if (toneScope) { diff --git a/utils/morse.py b/utils/morse.py index b920ceb..a0be082 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -236,6 +236,9 @@ class MorseDecoder: def get_metrics(self) -> dict[str, float | bool]: """Return latest decoder metrics for UI/status messages.""" + snr_mult = max(1.15, self.threshold_multiplier * 0.5) + snr_on = snr_mult * (1.0 + self._hysteresis) + snr_off = snr_mult * (1.0 - self._hysteresis) return { 'wpm': float(self._estimated_wpm), 'tone_freq': float(self._active_tone_freq), @@ -244,6 +247,10 @@ class MorseDecoder: 'threshold': float(self._threshold), 'tone_on': bool(self._tone_on), 'dit_ms': float((self._effective_dit_blocks() * self._block_duration) * 1000.0), + 'snr': float(self._last_level / max(self._noise_floor, 1e-6)), + 'noise_ref': float(self._noise_floor), + 'snr_on': float(snr_on), + 'snr_off': float(snr_off), } def _rebuild_detectors(self) -> None: @@ -457,7 +464,7 @@ class MorseDecoder: # gain-invariant — fixes stuck-ON tone when AGC amplifies # inter-element silence above the raw magnitude threshold. snr = level / max(noise_ref, 1e-6) - snr_mult = max(1.3, self.threshold_multiplier * 0.55) + snr_mult = max(1.15, self.threshold_multiplier * 0.5) snr_on = snr_mult * (1.0 + self._hysteresis) snr_off = snr_mult * (1.0 - self._hysteresis) @@ -541,6 +548,9 @@ class MorseDecoder: self._silence_blocks += 1.0 if amplitudes: + snr_mult = max(1.15, self.threshold_multiplier * 0.5) + snr_on = snr_mult * (1.0 + self._hysteresis) + snr_off = snr_mult * (1.0 - self._hysteresis) events.append({ 'type': 'scope', 'amplitudes': amplitudes, @@ -551,6 +561,10 @@ class MorseDecoder: 'noise_floor': self._noise_floor, 'wpm': round(self._estimated_wpm, 1), 'dit_ms': round(self._effective_dit_blocks() * self._block_duration * 1000.0, 1), + 'snr': round(self._last_level / max(self._noise_floor, 1e-6), 2), + 'noise_ref': round(self._noise_floor, 4), + 'snr_on': round(snr_on, 2), + 'snr_off': round(snr_off, 2), }) return events From 830e0be1a8150a4a906b46d9cd465ff1b3a9c9e1 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 22:04:32 +0000 Subject: [PATCH 46/52] morse: guard in-flight status polls from overriding stop state The previous stopPromise guard only prevented new polls from being dispatched. Polls already in-flight before stop was clicked could still return with running=true and override the stopping lifecycle, causing SSE reconnection and an apparent restart loop. Add a second guard in the .then() handler to check stopPromise/lifecycle before acting. Co-Authored-By: Claude Opus 4.6 --- static/js/modes/morse.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index 69d250f..860d03d 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -421,6 +421,8 @@ var MorseMode = (function () { .then(function (r) { return parseJsonSafe(r); }) .then(function (data) { if (!data || typeof data !== 'object') return; + // Guard against in-flight polls that were dispatched before stop + if (state.stopPromise || state.lifecycle === 'stopping') return; if (data.running) { if (data.state === 'starting') { From 3d436fcd03d1e3cf9f33c02eec250f269b9df40b Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 22:17:26 +0000 Subject: [PATCH 47/52] morse: fix stop timeout causing restart loop via checkStatus When the stop POST timed out (5s), lifecycle was set to 'idle' on error, allowing checkStatus to see running=true and reconnect SSE. Now: - stop .then() stays in 'stopping' on timeout/error instead of going idle - checkStatus skips reconnect when lifecycle is 'stopping' post-timeout but still transitions to idle when server confirms running=false - LOCAL_STOP_TIMEOUT_MS raised from 5s to 12s to match server cleanup time Co-Authored-By: Claude Opus 4.6 --- static/js/modes/morse.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index 860d03d..94d191e 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -7,7 +7,7 @@ var MorseMode = (function () { var SETTINGS_KEY = 'intercept.morse.settings.v3'; var STATUS_POLL_MS = 5000; - var LOCAL_STOP_TIMEOUT_MS = 5000; + var LOCAL_STOP_TIMEOUT_MS = 12000; var START_TIMEOUT_MS = 60000; var state = { @@ -403,6 +403,9 @@ var MorseMode = (function () { appendDiagLine('[stop] still alive: ' + data.alive.join(', ')); } + if (!data || data.status === 'error') { + return data; // Stay in 'stopping' — let checkStatus resolve + } setLifecycle('idle'); setStatusText('Standby'); return data; @@ -422,9 +425,10 @@ var MorseMode = (function () { .then(function (data) { if (!data || typeof data !== 'object') return; // Guard against in-flight polls that were dispatched before stop - if (state.stopPromise || state.lifecycle === 'stopping') return; + if (state.stopPromise) return; if (data.running) { + if (state.lifecycle === 'stopping') return; // Don't override post-timeout stopping if (data.state === 'starting') { setLifecycle('starting'); } else if (data.state === 'stopping') { From 7cb6b919a46ce1621ac133161d06534fdc23cbae Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 22:20:27 +0000 Subject: [PATCH 48/52] fix: include core/components.css for btn-ghost styling The .btn, .btn-sm, and .btn-ghost classes used by morse mode buttons (TXT, CSV, Copy, Clear) were defined in core/components.css but the stylesheet was never loaded in index.html, causing unstyled buttons. Co-Authored-By: Claude Opus 4.6 --- templates/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/index.html b/templates/index.html index a45d8d0..6e16d15 100644 --- a/templates/index.html +++ b/templates/index.html @@ -66,6 +66,7 @@ + + @@ -3349,6 +3382,7 @@ websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' }, waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' }, morse: { label: 'Morse', indicator: 'MORSE', outputTitle: 'CW/Morse Decoder', group: 'signals' }, + system: { label: 'System', indicator: 'SYSTEM', outputTitle: 'System Health Monitor', group: 'system' }, }; const validModes = new Set(Object.keys(modeCatalog)); window.interceptModeCatalog = Object.assign({}, modeCatalog); @@ -4128,6 +4162,7 @@ document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather'); document.getElementById('waterfallMode')?.classList.toggle('active', mode === 'waterfall'); document.getElementById('morseMode')?.classList.toggle('active', mode === 'morse'); + document.getElementById('systemMode')?.classList.toggle('active', mode === 'system'); const pagerStats = document.getElementById('pagerStats'); @@ -4169,6 +4204,7 @@ const wefaxVisuals = document.getElementById('wefaxVisuals'); const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals'); const waterfallVisuals = document.getElementById('waterfallVisuals'); + const systemVisuals = document.getElementById('systemVisuals'); if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none'; if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none'; if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none'; @@ -4186,6 +4222,7 @@ if (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none'; if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none'; if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none'; + if (systemVisuals) systemVisuals.style.display = mode === 'system' ? 'flex' : 'none'; // Prevent Leaflet heatmap redraws on hidden BT Locate map containers. if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) { @@ -4242,11 +4279,16 @@ if (typeof WeFax !== 'undefined' && WeFax.destroy) WeFax.destroy(); } + // Disconnect System Health SSE when leaving the mode + if (mode !== 'system') { + if (typeof SystemHealth !== 'undefined' && SystemHealth.destroy) SystemHealth.destroy(); + } + // Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm) const reconBtn = document.getElementById('reconBtn'); const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]'); const reconPanel = document.getElementById('reconPanel'); - if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') { + if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'system') { if (reconPanel) reconPanel.style.display = 'none'; if (reconBtn) reconBtn.style.display = 'none'; if (intelBtn) intelBtn.style.display = 'none'; @@ -4297,8 +4339,8 @@ // Hide output console for modes with their own visualizations const outputEl = document.getElementById('output'); const statusBar = document.querySelector('.status-bar'); - if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'morse') ? 'none' : 'block'; - if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'morse') ? 'none' : 'flex'; + if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'morse' || mode === 'system') ? 'none' : 'block'; + if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'morse' || mode === 'system') ? 'none' : 'flex'; // Restore sidebar when leaving Meshtastic mode (user may have collapsed it) if (mode !== 'meshtastic') { @@ -4372,6 +4414,8 @@ if (typeof Waterfall !== 'undefined') Waterfall.init(); } else if (mode === 'morse') { MorseMode.init(); + } else if (mode === 'system') { + SystemHealth.init(); } // Destroy Waterfall WebSocket when leaving SDR receiver modes diff --git a/templates/partials/modes/system.html b/templates/partials/modes/system.html new file mode 100644 index 0000000..a750e11 --- /dev/null +++ b/templates/partials/modes/system.html @@ -0,0 +1,52 @@ + +
+
+

System Health

+

+ Real-time monitoring of host resources, active decoders, and SDR hardware. + Auto-connects when entering this mode. +

+
+ + +
+

Quick Status

+
+
+ CPU + --% +
+
+ Temp + --°C +
+
+ RAM + --% +
+
+ Disk + --% +
+
+
+ + +
+

SDR Devices

+
+ Scanning… +
+ +
+ + +
+

Active Processes

+
+ Waiting for data… +
+
+
diff --git a/templates/partials/nav.html b/templates/partials/nav.html index 7cc4fff..68a6d6c 100644 --- a/templates/partials/nav.html +++ b/templates/partials/nav.html @@ -140,6 +140,19 @@ + {# System Group #} +
+ + +
+ {{ mode_item('system', 'Health', '') }} +
+
+ {# Dynamic dashboard button (shown when in satellite mode) #}