From 92d46b3dce14c917bda941928be068a83fcc43cd Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 29 May 2026 21:46:08 -0400 Subject: [PATCH] =?UTF-8?q?my-sea=20voice:=20volume-reactive=20equalizer?= =?UTF-8?q?=20glow=20+=20muted=20(--priRd/.fa-ban)=20state=20=E2=80=94=20T?= =?UTF-8?q?DD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5b of the my-sea voice batch. Resolves the Bug-B-vs-item-7 conflict per user call 2026-05-29: a click while connected MUTES (leaving stays on BYE/NVM), and the item-7 "--priRd/.fa-ban disconnected" visual maps onto the MUTED state. Replaces Phase 3's 2x-pulse stand-in with a real Web-Audio equalizer. - voice-mesh.js: an AnalyserNode taps each incoming peer stream (lazy AudioContext); `inputLevel()` returns the loudest current RMS (~0..1) across peers. Analysers torn down per-peer + on call end. - voice-glow.js: the live-mic glow now resolves to — alone → `.voice-pulse` (steady 2s cadence); others connected → `.voice-eq`, whose `--voice-level` CSS var is fed each frame from inputLevel() via a self-stopping rAF loop; muted → `.voice-muted` modifier on either (recolor only). rAF cancelled on destroy. - _burger.scss: `.voice-eq` box-shadow spread+alpha scale with `--voice-level` (no keyframe — audio drives it); `.voice-muted` recolors to --priRd (halo stays --ninUser) + flips the voice sub-btn icon to .fa-ban. Drops the unused `.voice-pulse--fast`. - VoiceGlowSpec: +4 specs (equalizer swap, muted-alone, muted-with-others, unmute clears); VoiceMeshSpec: +1 (inputLevel 0 with no analysers). 438 Jasmine specs green. Live-verify on staging (audio can't be auto-tested): the equalizer reacting to real speech + the muted/unmuted colour swap on a live call. Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Opus 4.8 (1M context) --- .../voice/static/apps/voice/voice-glow.js | 50 ++++++++++++++-- .../voice/static/apps/voice/voice-mesh.js | 58 +++++++++++++++++++ src/static/tests/VoiceGlowSpec.js | 35 +++++++++-- src/static/tests/VoiceMeshSpec.js | 12 ++++ src/static_src/scss/_burger.scss | 32 ++++++++-- src/static_src/tests/VoiceGlowSpec.js | 35 +++++++++-- src/static_src/tests/VoiceMeshSpec.js | 12 ++++ 7 files changed, 214 insertions(+), 20 deletions(-) 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