mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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, []))
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -32,6 +32,22 @@
|
||||
<label>Gain</label>
|
||||
<input type="number" id="dmrGain" value="40" min="0" max="50" style="width: 100%;">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>PPM Correction</label>
|
||||
<input type="number" id="dmrPPM" value="0" min="-200" max="200" step="1" style="width: 100%;"
|
||||
title="Frequency error correction for your RTL-SDR dongle. Digital voice is very sensitive to frequency offset.">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 4px;">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="checkbox" id="dmrRelaxCrc" style="width: auto; accent-color: var(--accent-cyan);">
|
||||
<span>Relax CRC (weak signals)</span>
|
||||
</label>
|
||||
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
|
||||
Allows more frames through on marginal signals at the cost of occasional errors
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
|
||||
@@ -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
|
||||
# ============================================
|
||||
|
||||
Reference in New Issue
Block a user