web: auto-reconnect on cross-LAN drops + live mic-input switching

- chat + zebra-audio: treat ICE 'disconnected' as recoverable (grace
  before acting), auto ICE-restart on 'failed'/timeout driven by the
  offerer to avoid glare, and auto-rejoin the signaling socket if it
  drops mid-call. Superseded-pc guard ignores late events from a
  closed connection.
- zebra-audio: input-device dropdown that hot-swaps the mic via
  replaceTrack (no renegotiation, mute preserved); refreshes on
  devicechange so a plugged-in interface appears mid-call.
This commit is contained in:
Russell Ballestrini 2026-05-28 21:29:50 -04:00
parent 21e3e84b14
commit 162b4b2917
No known key found for this signature in database
3 changed files with 210 additions and 61 deletions

View file

@ -1168,6 +1168,7 @@ function ensurePC() {
logLine('sys', 'codec preference: G.711/G.722 ahead of Opus (clean amplitude)');
}
} catch (_) {}
const selfPc = pc; /* snapshot so a superseded pc's late events are ignored */
pc.ontrack = (ev) => {
const stream = ev.streams[0] || new MediaStream([ev.track]);
attachInboundTrack(stream, ev.track.id);
@ -1181,20 +1182,34 @@ function ensurePC() {
logLine('sys', `ice candidate: ${typ}${mdns}`);
};
pc.onconnectionstatechange = () => {
if (selfPc !== pc) return; /* event from a superseded pc — ignore */
const s = pc.connectionState;
rtcStatus.textContent = 'connection: ' + s;
if (s === 'connected') {
dotRtc.className = 'dot ok';
clearRtcRecover(); rtcReconnectTries = 0;
logLine('sys', 'webrtc connected');
announceHello().catch(() => {});
setTimeout(reportTransport, 1200);
} else if (s === 'failed' || s === 'closed' || s === 'disconnected') {
} else if (s === 'disconnected') {
dotRtc.className = 'dot warn';
setPath('path: reconnecting…', null);
logLine('sys', 'webrtc disconnected — waiting for ICE to recover');
clearRtcRecover();
rtcRecoverTimer = setTimeout(attemptRtcRestart, 5000); /* often self-heals */
} else if (s === 'failed') {
dotRtc.className = 'dot warn';
setPath('path: reconnecting…', null);
logLine('sys', 'webrtc failed — restarting ICE');
attemptRtcRestart();
} else if (s === 'closed') {
dotRtc.className = 'dot';
clearRtcRecover();
setPath('path: not connected', null);
logLine('err', `webrtc ${s} — click "reset connection" then retry`);
}
};
pc.oniceconnectionstatechange = () => {
if (selfPc !== pc) return;
rtcStatus.textContent = 'ice: ' + pc.iceConnectionState;
};
return pc;
@ -1233,6 +1248,7 @@ async function reportTransport() {
function resetConnection() {
if (typeof disconnectRelay === 'function') disconnectRelay();
clearRtcRecover(); rtcReconnectTries = 0;
if (pc) {
try { pc.close(); } catch (_) {}
pc = null;
@ -1288,6 +1304,10 @@ const SIGNAL_SALT = new TextEncoder().encode('zebra-signal-v1');
let relayWS = null;
let relayRole = null;
let relayKey = null;
let relayWantOpen = false; /* true from connect until the user disconnects */
let relayReconnectTimer = null; /* auto-rejoin the signaling socket if it drops */
let rtcRecoverTimer = null; /* grace timer before forcing an ICE restart */
let rtcReconnectTries = 0;
async function deriveSignalRoom(code) {
const h = await crypto.subtle.digest('SHA-256',
@ -1313,13 +1333,35 @@ async function relaySendSDP(kind, desc) {
const ct = await aesEncrypt(relayKey, JSON.stringify(desc));
relaySend({ type: 'sdp', kind, data: b64(ct) });
}
async function relayMakeOffer() {
async function relayMakeOffer(iceRestart = false) {
ensurePC();
const off = await pc.createOffer();
const off = await pc.createOffer(iceRestart ? { iceRestart: true } : undefined);
await pc.setLocalDescription(off);
await waitForIceGathering(pc);
await relaySendSDP('offer', pc.localDescription);
relaySet('offer sent — waiting for answer', 'ok');
relaySet(iceRestart ? 'reconnecting — new offer sent' : 'offer sent — waiting for answer', 'ok');
}
/* Relay-path ICE recovery. A transient blip drops us to 'disconnected' (usually
* self-heals) or 'failed' (needs an ICE restart). The offerer owns restarts so
* the two sides don't both re-offer at once (glare); the answerer waits for the
* new offer to arrive over the relay and answers it on its existing pc. */
function clearRtcRecover() { if (rtcRecoverTimer) { clearTimeout(rtcRecoverTimer); rtcRecoverTimer = null; } }
async function attemptRtcRestart() {
clearRtcRecover();
if (!pc || pc.connectionState === 'connected') { rtcReconnectTries = 0; return; }
if (!relayWS || relayWS.readyState !== WebSocket.OPEN) return; /* WS rejoin re-offers */
if (relayRole !== 'offerer') { relaySet('reconnecting — waiting for peer'); return; }
if (rtcReconnectTries >= 6) { relaySet('reconnect failed — click "reset" then retry', 'err'); return; }
rtcReconnectTries++;
try {
await refreshTurnCred();
if (pc.setConfiguration) { try { pc.setConfiguration(rtcConfig); } catch (_) {} }
await relayMakeOffer(true);
logLine('sys', `ice restart attempt ${rtcReconnectTries}`);
} catch (e) { logLine('err', 'ice restart failed: ' + e.message); }
if (!pc || pc.connectionState !== 'connected') {
rtcRecoverTimer = setTimeout(attemptRtcRestart, Math.min(2000 * rtcReconnectTries, 10000));
}
}
async function onRelaySDP(kind, descJSON) {
const desc = JSON.parse(descJSON);
@ -1354,54 +1396,69 @@ async function connectRelay() {
try {
relayKey = await deriveSignalKey(code);
const room = await deriveSignalRoom(code);
relayWantOpen = true;
relaySet('connecting to relay…');
relayWS = new WebSocket(SIGNAL_URL + '?room=' + encodeURIComponent(room));
relayWS.onopen = () => {
$('dot-relay').className = 'dot ok';
$('btn-relay-connect').disabled = true;
$('btn-relay-disconnect').disabled = false;
logLine('sys', 'relay connected — waiting for peer');
};
relayWS.onclose = () => {
$('dot-relay').className = 'dot warn';
$('btn-relay-connect').disabled = false;
$('btn-relay-disconnect').disabled = true;
if (relayWS) relaySet('relay disconnected');
relayWS = null; relayRole = null;
};
relayWS.onerror = () => relaySet('relay connection error', 'err');
relayWS.onmessage = async (ev) => {
let m; try { m = JSON.parse(ev.data); } catch (_) { return; }
if (m.type === 'welcome') {
relayRole = m.role;
relaySet(`in room as ${m.role} — ${m.peers} peer(s) present`);
logLine('sys', `relay role: ${m.role} (${m.peers} present)`);
if (relayRole === 'offerer' && m.peers >= 2) await relayMakeOffer();
} else if (m.type === 'peer-joined') {
logLine('sys', 'peer joined the room');
if (relayRole === 'offerer') await relayMakeOffer();
} else if (m.type === 'peer-left') {
logLine('sys', 'peer left the room');
relaySet('peer left — waiting for a peer');
} else if (m.type === 'sdp') {
try {
const pt = await aesDecrypt(relayKey, unb64(m.data));
await onRelaySDP(m.kind, pt);
} catch (e) {
logLine('err', 'relay sdp failed (wrong code?): ' + e.message);
relaySet('sdp exchange failed — codes must match', 'err');
}
} else if (m.type === 'error') {
relaySet('relay: ' + m.message, 'err');
logLine('err', 'relay: ' + m.message);
}
};
openRelaySocket(room);
} catch (e) {
relaySet('connect failed: ' + e.message, 'err');
if (relayWS) { try { relayWS.close(); } catch (_) {} relayWS = null; }
}
}
function openRelaySocket(room) {
relayWS = new WebSocket(SIGNAL_URL + '?room=' + encodeURIComponent(room));
relayWS.onopen = () => {
$('dot-relay').className = 'dot ok';
$('btn-relay-connect').disabled = true;
$('btn-relay-disconnect').disabled = false;
logLine('sys', 'relay connected — waiting for peer');
};
relayWS.onclose = () => {
$('dot-relay').className = 'dot warn';
relayWS = null; relayRole = null;
if (relayWantOpen) {
$('btn-relay-disconnect').disabled = false;
relaySet('relay dropped — reconnecting…');
if (relayReconnectTimer) clearTimeout(relayReconnectTimer);
relayReconnectTimer = setTimeout(() => { if (relayWantOpen) openRelaySocket(room); }, 1500);
} else {
$('btn-relay-connect').disabled = false;
$('btn-relay-disconnect').disabled = true;
relaySet('relay disconnected');
}
};
relayWS.onerror = () => relaySet('relay connection error', 'err');
relayWS.onmessage = async (ev) => {
let m; try { m = JSON.parse(ev.data); } catch (_) { return; }
if (m.type === 'welcome') {
relayRole = m.role;
relaySet(`in room as ${m.role} — ${m.peers} peer(s) present`);
logLine('sys', `relay role: ${m.role} (${m.peers} present)`);
/* pc already exists ⇒ this is a re-join after a drop: ICE-restart offer */
if (relayRole === 'offerer' && m.peers >= 2) await relayMakeOffer(!!pc);
} else if (m.type === 'peer-joined') {
logLine('sys', 'peer joined the room');
if (relayRole === 'offerer') await relayMakeOffer(!!pc);
} else if (m.type === 'peer-left') {
logLine('sys', 'peer left the room');
relaySet('peer left — waiting for a peer');
} else if (m.type === 'sdp') {
try {
const pt = await aesDecrypt(relayKey, unb64(m.data));
await onRelaySDP(m.kind, pt);
} catch (e) {
logLine('err', 'relay sdp failed (wrong code?): ' + e.message);
relaySet('sdp exchange failed — codes must match', 'err');
}
} else if (m.type === 'error') {
relaySet('relay: ' + m.message, 'err');
logLine('err', 'relay: ' + m.message);
}
};
}
function disconnectRelay() {
relayWantOpen = false;
if (relayReconnectTimer) { clearTimeout(relayReconnectTimer); relayReconnectTimer = null; }
clearRtcRecover();
if (relayWS) { const w = relayWS; relayWS = null; try { w.close(); } catch (_) {} }
$('dot-relay').className = 'dot warn';
$('btn-relay-connect').disabled = false;
@ -1591,9 +1648,9 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
})();
</script>
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-05-28</span><br>
md5 <span class="stamp-md5">2eefd0f7f0c5ca9b42078a885e2d822d</span><br>
sha256 <span class="stamp-sha">20c008ca4c8341e9017f46488299120714599f4765e745820ab7e08d9b3b3306</span><br>
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-05-29</span><br>
md5 <span class="stamp-md5">3ded822c9d19eedb538d02156b0eb00b</span><br>
sha256 <span class="stamp-sha">ec5d4dc1662fbcd570d70b9e0d0dd40e13aa804e5c5f989bec57dc9643bffe5f</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span>
</footer>
</body>

View file

@ -266,9 +266,9 @@
</p>
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-05-28</span><br>
md5 <span class="stamp-md5">e4edc61635d2ae846d7bb7438742e8a7</span><br>
sha256 <span class="stamp-sha">16e749e33551edad39a1146e3809efc7486b8461eb51d1b1238b70f13301a5b6</span><br>
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-05-29</span><br>
md5 <span class="stamp-md5">a7363c33e5b129b38c5d385690d7622a</span><br>
sha256 <span class="stamp-sha">4b984972c9051c9fd1f4c416e957d9926e6070423ecca67c5404231435268c51</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span>
</footer>
</body>

View file

@ -44,6 +44,10 @@
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
padding: 0.45rem; background: #fff; color: #000; flex: 1; min-width: 0;
}
select {
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
padding: 0.45rem; background: #fff; color: #000; flex: 1; min-width: 0; cursor: pointer;
}
.dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid #000; background: #fff; flex-shrink: 0; }
.dot.ok { background: #060; border-color: #060; }
.dot.warn { background: #888; }
@ -88,6 +92,10 @@
<div class="dot" id="dot-path"></div>
<span id="path-status" class="status-line">path: —</span>
</div>
<div class="row">
<label class="note" for="mic-select" style="flex:0 0 auto;min-width:3rem">input</label>
<select id="mic-select" title="audio input device — switchable mid-call"><option value="">default microphone</option></select>
</div>
<div class="row">
<label class="note"><input type="checkbox" id="music-mode"> music mode — raw mic, no echo/noise cancellation (for playing audio through it)</label>
</div>
@ -176,13 +184,35 @@ async function refreshTurnCred(){
}
/* ---- audio ---- */
let micStream = null, audioCtx = null, musicMode = false;
let micStream = null, audioCtx = null, musicMode = false, micDeviceId = '';
/* voice mode = echo-cancel + noise-suppress + auto-gain (clean speech).
* music mode = all of that OFF so music/audio passes through un-mangled. */
function micConstraints(){
return musicMode
const base = musicMode
? { echoCancellation:false, noiseSuppression:false, autoGainControl:false }
: { echoCancellation:true, noiseSuppression:true, autoGainControl:true };
if (micDeviceId) base.deviceId = { exact: micDeviceId }; /* pick a specific input */
return base;
}
/* list audio inputs into the dropdown. Labels only appear once the user has
* granted mic permission, so this is re-run after getMic and on devicechange
* (e.g. plugging in an audio interface to play music through). */
async function refreshMicList(){
try {
const devs = await navigator.mediaDevices.enumerateDevices();
const mics = devs.filter(d=>d.kind==='audioinput');
const sel = $('mic-select'); if (!sel) return;
sel.innerHTML = '';
if (!mics.length){ sel.innerHTML = '<option value="">default microphone</option>'; return; }
mics.forEach((m,i)=>{
const o = document.createElement('option');
o.value = m.deviceId;
o.textContent = m.label || ('microphone '+(i+1));
sel.appendChild(o);
});
if (micDeviceId && mics.some(m=>m.deviceId===micDeviceId)) sel.value = micDeviceId;
else micDeviceId = sel.value; /* track whatever the browser defaulted to */
} catch(e){ logLine('err','could not list inputs: '+e.message); }
}
function tagTrack(t){ if (t) t.contentHint = musicMode ? 'music' : 'speech'; }
async function setSenderBitrate(sender){
@ -232,6 +262,8 @@ function meterFor(stream, fillId){
/* ---- WebRTC ---- */
let pc = null, relayWS = null, relayRole = null, relayKey = null;
let wantCall = false, rdvRoom = null, sigReconnectTimer = null;
let iAmOfferer = false, rtcTimer = null, rtcTries = 0;
function waitForIceGathering(p, timeoutMs=6000){
return new Promise(res=>{
@ -256,12 +288,21 @@ async function ensurePC(){
logLine('', 'remote audio connected');
};
pc.onicecandidate = (ev) => { if (ev.candidate){ const t=(ev.candidate.candidate.match(/typ\s+(\S+)/)||[])[1]; logLine('','ice: '+(t||'?')); } };
const self = pc;
pc.onconnectionstatechange = () => {
if (self !== pc) return; /* event from a superseded connection */
const s = pc.connectionState;
setStatus('call: '+s, s==='connected'?'ok':null);
$('dot-call').className = 'dot ' + (s==='connected'?'ok':'warn');
if (s==='connected'){ $('btn-mute').disabled=false; $('btn-hangup').disabled=false; setTimeout(reportPath,1200); partnerKnown=true; setMicIcons(); sendMicState(); }
else if (s==='failed'||s==='disconnected'||s==='closed'){ setPath('path: —', null); }
if (s==='connected'){
if (rtcTimer){ clearTimeout(rtcTimer); rtcTimer=null; } rtcTries=0;
$('btn-mute').disabled=false; $('btn-hangup').disabled=false; setTimeout(reportPath,1200); partnerKnown=true; setMicIcons(); sendMicState();
} else if (s==='disconnected'){
setPath('path: reconnecting…', null);
if (rtcTimer) clearTimeout(rtcTimer); rtcTimer=setTimeout(attemptReconnect, 5000); /* often self-heals */
} else if (s==='failed'){
setPath('path: reconnecting…', null); attemptReconnect();
} else if (s==='closed'){ setPath('path: —', null); }
};
return pc;
}
@ -288,6 +329,7 @@ function relaySend(obj){ if (relayWS && relayWS.readyState===1) relayWS.send(JSO
async function relaySendSDP(kind, desc){ relaySend({ type:'sdp', kind, data: b64(await aesEncrypt(relayKey, JSON.stringify(desc))) }); }
function freshPC(){ if (pc){ try{pc.close();}catch(_){} pc=null; } }
async function makeOffer(){
iAmOfferer = true; /* we drive any later ICE recovery */
freshPC(); /* always (re)negotiate on a clean connection */
await ensurePC();
await pc.setLocalDescription(await pc.createOffer());
@ -295,9 +337,27 @@ async function makeOffer(){
await relaySendSDP('offer', pc.localDescription);
setStatus('offer sent — connecting', null);
}
/* Relay-path recovery: when the media dies, the offerer re-runs the handshake
* (a fresh PC); the answerer just waits for the new offer. 'disconnected'
* usually self-heals, so the caller gives it a few seconds before redialing. */
async function attemptReconnect(){
if (rtcTimer){ clearTimeout(rtcTimer); rtcTimer=null; }
if (!relayWS || relayWS.readyState!==1) return; /* WS rejoin will redial */
if (pc && pc.connectionState==='connected'){ rtcTries=0; return; }
if (!iAmOfferer){ setStatus('reconnecting — waiting for partner', null); return; }
if (rtcTries>=6){ setStatus('reconnect failed — hang up & call again','err'); return; }
rtcTries++;
logLine('','reconnecting (attempt '+rtcTries+')');
try { await refreshTurnCred(); await makeOffer(); }
catch(e){ logLine('err','reconnect failed: '+e.message); }
if (!(pc && pc.connectionState==='connected')){
rtcTimer=setTimeout(attemptReconnect, Math.min(2000*rtcTries,10000));
}
}
async function onSDP(kind, json){
const desc = JSON.parse(json);
if (kind==='offer'){
iAmOfferer = false; /* the offerer drives recovery, not us */
freshPC(); /* every incoming offer = a fresh connection */
await ensurePC();
await pc.setRemoteDescription(desc);
@ -314,16 +374,27 @@ async function startCall(){
const code = $('rdv-code').value.trim();
if (!code){ setStatus('enter a rendezvous code first','err'); return; }
if (relayWS){ setStatus('already in a call — hang up first', null); return; }
try { await getMic(); meterFor(micStream,'meter-mic'); setMicIcons(); }
try { await getMic(); meterFor(micStream,'meter-mic'); setMicIcons(); await refreshMicList(); }
catch(e){ setStatus('microphone blocked: '+e.message,'err'); return; }
await refreshTurnCred();
relayKey = await deriveSignalKey(code);
const room = await deriveSignalRoom(code);
setStatus('connecting to rendezvous…', null);
$('btn-call').disabled = true;
wantCall = true; rdvRoom = room;
openSignal(room);
}
function openSignal(room){
relayWS = new WebSocket(SIGNAL_URL + '?room=' + encodeURIComponent(room));
relayWS.onopen = () => logLine('', 'rendezvous connected — waiting for partner');
relayWS.onclose = () => { if (relayWS) setStatus('rendezvous closed', null); relayWS=null; relayRole=null; $('btn-call').disabled=false; };
relayWS.onclose = () => {
relayWS=null; relayRole=null;
if (wantCall){
setStatus('rendezvous dropped — reconnecting…', null);
if (sigReconnectTimer) clearTimeout(sigReconnectTimer);
sigReconnectTimer = setTimeout(()=>{ if (wantCall) openSignal(rdvRoom); }, 1500);
} else { setStatus('rendezvous closed', null); $('btn-call').disabled=false; }
};
relayWS.onerror = () => setStatus('rendezvous error','err');
relayWS.onmessage = async (ev) => {
let m; try { m=JSON.parse(ev.data); } catch(_){ return; }
@ -350,6 +421,9 @@ async function startCall(){
};
}
function hangup(){
wantCall=false;
if (sigReconnectTimer){ clearTimeout(sigReconnectTimer); sigReconnectTimer=null; }
if (rtcTimer){ clearTimeout(rtcTimer); rtcTimer=null; } rtcTries=0;
if (relayWS){ const w=relayWS; relayWS=null; try{w.close();}catch(_){} }
if (pc){ try{pc.close();}catch(_){} pc=null; }
$('remote-audio').srcObject = null;
@ -392,13 +466,31 @@ $('music-mode').addEventListener('change', async (e)=>{
logLine('', 'mic mode: '+(musicMode?'MUSIC — raw, no voice isolation':'VOICE — echo/noise cancel'));
if (micStream){ try { await applyMicMode(); } catch(err){ logLine('err','mic mode switch failed: '+err.message); } }
});
/* swap the audio input device live: applyMicMode re-acquires with the new
* deviceId and hot-swaps the sender track — no renegotiation, mute preserved */
$('mic-select').addEventListener('change', async (e)=>{
micDeviceId = e.target.value;
logLine('', 'input device: '+((e.target.selectedOptions[0]||{}).textContent||'default'));
if (micStream){
try { await applyMicMode(); }
catch(err){
logLine('err','input switch failed: '+err.message);
await refreshMicList(); /* device may have vanished — resync the list */
}
}
});
/* a plugged/unplugged interface changes the available inputs mid-call */
if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){
navigator.mediaDevices.addEventListener('devicechange', refreshMicList);
}
refreshMicList(); /* populate up front (labels fill in after mic permission) */
logLine('', 'ready — type a rendezvous code and call. mic stays muted to the room until connected.');
})();
</script>
<footer style="max-width:560px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-05-28</span><br>
md5 <span class="stamp-md5">e5fddae2c65939dee70fa8cb05f246d0</span><br>
sha256 <span class="stamp-sha">ca3d20d2d9d8f33ce23faee41a148e459ef404e6a4be73001cd991781ce146bc</span><br>
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-05-29</span><br>
md5 <span class="stamp-md5">526a8823d1d01a64db80c1ae00c5cbad</span><br>
sha256 <span class="stamp-sha">edaddafa103af1832ea0e8f578af34e3891df77a86ed806674dc8fc285b632dd</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span>
</footer>
</body>