diff --git a/static/js/modes/dmr.js b/static/js/modes/dmr.js index 69888ae..6ff1dbd 100644 --- a/static/js/modes/dmr.js +++ b/static/js/modes/dmr.js @@ -11,6 +11,22 @@ let dmrSyncCount = 0; let dmrCallHistory = []; 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 ============== function checkDmrTools() { @@ -57,6 +73,11 @@ function startDmr() { 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 showNotification === 'function') { @@ -78,8 +99,11 @@ function stopDmr() { isDmrRunning = false; if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; } updateDmrUI(); + dmrEventType = 'stopped'; + dmrActivityTarget = 0; + updateDmrSynthStatus(); const statusEl = document.getElementById('dmrStatus'); - if (statusEl) statusEl.textContent = 'IDLE'; + if (statusEl) statusEl.textContent = 'STOPPED'; }) .catch(err => console.error('[DMR] Stop error:', err)); } @@ -103,6 +127,8 @@ function connectDmrSSE() { } function handleDmrMessage(msg) { + if (dmrSynthInitialized) dmrSynthPulse(msg.type); + if (msg.type === 'sync') { dmrCurrentProtocol = msg.protocol || '--'; const protocolEl = document.getElementById('dmrActiveProtocol'); @@ -193,8 +219,233 @@ function renderDmrHistory() { `).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 ============== window.startDmr = startDmr; window.stopDmr = stopDmr; window.checkDmrTools = checkDmrTools; +window.initDmrSynthesizer = initDmrSynthesizer; diff --git a/templates/index.html b/templates/index.html index 4aa7e43..30d1128 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1616,6 +1616,14 @@