mirror of
https://github.com/smittix/intercept.git
synced 2026-07-17 13:58:11 -07:00
Add DMR audio output, frequency persistence, and bookmarks
Stream decoded digital voice audio to the browser via ffmpeg pipeline (dsd-fme 8kHz PCM → ffmpeg → 44.1kHz WAV → chunked HTTP). Persist frequency/protocol/gain/ppm settings in localStorage so they survive page navigation. Add bookmark system for saving and recalling frequencies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+117
-12
@@ -37,8 +37,10 @@ dmr_bp = Blueprint('dmr', __name__, url_prefix='/dmr')
|
|||||||
|
|
||||||
dmr_rtl_process: Optional[subprocess.Popen] = None
|
dmr_rtl_process: Optional[subprocess.Popen] = None
|
||||||
dmr_dsd_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_thread: Optional[threading.Thread] = None
|
||||||
dmr_running = False
|
dmr_running = False
|
||||||
|
dmr_audio_running = False
|
||||||
dmr_lock = threading.Lock()
|
dmr_lock = threading.Lock()
|
||||||
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
dmr_active_device: Optional[int] = None
|
dmr_active_device: Optional[int] = None
|
||||||
@@ -102,6 +104,11 @@ def find_rtl_fm() -> str | None:
|
|||||||
return shutil.which('rtl_fm')
|
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:
|
def parse_dsd_output(line: str) -> dict | None:
|
||||||
"""Parse a line of DSD stderr output into a structured event.
|
"""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}")
|
logger.error(f"DSD stream error: {e}")
|
||||||
finally:
|
finally:
|
||||||
global dmr_active_device, dmr_rtl_process, dmr_dsd_process
|
global dmr_active_device, dmr_rtl_process, dmr_dsd_process
|
||||||
|
global dmr_audio_process, dmr_audio_running
|
||||||
dmr_running = False
|
dmr_running = False
|
||||||
|
dmr_audio_running = False
|
||||||
# Capture exit info for diagnostics
|
# Capture exit info for diagnostics
|
||||||
rc = dsd_process.poll()
|
rc = dsd_process.poll()
|
||||||
reason = 'stopped'
|
reason = 'stopped'
|
||||||
@@ -279,8 +288,8 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
logger.warning(f"DSD process exited with code {rc}: {detail}")
|
logger.warning(f"DSD process exited with code {rc}: {detail}")
|
||||||
# Cleanup both processes
|
# Cleanup all pipeline processes (audio encoder + decoder + demod)
|
||||||
for proc in [dsd_process, rtl_process]:
|
for proc in [dmr_audio_process, dsd_process, rtl_process]:
|
||||||
if proc and proc.poll() is None:
|
if proc and proc.poll() is None:
|
||||||
try:
|
try:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
@@ -294,6 +303,7 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
|
|||||||
unregister_process(proc)
|
unregister_process(proc)
|
||||||
dmr_rtl_process = None
|
dmr_rtl_process = None
|
||||||
dmr_dsd_process = None
|
dmr_dsd_process = None
|
||||||
|
dmr_audio_process = None
|
||||||
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
|
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if dmr_active_device is not None:
|
if dmr_active_device is not None:
|
||||||
@@ -311,9 +321,11 @@ def check_tools() -> Response:
|
|||||||
"""Check for required tools."""
|
"""Check for required tools."""
|
||||||
dsd_path, _ = find_dsd()
|
dsd_path, _ = find_dsd()
|
||||||
rtl_fm = find_rtl_fm()
|
rtl_fm = find_rtl_fm()
|
||||||
|
ffmpeg = find_ffmpeg()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'dsd': dsd_path is not None,
|
'dsd': dsd_path is not None,
|
||||||
'rtl_fm': rtl_fm 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,
|
'available': dsd_path is not None and rtl_fm is not None,
|
||||||
'protocols': VALID_PROTOCOLS,
|
'protocols': VALID_PROTOCOLS,
|
||||||
})
|
})
|
||||||
@@ -322,7 +334,8 @@ def check_tools() -> Response:
|
|||||||
@dmr_bp.route('/start', methods=['POST'])
|
@dmr_bp.route('/start', methods=['POST'])
|
||||||
def start_dmr() -> Response:
|
def start_dmr() -> Response:
|
||||||
"""Start digital voice decoding."""
|
"""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:
|
with dmr_lock:
|
||||||
if dmr_running:
|
if dmr_running:
|
||||||
@@ -385,10 +398,15 @@ def start_dmr() -> Response:
|
|||||||
rtl_cmd.extend(['-p', str(ppm)])
|
rtl_cmd.extend(['-p', str(ppm)])
|
||||||
|
|
||||||
# Build DSD command
|
# Build DSD command
|
||||||
# dsd-fme uses '-o null' to discard decoded audio (PulseAudio
|
# Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for
|
||||||
# unavailable on headless/remote servers); classic dsd uses '-o -'
|
# ffmpeg transcoding. Both dsd-fme and classic dsd support '-o -'.
|
||||||
# to send audio to stdout which we pipe to DEVNULL.
|
# If ffmpeg is unavailable, fall back to discarding audio.
|
||||||
audio_out = 'null' if is_fme else '-'
|
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]
|
dsd_cmd = [dsd_path, '-i', '-', '-o', audio_out]
|
||||||
if is_fme:
|
if is_fme:
|
||||||
dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, []))
|
dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, []))
|
||||||
@@ -411,10 +429,13 @@ def start_dmr() -> Response:
|
|||||||
)
|
)
|
||||||
register_process(dmr_rtl_process)
|
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(
|
dmr_dsd_process = subprocess.Popen(
|
||||||
dsd_cmd,
|
dsd_cmd,
|
||||||
stdin=dmr_rtl_process.stdout,
|
stdin=dmr_rtl_process.stdout,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=dsd_stdout,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
)
|
)
|
||||||
register_process(dmr_dsd_process)
|
register_process(dmr_dsd_process)
|
||||||
@@ -422,6 +443,31 @@ def start_dmr() -> Response:
|
|||||||
# Allow rtl_fm to send directly to dsd
|
# Allow rtl_fm to send directly to dsd
|
||||||
dmr_rtl_process.stdout.close()
|
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)
|
time.sleep(0.3)
|
||||||
|
|
||||||
rtl_rc = dmr_rtl_process.poll()
|
rtl_rc = dmr_rtl_process.poll()
|
||||||
@@ -435,8 +481,9 @@ def start_dmr() -> Response:
|
|||||||
if dmr_dsd_process.stderr:
|
if dmr_dsd_process.stderr:
|
||||||
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500]
|
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}")
|
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
|
# Terminate surviving processes and unregister all
|
||||||
for proc in [dmr_dsd_process, dmr_rtl_process]:
|
dmr_audio_running = False
|
||||||
|
for proc in [dmr_audio_process, dmr_dsd_process, dmr_rtl_process]:
|
||||||
if proc and proc.poll() is None:
|
if proc and proc.poll() is None:
|
||||||
try:
|
try:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
@@ -450,6 +497,7 @@ def start_dmr() -> Response:
|
|||||||
unregister_process(proc)
|
unregister_process(proc)
|
||||||
dmr_rtl_process = None
|
dmr_rtl_process = None
|
||||||
dmr_dsd_process = None
|
dmr_dsd_process = None
|
||||||
|
dmr_audio_process = None
|
||||||
if dmr_active_device is not None:
|
if dmr_active_device is not None:
|
||||||
app_module.release_sdr_device(dmr_active_device)
|
app_module.release_sdr_device(dmr_active_device)
|
||||||
dmr_active_device = None
|
dmr_active_device = None
|
||||||
@@ -485,6 +533,7 @@ def start_dmr() -> Response:
|
|||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
'protocol': protocol,
|
'protocol': protocol,
|
||||||
|
'has_audio': dmr_audio_running,
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -498,12 +547,14 @@ def start_dmr() -> Response:
|
|||||||
@dmr_bp.route('/stop', methods=['POST'])
|
@dmr_bp.route('/stop', methods=['POST'])
|
||||||
def stop_dmr() -> Response:
|
def stop_dmr() -> Response:
|
||||||
"""Stop digital voice decoding."""
|
"""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:
|
with dmr_lock:
|
||||||
dmr_running = False
|
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:
|
if proc and proc.poll() is None:
|
||||||
try:
|
try:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
@@ -518,6 +569,7 @@ def stop_dmr() -> Response:
|
|||||||
|
|
||||||
dmr_rtl_process = None
|
dmr_rtl_process = None
|
||||||
dmr_dsd_process = None
|
dmr_dsd_process = None
|
||||||
|
dmr_audio_process = None
|
||||||
|
|
||||||
if dmr_active_device is not None:
|
if dmr_active_device is not None:
|
||||||
app_module.release_sdr_device(dmr_active_device)
|
app_module.release_sdr_device(dmr_active_device)
|
||||||
@@ -532,9 +584,62 @@ def dmr_status() -> Response:
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'running': dmr_running,
|
'running': dmr_running,
|
||||||
'device': dmr_active_device,
|
'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')
|
@dmr_bp.route('/stream')
|
||||||
def stream_dmr() -> Response:
|
def stream_dmr() -> Response:
|
||||||
"""SSE stream for DMR decoder events."""
|
"""SSE stream for DMR decoder events."""
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ let dmrSyncCount = 0;
|
|||||||
let dmrCallHistory = [];
|
let dmrCallHistory = [];
|
||||||
let dmrCurrentProtocol = '--';
|
let dmrCurrentProtocol = '--';
|
||||||
let dmrModeLabel = 'dmr'; // Protocol label for device reservation
|
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 ==============
|
// ============== SYNTHESIZER STATE ==============
|
||||||
let dmrSynthCanvas = null;
|
let dmrSynthCanvas = null;
|
||||||
@@ -41,6 +47,7 @@ function checkDmrTools() {
|
|||||||
const missing = [];
|
const missing = [];
|
||||||
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
|
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
|
||||||
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
|
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
|
||||||
|
if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)');
|
||||||
|
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
warning.style.display = 'block';
|
warning.style.display = 'block';
|
||||||
@@ -48,6 +55,9 @@ function checkDmrTools() {
|
|||||||
} else {
|
} else {
|
||||||
warning.style.display = 'none';
|
warning.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update audio panel availability
|
||||||
|
updateDmrAudioStatus(data.ffmpeg ? 'OFF' : 'UNAVAILABLE');
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -70,6 +80,13 @@ function startDmr() {
|
|||||||
return;
|
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', {
|
fetch('/dmr/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -94,6 +111,10 @@ function startDmr() {
|
|||||||
if (typeof reserveDevice === 'function') {
|
if (typeof reserveDevice === 'function') {
|
||||||
reserveDevice(parseInt(device), dmrModeLabel);
|
reserveDevice(parseInt(device), dmrModeLabel);
|
||||||
}
|
}
|
||||||
|
// Start audio if available
|
||||||
|
dmrHasAudio = !!data.has_audio;
|
||||||
|
if (dmrHasAudio) startDmrAudio();
|
||||||
|
updateDmrAudioStatus(dmrHasAudio ? 'STREAMING' : 'UNAVAILABLE');
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification('Digital Voice', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
|
showNotification('Digital Voice', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
|
||||||
}
|
}
|
||||||
@@ -122,6 +143,7 @@ function startDmr() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stopDmr() {
|
function stopDmr() {
|
||||||
|
stopDmrAudio();
|
||||||
fetch('/dmr/stop', { method: 'POST' })
|
fetch('/dmr/stop', { method: 'POST' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -131,6 +153,7 @@ function stopDmr() {
|
|||||||
dmrEventType = 'stopped';
|
dmrEventType = 'stopped';
|
||||||
dmrActivityTarget = 0;
|
dmrActivityTarget = 0;
|
||||||
updateDmrSynthStatus();
|
updateDmrSynthStatus();
|
||||||
|
updateDmrAudioStatus('OFF');
|
||||||
const statusEl = document.getElementById('dmrStatus');
|
const statusEl = document.getElementById('dmrStatus');
|
||||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||||
if (typeof releaseDevice === 'function') {
|
if (typeof releaseDevice === 'function') {
|
||||||
@@ -231,10 +254,12 @@ function handleDmrMessage(msg) {
|
|||||||
if (statusEl) statusEl.textContent = 'DECODING';
|
if (statusEl) statusEl.textContent = 'DECODING';
|
||||||
} else if (msg.text === 'crashed') {
|
} else if (msg.text === 'crashed') {
|
||||||
isDmrRunning = false;
|
isDmrRunning = false;
|
||||||
|
stopDmrAudio();
|
||||||
updateDmrUI();
|
updateDmrUI();
|
||||||
dmrEventType = 'stopped';
|
dmrEventType = 'stopped';
|
||||||
dmrActivityTarget = 0;
|
dmrActivityTarget = 0;
|
||||||
updateDmrSynthStatus();
|
updateDmrSynthStatus();
|
||||||
|
updateDmrAudioStatus('OFF');
|
||||||
if (statusEl) statusEl.textContent = 'CRASHED';
|
if (statusEl) statusEl.textContent = 'CRASHED';
|
||||||
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
||||||
const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`;
|
const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`;
|
||||||
@@ -243,10 +268,12 @@ function handleDmrMessage(msg) {
|
|||||||
}
|
}
|
||||||
} else if (msg.text === 'stopped') {
|
} else if (msg.text === 'stopped') {
|
||||||
isDmrRunning = false;
|
isDmrRunning = false;
|
||||||
|
stopDmrAudio();
|
||||||
updateDmrUI();
|
updateDmrUI();
|
||||||
dmrEventType = 'stopped';
|
dmrEventType = 'stopped';
|
||||||
dmrActivityTarget = 0;
|
dmrActivityTarget = 0;
|
||||||
updateDmrSynthStatus();
|
updateDmrSynthStatus();
|
||||||
|
updateDmrAudioStatus('OFF');
|
||||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||||
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
||||||
}
|
}
|
||||||
@@ -519,6 +546,167 @@ function stopDmrSynthesizer() {
|
|||||||
|
|
||||||
window.addEventListener('resize', resizeDmrSynthesizer);
|
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 = '<div style="color: var(--text-muted); text-align: center; padding: 10px; font-size: 11px;">No bookmarks saved</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = dmrBookmarks.map((b, i) => `
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; padding: 4px 6px; background: rgba(0,0,0,0.2); border-radius: 3px; margin-bottom: 3px;">
|
||||||
|
<span style="cursor: pointer; color: var(--accent-cyan); font-size: 11px; flex: 1;" onclick="dmrQuickTune(${b.freq}, '${b.protocol}')" title="${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})">${b.label}</span>
|
||||||
|
<span style="color: var(--text-muted); font-size: 9px; margin: 0 6px;">${b.protocol.toUpperCase()}</span>
|
||||||
|
<button onclick="removeDmrBookmark(${i})" style="background: none; border: none; color: var(--accent-red); cursor: pointer; font-size: 12px; padding: 0 4px;">×</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
// ============== STATUS SYNC ==============
|
// ============== STATUS SYNC ==============
|
||||||
|
|
||||||
function checkDmrStatus() {
|
function checkDmrStatus() {
|
||||||
@@ -552,6 +740,13 @@ function checkDmrStatus() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============== INIT ==============
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
restoreDmrSettings();
|
||||||
|
loadDmrBookmarks();
|
||||||
|
});
|
||||||
|
|
||||||
// ============== EXPORTS ==============
|
// ============== EXPORTS ==============
|
||||||
|
|
||||||
window.startDmr = startDmr;
|
window.startDmr = startDmr;
|
||||||
@@ -559,3 +754,8 @@ window.stopDmr = stopDmr;
|
|||||||
window.checkDmrTools = checkDmrTools;
|
window.checkDmrTools = checkDmrTools;
|
||||||
window.checkDmrStatus = checkDmrStatus;
|
window.checkDmrStatus = checkDmrStatus;
|
||||||
window.initDmrSynthesizer = initDmrSynthesizer;
|
window.initDmrSynthesizer = initDmrSynthesizer;
|
||||||
|
window.setDmrAudioVolume = setDmrAudioVolume;
|
||||||
|
window.addDmrBookmark = addDmrBookmark;
|
||||||
|
window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark;
|
||||||
|
window.removeDmrBookmark = removeDmrBookmark;
|
||||||
|
window.dmrQuickTune = dmrQuickTune;
|
||||||
|
|||||||
@@ -1690,6 +1690,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<canvas id="dmrSynthCanvas" style="width: 100%; height: 70px; background: rgba(0,0,0,0.4); border-radius: 4px; display: block;"></canvas>
|
<canvas id="dmrSynthCanvas" style="width: 100%; height: 70px; background: rgba(0,0,0,0.4); border-radius: 4px; display: block;"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Audio Output -->
|
||||||
|
<div class="radio-module-box" style="padding: 8px 12px;">
|
||||||
|
<audio id="dmrAudioPlayer" style="display: none;"></audio>
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">AUDIO</span>
|
||||||
|
<span id="dmrAudioStatus" style="font-size: 9px; font-family: var(--font-mono); color: var(--text-muted);">OFF</span>
|
||||||
|
<div style="display: flex; align-items: center; gap: 4px; margin-left: auto;">
|
||||||
|
<span style="font-size: 10px; color: var(--text-muted);">VOL</span>
|
||||||
|
<input type="range" id="dmrAudioVolume" min="0" max="100" value="80" style="width: 80px;" oninput="setDmrAudioVolume(this.value)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
||||||
<!-- Call History Panel -->
|
<!-- Call History Panel -->
|
||||||
<div class="radio-module-box" style="padding: 10px;">
|
<div class="radio-module-box" style="padding: 10px;">
|
||||||
|
|||||||
@@ -50,6 +50,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bookmarks -->
|
||||||
|
<div class="section" style="margin-top: 8px;">
|
||||||
|
<h3>Bookmarks</h3>
|
||||||
|
<div style="display: flex; gap: 4px; margin-bottom: 6px;">
|
||||||
|
<input type="number" id="dmrBookmarkFreq" placeholder="Freq MHz" step="0.0001"
|
||||||
|
style="flex: 1; font-size: 11px; padding: 4px 6px;">
|
||||||
|
<button class="preset-btn" onclick="addDmrBookmark()" style="font-size: 10px; padding: 4px 8px;"
|
||||||
|
title="Add bookmark">+</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 4px; margin-bottom: 6px;">
|
||||||
|
<input type="text" id="dmrBookmarkLabel" placeholder="Label (optional)"
|
||||||
|
style="flex: 1; font-size: 11px; padding: 4px 6px;">
|
||||||
|
<button class="preset-btn" onclick="addCurrentDmrFreqBookmark()" style="font-size: 9px; padding: 4px 6px;"
|
||||||
|
title="Save current frequency">Save current</button>
|
||||||
|
</div>
|
||||||
|
<div id="dmrBookmarksList" style="max-height: 150px; overflow-y: auto;">
|
||||||
|
<div style="color: var(--text-muted); text-align: center; padding: 10px; font-size: 11px;">No bookmarks saved</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<button class="run-btn" id="startDmrBtn" onclick="startDmr()" style="margin-top: 12px;">
|
<button class="run-btn" id="startDmrBtn" onclick="startDmr()" style="margin-top: 12px;">
|
||||||
Start Decoder
|
Start Decoder
|
||||||
|
|||||||
Reference in New Issue
Block a user