Fix signal activity panel dying after DSD startup banner

The stream thread used a blocking readline() with no timeout, so once
DSD finished outputting its startup banner there were no more events
until actual signal activity. The frontend decayed to zero and appeared
dead. If DSD crashed, the synthesizer state never transitioned to
'stopped' so there was no visual or textual indication of failure.

- Use select() with 1s timeout on DSD stderr to avoid indefinite block
- Send heartbeat events every 3s while decoder is alive but idle
- Detect DSD crashes: capture exit code and remaining stderr, send as
  'crashed' status with details and show notification to user
- Frontend properly transitions synthesizer to 'stopped' on process
  death (was only happening on user-initiated stop)
- Increase idle breathing amplitude so LISTENING state is clearly
  visible (0.12 +/- 0.06 vs old 0.05 +/- 0.035)
- Release device reservation on crash, not just user stop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-07 12:49:35 +00:00
parent 19f382a31a
commit c0eda84644
2 changed files with 103 additions and 35 deletions

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import os
import queue
import re
import select
import shutil
import subprocess
import threading
@@ -169,49 +170,88 @@ def parse_dsd_output(line: str) -> dict | None:
}
_HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle
def _queue_put(event: dict):
"""Put an event on the DMR queue, dropping oldest if full."""
try:
dmr_queue.put_nowait(event)
except queue.Full:
try:
dmr_queue.get_nowait()
except queue.Empty:
pass
try:
dmr_queue.put_nowait(event)
except queue.Full:
pass
def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen):
"""Read DSD stderr output and push parsed events to the queue."""
"""Read DSD stderr output and push parsed events to the queue.
Uses select() with a timeout so we can send periodic heartbeat
events while readline() would otherwise block indefinitely during
silence (no signal being decoded).
"""
global dmr_running
try:
dmr_queue.put_nowait({'type': 'status', 'text': 'started'})
_queue_put({'type': 'status', 'text': 'started'})
last_heartbeat = time.time()
while dmr_running:
if dsd_process.poll() is not None:
break
line = dsd_process.stderr.readline()
if not line:
if dsd_process.poll() is not None:
break
continue
# Wait up to 1s for data on stderr instead of blocking forever
ready, _, _ = select.select([dsd_process.stderr], [], [], 1.0)
text = line.decode('utf-8', errors='replace').strip()
if not text:
continue
if ready:
line = dsd_process.stderr.readline()
if not line:
if dsd_process.poll() is not None:
break
continue
parsed = parse_dsd_output(text)
if parsed:
try:
dmr_queue.put_nowait(parsed)
except queue.Full:
try:
dmr_queue.get_nowait()
except queue.Empty:
pass
try:
dmr_queue.put_nowait(parsed)
except queue.Full:
pass
text = line.decode('utf-8', errors='replace').strip()
if not text:
continue
parsed = parse_dsd_output(text)
if parsed:
_queue_put(parsed)
last_heartbeat = time.time()
else:
# No stderr output — send heartbeat so frontend knows
# decoder is still alive and listening
now = time.time()
if now - last_heartbeat >= _HEARTBEAT_INTERVAL:
_queue_put({
'type': 'heartbeat',
'timestamp': datetime.now().strftime('%H:%M:%S'),
})
last_heartbeat = now
except Exception as e:
logger.error(f"DSD stream error: {e}")
finally:
dmr_running = False
try:
dmr_queue.put_nowait({'type': 'status', 'text': 'stopped'})
except queue.Full:
pass
# Capture exit info for diagnostics
rc = dsd_process.poll()
reason = 'stopped'
detail = ''
if rc is not None and rc != 0:
reason = 'crashed'
try:
remaining = dsd_process.stderr.read(1024)
if remaining:
detail = remaining.decode('utf-8', errors='replace').strip()[:200]
except Exception:
pass
logger.warning(f"DSD process exited with code {rc}: {detail}")
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
logger.info("DSD stream thread stopped")

View File

@@ -196,14 +196,42 @@ function handleDmrMessage(msg) {
// Raw DSD output — update last line display for diagnostics
const rawEl = document.getElementById('dmrRawOutput');
if (rawEl) rawEl.textContent = msg.text || '';
} else if (msg.type === 'heartbeat') {
// Decoder is alive and listening — keep synthesizer in listening state
if (isDmrRunning && dmrSynthInitialized) {
if (dmrEventType === 'idle' || dmrEventType === 'raw') {
dmrEventType = 'raw';
dmrActivityTarget = Math.max(dmrActivityTarget, 0.15);
dmrLastEventTime = Date.now();
updateDmrSynthStatus();
}
}
} else if (msg.type === 'status') {
const statusEl = document.getElementById('dmrStatus');
if (statusEl) {
statusEl.textContent = msg.text === 'started' ? 'DECODING' : 'IDLE';
}
if (msg.text === 'stopped') {
if (msg.text === 'started') {
if (statusEl) statusEl.textContent = 'DECODING';
} else if (msg.text === 'crashed') {
isDmrRunning = false;
updateDmrUI();
dmrEventType = 'stopped';
dmrActivityTarget = 0;
updateDmrSynthStatus();
if (statusEl) statusEl.textContent = 'CRASHED';
if (typeof releaseDevice === 'function') releaseDevice('dmr');
const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`;
if (typeof showNotification === 'function') {
showNotification('DMR Error', detail);
}
const rawEl = document.getElementById('dmrRawOutput');
if (rawEl) rawEl.textContent = detail;
} else if (msg.text === 'stopped') {
isDmrRunning = false;
updateDmrUI();
dmrEventType = 'stopped';
dmrActivityTarget = 0;
updateDmrSynthStatus();
if (statusEl) statusEl.textContent = 'STOPPED';
if (typeof releaseDevice === 'function') releaseDevice('dmr');
}
}
}
@@ -287,7 +315,7 @@ function drawDmrSynthesizer() {
if (timeSinceEvent > 2000) {
// No events for 2s — decay target toward idle
dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE);
if (dmrActivityTarget < 0.05 && dmrEventType !== 'stopped') {
if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') {
dmrEventType = 'idle';
updateDmrSynthStatus();
}
@@ -300,9 +328,9 @@ function drawDmrSynthesizer() {
let effectiveActivity = dmrActivityLevel;
if (dmrEventType === 'stopped') {
effectiveActivity = 0;
} else if (effectiveActivity < 0.05 && isDmrRunning) {
// Gentle idle breathing
effectiveActivity = 0.05 + Math.sin(now / 800) * 0.035;
} else if (effectiveActivity < 0.1 && isDmrRunning) {
// Visible idle breathing — shows decoder is alive and listening
effectiveActivity = 0.12 + Math.sin(now / 1000) * 0.06;
}
// Ripple timing for sync events