diff --git a/src/apps/voice/static/apps/voice/voice-glow.js b/src/apps/voice/static/apps/voice/voice-glow.js index d02df40..a3d9e8b 100644 --- a/src/apps/voice/static/apps/voice/voice-glow.js +++ b/src/apps/voice/static/apps/voice/voice-glow.js @@ -25,7 +25,7 @@ (function () { 'use strict'; - var OUR_CLASSES = ['voice-glow', 'voice-pulse', 'voice-pulse--fast']; + var OUR_CLASSES = ['voice-glow', 'voice-pulse', 'voice-eq', 'voice-muted']; function bindVoiceGlow() { var burger = document.getElementById('id_burger_btn'); @@ -35,7 +35,9 @@ var inCall = false; var peerCount = 0; + var muted = false; var observers = []; + var _raf = 0; // Reconcile el's OUR_CLASSES to exactly `want` — only mutate on an // actual diff so the MutationObserver below doesn't loop on our own @@ -57,8 +59,16 @@ var burgerOpen = burger.classList.contains('active'); var live = inCall || voice.classList.contains('in-call'); if (live) { - var pulse = peerCount > 0 ? 'voice-pulse--fast' : 'voice-pulse'; - (burgerOpen ? voiceWant : burgerWant).push('voice-glow', pulse); + // Mic live → the glow lands on the showing surface (voice + // sub-btn fan-open, burger fan-closed). Others connected → + // a volume-reactive equalizer (.voice-eq, fed --voice-level + // by the RAF loop); alone → a steady pulse. Muted recolors + // the lot to --priRd + flips the sub-btn icon to .fa-ban + // (the "disconnected from talking" cue, item 7). + var want = ['voice-glow', peerCount > 0 ? 'voice-eq' : 'voice-pulse']; + if (muted) want.push('voice-muted'); + var arr = burgerOpen ? voiceWant : burgerWant; + want.forEach(function (c) { arr.push(c); }); } else { var seaGlow = (sea && sea.classList.contains('glow-handoff')) || burger.classList.contains('glow-handoff'); @@ -69,17 +79,45 @@ _apply(voice, voiceWant); _apply(burger, burgerWant); + _ensureEqLoop(); + } + + // While an .voice-eq surface is showing, drive its --voice-level CSS + // var off the mesh's incoming RMS each frame (the glow box-shadow + // scales with it). Self-stops when no .voice-eq remains. + function _eqTick() { + var lvl = (window.VoiceRoom && window.VoiceRoom.inputLevel) + ? window.VoiceRoom.inputLevel() : 0; + var anyEq = false; + [voice, burger].forEach(function (el) { + if (el.classList.contains('voice-eq')) { + el.style.setProperty('--voice-level', lvl.toFixed(3)); + anyEq = true; + } else { + el.style.removeProperty('--voice-level'); + } + }); + _raf = (anyEq && typeof requestAnimationFrame === 'function') + ? requestAnimationFrame(_eqTick) : 0; + } + + function _ensureEqLoop() { + var anyEq = voice.classList.contains('voice-eq') + || burger.classList.contains('voice-eq'); + if (anyEq && !_raf && typeof requestAnimationFrame === 'function') { + _raf = requestAnimationFrame(_eqTick); + } } function setVoiceState(st) { st = st || {}; inCall = !!st.inCall; peerCount = st.peerCount || 0; + muted = inCall && !!st.muted; // Keep the voice btn's own flags in sync with the mesh truth so a // rejoin starts clean (dataset.inCall gates join-vs-mute in // burger-btn.js) and the render fallback can't get stuck "live". voice.classList.toggle('in-call', inCall); - voice.classList.toggle('muted', inCall && !!st.muted); if (!inCall) { delete voice.dataset.inCall; } render(); } @@ -118,6 +156,10 @@ destroy: function () { observers.forEach(function (o) { o.disconnect(); }); observers = []; + if (_raf && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(_raf); + } + _raf = 0; }, }; } diff --git a/src/apps/voice/static/apps/voice/voice-mesh.js b/src/apps/voice/static/apps/voice/voice-mesh.js index 5a00f04..0d4d325 100644 --- a/src/apps/voice/static/apps/voice/voice-mesh.js +++ b/src/apps/voice/static/apps/voice/voice-mesh.js @@ -44,8 +44,59 @@ // burger/voice-btn glow machine off it). Fired on join, every peer // add/drop, mute toggle, and teardown. this._onState = null; + // Web Audio analysers per peer — power the volume-reactive equalizer + // glow (item 5). One AnalyserNode taps each incoming peer stream; + // `inputLevel()` returns the loudest current RMS across them. + this._audioCtx = null; + this._analysers = {}; // peerId → AnalyserNode } + // ── Incoming-volume equalizer (drives voice-glow's --voice-level) ───────── + + VoiceRoom.prototype._ensureAudioCtx = function () { + if (this._audioCtx) return this._audioCtx; + var Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return null; + try { + this._audioCtx = new Ctx(); + if (this._audioCtx.resume) this._audioCtx.resume(); + } catch (e) { this._audioCtx = null; } + return this._audioCtx; + }; + + VoiceRoom.prototype._attachAnalyser = function (peerId, stream) { + try { + var ctx = this._ensureAudioCtx(); + if (!ctx) return; + var src = ctx.createMediaStreamSource(stream); + var an = ctx.createAnalyser(); + an.fftSize = 256; + src.connect(an); // analyser only — playback stays on the