zebra-audio: auto-reconnect on partner return + verifiable path

Reconnect: on "partner left", tear down the stale peer connection but stay in
the room, and have whichever peer is already present send the offer when the
other (re)joins — so a partner can leave and rejoin with no refresh, regardless
of who left. Every (re)negotiation runs on a fresh RTCPeerConnection.

Path indicator now reads the transport's selected candidate pair and prints the
actual local/remote IP:port + types, so DIRECT vs RELAYED is verifiable (host =
the two devices' IPs; relay = the TURN server's IP). The mic un-masks the real
host candidate, which is why a voice call reaches direct P2P on a LAN where the
no-mic chat fell back to TURN.
This commit is contained in:
Russell Ballestrini 2026-05-28 14:13:38 -04:00
parent 860afbd9e4
commit 8dfdd9561c
No known key found for this signature in database

View file

@ -225,21 +225,27 @@ async function ensurePC(){
async function reportPath(){ async function reportPath(){
if (!pc) return; if (!pc) return;
try { try {
const stats = await pc.getStats(); let pair=null; const stats = await pc.getStats(); let sel=null;
stats.forEach(r=>{ if(r.type==='candidate-pair'&&r.nominated&&r.state==='succeeded') pair=r; }); /* the authoritative active pair, per the transport */
if (!pair) stats.forEach(r=>{ if(!pair&&r.type==='candidate-pair'&&r.state==='succeeded') pair=r; }); stats.forEach(r=>{ if(r.type==='transport'&&r.selectedCandidatePairId) sel=stats.get(r.selectedCandidatePairId); });
if (!pair){ setPath('path: connecting…', null); return; } if (!sel) stats.forEach(r=>{ if(r.type==='candidate-pair'&&r.state==='succeeded'&&(r.nominated||r.selected)) sel=r; });
const loc=stats.get(pair.localCandidateId)||{}, rem=stats.get(pair.remoteCandidateId)||{}; if (!sel){ setPath('path: connecting…', null); return; }
if (loc.candidateType==='relay'||rem.candidateType==='relay') const loc=stats.get(sel.localCandidateId)||{}, rem=stats.get(sel.remoteCandidateId)||{};
setPath('path: RELAYED via TURN (a server forwards your encrypted audio)', 'err'); const la=(loc.address||loc.ip||'?')+':'+(loc.port||'?'), ra=(rem.address||rem.ip||'?')+':'+(rem.port||'?');
else const relayed = loc.candidateType==='relay'||rem.candidateType==='relay';
setPath('path: DIRECT peer-to-peer — nobody between you ('+(loc.candidateType||'?')+'/'+(rem.candidateType||'?')+')', 'ok'); /* show the real endpoints so it's verifiable: host = the two devices' IPs;
* relay = the TURN server's IP (142.93.73.64) appears on at least one side. */
setPath('path: '+(relayed?'RELAYED via TURN':'DIRECT P2P')+' — you '+la
+' ↔ peer '+ra+' ('+(loc.candidateType||'?')+'/'+(rem.candidateType||'?')+')', relayed?'err':'ok');
logLine('', 'active pair: '+la+' ['+(loc.candidateType||'?')+'] ↔ '+ra+' ['+(rem.candidateType||'?')+']');
} catch(_){} } catch(_){}
} }
function relaySend(obj){ if (relayWS && relayWS.readyState===1) relayWS.send(JSON.stringify(obj)); } function relaySend(obj){ if (relayWS && relayWS.readyState===1) relayWS.send(JSON.stringify(obj)); }
async function relaySendSDP(kind, desc){ relaySend({ type:'sdp', kind, data: b64(await aesEncrypt(relayKey, JSON.stringify(desc))) }); } 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(){ async function makeOffer(){
freshPC(); /* always (re)negotiate on a clean connection */
await ensurePC(); await ensurePC();
await pc.setLocalDescription(await pc.createOffer()); await pc.setLocalDescription(await pc.createOffer());
await waitForIceGathering(pc); await waitForIceGathering(pc);
@ -249,6 +255,7 @@ async function makeOffer(){
async function onSDP(kind, json){ async function onSDP(kind, json){
const desc = JSON.parse(json); const desc = JSON.parse(json);
if (kind==='offer'){ if (kind==='offer'){
freshPC(); /* every incoming offer = a fresh connection */
await ensurePC(); await ensurePC();
await pc.setRemoteDescription(desc); await pc.setRemoteDescription(desc);
await pc.setLocalDescription(await pc.createAnswer()); await pc.setLocalDescription(await pc.createAnswer());
@ -277,9 +284,22 @@ async function startCall(){
relayWS.onerror = () => setStatus('rendezvous error','err'); relayWS.onerror = () => setStatus('rendezvous error','err');
relayWS.onmessage = async (ev) => { relayWS.onmessage = async (ev) => {
let m; try { m=JSON.parse(ev.data); } catch(_){ return; } let m; try { m=JSON.parse(ev.data); } catch(_){ return; }
if (m.type==='welcome'){ relayRole=m.role; logLine('','role: '+m.role+' ('+m.peers+' present)'); if (relayRole==='offerer'&&m.peers>=2) await makeOffer(); } /* present-member-offers: whoever is already in the room offers when the
else if (m.type==='peer-joined'){ logLine('','partner joined'); if (relayRole==='offerer') await makeOffer(); } * other (re)joins; the joiner waits. Works for the first call and for any
else if (m.type==='peer-left'){ logLine('','partner left'); setStatus('partner left', null); } * reconnect, regardless of who left. */
if (m.type==='welcome'){
logLine('','joined room ('+m.peers+' present)');
setStatus(m.peers>=2 ? 'partner here — connecting' : 'waiting for partner', null);
}
else if (m.type==='peer-joined'){ logLine('','partner joined — calling'); await makeOffer(); }
else if (m.type==='peer-left'){
logLine('','partner left — staying in room, will reconnect when they return');
freshPC();
$('remote-audio').srcObject=null; $('meter-rem').style.width='0%';
$('dot-call').className='dot warn'; setPath('path: —', null);
$('btn-mute').disabled=true; muted=false; $('btn-mute').textContent='mute'; $('btn-mute').className='';
setStatus('partner left — waiting for them to return', null);
}
else if (m.type==='sdp'){ try { await onSDP(m.kind, await aesDecrypt(relayKey, unb64(m.data))); } catch(e){ logLine('err','sdp failed (codes must match): '+e.message); } } else if (m.type==='sdp'){ try { await onSDP(m.kind, await aesDecrypt(relayKey, unb64(m.data))); } catch(e){ logLine('err','sdp failed (codes must match): '+e.message); } }
else if (m.type==='error'){ setStatus('rendezvous: '+m.message,'err'); } else if (m.type==='error'){ setStatus('rendezvous: '+m.message,'err'); }
}; };