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:
@@ -25,7 +25,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'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() {
|
function bindVoiceGlow() {
|
||||||
var burger = document.getElementById('id_burger_btn');
|
var burger = document.getElementById('id_burger_btn');
|
||||||
@@ -35,7 +35,9 @@
|
|||||||
|
|
||||||
var inCall = false;
|
var inCall = false;
|
||||||
var peerCount = 0;
|
var peerCount = 0;
|
||||||
|
var muted = false;
|
||||||
var observers = [];
|
var observers = [];
|
||||||
|
var _raf = 0;
|
||||||
|
|
||||||
// Reconcile el's OUR_CLASSES to exactly `want` — only mutate on an
|
// Reconcile el's OUR_CLASSES to exactly `want` — only mutate on an
|
||||||
// actual diff so the MutationObserver below doesn't loop on our own
|
// actual diff so the MutationObserver below doesn't loop on our own
|
||||||
@@ -57,8 +59,16 @@
|
|||||||
var burgerOpen = burger.classList.contains('active');
|
var burgerOpen = burger.classList.contains('active');
|
||||||
var live = inCall || voice.classList.contains('in-call');
|
var live = inCall || voice.classList.contains('in-call');
|
||||||
if (live) {
|
if (live) {
|
||||||
var pulse = peerCount > 0 ? 'voice-pulse--fast' : 'voice-pulse';
|
// Mic live → the glow lands on the showing surface (voice
|
||||||
(burgerOpen ? voiceWant : burgerWant).push('voice-glow', pulse);
|
// 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 {
|
} else {
|
||||||
var seaGlow = (sea && sea.classList.contains('glow-handoff'))
|
var seaGlow = (sea && sea.classList.contains('glow-handoff'))
|
||||||
|| burger.classList.contains('glow-handoff');
|
|| burger.classList.contains('glow-handoff');
|
||||||
@@ -69,17 +79,45 @@
|
|||||||
|
|
||||||
_apply(voice, voiceWant);
|
_apply(voice, voiceWant);
|
||||||
_apply(burger, burgerWant);
|
_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) {
|
function setVoiceState(st) {
|
||||||
st = st || {};
|
st = st || {};
|
||||||
inCall = !!st.inCall;
|
inCall = !!st.inCall;
|
||||||
peerCount = st.peerCount || 0;
|
peerCount = st.peerCount || 0;
|
||||||
|
muted = inCall && !!st.muted;
|
||||||
// Keep the voice btn's own flags in sync with the mesh truth so a
|
// 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
|
// rejoin starts clean (dataset.inCall gates join-vs-mute in
|
||||||
// burger-btn.js) and the render fallback can't get stuck "live".
|
// burger-btn.js) and the render fallback can't get stuck "live".
|
||||||
voice.classList.toggle('in-call', inCall);
|
voice.classList.toggle('in-call', inCall);
|
||||||
voice.classList.toggle('muted', inCall && !!st.muted);
|
|
||||||
if (!inCall) { delete voice.dataset.inCall; }
|
if (!inCall) { delete voice.dataset.inCall; }
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@@ -118,6 +156,10 @@
|
|||||||
destroy: function () {
|
destroy: function () {
|
||||||
observers.forEach(function (o) { o.disconnect(); });
|
observers.forEach(function (o) { o.disconnect(); });
|
||||||
observers = [];
|
observers = [];
|
||||||
|
if (_raf && typeof cancelAnimationFrame === 'function') {
|
||||||
|
cancelAnimationFrame(_raf);
|
||||||
|
}
|
||||||
|
_raf = 0;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,59 @@
|
|||||||
// burger/voice-btn glow machine off it). Fired on join, every peer
|
// burger/voice-btn glow machine off it). Fired on join, every peer
|
||||||
// add/drop, mute toggle, and teardown.
|
// add/drop, mute toggle, and teardown.
|
||||||
this._onState = null;
|
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) ───────────────────
|
// ── State-change notification (drives the glow machine) ───────────────────
|
||||||
|
|
||||||
VoiceRoom.prototype.peerCount = function () {
|
VoiceRoom.prototype.peerCount = function () {
|
||||||
@@ -95,6 +146,7 @@
|
|||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
}
|
}
|
||||||
el.srcObject = e.streams[0];
|
el.srcObject = e.streams[0];
|
||||||
|
self._attachAnalyser(peerId, e.streams[0]);
|
||||||
};
|
};
|
||||||
this.peers[peerId] = pc;
|
this.peers[peerId] = pc;
|
||||||
this._notify(); // a peer joined the mesh
|
this._notify(); // a peer joined the mesh
|
||||||
@@ -130,6 +182,7 @@
|
|||||||
if (pc) { try { pc.close(); } catch (e) {} delete this.peers[peerId]; }
|
if (pc) { try { pc.close(); } catch (e) {} delete this.peers[peerId]; }
|
||||||
var el = document.getElementById('voice-audio-' + peerId);
|
var el = document.getElementById('voice-audio-' + peerId);
|
||||||
if (el && el.parentNode) el.parentNode.removeChild(el);
|
if (el && el.parentNode) el.parentNode.removeChild(el);
|
||||||
|
delete this._analysers[peerId];
|
||||||
this._notify(); // a peer left the mesh
|
this._notify(); // a peer left the mesh
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -236,6 +289,11 @@
|
|||||||
this.localStream.getTracks().forEach(function (t) { t.stop(); });
|
this.localStream.getTracks().forEach(function (t) { t.stop(); });
|
||||||
this.localStream = null;
|
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.muted = false; // a rejoin starts unmuted
|
||||||
this._notify(); // call ended — back to not-in-call
|
this._notify(); // call ended — back to not-in-call
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ describe("VoiceGlow", () => {
|
|||||||
|
|
||||||
const hasGlow = (el) => el.classList.contains("voice-glow");
|
const hasGlow = (el) => el.classList.contains("voice-glow");
|
||||||
const hasPulse = (el) => el.classList.contains("voice-pulse");
|
const hasPulse = (el) => el.classList.contains("voice-pulse");
|
||||||
const hasFast = (el) => el.classList.contains("voice-pulse--fast");
|
const hasEq = (el) => el.classList.contains("voice-eq");
|
||||||
|
const hasMuted = (el) => el.classList.contains("voice-muted");
|
||||||
|
|
||||||
describe("bindVoiceGlow()", () => {
|
describe("bindVoiceGlow()", () => {
|
||||||
it("returns null when the burger btn is absent", () => {
|
it("returns null when the burger btn is absent", () => {
|
||||||
@@ -102,7 +103,7 @@ describe("VoiceGlow", () => {
|
|||||||
vg.setVoiceState({ inCall: true, peerCount: 0 });
|
vg.setVoiceState({ inCall: true, peerCount: 0 });
|
||||||
expect(hasGlow(burger)).toBe(true);
|
expect(hasGlow(burger)).toBe(true);
|
||||||
expect(hasPulse(burger)).toBe(true);
|
expect(hasPulse(burger)).toBe(true);
|
||||||
expect(hasFast(burger)).toBe(false);
|
expect(hasEq(burger)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pulses the voice sub-btn while alone + fan open", () => {
|
it("pulses the voice sub-btn while alone + fan open", () => {
|
||||||
@@ -113,13 +114,35 @@ describe("VoiceGlow", () => {
|
|||||||
expect(hasPulse(burger)).toBe(false);
|
expect(hasPulse(burger)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("doubles the cadence once a 2nd party connects", () => {
|
it("switches to the equalizer once a 2nd party connects", () => {
|
||||||
vg = bindVoiceGlow();
|
vg = bindVoiceGlow();
|
||||||
vg.setVoiceState({ inCall: true, peerCount: 1 });
|
vg.setVoiceState({ inCall: true, peerCount: 1 });
|
||||||
expect(hasFast(burger)).toBe(true);
|
expect(hasEq(burger)).toBe(true);
|
||||||
expect(hasPulse(burger)).toBe(false);
|
expect(hasPulse(burger)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("recolors to muted (--priRd) when the mic is muted, both alone…", () => {
|
||||||
|
vg = bindVoiceGlow();
|
||||||
|
vg.setVoiceState({ inCall: true, peerCount: 0, muted: true });
|
||||||
|
expect(hasPulse(burger)).toBe(true); // still pulsing (alone)
|
||||||
|
expect(hasMuted(burger)).toBe(true); // …but in the muted colour
|
||||||
|
});
|
||||||
|
|
||||||
|
it("…and while the equalizer runs with others connected", () => {
|
||||||
|
vg = bindVoiceGlow();
|
||||||
|
vg.setVoiceState({ inCall: true, peerCount: 2, muted: true });
|
||||||
|
expect(hasEq(burger)).toBe(true);
|
||||||
|
expect(hasMuted(burger)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the muted colour on unmute", () => {
|
||||||
|
vg = bindVoiceGlow();
|
||||||
|
vg.setVoiceState({ inCall: true, peerCount: 0, muted: true });
|
||||||
|
expect(hasMuted(burger)).toBe(true);
|
||||||
|
vg.setVoiceState({ inCall: true, peerCount: 0, muted: false });
|
||||||
|
expect(hasMuted(burger)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("hands the pulse to whichever surface is showing (fan toggles)", () => {
|
it("hands the pulse to whichever surface is showing (fan toggles)", () => {
|
||||||
vg = bindVoiceGlow();
|
vg = bindVoiceGlow();
|
||||||
vg.setVoiceState({ inCall: true, peerCount: 0 });
|
vg.setVoiceState({ inCall: true, peerCount: 0 });
|
||||||
@@ -145,8 +168,8 @@ describe("VoiceGlow", () => {
|
|||||||
vg = bindVoiceGlow();
|
vg = bindVoiceGlow();
|
||||||
vg.setVoiceState({ inCall: true, peerCount: 1 });
|
vg.setVoiceState({ inCall: true, peerCount: 1 });
|
||||||
vg.render(); vg.render();
|
vg.render(); vg.render();
|
||||||
const fastCount = (burger.className.match(/voice-pulse--fast/g) || []).length;
|
const eqCount = (burger.className.match(/voice-eq/g) || []).length;
|
||||||
expect(fastCount).toBe(1);
|
expect(eqCount).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,6 +116,18 @@ describe('voice-mesh join guard', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Equalizer input level — the deterministic edge (no analysers → 0). The
|
||||||
|
// volume-reactive readout itself needs a live mesh + real audio to verify.
|
||||||
|
describe('voice-mesh inputLevel', function () {
|
||||||
|
it('is 0 when no peer analysers are attached', function () {
|
||||||
|
var vr = window.VoiceRoom;
|
||||||
|
var saved = vr._analysers;
|
||||||
|
vr._analysers = {};
|
||||||
|
expect(vr.inputLevel()).toBe(0);
|
||||||
|
vr._analysers = saved;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// bindVoiceBtn (burger-btn.js) must surface a failed join + roll back the
|
// bindVoiceBtn (burger-btn.js) must surface a failed join + roll back the
|
||||||
// optimistic in-call state so the next click can retry.
|
// optimistic in-call state so the next click can retry.
|
||||||
describe('voice btn join failure surfacing', function () {
|
describe('voice btn join failure surfacing', function () {
|
||||||
|
|||||||
@@ -224,10 +224,34 @@
|
|||||||
#id_burger_btn.voice-pulse {
|
#id_burger_btn.voice-pulse {
|
||||||
animation: voice-pulse 2s ease-in-out infinite;
|
animation: voice-pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
// Doubled cadence once a 2nd party is connected.
|
|
||||||
#id_voice_btn.voice-pulse--fast,
|
// Equalizer — once a 2nd+ party is connected the halo tracks the loudest
|
||||||
#id_burger_btn.voice-pulse--fast {
|
// incoming voice. voice-glow.js feeds `--voice-level` (0..1) each frame off
|
||||||
animation: voice-pulse 1s ease-in-out infinite;
|
// VoiceRoom.inputLevel(); the box-shadow spread + alpha scale with it (no
|
||||||
|
// keyframe — the audio drives it). Falls back to a faint steady halo at level 0.
|
||||||
|
#id_voice_btn.voice-eq,
|
||||||
|
#id_burger_btn.voice-eq {
|
||||||
|
box-shadow:
|
||||||
|
0 0 calc(0.3rem + var(--voice-level, 0) * 1.1rem)
|
||||||
|
calc(0.05rem + var(--voice-level, 0) * 0.45rem)
|
||||||
|
rgba(var(--ninUser), calc(0.3 + var(--voice-level, 0) * 0.6)),
|
||||||
|
0 0 calc(0.6rem + var(--voice-level, 0) * 1.6rem)
|
||||||
|
calc(0.1rem + var(--voice-level, 0) * 0.5rem)
|
||||||
|
rgba(var(--ninUser), calc(0.15 + var(--voice-level, 0) * 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Muted = the "disconnected from talking" state (item 7): recolor the lot to
|
||||||
|
// --priRd (halo stays --ninUser) + flip the voice sub-btn's icon to .fa-ban.
|
||||||
|
// Composes with .voice-pulse (alone) + .voice-eq (others connected) above —
|
||||||
|
// only the colour changes; the pulse/equalizer halo shape is shared.
|
||||||
|
#id_voice_btn.voice-muted,
|
||||||
|
#id_burger_btn.voice-muted {
|
||||||
|
color: rgba(var(--priRd), 1);
|
||||||
|
border-color: rgba(var(--priRd), 1);
|
||||||
|
}
|
||||||
|
#id_voice_btn.voice-muted {
|
||||||
|
.burger-fan-icon--on { display: none; }
|
||||||
|
.burger-fan-icon--off { display: inline-block; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Burger hides when bud_panel is open — LANDSCAPE only. In portrait the
|
// Burger hides when bud_panel is open — LANDSCAPE only. In portrait the
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ describe("VoiceGlow", () => {
|
|||||||
|
|
||||||
const hasGlow = (el) => el.classList.contains("voice-glow");
|
const hasGlow = (el) => el.classList.contains("voice-glow");
|
||||||
const hasPulse = (el) => el.classList.contains("voice-pulse");
|
const hasPulse = (el) => el.classList.contains("voice-pulse");
|
||||||
const hasFast = (el) => el.classList.contains("voice-pulse--fast");
|
const hasEq = (el) => el.classList.contains("voice-eq");
|
||||||
|
const hasMuted = (el) => el.classList.contains("voice-muted");
|
||||||
|
|
||||||
describe("bindVoiceGlow()", () => {
|
describe("bindVoiceGlow()", () => {
|
||||||
it("returns null when the burger btn is absent", () => {
|
it("returns null when the burger btn is absent", () => {
|
||||||
@@ -102,7 +103,7 @@ describe("VoiceGlow", () => {
|
|||||||
vg.setVoiceState({ inCall: true, peerCount: 0 });
|
vg.setVoiceState({ inCall: true, peerCount: 0 });
|
||||||
expect(hasGlow(burger)).toBe(true);
|
expect(hasGlow(burger)).toBe(true);
|
||||||
expect(hasPulse(burger)).toBe(true);
|
expect(hasPulse(burger)).toBe(true);
|
||||||
expect(hasFast(burger)).toBe(false);
|
expect(hasEq(burger)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pulses the voice sub-btn while alone + fan open", () => {
|
it("pulses the voice sub-btn while alone + fan open", () => {
|
||||||
@@ -113,13 +114,35 @@ describe("VoiceGlow", () => {
|
|||||||
expect(hasPulse(burger)).toBe(false);
|
expect(hasPulse(burger)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("doubles the cadence once a 2nd party connects", () => {
|
it("switches to the equalizer once a 2nd party connects", () => {
|
||||||
vg = bindVoiceGlow();
|
vg = bindVoiceGlow();
|
||||||
vg.setVoiceState({ inCall: true, peerCount: 1 });
|
vg.setVoiceState({ inCall: true, peerCount: 1 });
|
||||||
expect(hasFast(burger)).toBe(true);
|
expect(hasEq(burger)).toBe(true);
|
||||||
expect(hasPulse(burger)).toBe(false);
|
expect(hasPulse(burger)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("recolors to muted (--priRd) when the mic is muted, both alone…", () => {
|
||||||
|
vg = bindVoiceGlow();
|
||||||
|
vg.setVoiceState({ inCall: true, peerCount: 0, muted: true });
|
||||||
|
expect(hasPulse(burger)).toBe(true); // still pulsing (alone)
|
||||||
|
expect(hasMuted(burger)).toBe(true); // …but in the muted colour
|
||||||
|
});
|
||||||
|
|
||||||
|
it("…and while the equalizer runs with others connected", () => {
|
||||||
|
vg = bindVoiceGlow();
|
||||||
|
vg.setVoiceState({ inCall: true, peerCount: 2, muted: true });
|
||||||
|
expect(hasEq(burger)).toBe(true);
|
||||||
|
expect(hasMuted(burger)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the muted colour on unmute", () => {
|
||||||
|
vg = bindVoiceGlow();
|
||||||
|
vg.setVoiceState({ inCall: true, peerCount: 0, muted: true });
|
||||||
|
expect(hasMuted(burger)).toBe(true);
|
||||||
|
vg.setVoiceState({ inCall: true, peerCount: 0, muted: false });
|
||||||
|
expect(hasMuted(burger)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("hands the pulse to whichever surface is showing (fan toggles)", () => {
|
it("hands the pulse to whichever surface is showing (fan toggles)", () => {
|
||||||
vg = bindVoiceGlow();
|
vg = bindVoiceGlow();
|
||||||
vg.setVoiceState({ inCall: true, peerCount: 0 });
|
vg.setVoiceState({ inCall: true, peerCount: 0 });
|
||||||
@@ -145,8 +168,8 @@ describe("VoiceGlow", () => {
|
|||||||
vg = bindVoiceGlow();
|
vg = bindVoiceGlow();
|
||||||
vg.setVoiceState({ inCall: true, peerCount: 1 });
|
vg.setVoiceState({ inCall: true, peerCount: 1 });
|
||||||
vg.render(); vg.render();
|
vg.render(); vg.render();
|
||||||
const fastCount = (burger.className.match(/voice-pulse--fast/g) || []).length;
|
const eqCount = (burger.className.match(/voice-eq/g) || []).length;
|
||||||
expect(fastCount).toBe(1);
|
expect(eqCount).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,6 +116,18 @@ describe('voice-mesh join guard', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Equalizer input level — the deterministic edge (no analysers → 0). The
|
||||||
|
// volume-reactive readout itself needs a live mesh + real audio to verify.
|
||||||
|
describe('voice-mesh inputLevel', function () {
|
||||||
|
it('is 0 when no peer analysers are attached', function () {
|
||||||
|
var vr = window.VoiceRoom;
|
||||||
|
var saved = vr._analysers;
|
||||||
|
vr._analysers = {};
|
||||||
|
expect(vr.inputLevel()).toBe(0);
|
||||||
|
vr._analysers = saved;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// bindVoiceBtn (burger-btn.js) must surface a failed join + roll back the
|
// bindVoiceBtn (burger-btn.js) must surface a failed join + roll back the
|
||||||
// optimistic in-call state so the next click can retry.
|
// optimistic in-call state so the next click can retry.
|
||||||
describe('voice btn join failure surfacing', function () {
|
describe('voice btn join failure surfacing', function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user