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 = '