mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -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:
129
routes/dmr.py
129
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."""
|
||||
|
||||
@@ -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 = '<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 ==============
|
||||
|
||||
function checkDmrStatus() {
|
||||
@@ -552,6 +740,13 @@ function checkDmrStatus() {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ============== INIT ==============
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
restoreDmrSettings();
|
||||
loadDmrBookmarks();
|
||||
});
|
||||
|
||||
// ============== EXPORTS ==============
|
||||
|
||||
window.startDmr = startDmr;
|
||||
@@ -559,3 +754,8 @@ window.stopDmr = stopDmr;
|
||||
window.checkDmrTools = checkDmrTools;
|
||||
window.checkDmrStatus = checkDmrStatus;
|
||||
window.initDmrSynthesizer = initDmrSynthesizer;
|
||||
window.setDmrAudioVolume = setDmrAudioVolume;
|
||||
window.addDmrBookmark = addDmrBookmark;
|
||||
window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark;
|
||||
window.removeDmrBookmark = removeDmrBookmark;
|
||||
window.dmrQuickTune = dmrQuickTune;
|
||||
|
||||
@@ -1690,6 +1690,18 @@
|
||||
</div>
|
||||
<canvas id="dmrSynthCanvas" style="width: 100%; height: 70px; background: rgba(0,0,0,0.4); border-radius: 4px; display: block;"></canvas>
|
||||
</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;">
|
||||
<!-- Call History Panel -->
|
||||
<div class="radio-module-box" style="padding: 10px;">
|
||||
|
||||
@@ -50,6 +50,26 @@
|
||||
</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 -->
|
||||
<button class="run-btn" id="startDmrBtn" onclick="startDmr()" style="margin-top: 12px;">
|
||||
Start Decoder
|
||||
|
||||
Reference in New Issue
Block a user