From bd770230f99a84ec4b88aa6c3f29d9d03a4ce6a1 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Tue, 2 Jun 2026 10:45:53 -0400 Subject: [PATCH] =?UTF-8?q?zebra-spaces:=20graceful=20mic=20re-acquire=20o?= =?UTF-8?q?n=20BT/USB=20swap=20=E2=80=94=20no=20more=20leave+rejoin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the host's mic device disappears (BT disconnect, USB unplug, OS audio swap to laptop speakers), the active MediaStreamTrack ends but the senders on every PC keep pointing at the dead track. Host goes silent until a full leave+rejoin tears everything down and re-publishes. Fix: - watchMicTrack(track) listens for 'ended' on every mic track we hand out (initial getMic + applyMicMode re-acquire) - reacquireMic() gets a fresh getUserMedia with the same constraints, then sender.replaceTrack on every live mesh peer + sfuPubPC. No renegotiation — codec / SDP stays the same, just the track swaps under the existing transceiver. Single in-flight guard so a burst of devicechange events doesn't race. - devicechange listener is now an active probe: if the current track has gone readyState='ended' or muted=true since the last event, trigger reacquireMic. Firefox in particular doesn't always fire 'ended' on BT swap — the track stays 'live' but emits silence. --- web/zebra-spaces.html | 58 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 94eff97..349cf82 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -1622,8 +1622,47 @@ async function getMic(){ micStream = await navigator.mediaDevices.getUserMedia({ audio: micConstraints(), video:false }); const t = micStream.getAudioTracks()[0]; tagTrack(t); await enforceMicConstraints(t); + watchMicTrack(t); return micStream; } +/* If the underlying mic device disappears (BT disconnect, USB unplug, OS + * audio swap), the track fires 'ended' or stops emitting samples but the + * MediaStream object stays alive — so existing RTP senders keep pointing + * at a dead track and the host goes silent until they leave + rejoin. + * Watch the track and re-acquire on the fly. */ +let micReacquireInFlight = false; +function watchMicTrack(track){ + if (!track) return; + track.addEventListener('ended', () => { + logLine('err', 'mic track ended — re-acquiring'); + reacquireMic().catch(e => logLine('err','mic re-acquire failed: '+e.message)); + }); +} +async function reacquireMic(){ + if (micReacquireInFlight) return; + micReacquireInFlight = true; + try { + const ns = await navigator.mediaDevices.getUserMedia({ audio: micConstraints(), video:false }); + const nt = ns.getAudioTracks()[0]; + tagTrack(nt); nt.enabled = !muted; + await enforceMicConstraints(nt); + watchMicTrack(nt); + /* hot-swap onto every existing sender — no renegotiation needed + * since the codec / SDP didn't change, just the track behind it */ + async function swap(pc){ + const sender = pc.getSenders().find(s=>s.track && s.track.kind==='audio') || pc.getSenders()[0]; + if (sender){ try { await sender.replaceTrack(nt); } catch(_){} } + } + for (const [, pc] of peers) await swap(pc); + if (sfuPubPC) await swap(sfuPubPC); + if (micStream) micStream.getTracks().forEach(t=>t.stop()); + micStream = ns; + if (myUUID){ stopMeter(myUUID); startMeter(myUUID, micStream); } + logLine('', 'mic re-acquired'); + } finally { + micReacquireInFlight = false; + } +} async function setSenderBitrate(sender){ if (!sender) return; try { @@ -1681,6 +1720,7 @@ async function applyMicMode(){ const nt = ns.getAudioTracks()[0]; tagTrack(nt); nt.enabled = !muted; await enforceMicConstraints(nt); + watchMicTrack(nt); async function swap(pc){ const sender = pc.getSenders().find(s=>s.track && s.track.kind==='audio') || pc.getSenders()[0]; if (sender){ try { await sender.replaceTrack(nt); } catch(_){} setSenderBitrate(sender); } @@ -2552,7 +2592,19 @@ $('music-mode').addEventListener('change', async (e) => { if (micStream){ try { await applyMicMode(); } catch(err){ logLine('err','mic mode switch failed: '+err.message); } } }); if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){ - navigator.mediaDevices.addEventListener('devicechange', refreshMicList); + navigator.mediaDevices.addEventListener('devicechange', () => { + refreshMicList(); + /* belt-and-suspenders for browsers that don't fire track.onended on + * device disappearance (Firefox/BT swap can leave the track in + * 'live' state but emitting silence). If our current track has + * gone dead since the last devicechange, re-acquire. */ + if (micStream){ + const t = micStream.getAudioTracks()[0]; + if (t && (t.readyState === 'ended' || t.muted)){ + reacquireMic().catch(e => logLine('err','mic re-acquire on devicechange: '+e.message)); + } + } + }); } /* initial population so the dropdown lists the user's inputs before they * enter a space (matches what the camera-select does). Labels are blank @@ -2656,8 +2708,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');