From 4c13e980911d21a59e957d3fd0ce7561e39ea442 Mon Sep 17 00:00:00 2001 From: Smittix Date: Mon, 9 Feb 2026 08:44:23 +0000 Subject: [PATCH] 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 # ============================================