my-sea voice: volume-reactive equalizer glow + muted (--priRd/.fa-ban) state — TDD

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 <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-29 21:46:08 -04:00
parent f0b9f02c7c
commit 92d46b3dce
7 changed files with 214 additions and 20 deletions

View File

@@ -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;
},
};
}

View File

@@ -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 <audio> el
this._analysers[peerId] = an;
} catch (e) { /* analyser is best-effort; glow falls back to a pulse */ }
};
// Loudest current RMS across all peer analysers, scaled to ~0..1 for the
// glow (speech RMS is small, so ×3 then clamp). 0 when no analyser/audio.
VoiceRoom.prototype.inputLevel = function () {
var ids = Object.keys(this._analysers);
var max = 0;
for (var i = 0; i < ids.length; i++) {
var an = this._analysers[ids[i]];
if (!an) continue;
var buf = new Uint8Array(an.fftSize);
an.getByteTimeDomainData(buf);
var sum = 0;
for (var j = 0; j < buf.length; j++) {
var v = (buf[j] - 128) / 128;
sum += v * v;
}
var rms = Math.sqrt(sum / buf.length);
if (rms > max) max = rms;
}
return Math.max(0, Math.min(1, max * 3));
};
// ── State-change notification (drives the glow machine) ───────────────────
VoiceRoom.prototype.peerCount = function () {
@@ -95,6 +146,7 @@
document.body.appendChild(el);
}
el.srcObject = e.streams[0];
self._attachAnalyser(peerId, e.streams[0]);
};
this.peers[peerId] = pc;
this._notify(); // a peer joined the mesh
@@ -130,6 +182,7 @@
if (pc) { try { pc.close(); } catch (e) {} delete this.peers[peerId]; }
var el = document.getElementById('voice-audio-' + peerId);
if (el && el.parentNode) el.parentNode.removeChild(el);
delete this._analysers[peerId];
this._notify(); // a peer left the mesh
};
@@ -236,6 +289,11 @@
this.localStream.getTracks().forEach(function (t) { t.stop(); });
this.localStream = null;
}
this._analysers = {};
if (this._audioCtx) {
try { this._audioCtx.close(); } catch (e) {}
this._audioCtx = null;
}
this.muted = false; // a rejoin starts unmuted
this._notify(); // call ended — back to not-in-call
};