mirror of
https://github.com/smittix/intercept.git
synced 2026-07-12 11:38:12 -07:00
Add canvas-based visual synthesizer to DMR dashboard
Event-driven spring-physics bar visualization reacting to SSE events (sync/call/voice) with HSL color coding and center-outward ripple effects. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+252
-1
@@ -11,6 +11,22 @@ let dmrSyncCount = 0;
|
|||||||
let dmrCallHistory = [];
|
let dmrCallHistory = [];
|
||||||
let dmrCurrentProtocol = '--';
|
let dmrCurrentProtocol = '--';
|
||||||
|
|
||||||
|
// ============== 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 ==============
|
// ============== TOOLS CHECK ==============
|
||||||
|
|
||||||
function checkDmrTools() {
|
function checkDmrTools() {
|
||||||
@@ -57,6 +73,11 @@ function startDmr() {
|
|||||||
dmrCallHistory = [];
|
dmrCallHistory = [];
|
||||||
updateDmrUI();
|
updateDmrUI();
|
||||||
connectDmrSSE();
|
connectDmrSSE();
|
||||||
|
dmrEventType = 'idle';
|
||||||
|
dmrActivityTarget = 0.1;
|
||||||
|
dmrLastEventTime = Date.now();
|
||||||
|
if (!dmrSynthInitialized) initDmrSynthesizer();
|
||||||
|
updateDmrSynthStatus();
|
||||||
const statusEl = document.getElementById('dmrStatus');
|
const statusEl = document.getElementById('dmrStatus');
|
||||||
if (statusEl) statusEl.textContent = 'DECODING';
|
if (statusEl) statusEl.textContent = 'DECODING';
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
@@ -78,8 +99,11 @@ function stopDmr() {
|
|||||||
isDmrRunning = false;
|
isDmrRunning = false;
|
||||||
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
|
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
|
||||||
updateDmrUI();
|
updateDmrUI();
|
||||||
|
dmrEventType = 'stopped';
|
||||||
|
dmrActivityTarget = 0;
|
||||||
|
updateDmrSynthStatus();
|
||||||
const statusEl = document.getElementById('dmrStatus');
|
const statusEl = document.getElementById('dmrStatus');
|
||||||
if (statusEl) statusEl.textContent = 'IDLE';
|
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||||
})
|
})
|
||||||
.catch(err => console.error('[DMR] Stop error:', err));
|
.catch(err => console.error('[DMR] Stop error:', err));
|
||||||
}
|
}
|
||||||
@@ -103,6 +127,8 @@ function connectDmrSSE() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDmrMessage(msg) {
|
function handleDmrMessage(msg) {
|
||||||
|
if (dmrSynthInitialized) dmrSynthPulse(msg.type);
|
||||||
|
|
||||||
if (msg.type === 'sync') {
|
if (msg.type === 'sync') {
|
||||||
dmrCurrentProtocol = msg.protocol || '--';
|
dmrCurrentProtocol = msg.protocol || '--';
|
||||||
const protocolEl = document.getElementById('dmrActiveProtocol');
|
const protocolEl = document.getElementById('dmrActiveProtocol');
|
||||||
@@ -193,8 +219,233 @@ function renderDmrHistory() {
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============== SYNTHESIZER ==============
|
||||||
|
|
||||||
|
function initDmrSynthesizer() {
|
||||||
|
dmrSynthCanvas = document.getElementById('dmrSynthCanvas');
|
||||||
|
if (!dmrSynthCanvas) return;
|
||||||
|
|
||||||
|
const rect = dmrSynthCanvas.parentElement.getBoundingClientRect();
|
||||||
|
dmrSynthCanvas.width = rect.width - 20;
|
||||||
|
dmrSynthCanvas.height = 70;
|
||||||
|
|
||||||
|
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
|
||||||
|
const timeSinceEvent = now - dmrLastEventTime;
|
||||||
|
if (timeSinceEvent > 2000) {
|
||||||
|
// No events for 2s — decay target toward idle
|
||||||
|
dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE);
|
||||||
|
if (dmrActivityTarget < 0.05 && dmrEventType !== 'stopped') {
|
||||||
|
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.05 && isDmrRunning) {
|
||||||
|
// Gentle idle breathing
|
||||||
|
effectiveActivity = 0.05 + Math.sin(now / 800) * 0.035;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
// keepalive and status don't change visuals
|
||||||
|
|
||||||
|
updateDmrSynthStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDmrSynthStatus() {
|
||||||
|
const el = document.getElementById('dmrSynthStatus');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
stopped: 'STOPPED',
|
||||||
|
idle: 'IDLE',
|
||||||
|
sync: 'SYNC',
|
||||||
|
call: 'CALL',
|
||||||
|
voice: 'VOICE'
|
||||||
|
};
|
||||||
|
const colors = {
|
||||||
|
stopped: 'var(--text-muted)',
|
||||||
|
idle: 'var(--text-muted)',
|
||||||
|
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.parentElement.getBoundingClientRect();
|
||||||
|
if (rect.width > 0) {
|
||||||
|
dmrSynthCanvas.width = rect.width - 20;
|
||||||
|
dmrSynthCanvas.height = 70;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDmrSynthesizer() {
|
||||||
|
if (dmrSynthAnimationId) {
|
||||||
|
cancelAnimationFrame(dmrSynthAnimationId);
|
||||||
|
dmrSynthAnimationId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', resizeDmrSynthesizer);
|
||||||
|
|
||||||
// ============== EXPORTS ==============
|
// ============== EXPORTS ==============
|
||||||
|
|
||||||
window.startDmr = startDmr;
|
window.startDmr = startDmr;
|
||||||
window.stopDmr = stopDmr;
|
window.stopDmr = stopDmr;
|
||||||
window.checkDmrTools = checkDmrTools;
|
window.checkDmrTools = checkDmrTools;
|
||||||
|
window.initDmrSynthesizer = initDmrSynthesizer;
|
||||||
|
|||||||
@@ -1616,6 +1616,14 @@
|
|||||||
|
|
||||||
<!-- DMR / Digital Voice Dashboard -->
|
<!-- DMR / Digital Voice Dashboard -->
|
||||||
<div id="dmrVisuals" style="display: none; padding: 12px; flex-direction: column; gap: 12px; flex: 1; min-height: 0;">
|
<div id="dmrVisuals" style="display: none; padding: 12px; flex-direction: column; gap: 12px; flex: 1; min-height: 0;">
|
||||||
|
<!-- DMR Synthesizer -->
|
||||||
|
<div class="radio-module-box" style="padding: 10px;">
|
||||||
|
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px;">
|
||||||
|
<span>SIGNAL ACTIVITY</span>
|
||||||
|
<span id="dmrSynthStatus" style="color: var(--text-muted); font-size: 9px; font-family: var(--font-mono);">IDLE</span>
|
||||||
|
</div>
|
||||||
|
<canvas id="dmrSynthCanvas" style="width: 100%; height: 70px; background: rgba(0,0,0,0.4); border-radius: 4px; display: block;"></canvas>
|
||||||
|
</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;">
|
||||||
@@ -3057,6 +3065,7 @@
|
|||||||
SSTVGeneral.init();
|
SSTVGeneral.init();
|
||||||
} else if (mode === 'dmr') {
|
} else if (mode === 'dmr') {
|
||||||
if (typeof checkDmrTools === 'function') checkDmrTools();
|
if (typeof checkDmrTools === 'function') checkDmrTools();
|
||||||
|
if (typeof initDmrSynthesizer === 'function') setTimeout(initDmrSynthesizer, 100);
|
||||||
} else if (mode === 'websdr') {
|
} else if (mode === 'websdr') {
|
||||||
if (typeof initWebSDR === 'function') initWebSDR();
|
if (typeof initWebSDR === 'function') initWebSDR();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user