mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
Add .gitignore entry for data/subghz/captures/ to prevent large IQ recording files from being committed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
853 lines
31 KiB
JavaScript
853 lines
31 KiB
JavaScript
/**
|
|
* Intercept - DMR / Digital Voice Mode
|
|
* Decoding DMR, P25, NXDN, D-STAR digital voice protocols
|
|
*/
|
|
|
|
// ============== STATE ==============
|
|
let isDmrRunning = false;
|
|
let dmrEventSource = null;
|
|
let dmrCallCount = 0;
|
|
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';
|
|
const DMR_BOOKMARK_PROTOCOLS = new Set(['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']);
|
|
|
|
// ============== SYNTHESIZER STATE ==============
|
|
let dmrSynthCanvas = null;
|
|
let dmrSynthCtx = null;
|
|
let dmrSynthBars = [];
|
|
let dmrSynthAnimationId = null;
|
|
let dmrSynthInitialized = false;
|
|
let dmrActivityLevel = 0;
|
|
let dmrActivityTarget = 0;
|
|
let dmrEventType = 'idle';
|
|
let dmrLastEventTime = 0;
|
|
const DMR_BAR_COUNT = 48;
|
|
const DMR_DECAY_RATE = 0.015;
|
|
const DMR_BURST_SYNC = 0.6;
|
|
const DMR_BURST_CALL = 0.85;
|
|
const DMR_BURST_VOICE = 0.95;
|
|
|
|
// ============== TOOLS CHECK ==============
|
|
|
|
function checkDmrTools() {
|
|
fetch('/dmr/tools')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const warning = document.getElementById('dmrToolsWarning');
|
|
const warningText = document.getElementById('dmrToolsWarningText');
|
|
if (!warning) return;
|
|
|
|
const selectedType = (typeof getSelectedSDRType === 'function')
|
|
? getSelectedSDRType()
|
|
: 'rtlsdr';
|
|
const missing = [];
|
|
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
|
|
if (selectedType === 'rtlsdr') {
|
|
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
|
|
} else if (!data.rx_fm) {
|
|
missing.push('rx_fm (SoapySDR demodulator)');
|
|
}
|
|
if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)');
|
|
|
|
if (missing.length > 0) {
|
|
warning.style.display = 'block';
|
|
if (warningText) warningText.textContent = missing.join(', ');
|
|
} else {
|
|
warning.style.display = 'none';
|
|
}
|
|
|
|
// Update audio panel availability
|
|
updateDmrAudioStatus(data.ffmpeg ? 'OFF' : 'UNAVAILABLE');
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
// ============== START / STOP ==============
|
|
|
|
function startDmr() {
|
|
const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625);
|
|
const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
|
|
const gain = parseInt(document.getElementById('dmrGain')?.value || 40);
|
|
const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0);
|
|
const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false;
|
|
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
|
const sdrType = (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr';
|
|
|
|
// Use protocol name for device reservation so panel shows "D-STAR", "P25", etc.
|
|
dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr';
|
|
|
|
// Check device availability before starting
|
|
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability(dmrModeLabel)) {
|
|
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' },
|
|
body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc, sdr_type: sdrType })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status === 'started') {
|
|
isDmrRunning = true;
|
|
dmrCallCount = 0;
|
|
dmrSyncCount = 0;
|
|
dmrCallHistory = [];
|
|
updateDmrUI();
|
|
connectDmrSSE();
|
|
dmrEventType = 'idle';
|
|
dmrActivityTarget = 0.1;
|
|
dmrLastEventTime = Date.now();
|
|
if (!dmrSynthInitialized) initDmrSynthesizer();
|
|
updateDmrSynthStatus();
|
|
const statusEl = document.getElementById('dmrStatus');
|
|
if (statusEl) statusEl.textContent = 'DECODING';
|
|
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()})`);
|
|
}
|
|
} else if (data.status === 'error' && data.message === 'Already running') {
|
|
// Backend has an active session the frontend lost track of — resync
|
|
isDmrRunning = true;
|
|
updateDmrUI();
|
|
connectDmrSSE();
|
|
if (!dmrSynthInitialized) initDmrSynthesizer();
|
|
dmrEventType = 'idle';
|
|
dmrActivityTarget = 0.1;
|
|
dmrLastEventTime = Date.now();
|
|
updateDmrSynthStatus();
|
|
const statusEl = document.getElementById('dmrStatus');
|
|
if (statusEl) statusEl.textContent = 'DECODING';
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('DMR', 'Reconnected to active session');
|
|
}
|
|
} else {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error', data.message || 'Failed to start DMR');
|
|
}
|
|
}
|
|
})
|
|
.catch(err => console.error('[DMR] Start error:', err));
|
|
}
|
|
|
|
function stopDmr() {
|
|
stopDmrAudio();
|
|
fetch('/dmr/stop', { method: 'POST' })
|
|
.then(r => r.json())
|
|
.then(() => {
|
|
isDmrRunning = false;
|
|
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
|
|
updateDmrUI();
|
|
dmrEventType = 'stopped';
|
|
dmrActivityTarget = 0;
|
|
updateDmrSynthStatus();
|
|
updateDmrAudioStatus('OFF');
|
|
const statusEl = document.getElementById('dmrStatus');
|
|
if (statusEl) statusEl.textContent = 'STOPPED';
|
|
if (typeof releaseDevice === 'function') {
|
|
releaseDevice(dmrModeLabel);
|
|
}
|
|
})
|
|
.catch(err => console.error('[DMR] Stop error:', err));
|
|
}
|
|
|
|
// ============== SSE STREAMING ==============
|
|
|
|
function connectDmrSSE() {
|
|
if (dmrEventSource) dmrEventSource.close();
|
|
dmrEventSource = new EventSource('/dmr/stream');
|
|
|
|
dmrEventSource.onmessage = function(event) {
|
|
const msg = JSON.parse(event.data);
|
|
handleDmrMessage(msg);
|
|
};
|
|
|
|
dmrEventSource.onerror = function() {
|
|
if (isDmrRunning) {
|
|
setTimeout(connectDmrSSE, 2000);
|
|
}
|
|
};
|
|
}
|
|
|
|
function handleDmrMessage(msg) {
|
|
if (dmrSynthInitialized) dmrSynthPulse(msg.type);
|
|
|
|
if (msg.type === 'sync') {
|
|
dmrCurrentProtocol = msg.protocol || '--';
|
|
const protocolEl = document.getElementById('dmrActiveProtocol');
|
|
if (protocolEl) protocolEl.textContent = dmrCurrentProtocol;
|
|
const mainProtocolEl = document.getElementById('dmrMainProtocol');
|
|
if (mainProtocolEl) mainProtocolEl.textContent = dmrCurrentProtocol;
|
|
dmrSyncCount++;
|
|
const syncCountEl = document.getElementById('dmrSyncCount');
|
|
if (syncCountEl) syncCountEl.textContent = dmrSyncCount;
|
|
} else if (msg.type === 'call') {
|
|
dmrCallCount++;
|
|
const countEl = document.getElementById('dmrCallCount');
|
|
if (countEl) countEl.textContent = dmrCallCount;
|
|
const mainCountEl = document.getElementById('dmrMainCallCount');
|
|
if (mainCountEl) mainCountEl.textContent = dmrCallCount;
|
|
|
|
// Update current call display
|
|
const slotInfo = msg.slot != null ? `
|
|
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
|
<span style="color: var(--text-muted);">Slot</span>
|
|
<span style="color: var(--accent-orange); font-family: var(--font-mono);">${msg.slot}</span>
|
|
</div>` : '';
|
|
const callEl = document.getElementById('dmrCurrentCall');
|
|
if (callEl) {
|
|
callEl.innerHTML = `
|
|
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
|
<span style="color: var(--text-muted);">Talkgroup</span>
|
|
<span style="color: var(--accent-green); font-weight: bold; font-family: var(--font-mono);">${msg.talkgroup}</span>
|
|
</div>
|
|
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
|
<span style="color: var(--text-muted);">Source ID</span>
|
|
<span style="color: var(--accent-cyan); font-family: var(--font-mono);">${msg.source_id}</span>
|
|
</div>${slotInfo}
|
|
<div style="display: flex; justify-content: space-between;">
|
|
<span style="color: var(--text-muted);">Time</span>
|
|
<span style="color: var(--text-primary);">${msg.timestamp}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Add to history
|
|
dmrCallHistory.unshift({
|
|
talkgroup: msg.talkgroup,
|
|
source_id: msg.source_id,
|
|
protocol: dmrCurrentProtocol,
|
|
time: msg.timestamp,
|
|
});
|
|
if (dmrCallHistory.length > 50) dmrCallHistory.length = 50;
|
|
renderDmrHistory();
|
|
|
|
} else if (msg.type === 'slot') {
|
|
// Update slot info in current call
|
|
} else if (msg.type === 'raw') {
|
|
// Raw DSD output — triggers synthesizer activity via dmrSynthPulse
|
|
} 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 (msg.text === 'started') {
|
|
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})`;
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('DMR Error', detail);
|
|
}
|
|
} 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============== UI ==============
|
|
|
|
function updateDmrUI() {
|
|
const startBtn = document.getElementById('startDmrBtn');
|
|
const stopBtn = document.getElementById('stopDmrBtn');
|
|
if (startBtn) startBtn.style.display = isDmrRunning ? 'none' : 'block';
|
|
if (stopBtn) stopBtn.style.display = isDmrRunning ? 'block' : 'none';
|
|
}
|
|
|
|
function renderDmrHistory() {
|
|
const container = document.getElementById('dmrHistoryBody');
|
|
if (!container) return;
|
|
|
|
const historyCountEl = document.getElementById('dmrHistoryCount');
|
|
if (historyCountEl) historyCountEl.textContent = `${dmrCallHistory.length} calls`;
|
|
|
|
if (dmrCallHistory.length === 0) {
|
|
container.innerHTML = '<tr><td colspan="4" style="padding: 10px; text-align: center; color: var(--text-muted);">No calls recorded</td></tr>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = dmrCallHistory.slice(0, 20).map(call => `
|
|
<tr>
|
|
<td style="padding: 3px 6px; font-family: var(--font-mono);">${call.time}</td>
|
|
<td style="padding: 3px 6px; color: var(--accent-green);">${call.talkgroup}</td>
|
|
<td style="padding: 3px 6px; color: var(--accent-cyan);">${call.source_id}</td>
|
|
<td style="padding: 3px 6px;">${call.protocol}</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
// ============== SYNTHESIZER ==============
|
|
|
|
function initDmrSynthesizer() {
|
|
dmrSynthCanvas = document.getElementById('dmrSynthCanvas');
|
|
if (!dmrSynthCanvas) return;
|
|
|
|
// Use the canvas element's own rendered size for the backing buffer
|
|
const rect = dmrSynthCanvas.getBoundingClientRect();
|
|
const w = Math.round(rect.width) || 600;
|
|
const h = Math.round(rect.height) || 70;
|
|
dmrSynthCanvas.width = w;
|
|
dmrSynthCanvas.height = h;
|
|
|
|
dmrSynthCtx = dmrSynthCanvas.getContext('2d');
|
|
|
|
dmrSynthBars = [];
|
|
for (let i = 0; i < DMR_BAR_COUNT; i++) {
|
|
dmrSynthBars[i] = { height: 2, targetHeight: 2, velocity: 0 };
|
|
}
|
|
|
|
dmrActivityLevel = 0;
|
|
dmrActivityTarget = 0;
|
|
dmrEventType = isDmrRunning ? 'idle' : 'stopped';
|
|
dmrSynthInitialized = true;
|
|
|
|
updateDmrSynthStatus();
|
|
|
|
if (dmrSynthAnimationId) cancelAnimationFrame(dmrSynthAnimationId);
|
|
drawDmrSynthesizer();
|
|
}
|
|
|
|
function drawDmrSynthesizer() {
|
|
if (!dmrSynthCtx || !dmrSynthCanvas) return;
|
|
|
|
const width = dmrSynthCanvas.width;
|
|
const height = dmrSynthCanvas.height;
|
|
const barWidth = (width / DMR_BAR_COUNT) - 2;
|
|
const now = Date.now();
|
|
|
|
// Clear canvas
|
|
dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
|
dmrSynthCtx.fillRect(0, 0, width, height);
|
|
|
|
// Decay activity toward target. Window must exceed the backend
|
|
// heartbeat interval (3s) so the status doesn't flip-flop between
|
|
// LISTENING and IDLE on every heartbeat cycle.
|
|
const timeSinceEvent = now - dmrLastEventTime;
|
|
if (timeSinceEvent > 5000) {
|
|
// No events for 5s — decay target toward idle
|
|
dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE);
|
|
if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') {
|
|
dmrEventType = 'idle';
|
|
updateDmrSynthStatus();
|
|
}
|
|
}
|
|
|
|
// Smooth approach to target
|
|
dmrActivityLevel += (dmrActivityTarget - dmrActivityLevel) * 0.08;
|
|
|
|
// Determine effective activity (idle breathing when stopped/idle)
|
|
let effectiveActivity = dmrActivityLevel;
|
|
if (dmrEventType === 'stopped') {
|
|
effectiveActivity = 0;
|
|
} 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
|
|
const syncRippleAge = (dmrEventType === 'sync' && timeSinceEvent < 500) ? 1 - (timeSinceEvent / 500) : 0;
|
|
// Voice ripple overlay
|
|
const voiceRipple = (dmrEventType === 'voice') ? Math.sin(now / 60) * 0.15 : 0;
|
|
|
|
// Update bar targets and physics
|
|
for (let i = 0; i < DMR_BAR_COUNT; i++) {
|
|
const time = now / 200;
|
|
const wave1 = Math.sin(time + i * 0.3) * 0.2;
|
|
const wave2 = Math.sin(time * 1.7 + i * 0.5) * 0.15;
|
|
const randomAmount = 0.05 + effectiveActivity * 0.25;
|
|
const random = (Math.random() - 0.5) * randomAmount;
|
|
|
|
// Bell curve — center bars taller
|
|
const centerDist = Math.abs(i - DMR_BAR_COUNT / 2) / (DMR_BAR_COUNT / 2);
|
|
const centerBoost = 1 - centerDist * 0.5;
|
|
|
|
// Sync ripple: center-outward wave burst
|
|
let rippleBoost = 0;
|
|
if (syncRippleAge > 0) {
|
|
const ripplePos = (1 - syncRippleAge) * DMR_BAR_COUNT / 2;
|
|
const distFromRipple = Math.abs(i - DMR_BAR_COUNT / 2) - ripplePos;
|
|
rippleBoost = Math.max(0, 1 - Math.abs(distFromRipple) / 4) * syncRippleAge * 0.4;
|
|
}
|
|
|
|
const baseHeight = 0.1 + effectiveActivity * 0.55;
|
|
dmrSynthBars[i].targetHeight = Math.max(2,
|
|
(baseHeight + wave1 + wave2 + random + rippleBoost + voiceRipple) *
|
|
effectiveActivity * centerBoost * height
|
|
);
|
|
|
|
// Spring physics
|
|
const springStrength = effectiveActivity > 0.3 ? 0.15 : 0.1;
|
|
const diff = dmrSynthBars[i].targetHeight - dmrSynthBars[i].height;
|
|
dmrSynthBars[i].velocity += diff * springStrength;
|
|
dmrSynthBars[i].velocity *= 0.78;
|
|
dmrSynthBars[i].height += dmrSynthBars[i].velocity;
|
|
dmrSynthBars[i].height = Math.max(2, Math.min(height - 4, dmrSynthBars[i].height));
|
|
}
|
|
|
|
// Draw bars
|
|
for (let i = 0; i < DMR_BAR_COUNT; i++) {
|
|
const x = i * (barWidth + 2) + 1;
|
|
const barHeight = dmrSynthBars[i].height;
|
|
const y = (height - barHeight) / 2;
|
|
|
|
// HSL color by event type
|
|
let hue, saturation, lightness;
|
|
if (dmrEventType === 'voice' && timeSinceEvent < 3000) {
|
|
hue = 30; // Orange
|
|
saturation = 85;
|
|
lightness = 40 + (barHeight / height) * 25;
|
|
} else if (dmrEventType === 'call' && timeSinceEvent < 3000) {
|
|
hue = 120; // Green
|
|
saturation = 80;
|
|
lightness = 35 + (barHeight / height) * 30;
|
|
} else if (dmrEventType === 'sync' && timeSinceEvent < 2000) {
|
|
hue = 185; // Cyan
|
|
saturation = 85;
|
|
lightness = 38 + (barHeight / height) * 25;
|
|
} else if (dmrEventType === 'stopped') {
|
|
hue = 220;
|
|
saturation = 20;
|
|
lightness = 18 + (barHeight / height) * 8;
|
|
} else {
|
|
// Idle / decayed
|
|
hue = 210;
|
|
saturation = 40;
|
|
lightness = 25 + (barHeight / height) * 15;
|
|
}
|
|
|
|
// Vertical gradient per bar
|
|
const gradient = dmrSynthCtx.createLinearGradient(x, y, x, y + barHeight);
|
|
gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`);
|
|
gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`);
|
|
gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`);
|
|
|
|
dmrSynthCtx.fillStyle = gradient;
|
|
dmrSynthCtx.fillRect(x, y, barWidth, barHeight);
|
|
|
|
// Glow on tall bars
|
|
if (barHeight > height * 0.5 && effectiveActivity > 0.4) {
|
|
dmrSynthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`;
|
|
dmrSynthCtx.shadowBlur = 8;
|
|
dmrSynthCtx.fillRect(x, y, barWidth, barHeight);
|
|
dmrSynthCtx.shadowBlur = 0;
|
|
}
|
|
}
|
|
|
|
// Center line
|
|
dmrSynthCtx.strokeStyle = 'rgba(0, 212, 255, 0.15)';
|
|
dmrSynthCtx.lineWidth = 1;
|
|
dmrSynthCtx.beginPath();
|
|
dmrSynthCtx.moveTo(0, height / 2);
|
|
dmrSynthCtx.lineTo(width, height / 2);
|
|
dmrSynthCtx.stroke();
|
|
|
|
dmrSynthAnimationId = requestAnimationFrame(drawDmrSynthesizer);
|
|
}
|
|
|
|
function dmrSynthPulse(type) {
|
|
dmrLastEventTime = Date.now();
|
|
|
|
if (type === 'sync') {
|
|
dmrActivityTarget = Math.max(dmrActivityTarget, DMR_BURST_SYNC);
|
|
dmrEventType = 'sync';
|
|
} else if (type === 'call') {
|
|
dmrActivityTarget = DMR_BURST_CALL;
|
|
dmrEventType = 'call';
|
|
} else if (type === 'voice') {
|
|
dmrActivityTarget = DMR_BURST_VOICE;
|
|
dmrEventType = 'voice';
|
|
} else if (type === 'slot' || type === 'nac') {
|
|
dmrActivityTarget = Math.max(dmrActivityTarget, 0.5);
|
|
} else if (type === 'raw') {
|
|
// Any DSD output means the decoder is alive and processing
|
|
dmrActivityTarget = Math.max(dmrActivityTarget, 0.25);
|
|
if (dmrEventType === 'idle') dmrEventType = 'raw';
|
|
}
|
|
// keepalive and status don't change visuals
|
|
|
|
updateDmrSynthStatus();
|
|
}
|
|
|
|
function updateDmrSynthStatus() {
|
|
const el = document.getElementById('dmrSynthStatus');
|
|
if (!el) return;
|
|
|
|
const labels = {
|
|
stopped: 'STOPPED',
|
|
idle: 'IDLE',
|
|
raw: 'LISTENING',
|
|
sync: 'SYNC',
|
|
call: 'CALL',
|
|
voice: 'VOICE'
|
|
};
|
|
const colors = {
|
|
stopped: 'var(--text-muted)',
|
|
idle: 'var(--text-muted)',
|
|
raw: '#607d8b',
|
|
sync: '#00e5ff',
|
|
call: '#4caf50',
|
|
voice: '#ff9800'
|
|
};
|
|
|
|
el.textContent = labels[dmrEventType] || 'IDLE';
|
|
el.style.color = colors[dmrEventType] || 'var(--text-muted)';
|
|
}
|
|
|
|
function resizeDmrSynthesizer() {
|
|
if (!dmrSynthCanvas) return;
|
|
const rect = dmrSynthCanvas.getBoundingClientRect();
|
|
if (rect.width > 0) {
|
|
dmrSynthCanvas.width = Math.round(rect.width);
|
|
dmrSynthCanvas.height = Math.round(rect.height) || 70;
|
|
}
|
|
}
|
|
|
|
function stopDmrSynthesizer() {
|
|
if (dmrSynthAnimationId) {
|
|
cancelAnimationFrame(dmrSynthAnimationId);
|
|
dmrSynthAnimationId = null;
|
|
}
|
|
}
|
|
|
|
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 = () => {
|
|
// Retry if decoder is still running (stream may have dropped)
|
|
if (isDmrRunning && dmrHasAudio) {
|
|
console.warn('[DMR] Audio stream error, retrying in 2s...');
|
|
updateDmrAudioStatus('RECONNECTING');
|
|
setTimeout(() => {
|
|
if (isDmrRunning && dmrHasAudio) startDmrAudio();
|
|
}, 2000);
|
|
} else {
|
|
updateDmrAudioStatus('OFF');
|
|
}
|
|
};
|
|
|
|
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);
|
|
const parsed = saved ? JSON.parse(saved) : [];
|
|
if (!Array.isArray(parsed)) {
|
|
dmrBookmarks = [];
|
|
} else {
|
|
dmrBookmarks = parsed
|
|
.map((entry) => {
|
|
const freq = Number(entry?.freq);
|
|
if (!Number.isFinite(freq) || freq <= 0) return null;
|
|
const protocol = sanitizeDmrBookmarkProtocol(entry?.protocol);
|
|
const rawLabel = String(entry?.label || '').trim();
|
|
const label = rawLabel || `${freq.toFixed(4)} MHz`;
|
|
return {
|
|
freq,
|
|
protocol,
|
|
label,
|
|
added: entry?.added,
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
} catch (e) {
|
|
dmrBookmarks = [];
|
|
}
|
|
renderDmrBookmarks();
|
|
}
|
|
|
|
function saveDmrBookmarks() {
|
|
try {
|
|
localStorage.setItem(DMR_BOOKMARKS_KEY, JSON.stringify(dmrBookmarks));
|
|
} catch (e) { /* localStorage unavailable */ }
|
|
}
|
|
|
|
function sanitizeDmrBookmarkProtocol(protocol) {
|
|
const value = String(protocol || 'auto').toLowerCase();
|
|
return DMR_BOOKMARK_PROTOCOLS.has(value) ? value : 'auto';
|
|
}
|
|
|
|
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 = sanitizeDmrBookmarkProtocol(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 && Number.isFinite(freq)) freqEl.value = freq;
|
|
if (protoEl) protoEl.value = sanitizeDmrBookmarkProtocol(protocol);
|
|
}
|
|
|
|
function renderDmrBookmarks() {
|
|
const container = document.getElementById('dmrBookmarksList');
|
|
if (!container) return;
|
|
|
|
container.replaceChildren();
|
|
|
|
if (dmrBookmarks.length === 0) {
|
|
const emptyEl = document.createElement('div');
|
|
emptyEl.style.color = 'var(--text-muted)';
|
|
emptyEl.style.textAlign = 'center';
|
|
emptyEl.style.padding = '10px';
|
|
emptyEl.style.fontSize = '11px';
|
|
emptyEl.textContent = 'No bookmarks saved';
|
|
container.appendChild(emptyEl);
|
|
return;
|
|
}
|
|
|
|
dmrBookmarks.forEach((b, i) => {
|
|
const row = document.createElement('div');
|
|
row.style.display = 'flex';
|
|
row.style.justifyContent = 'space-between';
|
|
row.style.alignItems = 'center';
|
|
row.style.padding = '4px 6px';
|
|
row.style.background = 'rgba(0,0,0,0.2)';
|
|
row.style.borderRadius = '3px';
|
|
row.style.marginBottom = '3px';
|
|
|
|
const tuneBtn = document.createElement('button');
|
|
tuneBtn.type = 'button';
|
|
tuneBtn.style.cursor = 'pointer';
|
|
tuneBtn.style.color = 'var(--accent-cyan)';
|
|
tuneBtn.style.fontSize = '11px';
|
|
tuneBtn.style.flex = '1';
|
|
tuneBtn.style.background = 'none';
|
|
tuneBtn.style.border = 'none';
|
|
tuneBtn.style.textAlign = 'left';
|
|
tuneBtn.style.padding = '0';
|
|
tuneBtn.textContent = b.label;
|
|
tuneBtn.title = `${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})`;
|
|
tuneBtn.addEventListener('click', () => dmrQuickTune(b.freq, b.protocol));
|
|
|
|
const protocolEl = document.createElement('span');
|
|
protocolEl.style.color = 'var(--text-muted)';
|
|
protocolEl.style.fontSize = '9px';
|
|
protocolEl.style.margin = '0 6px';
|
|
protocolEl.textContent = b.protocol.toUpperCase();
|
|
|
|
const deleteBtn = document.createElement('button');
|
|
deleteBtn.type = 'button';
|
|
deleteBtn.style.background = 'none';
|
|
deleteBtn.style.border = 'none';
|
|
deleteBtn.style.color = 'var(--accent-red)';
|
|
deleteBtn.style.cursor = 'pointer';
|
|
deleteBtn.style.fontSize = '12px';
|
|
deleteBtn.style.padding = '0 4px';
|
|
deleteBtn.textContent = '\u00d7';
|
|
deleteBtn.addEventListener('click', () => removeDmrBookmark(i));
|
|
|
|
row.appendChild(tuneBtn);
|
|
row.appendChild(protocolEl);
|
|
row.appendChild(deleteBtn);
|
|
container.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// ============== STATUS SYNC ==============
|
|
|
|
function checkDmrStatus() {
|
|
fetch('/dmr/status')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.running && !isDmrRunning) {
|
|
// Backend is running but frontend lost track — resync
|
|
isDmrRunning = true;
|
|
updateDmrUI();
|
|
connectDmrSSE();
|
|
if (!dmrSynthInitialized) initDmrSynthesizer();
|
|
dmrEventType = 'idle';
|
|
dmrActivityTarget = 0.1;
|
|
dmrLastEventTime = Date.now();
|
|
updateDmrSynthStatus();
|
|
const statusEl = document.getElementById('dmrStatus');
|
|
if (statusEl) statusEl.textContent = 'DECODING';
|
|
} else if (!data.running && isDmrRunning) {
|
|
// Backend stopped but frontend didn't know
|
|
isDmrRunning = false;
|
|
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
|
|
updateDmrUI();
|
|
dmrEventType = 'stopped';
|
|
dmrActivityTarget = 0;
|
|
updateDmrSynthStatus();
|
|
const statusEl = document.getElementById('dmrStatus');
|
|
if (statusEl) statusEl.textContent = 'STOPPED';
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
// ============== INIT ==============
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
restoreDmrSettings();
|
|
loadDmrBookmarks();
|
|
});
|
|
|
|
// ============== EXPORTS ==============
|
|
|
|
window.startDmr = startDmr;
|
|
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;
|