Add DMR quality meter and fine tune sweep

This commit is contained in:
Smittix
2026-02-10 15:24:49 +00:00
parent cbcb8b02fa
commit b4c47ed28b
4 changed files with 195 additions and 17 deletions
+22
View File
@@ -202,6 +202,28 @@ def parse_dsd_output(line: str) -> dict | None:
ts = datetime.now().strftime('%H:%M:%S')
# Frame-level error / OK indicators (useful for quality metrics)
if re.search(r'\bDUID\s+ERR\b', line, re.IGNORECASE):
return {
'type': 'frame_error',
'kind': 'duid',
'detail': line[:200],
'timestamp': ts,
}
if re.search(r'\bR-?S\s+ERR\b', line, re.IGNORECASE):
return {
'type': 'frame_error',
'kind': 'rs',
'detail': line[:200],
'timestamp': ts,
}
if re.search(r'\bP25p2\b.*\b4V\b', line, re.IGNORECASE):
return {
'type': 'frame_ok',
'kind': 'p25p2',
'timestamp': ts,
}
# If dsd-fme is emitting JSON (via -J), parse it first.
if line.startswith('{') and line.endswith('}'):
try:
+124 -2
View File
@@ -12,6 +12,9 @@ let dmrCallHistory = [];
let dmrCurrentProtocol = '--';
let dmrModeLabel = 'dmr'; // Protocol label for device reservation
let dmrHasAudio = false;
let dmrQualitySamples = [];
let dmrQualityScore = null;
let dmrSweepInProgress = false;
// ============== BOOKMARKS ==============
let dmrBookmarks = [];
@@ -89,7 +92,7 @@ function startDmr() {
}));
} catch (e) { /* localStorage unavailable */ }
fetch('/dmr/start', {
return fetch('/dmr/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequency, protocol, gain, device, ppm, fineTune, relaxCrc, demod })
@@ -98,6 +101,9 @@ function startDmr() {
.then(data => {
if (data.status === 'started') {
isDmrRunning = true;
dmrQualitySamples = [];
dmrQualityScore = null;
updateDmrQualityUI();
dmrCallCount = 0;
dmrSyncCount = 0;
dmrCallHistory = [];
@@ -146,11 +152,14 @@ function startDmr() {
function stopDmr() {
stopDmrAudio();
fetch('/dmr/stop', { method: 'POST' })
return fetch('/dmr/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
isDmrRunning = false;
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
dmrQualitySamples = [];
dmrQualityScore = null;
updateDmrQualityUI();
updateDmrUI();
dmrEventType = 'stopped';
dmrActivityTarget = 0;
@@ -196,6 +205,7 @@ function handleDmrMessage(msg) {
const syncCountEl = document.getElementById('dmrSyncCount');
if (syncCountEl) syncCountEl.textContent = dmrSyncCount;
} else if (msg.type === 'call') {
recordDmrQuality(true);
dmrCallCount++;
const countEl = document.getElementById('dmrCallCount');
if (countEl) countEl.textContent = dmrCallCount;
@@ -238,8 +248,14 @@ function handleDmrMessage(msg) {
} else if (msg.type === 'slot') {
// Update slot info in current call
} else if (msg.type === 'frame_ok') {
recordDmrQuality(true);
} else if (msg.type === 'frame_error') {
recordDmrQuality(false);
} else if (msg.type === 'raw') {
// Raw DSD output — triggers synthesizer activity via dmrSynthPulse
} else if (msg.type === 'voice') {
recordDmrQuality(true);
} else if (msg.type === 'heartbeat') {
// Decoder is alive and listening — keep synthesizer in listening state
if (isDmrRunning && dmrSynthInitialized) {
@@ -282,6 +298,111 @@ function handleDmrMessage(msg) {
}
}
// ============== QUALITY METER ==============
function recordDmrQuality(ok) {
dmrQualitySamples.push(!!ok);
if (dmrQualitySamples.length > 200) dmrQualitySamples.shift();
const total = dmrQualitySamples.length;
if (total < 5) {
dmrQualityScore = null;
updateDmrQualityUI();
return;
}
const errors = dmrQualitySamples.reduce((sum, v) => sum + (v ? 0 : 1), 0);
dmrQualityScore = Math.max(0, Math.min(100, Math.round(100 * (1 - (errors / total)))));
updateDmrQualityUI();
}
function updateDmrQualityUI() {
const textEl = document.getElementById('dmrQualityText');
const barEl = document.getElementById('dmrQualityBar');
if (!textEl || !barEl) return;
if (dmrQualityScore == null) {
textEl.textContent = '--';
barEl.style.width = '0%';
barEl.style.background = 'var(--text-muted)';
return;
}
textEl.textContent = `${dmrQualityScore}%`;
barEl.style.width = `${dmrQualityScore}%`;
if (dmrQualityScore >= 80) {
barEl.style.background = 'var(--accent-green)';
} else if (dmrQualityScore >= 50) {
barEl.style.background = 'var(--accent-amber, #f59e0b)';
} else {
barEl.style.background = 'var(--accent-red)';
}
}
// ============== FINE TUNE SWEEP ==============
async function sweepDmrFineTune() {
if (!isDmrRunning) {
if (typeof showNotification === 'function') {
showNotification('Digital Voice', 'Start the decoder before sweeping fine tune.');
}
return;
}
if (dmrSweepInProgress) return;
dmrSweepInProgress = true;
const freqEl = document.getElementById('dmrFrequency');
const protoEl = document.getElementById('dmrProtocol');
const gainEl = document.getElementById('dmrGain');
const ppmEl = document.getElementById('dmrPPM');
const fineEl = document.getElementById('dmrFineTune');
const crcEl = document.getElementById('dmrRelaxCrc');
const demodEl = document.getElementById('dmrDemod');
const sweepBtn = document.getElementById('dmrFineTuneSweepBtn');
const original = {
frequency: freqEl?.value,
protocol: protoEl?.value,
gain: gainEl?.value,
ppm: ppmEl?.value,
fineTune: fineEl?.value,
relaxCrc: crcEl?.checked,
demod: demodEl?.value,
};
if (sweepBtn) {
sweepBtn.disabled = true;
sweepBtn.textContent = 'Sweeping...';
}
const offsets = [-2000, -1500, -1000, -500, 0, 500, 1000, 1500, 2000];
let best = { offset: parseInt(original.fineTune || 0, 10) || 0, score: -1 };
for (const offset of offsets) {
if (fineEl) fineEl.value = offset;
await stopDmr();
await new Promise(r => setTimeout(r, 300));
await startDmr();
dmrQualitySamples = [];
dmrQualityScore = null;
updateDmrQualityUI();
await new Promise(r => setTimeout(r, 700));
await new Promise(r => setTimeout(r, 2500));
const score = dmrQualityScore == null ? 0 : dmrQualityScore;
if (score > best.score) best = { offset, score };
}
if (fineEl) fineEl.value = best.offset;
await stopDmr();
await new Promise(r => setTimeout(r, 300));
await startDmr();
if (sweepBtn) {
sweepBtn.disabled = false;
sweepBtn.textContent = 'Sweep Fine Tune';
}
dmrSweepInProgress = false;
if (typeof showNotification === 'function') {
showNotification('Digital Voice', `Sweep complete: best offset ${best.offset} Hz (${best.score}%)`);
}
}
// ============== UI ==============
function updateDmrUI() {
@@ -776,3 +897,4 @@ window.addDmrBookmark = addDmrBookmark;
window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark;
window.removeDmrBookmark = removeDmrBookmark;
window.dmrQuickTune = dmrQuickTune;
window.sweepDmrFineTune = sweepDmrFineTune;
+27 -15
View File
@@ -59,6 +59,9 @@
Adjust in 100 Hz steps; small offsets can dramatically improve P25 decode.
</span>
</div>
<button class="preset-btn" id="dmrFineTuneSweepBtn" onclick="sweepDmrFineTune()" style="width: 100%; margin-top: 6px;">
Sweep Fine Tune
</button>
<div class="form-group" style="margin-top: 4px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
@@ -108,21 +111,30 @@
</div>
<!-- Status -->
<div class="section" style="margin-top: 12px;">
<h3>Status</h3>
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
<span id="dmrStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Protocol</span>
<span id="dmrActiveProtocol" style="font-size: 11px; color: var(--text-primary);">--</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Calls</span>
<span id="dmrCallCount" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
<div class="section" style="margin-top: 12px;">
<h3>Status</h3>
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
<span id="dmrStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Protocol</span>
<span id="dmrActiveProtocol" style="font-size: 11px; color: var(--text-primary);">--</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Calls</span>
<span id="dmrCallCount" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
</div>
<div style="margin-top: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Quality</span>
<span id="dmrQualityText" style="font-size: 11px; color: var(--text-primary);">--</span>
</div>
<div style="height: 6px; background: rgba(255,255,255,0.08); border-radius: 6px; overflow: hidden;">
<div id="dmrQualityBar" style="height: 100%; width: 0%; background: var(--text-muted); transition: width 0.2s ease;"></div>
</div>
</div>
</div>
</div>
</div>
</div>
+22
View File
@@ -237,3 +237,25 @@ def test_dmr_stream_mimetype(auth_client):
"""Stream should return event-stream content type."""
resp = auth_client.get('/dmr/stream')
assert resp.content_type.startswith('text/event-stream')
def test_parse_frame_error_duid():
"""Should parse DUID errors as frame_error."""
result = parse_dsd_output('P25p2 LCH 0 DUID ERR 11')
assert result is not None
assert result['type'] == 'frame_error'
assert result['kind'] == 'duid'
def test_parse_frame_error_rs():
"""Should parse Reed-Solomon errors as frame_error."""
result = parse_dsd_output('P25p2 SACCH R-S ERR Sc')
assert result is not None
assert result['type'] == 'frame_error'
assert result['kind'] == 'rs'
def test_parse_frame_ok_p25p2():
"""Should parse P25p2 4V frames as OK."""
result = parse_dsd_output('P25p2 LCH 1 4V 1')
assert result is not None
assert result['type'] == 'frame_ok'
assert result['kind'] == 'p25p2'