From 162b4b29176896af311961f2defdb2a0278fe9b1 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 28 May 2026 21:29:50 -0400 Subject: [PATCH] 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. --- web/chat.html | 155 +++++++++++++++++++++++++++++------------- web/how-it-works.html | 6 +- web/zebra-audio.html | 110 +++++++++++++++++++++++++++--- 3 files changed, 210 insertions(+), 61 deletions(-) diff --git a/web/chat.html b/web/chat.html index 840352a..c37ec31 100644 --- a/web/chat.html +++ b/web/chat.html @@ -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 })(); diff --git a/web/how-it-works.html b/web/how-it-works.html index 81440a1..e3f055c 100644 --- a/web/how-it-works.html +++ b/web/how-it-works.html @@ -266,9 +266,9 @@

diff --git a/web/zebra-audio.html b/web/zebra-audio.html index d529513..3e44977 100644 --- a/web/zebra-audio.html +++ b/web/zebra-audio.html @@ -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 @@
path: — +
+ + +
@@ -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 = ''; 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.'); })();