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