diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 1785260..b488673 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -499,22 +499,37 @@ async function applyMicMode(){
}
if (micStream) micStream.getTracks().forEach(t=>t.stop());
micStream = ns;
+ /* old analyser is now dead — rewire local meter against the fresh stream */
+ if (myUUID){ stopMeter(myUUID); startMeter(myUUID, micStream); }
}
-function meterFor(stream, elId){
+/* per-peer meter: one analyser node + one rAF loop, keyed by uuid. The tick
+ * reads members.get(uuid)._meterEl fresh each frame so renderRoom can replace
+ * the DOM element without killing the meter. Cleanup happens when the uuid
+ * leaves the room (members loses the key) or stopMeter is called. */
+const meterCtl = new Map(); /* uuid -> {an, buf} */
+function startMeter(uuid, stream){
+ if (!stream || meterCtl.has(uuid)) return;
if (!audioCtx) audioCtx = new (window.AudioContext||window.webkitAudioContext)();
- const src = audioCtx.createMediaStreamSource(stream);
+ let src;
+ try { src = audioCtx.createMediaStreamSource(stream); }
+ catch(e){ logLine('err','meter for '+shortHex(uuid)+': '+e.message); return; }
const an = audioCtx.createAnalyser(); an.fftSize = 512;
src.connect(an);
const buf = new Uint8Array(an.fftSize);
- const fill = typeof elId==='string' ? $(elId) : elId;
+ meterCtl.set(uuid, { an, buf });
(function tick(){
- if (!fill || !document.contains(fill)) return; /* meter removed with member row */
- an.getByteTimeDomainData(buf);
- let peak=0; for (let i=0;ipeak)peak=v; }
- fill.style.width = Math.min(100, Math.round(peak*180))+'%';
+ const c = meterCtl.get(uuid);
+ if (!c) return;
+ if (!members.has(uuid)){ meterCtl.delete(uuid); return; }
+ c.an.getByteTimeDomainData(c.buf);
+ let peak=0;
+ for (let i=0;ipeak)peak=v; }
+ const m = members.get(uuid);
+ if (m && m._meterEl) m._meterEl.style.width = Math.min(100, Math.round(peak*180))+'%';
requestAnimationFrame(tick);
})();
}
+function stopMeter(uuid){ meterCtl.delete(uuid); }
/* ==================================================================
* room state mirror (server is source of truth, we mirror locally for
@@ -766,10 +781,12 @@ async function ensureMicAndUI(){
try {
await getMic(); await refreshMicList();
$('btn-mute').disabled = false;
+ if (myUUID) startMeter(myUUID, micStream);
} catch(e){ logLine('err','mic blocked: '+e.message); }
updateRoleUI();
}
function dropMic(){
+ if (myUUID) stopMeter(myUUID);
if (micStream){ micStream.getTracks().forEach(t=>t.stop()); micStream = null; }
$('btn-mute').disabled = true;
}
@@ -796,10 +813,7 @@ async function connectToPeer(uuid, weOffer){
let a = remoteAudio.get(uuid);
if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); }
a.srcObject = ev.streams[0] || new MediaStream([ev.track]);
- try {
- const m = members.get(uuid);
- if (m && m._meterEl) meterFor(a.srcObject, m._meterEl);
- } catch(_){}
+ startMeter(uuid, a.srcObject);
};
pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
pc.onconnectionstatechange = () => {
@@ -818,6 +832,7 @@ async function connectToPeer(uuid, weOffer){
}
}
function tearPeer(uuid){
+ stopMeter(uuid);
const pc = peers.get(uuid);
if (pc){ try { pc.close(); } catch(_){} peers.delete(uuid); }
const a = remoteAudio.get(uuid);
@@ -1021,8 +1036,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');