diff --git a/CHANGELOG.md b/CHANGELOG.md index ddf0e2d..4a53a7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,39 @@ All notable changes to iNTERCEPT will be documented in this file. +## [2.15.0] - 2026-02-09 + +### Added +- **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT + - Click-to-tune, zoom controls, and auto-scaling quantization + - Shared waterfall UI across SDR modes with function bar controls + - WebSocket frame serialization and connection reuse +- **Cross-Module Frequency Routing** - Tune from Listening Post directly to decoders +- **Pure Python SSTV Decoder** - Replaces broken slowrx C dependency + - Real-time decode progress with partial image streaming + - VIS detector state in signal monitor diagnostics + - Image gallery with delete and download functionality +- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes +- **SSTV Image Gallery** - Delete and download decoded images +- **USB Device Probe** - Detect broken SDR devices before rtl_fm crashes + +### Fixed +- DMR dsd-fme protocol flags, device label, and tuning controls +- DMR frontend/backend state desync causing 409 on start +- Digital voice decoder producing no output due to wrong dsd-fme flags +- SDR device lock-up from unreleased device registry on process crash +- APRS crash on large station count and station list overflow +- Settings modal overflowing viewport on smaller screens +- Waterfall crash on zoom by reusing WebSocket and adding USB release retry +- PD120 SSTV decode hang and false leader tone detection +- WebSocket waterfall blocked by login redirect +- TSCM sweep KeyError on RiskLevel.NEEDS_REVIEW + +### Removed +- GSM Spy functionality removed for legal compliance + +--- + ## [2.14.0] - 2026-02-06 ### Added diff --git a/config.py b/config.py index b7758cb..973a87f 100644 --- a/config.py +++ b/config.py @@ -7,10 +7,23 @@ import os import sys # Application version -VERSION = "2.14.0" +VERSION = "2.15.0" # Changelog - latest release notes (shown on welcome screen) CHANGELOG = [ + { + "version": "2.15.0", + "date": "February 2026", + "highlights": [ + "Real-time WebSocket waterfall with I/Q capture and server-side FFT", + "Cross-module frequency routing from Listening Post to decoders", + "Pure Python SSTV decoder replacing broken slowrx dependency", + "Real-time signal scope for pager, sensor, and SSTV modes", + "USB-level device probe to prevent cryptic rtl_fm crashes", + "DMR dsd-fme protocol fixes, tuning controls, and state sync", + "SDR device lock-up fix from unreleased device registry on crash", + ] + }, { "version": "2.14.0", "date": "February 2026", diff --git a/pyproject.toml b/pyproject.toml index 1040a65..060f795 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "intercept" -version = "2.14.0" +version = "2.15.0" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" readme = "README.md" requires-python = ">=3.9" diff --git a/routes/dmr.py b/routes/dmr.py index e8d91fa..ec4db2d 100644 --- a/routes/dmr.py +++ b/routes/dmr.py @@ -20,7 +20,7 @@ from utils.logging import get_logger from utils.sse import format_sse from utils.event_pipeline import process_event from utils.process import register_process, unregister_process -from utils.validation import validate_frequency, validate_gain, validate_device_index +from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm from utils.constants import ( SSE_QUEUE_TIMEOUT, SSE_KEEPALIVE_INTERVAL, @@ -39,10 +39,17 @@ dmr_rtl_process: Optional[subprocess.Popen] = None dmr_dsd_process: Optional[subprocess.Popen] = None dmr_thread: Optional[threading.Thread] = None dmr_running = False +dmr_has_audio = False # True when ffmpeg available and dsd outputs audio dmr_lock = threading.Lock() dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) dmr_active_device: Optional[int] = None +# Audio mux: the sole reader of dsd-fme stdout. Writes to an ffmpeg +# stdin when a streaming client is connected, discards otherwise. +# This prevents dsd-fme from blocking on stdout (which would also +# freeze stderr / text data output). +_active_ffmpeg_stdin: Optional[object] = None # set by stream endpoint + VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice'] # Classic dsd flags @@ -55,14 +62,26 @@ _DSD_PROTOCOL_FLAGS = { 'provoice': ['-fv'], } -# dsd-fme uses different flag names +# dsd-fme remapped several flags from classic DSD: +# -fs = DMR Simplex (NOT -fd which is D-STAR!), +# -fd = D-STAR (NOT DMR!), -fp = ProVoice (NOT P25), +# -fi = NXDN48 (NOT D-Star), -f1 = P25 Phase 1, +# -ft = XDMA multi-protocol decoder _DSD_FME_PROTOCOL_FLAGS = { - 'auto': [], - 'dmr': ['-fd'], - 'p25': ['-fp'], - 'nxdn': ['-fn'], - 'dstar': ['-fi'], - 'provoice': ['-fv'], + 'auto': ['-ft'], # XDMA: auto-detect DMR/P25/YSF + 'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!) + 'p25': ['-f1'], # P25 Phase 1 (-fp is ProVoice in dsd-fme!) + 'nxdn': ['-fn'], # NXDN96 + 'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!) + 'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv) +} + +# Modulation hints: force C4FM for protocols that use it, improving +# sync reliability vs letting dsd-fme auto-detect modulation type. +_DSD_FME_MODULATION = { + 'dmr': ['-mc'], # C4FM + 'p25': ['-mc'], # C4FM (Phase 1; Phase 2 would use -mq) + 'nxdn': ['-mc'], # C4FM } # ============================================ @@ -90,6 +109,11 @@ def find_rtl_fm() -> str | None: return shutil.which('rtl_fm') +def find_ffmpeg() -> str | None: + """Find ffmpeg for audio encoding.""" + return shutil.which('ffmpeg') + + def parse_dsd_output(line: str) -> dict | None: """Parse a line of DSD stderr output into a structured event. @@ -101,8 +125,11 @@ def parse_dsd_output(line: str) -> dict | None: return None # Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.) - # These contain box-drawing characters or are pure decoration. - if re.search(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░]', line): + # Only filter lines that are purely decorative — dsd-fme uses box-drawing + # characters (│, ─) as column separators in DATA lines, so we must not + # discard lines that also contain alphanumeric content. + stripped_of_box = re.sub(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░\s]', '', line) + if not stripped_of_box: return None if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line): return None @@ -122,8 +149,9 @@ def parse_dsd_output(line: str) -> dict | None: # is captured as a call event rather than a bare slot event. # Classic dsd: "TG: 12345 Src: 67890" # dsd-fme: "TG: 12345, Src: 67890" or "Talkgroup: 12345, Source: 67890" + # "TGT: 12345 | SRC: 67890" (pipe-delimited variant) tg_match = re.search( - r'(?:TG|Talkgroup)[:\s]+(\d+)[,\s]+(?:Src|Source)[:\s]+(\d+)', line, re.IGNORECASE + r'(?:TGT?|Talkgroup)[:\s]+(\d+)[,|│\s]+(?:Src|Source|SRC)[:\s]+(\d+)', line, re.IGNORECASE ) if tg_match: result = { @@ -182,6 +210,45 @@ def parse_dsd_output(line: str) -> dict | None: _HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle +# 100ms of silence at 8kHz 16-bit mono = 1600 bytes +_SILENCE_CHUNK = b'\x00' * 1600 + + +def _dsd_audio_mux(dsd_stdout): + """Mux thread: sole reader of dsd-fme stdout. + + Always drains dsd-fme's audio output to prevent the process from + blocking on stdout writes (which would also freeze stderr / text + data). When an audio streaming client is connected, forwards audio + to its ffmpeg stdin with silence fill during voice gaps. When no + client is connected, simply discards the data. + """ + try: + while dmr_running: + ready, _, _ = select.select([dsd_stdout], [], [], 0.1) + if ready: + data = os.read(dsd_stdout.fileno(), 4096) + if not data: + break + sink = _active_ffmpeg_stdin + if sink: + try: + sink.write(data) + sink.flush() + except (BrokenPipeError, OSError, ValueError): + pass + else: + # No audio from decoder — feed silence if client connected + sink = _active_ffmpeg_stdin + if sink: + try: + sink.write(_SILENCE_CHUNK) + sink.flush() + except (BrokenPipeError, OSError, ValueError): + pass + except (OSError, ValueError): + pass + def _queue_put(event: dict): """Put an event on the DMR queue, dropping oldest if full.""" @@ -229,6 +296,7 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop if not text: continue + logger.debug("DSD raw: %s", text) parsed = parse_dsd_output(text) if parsed: _queue_put(parsed) @@ -262,7 +330,7 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop except Exception: pass logger.warning(f"DSD process exited with code {rc}: {detail}") - # Cleanup both processes + # Cleanup decoder + demod processes for proc in [dsd_process, rtl_process]: if proc and proc.poll() is None: try: @@ -294,9 +362,11 @@ def check_tools() -> Response: """Check for required tools.""" dsd_path, _ = find_dsd() rtl_fm = find_rtl_fm() + ffmpeg = find_ffmpeg() return jsonify({ 'dsd': dsd_path is not None, 'rtl_fm': rtl_fm is not None, + 'ffmpeg': ffmpeg is not None, 'available': dsd_path is not None and rtl_fm is not None, 'protocols': VALID_PROTOCOLS, }) @@ -305,7 +375,8 @@ def check_tools() -> Response: @dmr_bp.route('/start', methods=['POST']) def start_dmr() -> Response: """Start digital voice decoding.""" - global dmr_rtl_process, dmr_dsd_process, dmr_thread, dmr_running, dmr_active_device + global dmr_rtl_process, dmr_dsd_process, dmr_thread + global dmr_running, dmr_has_audio, dmr_active_device with dmr_lock: if dmr_running: @@ -326,6 +397,7 @@ def start_dmr() -> Response: gain = int(validate_gain(data.get('gain', 40))) device = validate_device_index(data.get('device', 0)) protocol = str(data.get('protocol', 'auto')).lower() + ppm = validate_ppm(data.get('ppm', 0)) except (ValueError, TypeError) as e: return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400 @@ -339,8 +411,10 @@ def start_dmr() -> Response: except queue.Empty: pass - # Claim SDR device - error = app_module.claim_sdr_device(device, 'dmr') + # Claim SDR device — use protocol name so the device panel shows + # "D-STAR", "P25", etc. instead of always "DMR" + mode_label = protocol.upper() if protocol != 'auto' else 'DMR' + error = app_module.claim_sdr_device(device, mode_label) if error: return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409 @@ -348,7 +422,10 @@ def start_dmr() -> Response: freq_hz = int(frequency * 1e6) - # Build rtl_fm command (48kHz sample rate for DSD) + # Build rtl_fm command (48kHz sample rate for DSD). + # Squelch disabled (-l 0): rtl_fm's squelch chops the bitstream + # mid-frame, destroying DSD sync. The decoder handles silence + # internally via its own frame-sync detection. rtl_cmd = [ rtl_fm_path, '-M', 'fm', @@ -356,15 +433,32 @@ def start_dmr() -> Response: '-s', '48000', '-g', str(gain), '-d', str(device), - '-l', '1', # squelch level + '-l', '0', ] + if ppm != 0: + rtl_cmd.extend(['-p', str(ppm)]) # Build DSD command - # Use -o - to send decoded audio to stdout (piped to DEVNULL) - # instead of PulseAudio which may not be available under sudo - dsd_cmd = [dsd_path, '-i', '-', '-o', '-'] + # Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for + # ffmpeg transcoding. Both dsd-fme and classic dsd support '-o -'. + # If ffmpeg is unavailable, fall back to discarding audio. + ffmpeg_path = find_ffmpeg() + if ffmpeg_path: + audio_out = '-' + else: + audio_out = 'null' if is_fme else '-' + logger.warning("ffmpeg not found — audio streaming disabled, data-only mode") + dsd_cmd = [dsd_path, '-i', '-', '-o', audio_out] if is_fme: dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, [])) + dsd_cmd.extend(_DSD_FME_MODULATION.get(protocol, [])) + # Event log to stderr so we capture TG/Source/Voice data that + # dsd-fme may not output on stderr by default. + dsd_cmd.extend(['-J', '/dev/stderr']) + # Relax CRC checks for marginal signals — lets more frames + # through at the cost of occasional decode errors. + if data.get('relaxCrc', False): + dsd_cmd.append('-F') else: dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, [])) @@ -376,10 +470,13 @@ def start_dmr() -> Response: ) register_process(dmr_rtl_process) + # DSD stdout → PIPE when ffmpeg available (audio pipeline), + # otherwise DEVNULL (data-only mode) + dsd_stdout = subprocess.PIPE if ffmpeg_path else subprocess.DEVNULL dmr_dsd_process = subprocess.Popen( dsd_cmd, stdin=dmr_rtl_process.stdout, - stdout=subprocess.DEVNULL, + stdout=dsd_stdout, stderr=subprocess.PIPE, ) register_process(dmr_dsd_process) @@ -387,6 +484,17 @@ def start_dmr() -> Response: # Allow rtl_fm to send directly to dsd dmr_rtl_process.stdout.close() + # Start mux thread: always drains dsd-fme stdout to prevent the + # process from blocking (which would freeze stderr / text data). + # ffmpeg is started lazily per-client in /dmr/audio/stream. + if ffmpeg_path and dmr_dsd_process.stdout: + dmr_has_audio = True + threading.Thread( + target=_dsd_audio_mux, + args=(dmr_dsd_process.stdout,), + daemon=True, + ).start() + time.sleep(0.3) rtl_rc = dmr_rtl_process.poll() @@ -400,7 +508,8 @@ def start_dmr() -> Response: if dmr_dsd_process.stderr: dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500] logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}") - # Terminate surviving process and unregister both + # Terminate surviving processes and unregister all + dmr_has_audio = False for proc in [dmr_dsd_process, dmr_rtl_process]: if proc and proc.poll() is None: try: @@ -450,6 +559,7 @@ def start_dmr() -> Response: 'status': 'started', 'frequency': frequency, 'protocol': protocol, + 'has_audio': dmr_has_audio, }) except Exception as e: @@ -463,10 +573,12 @@ def start_dmr() -> Response: @dmr_bp.route('/stop', methods=['POST']) def stop_dmr() -> Response: """Stop digital voice decoding.""" - global dmr_rtl_process, dmr_dsd_process, dmr_running, dmr_active_device + global dmr_rtl_process, dmr_dsd_process + global dmr_running, dmr_has_audio, dmr_active_device with dmr_lock: dmr_running = False + dmr_has_audio = False for proc in [dmr_dsd_process, dmr_rtl_process]: if proc and proc.poll() is None: @@ -497,9 +609,99 @@ def dmr_status() -> Response: return jsonify({ 'running': dmr_running, 'device': dmr_active_device, + 'has_audio': dmr_has_audio, }) +@dmr_bp.route('/audio/stream') +def stream_dmr_audio() -> Response: + """Stream decoded digital voice audio as WAV. + + Starts a per-client ffmpeg encoder. The global mux thread + (_dsd_audio_mux) forwards DSD audio to this ffmpeg's stdin while + the client is connected, and discards audio otherwise. This avoids + the pipe-buffer deadlock that occurs when ffmpeg is started at + decoder launch (its stdout fills up before any HTTP client reads + it, back-pressuring the entire pipeline and freezing stderr/text + data output). + """ + global _active_ffmpeg_stdin + + if not dmr_running or not dmr_has_audio: + return Response(b'', mimetype='audio/wav', status=204) + + ffmpeg_path = find_ffmpeg() + if not ffmpeg_path: + return Response(b'', mimetype='audio/wav', status=503) + + encoder_cmd = [ + ffmpeg_path, '-hide_banner', '-loglevel', 'error', + '-fflags', 'nobuffer', '-flags', 'low_delay', + '-probesize', '32', '-analyzeduration', '0', + '-f', 's16le', '-ar', '8000', '-ac', '1', '-i', 'pipe:0', + '-acodec', 'pcm_s16le', '-ar', '44100', '-f', 'wav', 'pipe:1', + ] + audio_proc = subprocess.Popen( + encoder_cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + # Drain ffmpeg stderr to prevent blocking + threading.Thread( + target=lambda p: [None for _ in p.stderr], + args=(audio_proc,), daemon=True, + ).start() + + # Tell the mux thread to start writing to this ffmpeg + _active_ffmpeg_stdin = audio_proc.stdin + + def generate(): + global _active_ffmpeg_stdin + try: + while dmr_running and audio_proc.poll() is None: + ready, _, _ = select.select([audio_proc.stdout], [], [], 2.0) + if ready: + chunk = audio_proc.stdout.read(4096) + if chunk: + yield chunk + else: + break + else: + if audio_proc.poll() is not None: + break + except GeneratorExit: + pass + except Exception as e: + logger.error(f"DMR audio stream error: {e}") + finally: + # Disconnect mux → ffmpeg, then clean up + _active_ffmpeg_stdin = None + try: + audio_proc.stdin.close() + except Exception: + pass + try: + audio_proc.terminate() + audio_proc.wait(timeout=2) + except Exception: + try: + audio_proc.kill() + except Exception: + pass + + return Response( + generate(), + mimetype='audio/wav', + headers={ + 'Content-Type': 'audio/wav', + 'Cache-Control': 'no-cache, no-store', + 'X-Accel-Buffering': 'no', + 'Transfer-Encoding': 'chunked', + }, + ) + + @dmr_bp.route('/stream') def stream_dmr() -> Response: """SSE stream for DMR decoder events.""" diff --git a/routes/listening_post.py b/routes/listening_post.py index 8c48402..cb43aec 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -1310,20 +1310,35 @@ def start_audio() -> Response: _stop_waterfall_internal() time.sleep(0.2) - # Release waterfall device claim if the WebSocket waterfall is still - # holding it. The JS client sends a stop command and closes the - # WebSocket before requesting audio, but the backend handler may not - # have finished its cleanup yet. - device_status = app_module.get_sdr_device_status() - if device_status.get(device) == 'waterfall': - app_module.release_sdr_device(device) - time.sleep(0.3) - - # Claim device for listening audio + # Claim device for listening audio. The WebSocket waterfall handler + # may still be tearing down its IQ capture process (thread join + + # safe_terminate can take several seconds), so we retry with back-off + # to give the USB device time to be fully released. if listening_active_device is None or listening_active_device != device: if listening_active_device is not None: app_module.release_sdr_device(listening_active_device) - error = app_module.claim_sdr_device(device, 'listening') + listening_active_device = None + + error = None + max_claim_attempts = 6 + for attempt in range(max_claim_attempts): + # Force-release a stale waterfall registry entry on each + # attempt — the WebSocket handler may not have finished + # cleanup yet. + device_status = app_module.get_sdr_device_status() + if device_status.get(device) == 'waterfall': + app_module.release_sdr_device(device) + + error = app_module.claim_sdr_device(device, 'listening') + if not error: + break + if attempt < max_claim_attempts - 1: + logger.debug( + f"Device claim attempt {attempt + 1}/{max_claim_attempts} " + f"failed, retrying in 0.5s: {error}" + ) + time.sleep(0.5) + if error: return jsonify({ 'status': 'error', diff --git a/static/js/modes/dmr.js b/static/js/modes/dmr.js index 7504dd7..f0e63c0 100644 --- a/static/js/modes/dmr.js +++ b/static/js/modes/dmr.js @@ -10,6 +10,13 @@ let dmrCallCount = 0; let dmrSyncCount = 0; let dmrCallHistory = []; let dmrCurrentProtocol = '--'; +let dmrModeLabel = 'dmr'; // Protocol label for device reservation +let dmrHasAudio = false; + +// ============== BOOKMARKS ============== +let dmrBookmarks = []; +const DMR_BOOKMARKS_KEY = 'dmrBookmarks'; +const DMR_SETTINGS_KEY = 'dmrSettings'; // ============== SYNTHESIZER STATE ============== let dmrSynthCanvas = null; @@ -40,6 +47,7 @@ function checkDmrTools() { const missing = []; if (!data.dsd) missing.push('dsd (Digital Speech Decoder)'); if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)'); + if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)'); if (missing.length > 0) { warning.style.display = 'block'; @@ -47,6 +55,9 @@ function checkDmrTools() { } else { warning.style.display = 'none'; } + + // Update audio panel availability + updateDmrAudioStatus(data.ffmpeg ? 'OFF' : 'UNAVAILABLE'); }) .catch(() => {}); } @@ -57,17 +68,29 @@ function startDmr() { const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625); const protocol = document.getElementById('dmrProtocol')?.value || 'auto'; const gain = parseInt(document.getElementById('dmrGain')?.value || 40); + const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0); + const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false; const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0; + // Use protocol name for device reservation so panel shows "D-STAR", "P25", etc. + dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr'; + // Check device availability before starting - if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('dmr')) { + if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability(dmrModeLabel)) { return; } + // Save settings to localStorage for persistence + try { + localStorage.setItem(DMR_SETTINGS_KEY, JSON.stringify({ + frequency, protocol, gain, ppm, relaxCrc + })); + } catch (e) { /* localStorage unavailable */ } + fetch('/dmr/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ frequency, protocol, gain, device }) + body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc }) }) .then(r => r.json()) .then(data => { @@ -86,10 +109,14 @@ function startDmr() { const statusEl = document.getElementById('dmrStatus'); if (statusEl) statusEl.textContent = 'DECODING'; if (typeof reserveDevice === 'function') { - reserveDevice(parseInt(device), 'dmr'); + reserveDevice(parseInt(device), dmrModeLabel); } + // Start audio if available + dmrHasAudio = !!data.has_audio; + if (dmrHasAudio) startDmrAudio(); + updateDmrAudioStatus(dmrHasAudio ? 'STREAMING' : 'UNAVAILABLE'); if (typeof showNotification === 'function') { - showNotification('DMR', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`); + showNotification('Digital Voice', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`); } } else if (data.status === 'error' && data.message === 'Already running') { // Backend has an active session the frontend lost track of — resync @@ -116,6 +143,7 @@ function startDmr() { } function stopDmr() { + stopDmrAudio(); fetch('/dmr/stop', { method: 'POST' }) .then(r => r.json()) .then(() => { @@ -125,10 +153,11 @@ function stopDmr() { dmrEventType = 'stopped'; dmrActivityTarget = 0; updateDmrSynthStatus(); + updateDmrAudioStatus('OFF'); const statusEl = document.getElementById('dmrStatus'); if (statusEl) statusEl.textContent = 'STOPPED'; if (typeof releaseDevice === 'function') { - releaseDevice('dmr'); + releaseDevice(dmrModeLabel); } }) .catch(err => console.error('[DMR] Stop error:', err)); @@ -225,24 +254,28 @@ function handleDmrMessage(msg) { if (statusEl) statusEl.textContent = 'DECODING'; } else if (msg.text === 'crashed') { isDmrRunning = false; + stopDmrAudio(); updateDmrUI(); dmrEventType = 'stopped'; dmrActivityTarget = 0; updateDmrSynthStatus(); + updateDmrAudioStatus('OFF'); if (statusEl) statusEl.textContent = 'CRASHED'; - if (typeof releaseDevice === 'function') releaseDevice('dmr'); + if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel); const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`; if (typeof showNotification === 'function') { showNotification('DMR Error', detail); } } else if (msg.text === 'stopped') { isDmrRunning = false; + stopDmrAudio(); updateDmrUI(); dmrEventType = 'stopped'; dmrActivityTarget = 0; updateDmrSynthStatus(); + updateDmrAudioStatus('OFF'); if (statusEl) statusEl.textContent = 'STOPPED'; - if (typeof releaseDevice === 'function') releaseDevice('dmr'); + if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel); } } } @@ -321,10 +354,12 @@ function drawDmrSynthesizer() { dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)'; dmrSynthCtx.fillRect(0, 0, width, height); - // Decay activity toward target + // Decay activity toward target. Window must exceed the backend + // heartbeat interval (3s) so the status doesn't flip-flop between + // LISTENING and IDLE on every heartbeat cycle. const timeSinceEvent = now - dmrLastEventTime; - if (timeSinceEvent > 2000) { - // No events for 2s — decay target toward idle + if (timeSinceEvent > 5000) { + // No events for 5s — decay target toward idle dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE); if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') { dmrEventType = 'idle'; @@ -511,6 +546,178 @@ function stopDmrSynthesizer() { window.addEventListener('resize', resizeDmrSynthesizer); +// ============== AUDIO ============== + +function startDmrAudio() { + const audioPlayer = document.getElementById('dmrAudioPlayer'); + if (!audioPlayer) return; + const streamUrl = `/dmr/audio/stream?t=${Date.now()}`; + audioPlayer.src = streamUrl; + const volSlider = document.getElementById('dmrAudioVolume'); + if (volSlider) audioPlayer.volume = volSlider.value / 100; + + audioPlayer.onplaying = () => updateDmrAudioStatus('STREAMING'); + audioPlayer.onerror = () => { + // Retry if decoder is still running (stream may have dropped) + if (isDmrRunning && dmrHasAudio) { + console.warn('[DMR] Audio stream error, retrying in 2s...'); + updateDmrAudioStatus('RECONNECTING'); + setTimeout(() => { + if (isDmrRunning && dmrHasAudio) startDmrAudio(); + }, 2000); + } else { + updateDmrAudioStatus('OFF'); + } + }; + + audioPlayer.play().catch(e => { + console.warn('[DMR] Audio autoplay blocked:', e); + if (typeof showNotification === 'function') { + showNotification('Audio Ready', 'Click the page or interact to enable audio playback'); + } + }); +} + +function stopDmrAudio() { + const audioPlayer = document.getElementById('dmrAudioPlayer'); + if (audioPlayer) { + audioPlayer.pause(); + audioPlayer.src = ''; + } + dmrHasAudio = false; +} + +function setDmrAudioVolume(value) { + const audioPlayer = document.getElementById('dmrAudioPlayer'); + if (audioPlayer) audioPlayer.volume = value / 100; +} + +function updateDmrAudioStatus(status) { + const el = document.getElementById('dmrAudioStatus'); + if (!el) return; + el.textContent = status; + const colors = { + 'OFF': 'var(--text-muted)', + 'STREAMING': 'var(--accent-green)', + 'ERROR': 'var(--accent-red)', + 'UNAVAILABLE': 'var(--text-muted)', + }; + el.style.color = colors[status] || 'var(--text-muted)'; +} + +// ============== SETTINGS PERSISTENCE ============== + +function restoreDmrSettings() { + try { + const saved = localStorage.getItem(DMR_SETTINGS_KEY); + if (!saved) return; + const s = JSON.parse(saved); + const freqEl = document.getElementById('dmrFrequency'); + const protoEl = document.getElementById('dmrProtocol'); + const gainEl = document.getElementById('dmrGain'); + const ppmEl = document.getElementById('dmrPPM'); + const crcEl = document.getElementById('dmrRelaxCrc'); + if (freqEl && s.frequency != null) freqEl.value = s.frequency; + if (protoEl && s.protocol) protoEl.value = s.protocol; + if (gainEl && s.gain != null) gainEl.value = s.gain; + if (ppmEl && s.ppm != null) ppmEl.value = s.ppm; + if (crcEl && s.relaxCrc != null) crcEl.checked = s.relaxCrc; + } catch (e) { /* localStorage unavailable */ } +} + +// ============== BOOKMARKS ============== + +function loadDmrBookmarks() { + try { + const saved = localStorage.getItem(DMR_BOOKMARKS_KEY); + dmrBookmarks = saved ? JSON.parse(saved) : []; + } catch (e) { + dmrBookmarks = []; + } + renderDmrBookmarks(); +} + +function saveDmrBookmarks() { + try { + localStorage.setItem(DMR_BOOKMARKS_KEY, JSON.stringify(dmrBookmarks)); + } catch (e) { /* localStorage unavailable */ } +} + +function addDmrBookmark() { + const freqInput = document.getElementById('dmrBookmarkFreq'); + const labelInput = document.getElementById('dmrBookmarkLabel'); + if (!freqInput) return; + + const freq = parseFloat(freqInput.value); + if (isNaN(freq) || freq <= 0) { + if (typeof showNotification === 'function') { + showNotification('Invalid Frequency', 'Enter a valid frequency'); + } + return; + } + + const protocol = document.getElementById('dmrProtocol')?.value || 'auto'; + const label = (labelInput?.value || '').trim() || `${freq.toFixed(4)} MHz`; + + // Duplicate check + if (dmrBookmarks.some(b => b.freq === freq && b.protocol === protocol)) { + if (typeof showNotification === 'function') { + showNotification('Duplicate', 'This frequency/protocol is already bookmarked'); + } + return; + } + + dmrBookmarks.push({ freq, protocol, label, added: new Date().toISOString() }); + saveDmrBookmarks(); + renderDmrBookmarks(); + freqInput.value = ''; + if (labelInput) labelInput.value = ''; + + if (typeof showNotification === 'function') { + showNotification('Bookmark Added', `${freq.toFixed(4)} MHz saved`); + } +} + +function addCurrentDmrFreqBookmark() { + const freqEl = document.getElementById('dmrFrequency'); + const freqInput = document.getElementById('dmrBookmarkFreq'); + if (freqEl && freqInput) { + freqInput.value = freqEl.value; + } + addDmrBookmark(); +} + +function removeDmrBookmark(index) { + dmrBookmarks.splice(index, 1); + saveDmrBookmarks(); + renderDmrBookmarks(); +} + +function dmrQuickTune(freq, protocol) { + const freqEl = document.getElementById('dmrFrequency'); + const protoEl = document.getElementById('dmrProtocol'); + if (freqEl) freqEl.value = freq; + if (protoEl) protoEl.value = protocol; +} + +function renderDmrBookmarks() { + const container = document.getElementById('dmrBookmarksList'); + if (!container) return; + + if (dmrBookmarks.length === 0) { + container.innerHTML = '
No bookmarks saved
'; + return; + } + + container.innerHTML = dmrBookmarks.map((b, i) => ` +
+ ${b.label} + ${b.protocol.toUpperCase()} + +
+ `).join(''); +} + // ============== STATUS SYNC ============== function checkDmrStatus() { @@ -544,6 +751,13 @@ function checkDmrStatus() { .catch(() => {}); } +// ============== INIT ============== + +document.addEventListener('DOMContentLoaded', () => { + restoreDmrSettings(); + loadDmrBookmarks(); +}); + // ============== EXPORTS ============== window.startDmr = startDmr; @@ -551,3 +765,8 @@ window.stopDmr = stopDmr; window.checkDmrTools = checkDmrTools; window.checkDmrStatus = checkDmrStatus; window.initDmrSynthesizer = initDmrSynthesizer; +window.setDmrAudioVolume = setDmrAudioVolume; +window.addDmrBookmark = addDmrBookmark; +window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark; +window.removeDmrBookmark = removeDmrBookmark; +window.dmrQuickTune = dmrQuickTune; diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js index 4273b23..98c7339 100644 --- a/static/js/modes/listening-post.js +++ b/static/js/modes/listening-post.js @@ -3943,11 +3943,31 @@ async function stopWaterfall() { // WebSocket path if (waterfallUseWebSocket && waterfallWebSocket) { + const ws = waterfallWebSocket; try { - if (waterfallWebSocket.readyState === WebSocket.OPEN) { - waterfallWebSocket.send(JSON.stringify({ cmd: 'stop' })); + if (ws.readyState === WebSocket.OPEN) { + // Wait for server to confirm stop (it terminates the IQ + // process and releases the USB device before responding). + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 4000); + const prevHandler = ws.onmessage; + ws.onmessage = (event) => { + if (typeof event.data === 'string') { + try { + const msg = JSON.parse(event.data); + if (msg.status === 'stopped') { + clearTimeout(timeout); + resolve(); + return; + } + } catch (_) {} + } + if (prevHandler) prevHandler(event); + }; + ws.send(JSON.stringify({ cmd: 'stop' })); + }); } - waterfallWebSocket.close(); + ws.close(); } catch (e) { console.error('[WATERFALL] WebSocket stop error:', e); } @@ -3958,8 +3978,6 @@ async function stopWaterfall() { if (typeof releaseDevice === 'function') { releaseDevice('waterfall'); } - // Allow backend WebSocket handler to finish cleanup and release SDR - await new Promise(resolve => setTimeout(resolve, 300)); return; } diff --git a/templates/index.html b/templates/index.html index dfeccee..ae5062d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1699,6 +1699,18 @@ + +
+ +
+ AUDIO + OFF +
+ VOL + +
+
+
diff --git a/templates/partials/modes/dmr.html b/templates/partials/modes/dmr.html index 9374a5c..bb9983d 100644 --- a/templates/partials/modes/dmr.html +++ b/templates/partials/modes/dmr.html @@ -32,6 +32,42 @@
+ +
+ + +
+ +
+ + + Allows more frames through on marginal signals at the cost of occasional errors + +
+
+ + +
+

Bookmarks

+
+ + +
+
+ + +
+
+
No bookmarks saved
+
diff --git a/tests/test_dmr.py b/tests/test_dmr.py index 8f6e4e7..d245663 100644 --- a/tests/test_dmr.py +++ b/tests/test_dmr.py @@ -2,7 +2,7 @@ from unittest.mock import patch, MagicMock import pytest -from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS +from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS, _DSD_FME_MODULATION # ============================================ @@ -66,6 +66,16 @@ def test_parse_talkgroup_dsd_fme_format(): assert result['source_id'] == 67890 +def test_parse_talkgroup_dsd_fme_tgt_src_format(): + """Should parse dsd-fme TGT/SRC pipe-delimited format.""" + result = parse_dsd_output('Slot 1 | TGT: 12345 | SRC: 67890') + assert result is not None + assert result['type'] == 'call' + assert result['talkgroup'] == 12345 + assert result['source_id'] == 67890 + assert result['slot'] == 1 + + def test_parse_talkgroup_with_slot(): """TG line with slot info should capture both.""" result = parse_dsd_output('Slot 1 Voice LC, TG: 100, Src: 200') @@ -98,13 +108,40 @@ def test_parse_unrecognized(): assert result['text'] == 'some random text' -def test_dsd_fme_protocol_flags_match_classic(): - """dsd-fme flags must match classic DSD flags (same fork, same CLI).""" - assert _DSD_FME_PROTOCOL_FLAGS == _DSD_PROTOCOL_FLAGS +def test_parse_banner_filtered(): + """Pure box-drawing lines (banners) should be filtered.""" + assert parse_dsd_output('╔══════════════╗') is None + assert parse_dsd_output('║ ║') is None + assert parse_dsd_output('╚══════════════╝') is None + assert parse_dsd_output('───────────────') is None + + +def test_parse_box_drawing_with_data_not_filtered(): + """Lines with box-drawing separators AND data should NOT be filtered.""" + result = parse_dsd_output('DMR BS │ Slot 1 │ TG: 12345 │ SRC: 67890') + assert result is not None + assert result['type'] == 'call' + assert result['talkgroup'] == 12345 + assert result['source_id'] == 67890 + + +def test_dsd_fme_flags_differ_from_classic(): + """dsd-fme remapped several flags; tables must NOT be identical.""" + assert _DSD_FME_PROTOCOL_FLAGS != _DSD_PROTOCOL_FLAGS + + +def test_dsd_fme_protocol_flags_known_values(): + """dsd-fme flags use its own flag names (NOT classic DSD mappings).""" + assert _DSD_FME_PROTOCOL_FLAGS['auto'] == ['-ft'] # XDMA + assert _DSD_FME_PROTOCOL_FLAGS['dmr'] == ['-fs'] # Simplex (-fd is D-STAR!) + assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-f1'] # NOT -fp (ProVoice in fme) + assert _DSD_FME_PROTOCOL_FLAGS['nxdn'] == ['-fn'] + assert _DSD_FME_PROTOCOL_FLAGS['dstar'] == ['-fd'] # -fd is D-STAR in dsd-fme + assert _DSD_FME_PROTOCOL_FLAGS['provoice'] == ['-fp'] # NOT -fv def test_dsd_protocol_flags_known_values(): - """Protocol flags should map to the correct DSD -f flags.""" + """Classic DSD protocol flags should map to the correct -f flags.""" assert _DSD_PROTOCOL_FLAGS['dmr'] == ['-fd'] assert _DSD_PROTOCOL_FLAGS['p25'] == ['-fp'] assert _DSD_PROTOCOL_FLAGS['nxdn'] == ['-fn'] @@ -113,6 +150,16 @@ def test_dsd_protocol_flags_known_values(): assert _DSD_PROTOCOL_FLAGS['auto'] == [] +def test_dsd_fme_modulation_hints(): + """C4FM modulation hints should be set for C4FM protocols.""" + assert _DSD_FME_MODULATION['dmr'] == ['-mc'] + assert _DSD_FME_MODULATION['p25'] == ['-mc'] + assert _DSD_FME_MODULATION['nxdn'] == ['-mc'] + # D-Star and ProVoice should not have forced modulation + assert 'dstar' not in _DSD_FME_MODULATION + assert 'provoice' not in _DSD_FME_MODULATION + + # ============================================ # Endpoint tests # ============================================