diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index 94eff97..349cf82 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/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');