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');