From 8dfdd9561c0047293f61b282fe6f29c7e743390a Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 28 May 2026 14:13:38 -0400 Subject: [PATCH] zebra-audio: auto-reconnect on partner return + verifiable path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/zebra-audio.html | 44 ++++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/web/zebra-audio.html b/web/zebra-audio.html index bce0500..15e90f5 100644 --- a/web/zebra-audio.html +++ b/web/zebra-audio.html @@ -225,21 +225,27 @@ async function ensurePC(){ async function reportPath(){ if (!pc) return; try { - const stats = await pc.getStats(); let pair=null; - stats.forEach(r=>{ if(r.type==='candidate-pair'&&r.nominated&&r.state==='succeeded') pair=r; }); - if (!pair) stats.forEach(r=>{ if(!pair&&r.type==='candidate-pair'&&r.state==='succeeded') pair=r; }); - if (!pair){ setPath('path: connecting…', null); return; } - const loc=stats.get(pair.localCandidateId)||{}, rem=stats.get(pair.remoteCandidateId)||{}; - if (loc.candidateType==='relay'||rem.candidateType==='relay') - setPath('path: RELAYED via TURN (a server forwards your encrypted audio)', 'err'); - else - setPath('path: DIRECT peer-to-peer — nobody between you ('+(loc.candidateType||'?')+'/'+(rem.candidateType||'?')+')', 'ok'); + const stats = await pc.getStats(); let sel=null; + /* the authoritative active pair, per the transport */ + stats.forEach(r=>{ if(r.type==='transport'&&r.selectedCandidatePairId) sel=stats.get(r.selectedCandidatePairId); }); + if (!sel) stats.forEach(r=>{ if(r.type==='candidate-pair'&&r.state==='succeeded'&&(r.nominated||r.selected)) sel=r; }); + if (!sel){ setPath('path: connecting…', null); return; } + const loc=stats.get(sel.localCandidateId)||{}, rem=stats.get(sel.remoteCandidateId)||{}; + const la=(loc.address||loc.ip||'?')+':'+(loc.port||'?'), ra=(rem.address||rem.ip||'?')+':'+(rem.port||'?'); + const relayed = loc.candidateType==='relay'||rem.candidateType==='relay'; + /* 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(_){} } 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))) }); } +function freshPC(){ if (pc){ try{pc.close();}catch(_){} pc=null; } } async function makeOffer(){ + freshPC(); /* always (re)negotiate on a clean connection */ await ensurePC(); await pc.setLocalDescription(await pc.createOffer()); await waitForIceGathering(pc); @@ -249,6 +255,7 @@ async function makeOffer(){ async function onSDP(kind, json){ const desc = JSON.parse(json); if (kind==='offer'){ + freshPC(); /* every incoming offer = a fresh connection */ await ensurePC(); await pc.setRemoteDescription(desc); await pc.setLocalDescription(await pc.createAnswer()); @@ -277,9 +284,22 @@ async function startCall(){ relayWS.onerror = () => setStatus('rendezvous error','err'); relayWS.onmessage = async (ev) => { 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(); } - else if (m.type==='peer-joined'){ logLine('','partner joined'); if (relayRole==='offerer') await makeOffer(); } - else if (m.type==='peer-left'){ logLine('','partner left'); setStatus('partner left', null); } + /* present-member-offers: whoever is already in the room offers when the + * other (re)joins; the joiner waits. Works for the first call and for any + * 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==='error'){ setStatus('rendezvous: '+m.message,'err'); } };