From 7e54e1b7ef33507b1a21ceedc129e95860150aa6 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sun, 31 May 2026 12:10:57 -0400 Subject: [PATCH] zebra-spaces: persistent per-peer meter runner (survives re-renders + wires local mic) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real defects in the meter wiring: - the local mic was never fed to its meter — only remote streams were - renderRoom replaces every member's meter DOM element, so the old meterFor tick loop (which captured the OLD fill via closure) exited on the next frame and the bar froze forever Replace with a per-uuid meterCtl Map that holds the analyser + buffer once and re-reads members.get(uuid)._meterEl fresh every animation frame. Cleanup is automatic when the uuid drops from members (peer- left, boot) or explicitly via stopMeter (dropMic, applyMicMode). applyMicMode re-wires the local meter against the freshly acquired stream since the old MediaStreamSource dies with the old track. --- web/zebra-spaces.html | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) 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');