From 4c13e980911d21a59e957d3fd0ce7561e39ea442 Mon Sep 17 00:00:00 2001 From: Smittix Date: Mon, 9 Feb 2026 08:44:23 +0000 Subject: [PATCH 1/8] Fix dsd-fme protocol flags, device label, and add tuning controls dsd-fme remapped several flags from classic DSD: -fp is ProVoice (not P25), -fi is NXDN48 (not D-Star), -fv doesn't exist. This caused P25 to trigger ProVoice decoding and D-Star to trigger NXDN48. Corrected flag table and added C4FM modulation hints for better sync reliability. Also fixes: device panel showing "DMR" regardless of protocol, signal activity status flip-flopping between LISTENING and IDLE, and rtl_fm squelch chopping the bitstream mid-frame. Adds PPM correction and relax CRC controls for fine-tuning on marginal signals. Co-Authored-By: Claude Opus 4.6 --- routes/dmr.py | 47 +++++++++++++++++++++++-------- static/js/modes/dmr.js | 28 +++++++++++------- templates/partials/modes/dmr.html | 16 +++++++++++ tests/test_dmr.py | 30 ++++++++++++++++---- 4 files changed, 94 insertions(+), 27 deletions(-) diff --git a/routes/dmr.py b/routes/dmr.py index e8d91fa..64f3ab5 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, @@ -55,14 +55,24 @@ _DSD_PROTOCOL_FLAGS = { 'provoice': ['-fv'], } -# dsd-fme uses different flag names +# dsd-fme remapped several flags from classic DSD: +# -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': ['-fd'], # DMR (classic flag, works in dsd-fme) + 'p25': ['-f1'], # P25 Phase 1 (-fp is ProVoice in dsd-fme!) + 'nxdn': ['-fn'], # NXDN96 + 'dstar': [], # No dedicated flag in dsd-fme; auto-detect + '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 } # ============================================ @@ -326,6 +336,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 +350,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 +361,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,8 +372,10 @@ 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) @@ -365,6 +383,11 @@ def start_dmr() -> Response: dsd_cmd = [dsd_path, '-i', '-', '-o', '-'] if is_fme: dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, [])) + dsd_cmd.extend(_DSD_FME_MODULATION.get(protocol, [])) + # 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, [])) diff --git a/static/js/modes/dmr.js b/static/js/modes/dmr.js index 7504dd7..f2985e1 100644 --- a/static/js/modes/dmr.js +++ b/static/js/modes/dmr.js @@ -10,6 +10,7 @@ let dmrCallCount = 0; let dmrSyncCount = 0; let dmrCallHistory = []; let dmrCurrentProtocol = '--'; +let dmrModeLabel = 'dmr'; // Protocol label for device reservation // ============== SYNTHESIZER STATE ============== let dmrSynthCanvas = null; @@ -57,17 +58,22 @@ 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; } 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 +92,10 @@ function startDmr() { const statusEl = document.getElementById('dmrStatus'); if (statusEl) statusEl.textContent = 'DECODING'; if (typeof reserveDevice === 'function') { - reserveDevice(parseInt(device), 'dmr'); + reserveDevice(parseInt(device), dmrModeLabel); } 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 @@ -128,7 +134,7 @@ function stopDmr() { 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)); @@ -230,7 +236,7 @@ function handleDmrMessage(msg) { dmrActivityTarget = 0; updateDmrSynthStatus(); 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); @@ -242,7 +248,7 @@ function handleDmrMessage(msg) { dmrActivityTarget = 0; updateDmrSynthStatus(); if (statusEl) statusEl.textContent = 'STOPPED'; - if (typeof releaseDevice === 'function') releaseDevice('dmr'); + if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel); } } } @@ -321,10 +327,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'; diff --git a/templates/partials/modes/dmr.html b/templates/partials/modes/dmr.html index 9374a5c..14ac367 100644 --- a/templates/partials/modes/dmr.html +++ b/templates/partials/modes/dmr.html @@ -32,6 +32,22 @@ + +
+ + +
+ +
+ + + Allows more frames through on marginal signals at the cost of occasional errors + +
diff --git a/tests/test_dmr.py b/tests/test_dmr.py index 8f6e4e7..523bfa1 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 # ============================================ @@ -98,13 +98,23 @@ 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_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'] == ['-fd'] + 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'] == [] # No dedicated flag + 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 +123,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 # ============================================ From 51aba87852c2b5bbac501d419764ea49b2b67178 Mon Sep 17 00:00:00 2001 From: Smittix Date: Mon, 9 Feb 2026 09:22:41 +0000 Subject: [PATCH 2/8] Bump version to 2.15.0 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ config.py | 15 ++++++++++++++- pyproject.toml | 2 +- 3 files changed, 48 insertions(+), 2 deletions(-) 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 2e01d48..546d941 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" From 4e168ff502c68c9030236846eb3a0e666e1baed5 Mon Sep 17 00:00:00 2001 From: Smittix Date: Mon, 9 Feb 2026 10:31:44 +0000 Subject: [PATCH 3/8] Fix dsd-fme DMR flag (-fd is D-STAR, not DMR) and audio output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -fd means D-STAR in dsd-fme, not DMR — causing sync detection (shared C4FM modulation) but no decoded data. DMR Simplex is -fs. Also fix -o - (invalid in dsd-fme) to -o null for headless servers, add D-STAR flag mapping, and handle TGT/SRC output format in parser. Co-Authored-By: Claude Opus 4.6 --- routes/dmr.py | 21 +++++++++++++-------- tests/test_dmr.py | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/routes/dmr.py b/routes/dmr.py index 64f3ab5..6924b37 100644 --- a/routes/dmr.py +++ b/routes/dmr.py @@ -56,14 +56,16 @@ _DSD_PROTOCOL_FLAGS = { } # dsd-fme remapped several flags from classic DSD: -# -fp = ProVoice (NOT P25), -fi = NXDN48 (NOT D-Star), -# -f1 = P25 Phase 1, -ft = XDMA multi-protocol decoder +# -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': ['-ft'], # XDMA: auto-detect DMR/P25/YSF - 'dmr': ['-fd'], # DMR (classic flag, works in dsd-fme) + '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': [], # No dedicated flag in dsd-fme; auto-detect + 'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!) 'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv) } @@ -132,8 +134,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 = { @@ -378,9 +381,11 @@ def start_dmr() -> Response: 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', '-'] + # dsd-fme uses '-o null' to discard decoded audio (PulseAudio + # unavailable on headless/remote servers); classic dsd uses '-o -' + # to send audio to stdout which we pipe to DEVNULL. + audio_out = 'null' if is_fme else '-' + 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, [])) diff --git a/tests/test_dmr.py b/tests/test_dmr.py index 523bfa1..e5b15b4 100644 --- a/tests/test_dmr.py +++ b/tests/test_dmr.py @@ -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') @@ -106,10 +116,10 @@ def test_dsd_fme_flags_differ_from_classic(): 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'] == ['-fd'] + 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'] == [] # No dedicated flag + assert _DSD_FME_PROTOCOL_FLAGS['dstar'] == ['-fd'] # -fd is D-STAR in dsd-fme assert _DSD_FME_PROTOCOL_FLAGS['provoice'] == ['-fp'] # NOT -fv From a2a7ac8fecfc339b199567c992c61743e678260b Mon Sep 17 00:00:00 2001 From: Smittix Date: Mon, 9 Feb 2026 11:41:32 +0000 Subject: [PATCH 4/8] Fix banner filter eating dsd-fme data lines and add event log capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The box-drawing character filter was dropping ANY line containing │ or ─, including dsd-fme data lines that use these as column separators (e.g. "DMR BS │ Slot 1 │ TG: 12345 │ SRC: 67890"). Now only filters lines that are purely decorative (no alphanumeric content). Also adds -J /dev/stderr so dsd-fme writes its event log to stderr where we capture it, and debug logging of raw stderr lines. Co-Authored-By: Claude Opus 4.6 --- routes/dmr.py | 13 ++++++++++--- tests/test_dmr.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/routes/dmr.py b/routes/dmr.py index 6924b37..5eb1622 100644 --- a/routes/dmr.py +++ b/routes/dmr.py @@ -113,8 +113,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 @@ -136,7 +139,7 @@ def parse_dsd_output(line: str) -> dict | None: # dsd-fme: "TG: 12345, Src: 67890" or "Talkgroup: 12345, Source: 67890" # "TGT: 12345 | SRC: 67890" (pipe-delimited variant) tg_match = re.search( - r'(?:TGT?|Talkgroup)[:\s]+(\d+)[,|\s]+(?:Src|Source|SRC)[:\s]+(\d+)', line, re.IGNORECASE + r'(?:TGT?|Talkgroup)[:\s]+(\d+)[,|│\s]+(?:Src|Source|SRC)[:\s]+(\d+)', line, re.IGNORECASE ) if tg_match: result = { @@ -242,6 +245,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) @@ -389,6 +393,9 @@ def start_dmr() -> Response: 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): diff --git a/tests/test_dmr.py b/tests/test_dmr.py index e5b15b4..d245663 100644 --- a/tests/test_dmr.py +++ b/tests/test_dmr.py @@ -108,6 +108,23 @@ def test_parse_unrecognized(): assert result['text'] == 'some random text' +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 From a8f2912b905ffae63d090937ca660202e7756105 Mon Sep 17 00:00:00 2001 From: Smittix Date: Mon, 9 Feb 2026 13:58:42 +0000 Subject: [PATCH 5/8] Fix waterfall-to-listen SDR busy race condition Wait for server-side WebSocket stop confirmation before closing the connection, ensuring the IQ process is fully terminated and the USB device released. Add retry logic with back-off in the audio start endpoint as defense-in-depth for any remaining timing gaps. Co-Authored-By: Claude Opus 4.6 --- routes/listening_post.py | 37 ++++++++++++++++++++++--------- static/js/modes/listening-post.js | 28 ++++++++++++++++++----- 2 files changed, 49 insertions(+), 16 deletions(-) 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/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; } From b023e4cdc76aa9258d72154ea7b8f40709b6c2e1 Mon Sep 17 00:00:00 2001 From: Smittix Date: Mon, 9 Feb 2026 18:05:27 +0000 Subject: [PATCH 6/8] Add DMR audio output, frequency persistence, and bookmarks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stream decoded digital voice audio to the browser via ffmpeg pipeline (dsd-fme 8kHz PCM → ffmpeg → 44.1kHz WAV → chunked HTTP). Persist frequency/protocol/gain/ppm settings in localStorage so they survive page navigation. Add bookmark system for saving and recalling frequencies. Co-Authored-By: Claude Opus 4.6 --- routes/dmr.py | 129 +++++++++++++++++-- static/js/modes/dmr.js | 200 ++++++++++++++++++++++++++++++ templates/index.html | 12 ++ templates/partials/modes/dmr.html | 20 +++ 4 files changed, 349 insertions(+), 12 deletions(-) diff --git a/routes/dmr.py b/routes/dmr.py index 5eb1622..bf368e9 100644 --- a/routes/dmr.py +++ b/routes/dmr.py @@ -37,8 +37,10 @@ dmr_bp = Blueprint('dmr', __name__, url_prefix='/dmr') dmr_rtl_process: Optional[subprocess.Popen] = None dmr_dsd_process: Optional[subprocess.Popen] = None +dmr_audio_process: Optional[subprocess.Popen] = None dmr_thread: Optional[threading.Thread] = None dmr_running = False +dmr_audio_running = False dmr_lock = threading.Lock() dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) dmr_active_device: Optional[int] = None @@ -102,6 +104,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. @@ -265,7 +272,9 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop logger.error(f"DSD stream error: {e}") finally: global dmr_active_device, dmr_rtl_process, dmr_dsd_process + global dmr_audio_process, dmr_audio_running dmr_running = False + dmr_audio_running = False # Capture exit info for diagnostics rc = dsd_process.poll() reason = 'stopped' @@ -279,8 +288,8 @@ 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 - for proc in [dsd_process, rtl_process]: + # Cleanup all pipeline processes (audio encoder + decoder + demod) + for proc in [dmr_audio_process, dsd_process, rtl_process]: if proc and proc.poll() is None: try: proc.terminate() @@ -294,6 +303,7 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop unregister_process(proc) dmr_rtl_process = None dmr_dsd_process = None + dmr_audio_process = None _queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail}) # Release SDR device if dmr_active_device is not None: @@ -311,9 +321,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, }) @@ -322,7 +334,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_audio_process, dmr_thread + global dmr_running, dmr_audio_running, dmr_active_device with dmr_lock: if dmr_running: @@ -385,10 +398,15 @@ def start_dmr() -> Response: rtl_cmd.extend(['-p', str(ppm)]) # Build DSD command - # dsd-fme uses '-o null' to discard decoded audio (PulseAudio - # unavailable on headless/remote servers); classic dsd uses '-o -' - # to send audio to stdout which we pipe to DEVNULL. - audio_out = 'null' if is_fme else '-' + # 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, [])) @@ -411,10 +429,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) @@ -422,6 +443,31 @@ def start_dmr() -> Response: # Allow rtl_fm to send directly to dsd dmr_rtl_process.stdout.close() + # Start ffmpeg to transcode DSD 8kHz s16le PCM → 44.1kHz WAV + if ffmpeg_path and dmr_dsd_process.stdout: + 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', + ] + dmr_audio_process = subprocess.Popen( + encoder_cmd, + stdin=dmr_dsd_process.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + register_process(dmr_audio_process) + # Allow DSD to pipe directly to ffmpeg + dmr_dsd_process.stdout.close() + dmr_audio_running = True + # Drain ffmpeg stderr to prevent blocking + threading.Thread( + target=lambda p: [None for _ in p.stderr], + args=(dmr_audio_process,), daemon=True, + ).start() + time.sleep(0.3) rtl_rc = dmr_rtl_process.poll() @@ -435,8 +481,9 @@ 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 - for proc in [dmr_dsd_process, dmr_rtl_process]: + # Terminate surviving processes and unregister all + dmr_audio_running = False + for proc in [dmr_audio_process, dmr_dsd_process, dmr_rtl_process]: if proc and proc.poll() is None: try: proc.terminate() @@ -450,6 +497,7 @@ def start_dmr() -> Response: unregister_process(proc) dmr_rtl_process = None dmr_dsd_process = None + dmr_audio_process = None if dmr_active_device is not None: app_module.release_sdr_device(dmr_active_device) dmr_active_device = None @@ -485,6 +533,7 @@ def start_dmr() -> Response: 'status': 'started', 'frequency': frequency, 'protocol': protocol, + 'has_audio': dmr_audio_running, }) except Exception as e: @@ -498,12 +547,14 @@ 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, dmr_audio_process + global dmr_running, dmr_audio_running, dmr_active_device with dmr_lock: dmr_running = False + dmr_audio_running = False - for proc in [dmr_dsd_process, dmr_rtl_process]: + for proc in [dmr_audio_process, dmr_dsd_process, dmr_rtl_process]: if proc and proc.poll() is None: try: proc.terminate() @@ -518,6 +569,7 @@ def stop_dmr() -> Response: dmr_rtl_process = None dmr_dsd_process = None + dmr_audio_process = None if dmr_active_device is not None: app_module.release_sdr_device(dmr_active_device) @@ -532,9 +584,62 @@ def dmr_status() -> Response: return jsonify({ 'running': dmr_running, 'device': dmr_active_device, + 'has_audio': dmr_audio_running, }) +@dmr_bp.route('/audio/stream') +def stream_dmr_audio() -> Response: + """Stream decoded digital voice audio as WAV.""" + # Wait briefly for audio pipeline to be ready + for _ in range(100): + if dmr_audio_running and dmr_audio_process: + break + time.sleep(0.05) + + if not dmr_audio_running or not dmr_audio_process: + return Response(b'', mimetype='audio/wav', status=204) + + def generate(): + proc = dmr_audio_process + if not proc or not proc.stdout: + return + try: + # Digital voice is intermittent — allow longer first-chunk + # timeout since the decoder only produces audio when there + # is an active voice transmission on the channel. + first_chunk_deadline = time.time() + 5.0 + while dmr_audio_running and proc.poll() is None: + ready, _, _ = select.select([proc.stdout], [], [], 2.0) + if ready: + chunk = proc.stdout.read(4096) + if chunk: + yield chunk + else: + break + else: + if time.time() > first_chunk_deadline: + logger.warning("DMR audio stream timed out waiting for first chunk") + break + if proc.poll() is not None: + break + except GeneratorExit: + pass + except Exception as e: + logger.error(f"DMR audio stream error: {e}") + + 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/static/js/modes/dmr.js b/static/js/modes/dmr.js index f2985e1..3868e07 100644 --- a/static/js/modes/dmr.js +++ b/static/js/modes/dmr.js @@ -11,6 +11,12 @@ 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; @@ -41,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'; @@ -48,6 +55,9 @@ function checkDmrTools() { } else { warning.style.display = 'none'; } + + // Update audio panel availability + updateDmrAudioStatus(data.ffmpeg ? 'OFF' : 'UNAVAILABLE'); }) .catch(() => {}); } @@ -70,6 +80,13 @@ function startDmr() { 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' }, @@ -94,6 +111,10 @@ function startDmr() { if (typeof reserveDevice === 'function') { reserveDevice(parseInt(device), dmrModeLabel); } + // Start audio if available + dmrHasAudio = !!data.has_audio; + if (dmrHasAudio) startDmrAudio(); + updateDmrAudioStatus(dmrHasAudio ? 'STREAMING' : 'UNAVAILABLE'); if (typeof showNotification === 'function') { showNotification('Digital Voice', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`); } @@ -122,6 +143,7 @@ function startDmr() { } function stopDmr() { + stopDmrAudio(); fetch('/dmr/stop', { method: 'POST' }) .then(r => r.json()) .then(() => { @@ -131,6 +153,7 @@ function stopDmr() { dmrEventType = 'stopped'; dmrActivityTarget = 0; updateDmrSynthStatus(); + updateDmrAudioStatus('OFF'); const statusEl = document.getElementById('dmrStatus'); if (statusEl) statusEl.textContent = 'STOPPED'; if (typeof releaseDevice === 'function') { @@ -231,10 +254,12 @@ 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(dmrModeLabel); const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`; @@ -243,10 +268,12 @@ function handleDmrMessage(msg) { } } 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(dmrModeLabel); } @@ -519,6 +546,167 @@ 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 = () => updateDmrAudioStatus('ERROR'); + + 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() { @@ -552,6 +740,13 @@ function checkDmrStatus() { .catch(() => {}); } +// ============== INIT ============== + +document.addEventListener('DOMContentLoaded', () => { + restoreDmrSettings(); + loadDmrBookmarks(); +}); + // ============== EXPORTS ============== window.startDmr = startDmr; @@ -559,3 +754,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/templates/index.html b/templates/index.html index 5c90827..26af3ca 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1690,6 +1690,18 @@ + +
+ +
+ AUDIO + OFF +
+ VOL + +
+
+
diff --git a/templates/partials/modes/dmr.html b/templates/partials/modes/dmr.html index 14ac367..bb9983d 100644 --- a/templates/partials/modes/dmr.html +++ b/templates/partials/modes/dmr.html @@ -50,6 +50,26 @@
+ +
+

Bookmarks

+
+ + +
+
+ + +
+
+
No bookmarks saved
+
+
+