zebra-spaces: graceful mic re-acquire on BT/USB swap — no more leave+rejoin

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.
This commit is contained in:
Russell Ballestrini 2026-06-02 10:45:53 -04:00
parent cdcc15365a
commit bd770230f9
No known key found for this signature in database

View file

@ -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');
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-02</span><br>
md5 <span class="stamp-md5">ad6a80d1c54036ff0d7782df2b98c4db</span><br>
sha256 <span class="stamp-sha">77b28cfbf2690bdf0b12697c141ab460d0c95301344ce0245936a1c498aa35a9</span><br>
md5 <span class="stamp-md5">181a0e0d4e4da7c4a40e2e5e576954fd</span><br>
sha256 <span class="stamp-sha">29024b972f5dede7d5fd2b924170aaf9aa097afc9bac4402e86b01f9d543a4ed</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>