zebra-report/web/zebra-audio.html
Russell Ballestrini caa0548113
zebra-spaces: fresh-stream wrap on mic ontrack + mute-gate mesh swap (kill fedora-chrome silence)
Fox 2026-06-07 telemetry on blanka-chrome ruled out the sink-routing
hypothesis from 467146a:

  audio via AudioContext 085b target=0.5s ctxState=running sink=default
  mesh stream swapped into worklet for 085b
  ...
  aud.recv pkt=14622 lost=1 bytes=9117575 jitter=0.0100 level=0.000 jbuf=? lp=0.1s

50 pps stereo Opus arriving on the SFU sub PC, sink routed to the
picked device, but jbuf=? (jitterBufferEmittedCount=0) + level=0 — the
native decoder consumes nothing because no MediaStreamSource is bound
to the SFU receiver's track. fxhp-phone same room same window decodes
fine (jbuf=0.49s level=0.001), so the SFU is healthy and the defect is
per-client wiring.

Two coupled defects:

(A) tracks=0 race in handleRemoteSfuTrack mic branch. Chrome can fire
ontrack with ev.streams[0] still empty at handler-time (the live track
arrives a microtask later, or MSID-supplant merges new+dead tracks).
Caching ev.streams[0] then handing it to attachSfuTrack drops the
listener into the silent <audio> fallback — telemetry: "sfu attach
b1a7 fresh=1 tracks=0" + "meter for b1a7: MediaStream has no audio
track". Screen/camera/game already wrap ev.track in a fresh
MediaStream; mic now does the same. Single track, guaranteed live,
every time.

(B) Mesh ontrack unconditionally swaps the worklet's source to the
mesh stream — even when the mesh track is still muted (no RTP). The
SFU receiver becomes orphaned (no decoder), and if the mesh track
never unmutes the listener hears silence with no automatic SFU
restore. Gate the swap on track-not-muted: swap immediately if mesh
is already flowing, otherwise wait for 'unmute'. Add a mute-watchdog
that swaps back to the cached SFU stream after MESH_MUTE_WINDOW_MS
of mesh silence — same shape as the existing
connectionState=='failed' restore path, but driven by track-level
mute instead of PC-level failure.

Hard refresh + leave/enter worked because the fresh negotiation
delivered the mesh track already unmuted, so the eager swap landed
on a live source. Now the same swap waits for live source instead of
hoping for one.

Tests:
  - handleRemoteSfuTrack: ev.streams[0] empty but ev.track live →
    fresh MediaStream wrap rescues the attach (chain built, audible
    path verified)

Ticket 0001 updated with the telemetry comparison and the new
diagnosis. Mesh-mute-watchdog has no dedicated test yet (the mesh
ontrack lives in connectToPeer which the listener test harness
doesn't extract) — pin in multi-peer-mesh.test.js next pass.
2026-06-07 11:33:57 -04:00

775 lines
37 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>zebra audio</title>
<style>
@font-face {
font-family: 'chunkfiveregular';
src: url('fonts/chunkfive-regular-webfont.woff2') format('woff2'),
url('fonts/chunkfive-regular-webfont.woff') format('woff');
font-weight: normal; font-style: normal;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: monospace; background: #fff; color: #000;
padding: 2rem; max-width: 560px; margin: 0 auto;
}
h1 {
font-family: 'chunkfiveregular', serif;
font-size: 3rem; font-weight: normal; letter-spacing: 0.02em;
line-height: 1; margin-bottom: 0.2rem;
}
.sub {
font-size: 0.75rem; color: #555; margin-bottom: 2rem;
letter-spacing: 0.05em; text-transform: uppercase;
}
.sub a { color: #555; }
h2 {
font-family: 'chunkfiveregular', serif; font-size: 1.1rem; font-weight: normal;
border-bottom: 1px solid #000; padding-bottom: 0.25rem; margin-bottom: 0.8rem;
}
section { margin-bottom: 1.6rem; }
button {
background: #fff; color: #000; border: 1px solid #000;
padding: 0.45rem 1.2rem; font-family: monospace; font-size: 0.9rem; cursor: pointer;
}
button:hover:not(:disabled) { background: #f0f0f0; }
button:disabled { opacity: 0.3; cursor: default; }
button.invert { background: #000; color: #fff; }
button.invert:hover:not(:disabled) { background: #333; }
.row {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, max-content);
align-items: center; gap: 0.75rem; margin-bottom: 0.75rem;
min-width: 0;
}
.row:has(> :first-child + input[type=text]),
.row:has(> :first-child + input[type=password]),
.row:has(> :first-child + select) {
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
}
.row:has(> input[type=text]:first-child),
.row:has(> input[type=password]:first-child),
.row:has(> select:first-child) {
grid-template-columns: minmax(0, 1fr);
}
.row > .note {
grid-column: 1 / -1;
margin-top: 0.05rem; line-height: 1.4;
}
.row > label { white-space: normal; }
input[type=text] {
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
padding: 0.45rem; background: #fff; color: #000; min-width: 0; width: 100%;
}
select {
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
padding: 0.45rem; background: #fff; color: #000; min-width: 0; width: 100%; 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; }
.status-line { font-size: 0.8rem; color: #555; }
.status-line.ok { color: #060; }
.status-line.err { color: #b00; }
.note { font-size: 0.75rem; color: #555; line-height: 1.5; }
.meter { height: 12px; border: 1px solid #000; background: #fff; position: relative; overflow: hidden; }
.meter-fill { height: 100%; background: #000; width: 0%; transition: width 0.06s linear; }
/* grid layouts so controls never reflow when a label/state changes */
.controls { display: grid; grid-template-columns: auto 1fr auto auto; align-items: center; gap: 0.6rem; margin-bottom: 0.75rem; }
#btn-mute { width: 6rem; text-align: center; } /* fixed width: 'mute'/'unmute' never shifts the row */
.level { display: grid; grid-template-columns: 3rem 1.5rem 1fr; align-items: center; gap: 0.6rem; margin-bottom: 0.6rem; }
.level .who { font-size: 0.8rem; color: #555; }
.mic { width: 18px; height: 18px; }
.mic svg { width: 18px; height: 18px; display: block; }
.log { border: 1px solid #000; height: 150px; overflow-y: auto; padding: 0.5rem; font-size: 0.75rem; line-height: 1.5; background: #fafafa; }
.log-line { margin-bottom: 0.2rem; word-wrap: break-word; }
.log-line .ts { color: #888; }
.log-line.err { color: #b00; }
@media (max-width: 600px) {
body { padding: 1.25rem; }
h1 { font-size: 2.2rem; }
.row, .controls, .level { gap: 0.5rem; }
/* drop the fixed mute-button width on phones so the row fits */
#btn-mute { width: auto; padding: 0.45rem 0.9rem; }
}
/* ---- shared dark mode: pure black canvas, dim text, low light ---- */
html.theme-dark { background: #000; }
html.theme-dark body { background: #000; color: #ccc; }
html.theme-dark a { color: #9cf; }
html.theme-dark .sub, html.theme-dark .sub a, html.theme-dark .note,
html.theme-dark .foot, html.theme-dark label,
html.theme-dark .status-line { color: #888; }
html.theme-dark h2 { border-bottom-color: #333; }
html.theme-dark code, html.theme-dark .key-display {
background: #1a1a1a; border-color: #333; color: #ccc;
}
html.theme-dark .key-display:hover { background: #222; }
html.theme-dark button {
background: #000; color: #ccc; border-color: #444;
}
html.theme-dark button:hover:not(:disabled) { background: #1a1a1a; }
html.theme-dark button.invert, html.theme-dark .cta {
background: #ccc; color: #000; border-color: #ccc;
}
html.theme-dark button.invert:hover:not(:disabled),
html.theme-dark .cta:hover { background: #999; }
html.theme-dark input[type=text], html.theme-dark input[type=password],
html.theme-dark textarea, html.theme-dark select {
background: #0a0a0a; color: #ccc; border-color: #333;
}
html.theme-dark .dot { background: #000; border-color: #444; }
html.theme-dark .dot.on { background: #ccc; border-color: #ccc; }
html.theme-dark .dot.ok { background: #0a0; border-color: #0a0; }
html.theme-dark .meter { background: #000; border-color: #444; }
html.theme-dark .meter-fill { background: #ccc; }
html.theme-dark .status-line.ok { color: #6c6; }
html.theme-dark .status-line.err { color: #f66; }
html.theme-dark .log, html.theme-dark .diagram {
background: #050505; border-color: #333; color: #ccc;
}
html.theme-dark .log-line .ts { color: #666; }
html.theme-dark .log-line.sys { color: #888; font-style: italic; }
html.theme-dark .log-line.err { color: #f66; }
html.theme-dark hr { border-top-color: #222; }
html.theme-dark footer { color: #555 !important; }
html.theme-dark footer span { color: #555 !important; }
html.theme-dark footer a { color: #777 !important; }
.theme-toggle {
position: fixed; top: 0.6rem; right: 0.8rem; z-index: 9999;
background: #fff; color: #000; border: 1px solid #000;
padding: 0.2rem 0.55rem; font-family: monospace; font-size: 0.7rem;
cursor: pointer; line-height: 1;
}
.theme-toggle:hover { background: #f0f0f0; }
html.theme-dark .theme-toggle { background: #000; color: #ccc; border-color: #444; }
html.theme-dark .theme-toggle:hover { background: #1a1a1a; }
</style>
<script>
/* shared zebra dark-mode pre-paint script — default IS dark, only an
* explicit 'light' choice opts out so first-time visitors land dark
* without a white flash */
try {
var pref = localStorage.getItem('zebra-theme-v1');
if (!pref) pref = localStorage.getItem('zebra-spaces-theme-v1'); // legacy
if (pref !== 'light'){
document.documentElement.classList.add('theme-dark');
}
} catch(_){ document.documentElement.classList.add('theme-dark'); }
</script>
</head>
<body>
<button id="btn-theme" class="theme-toggle" type="button" aria-label="toggle dark mode">light</button>
<h1>zebra audio</h1>
<p class="sub">encrypted voice &nbsp;·&nbsp; webrtc &nbsp;·&nbsp; rendezvous &nbsp;·&nbsp;
<a href="zebra-spaces.html">spaces</a> &nbsp;·&nbsp;
<a href="host-your-own.html">host your own</a> &nbsp;·&nbsp;
<a href="/">unturf</a></p>
<section>
<h2>call</h2>
<div class="row">
<input type="text" id="rdv-code" placeholder="rendezvous code — both type the same thing">
<button id="btn-call" class="invert">call</button>
</div>
<div class="controls">
<div class="dot warn" id="dot-call"></div>
<span id="call-status" class="status-line">idle</span>
<button id="btn-mute" disabled>mute</button>
<button id="btn-hangup" disabled>hang up</button>
</div>
<div class="row">
<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>
<p class="note">
both partners type the same code and hit call. audio is end-to-end
encrypted (DTLS-SRTP), peer-to-peer when the network allows, relayed through
a TURN server otherwise. the rendezvous server only sees an opaque room id
and code-encrypted setup data.
&nbsp;<a href="host-your-own.html"><strong>run this for your own community &rarr;</strong></a>
</p>
</section>
<section>
<h2>levels</h2>
<div class="level">
<span class="who">you</span>
<span class="mic" id="you-mic" title="your mic"></span>
<div class="meter"><div class="meter-fill" id="meter-mic"></div></div>
</div>
<div class="level">
<span class="who">them</span>
<span class="mic" id="them-mic" title="partner mic"></span>
<div class="meter"><div class="meter-fill" id="meter-rem"></div></div>
</div>
</section>
<section>
<h2>log</h2>
<div class="log" id="log"></div>
</section>
<audio id="remote-audio" autoplay></audio>
<script>
(async () => {
const $ = (id) => document.getElementById(id);
function b64(buf){ const u8=buf instanceof Uint8Array?buf:new Uint8Array(buf); let s=''; for(const b of u8) s+=String.fromCharCode(b); return btoa(s); }
function unb64(s){ return Uint8Array.from(atob(s), c=>c.charCodeAt(0)); }
function hex(buf){ const u8=buf instanceof Uint8Array?buf:new Uint8Array(buf); return Array.from(u8).map(b=>b.toString(16).padStart(2,'0')).join(''); }
const logEl = $('log');
function logLine(kind, msg){
const d=document.createElement('div'); d.className='log-line '+(kind||'');
d.innerHTML='<span class="ts">'+new Date().toLocaleTimeString()+'</span> '+msg;
logEl.appendChild(d); logEl.scrollTop=logEl.scrollHeight;
}
/* ---- rendezvous signaling (shared scheme with zebra-report) ---- */
const SIGNAL_URL = new URLSearchParams(location.search).get('signal')
|| 'wss://cors-proxy.uncloseai.com/zebra-signal';
const SIGNAL_SALT = new TextEncoder().encode('zebra-signal-v1');
const PBKDF2_ITER = 600000;
async function deriveSignalRoom(code){
const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode('zebra-signal-room|'+code));
return hex(h);
}
async function deriveSignalKey(code){
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(code), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey({ name:'PBKDF2', salt:SIGNAL_SALT, iterations:PBKDF2_ITER, hash:'SHA-256' },
base, { name:'AES-GCM', length:256 }, false, ['encrypt','decrypt']);
}
async function aesEncrypt(key, str){
const iv=crypto.getRandomValues(new Uint8Array(12));
const ct=new Uint8Array(await crypto.subtle.encrypt({name:'AES-GCM',iv}, key, new TextEncoder().encode(str)));
const out=new Uint8Array(12+ct.length); out.set(iv); out.set(ct,12); return out;
}
async function aesDecrypt(key, bytes){
const iv=bytes.slice(0,12), ct=bytes.slice(12);
return new TextDecoder().decode(await crypto.subtle.decrypt({name:'AES-GCM',iv}, key, ct));
}
/* ---- ephemeral TURN credentials ---- */
const TURN_CRED_URL = new URLSearchParams(location.search).get('turncred')
|| 'https://cors-proxy.uncloseai.com/turn-cred';
let rtcConfig = { iceServers: [{ urls: ['stun:proxy.uncloseai.com:3478','stun:stun.l.google.com:19302'] }] };
async function refreshTurnCred(){
try {
const c = await (await fetch(TURN_CRED_URL, {cache:'no-store'})).json();
if (c && c.credential && c.uris) {
rtcConfig = { iceServers: [
{ urls: c.stun || ['stun:proxy.uncloseai.com:3478'] },
{ urls: c.uris, username: c.username, credential: c.credential }
] };
logLine('', 'TURN credentials fetched (relay fallback ready)');
}
} catch (e) { logLine('', 'no TURN creds — direct/STUN only ('+e.message+')'); }
}
/* ---- audio ---- */
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(){
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){
if (!sender) return;
try {
const p = sender.getParameters();
if (!p.encodings || !p.encodings.length) p.encodings = [{}];
p.encodings[0].maxBitrate = musicMode ? 160000 : 40000; /* bits/s */
await sender.setParameters(p);
} catch(_){}
}
async function getMic(){
if (micStream) return micStream;
micStream = await navigator.mediaDevices.getUserMedia({ audio: micConstraints(), video:false });
tagTrack(micStream.getAudioTracks()[0]);
return micStream;
}
/* switch voice<->music mid-call: re-acquire the mic and hot-swap the track on
* the live connection (no renegotiation), preserving mute state */
async function applyMicMode(){
const ns = await navigator.mediaDevices.getUserMedia({ audio: micConstraints(), video:false });
const nt = ns.getAudioTracks()[0];
tagTrack(nt); nt.enabled = !muted;
if (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); }
}
if (micStream) micStream.getTracks().forEach(t=>t.stop());
micStream = ns;
meterFor(micStream, 'meter-mic');
}
function meterFor(stream, fillId){
if (!audioCtx) audioCtx = new (window.AudioContext||window.webkitAudioContext)();
const src = audioCtx.createMediaStreamSource(stream);
const an = audioCtx.createAnalyser(); an.fftSize = 512;
src.connect(an);
const buf = new Uint8Array(an.fftSize);
const fill = $(fillId);
(function tick(){
if (!micStream && fillId==='meter-mic') return;
an.getByteTimeDomainData(buf);
let peak=0; for (let i=0;i<buf.length;i++){ const v=Math.abs(buf[i]-128)/128; if(v>peak)peak=v; }
fill.style.width = Math.min(100, Math.round(peak*180))+'%';
requestAnimationFrame(tick);
})();
}
/* ---- 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=>{
if (p.iceGatheringState==='complete') return res();
let done=false; const fin=()=>{ if(done)return; done=true; p.removeEventListener('icegatheringstatechange',h); clearTimeout(t); res(); };
const h=()=>{ if(p.iceGatheringState==='complete') fin(); };
p.addEventListener('icegatheringstatechange',h); const t=setTimeout(fin,timeoutMs);
});
}
function setStatus(msg, cls){ const e=$('call-status'); e.textContent=msg; e.className='status-line'+(cls?' '+cls:''); }
function setPath(msg, cls){ const e=$('path-status'); e.textContent=msg; e.className='status-line'+(cls?' '+cls:''); const d=$('dot-path'); d.className='dot'+(cls==='ok'?' ok':cls==='err'?' warn':''); }
async function ensurePC(){
if (pc) return pc;
pc = new RTCPeerConnection(rtcConfig);
for (const tr of micStream.getTracks()) { tagTrack(tr); pc.addTrack(tr, micStream); }
setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio'));
pc.ontrack = (ev) => {
const stream = ev.streams[0] || new MediaStream([ev.track]);
$('remote-audio').srcObject = stream;
try { meterFor(stream, 'meter-rem'); } catch(_){}
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'){
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;
}
async function reportPath(){
if (!pc) return;
try {
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 relayed = loc.candidateType==='relay'||rem.candidateType==='relay';
/* deliberately do NOT surface loc.address/loc.port/rem.address/rem.port —
* printing them would dox the peer to anyone who glances at the screen.
* candidate types (host/srflx/relay) are abstract enough to show: they
* tell you whether you're direct or relayed without leaking an address. */
setPath('path: '+(relayed
? 'RELAYED via TURN — a server forwards your (encrypted) audio'
: 'DIRECT peer-to-peer — nobody between you')
+' ('+(loc.candidateType||'?')+'/'+(rem.candidateType||'?')+')', relayed?'err':'ok');
logLine('', 'transport: '+(relayed?'relayed via TURN':'direct peer-to-peer')+' ('+(loc.candidateType||'?')+'/'+(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(){
iAmOfferer = true; /* we drive any later ICE recovery */
freshPC(); /* always (re)negotiate on a clean connection */
await ensurePC();
await pc.setLocalDescription(await pc.createOffer());
await waitForIceGathering(pc);
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);
await pc.setLocalDescription(await pc.createAnswer());
await waitForIceGathering(pc);
await relaySendSDP('answer', pc.localDescription);
setStatus('answer sent — connecting', null);
} else if (kind==='answer' && pc) {
await pc.setRemoteDescription(desc);
}
}
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(); 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 = () => {
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; }
/* 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='';
partnerKnown=false; partnerMuted=false; setMicIcons();
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==='mic'){ try { const s=JSON.parse(await aesDecrypt(relayKey, unb64(m.data))); setPartnerMic(!!s.muted); } catch(_){} }
else if (m.type==='error'){ setStatus('rendezvous: '+m.message,'err'); }
};
}
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;
$('btn-mute').disabled=true; $('btn-hangup').disabled=true; $('btn-call').disabled=false;
$('dot-call').className='dot warn'; setStatus('idle', null); setPath('path: —', null);
$('meter-rem').style.width='0%';
muted=false; partnerMuted=false; partnerKnown=false; $('btn-mute').textContent='mute'; $('btn-mute').className=''; setMicIcons();
logLine('', 'call ended');
}
let muted=false, partnerMuted=false, partnerKnown=false;
const MIC_ON = '<svg viewBox="0 0 24 24" fill="none" stroke="#060" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
const MIC_OFF = '<svg viewBox="0 0 24 24" fill="none" stroke="#b00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
function setMicIcons(){
$('you-mic').innerHTML = micStream ? (muted ? MIC_OFF : MIC_ON) : '';
$('them-mic').innerHTML = partnerKnown ? (partnerMuted ? MIC_OFF : MIC_ON) : '';
}
function setPartnerMic(m){ partnerMuted=m; partnerKnown=true; setMicIcons(); logLine('', 'partner '+(m?'muted':'unmuted')); }
/* advertise our mic state to the partner over the rendezvous channel, encrypted
* with the room code so the relay never learns it */
async function sendMicState(){
if (!relayWS || relayWS.readyState!==1 || !relayKey) return;
try { relayWS.send(JSON.stringify({ type:'mic', data: b64(await aesEncrypt(relayKey, JSON.stringify({muted}))) })); } catch(_){}
}
function toggleMute(){
if (!micStream) return;
muted=!muted;
micStream.getAudioTracks().forEach(t=>t.enabled=!muted);
$('btn-mute').textContent = muted?'unmute':'mute';
$('btn-mute').className = muted?'invert':'';
setMicIcons();
sendMicState();
}
$('btn-call').addEventListener('click', startCall);
$('btn-hangup').addEventListener('click', hangup);
$('btn-mute').addEventListener('click', toggleMute);
$('rdv-code').addEventListener('keydown', e=>{ if(e.key==='Enter'){ e.preventDefault(); startCall(); } });
$('music-mode').addEventListener('change', async (e)=>{
musicMode = e.target.checked;
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.');
/* ============================================================== *
* puppet — hidden TTS console (triple-click the footer seal) *
* *
* Synthesizes typed text at speech.ai.unturf.com (OpenAI-compatible *
* /v1/audio/speech, no key, CORS open) and plays it into the call: *
* the decoded speech is swapped onto the outbound RTC sender so the *
* peer hears the voice, and connected to the local output so we do *
* too. When playback ends the live mic track is swapped back. *
* ============================================================== */
const TTS_BASE = (new URLSearchParams(location.search).get('tts') || 'https://speech.ai.unturf.com/v1').replace(/\/$/, '');
let puppeting = false, puppetVoicesLoaded = false;
let currentSource = null, currentAbort = null, stopRequested = false;
function setPuppet(msg, cls){
const e = $('puppet-status'); if (!e) return;
e.textContent = msg; e.className = 'status-line' + (cls ? ' ' + cls : '');
$('puppet-dot').className = 'dot' + (cls === 'ok' ? ' ok' : cls === 'err' ? ' warn' : '');
}
async function loadPuppetVoices(){
if (puppetVoicesLoaded) return;
puppetVoicesLoaded = true;
try {
const j = await (await fetch(TTS_BASE + '/voices', { cache:'no-store' })).json();
const voices = (j.data && j.data[0] && j.data[0].voices) || [];
if (voices.length){
const sel = $('puppet-voice'); sel.innerHTML = '';
for (const name of voices){ const o = document.createElement('option'); o.value = name; o.textContent = name; sel.appendChild(o); }
sel.value = voices.includes('aria') ? 'aria' : voices[0];
}
} catch(_){ /* keep the hardcoded 'aria' fallback option */ }
}
function revealPuppet(){
const p = $('puppet'); if (!p) return;
p.hidden = false;
loadPuppetVoices();
p.scrollIntoView({ behavior:'smooth', block:'center' });
$('puppet-text').focus();
}
/* play one TTS buffer: local output always; the peer when a call is up */
function playPuppet(audioBuf){
return new Promise((resolve) => {
const src = audioCtx.createBufferSource();
src.buffer = audioBuf;
currentSource = src; /* so puppetStop() can halt it */
src.connect(audioCtx.destination); /* local — we hear it too ("both") */
const sender = pc && (pc.getSenders().find(s => s.track && s.track.kind === 'audio') || pc.getSenders()[0]);
let restored = false;
const restore = async () => {
if (restored) return; restored = true;
currentSource = null;
const mt = micStream && micStream.getAudioTracks()[0];
if (sender && mt){ try { await sender.replaceTrack(mt); } catch(_){} }
resolve();
};
if (sender){
const dest = audioCtx.createMediaStreamDestination();
src.connect(dest);
sender.replaceTrack(dest.stream.getAudioTracks()[0]).catch(()=>{});
logLine('', 'puppet: speaking into the call');
} else {
logLine('', 'puppet: no call active — local playback only');
}
src.onended = restore;
src.start();
});
}
async function puppetSpeak(){
if (puppeting) return;
const text = ($('puppet-text').value || '').trim();
if (!text){ setPuppet('type something first', 'err'); return; }
const voice = $('puppet-voice').value || 'aria';
puppeting = true; stopRequested = false;
$('puppet-speak').textContent = 'stop'; /* same button now interrupts */
setPuppet('synthesizing…', null);
currentAbort = new AbortController();
try {
const res = await fetch(TTS_BASE + '/audio/speech', {
method:'POST', headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ input: text, voice }), signal: currentAbort.signal
});
if (!res.ok) throw new Error('tts http ' + res.status);
const bytes = await res.arrayBuffer();
if (stopRequested) throw new DOMException('stopped', 'AbortError');
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended'){ try { await audioCtx.resume(); } catch(_){} }
const audioBuf = await audioCtx.decodeAudioData(bytes);
if (stopRequested) throw new DOMException('stopped', 'AbortError');
setPuppet('speaking…', null);
await playPuppet(audioBuf);
if (stopRequested){ setPuppet('stopped', null); }
else { setPuppet('spoke: "' + text.slice(0, 48) + (text.length > 48 ? '…' : '') + '"', 'ok'); $('puppet-text').value = ''; }
} catch(e){
if (e.name === 'AbortError' || stopRequested) setPuppet('stopped', null);
else setPuppet('failed: ' + e.message, 'err');
} finally {
puppeting = false; currentSource = null; currentAbort = null;
$('puppet-speak').textContent = 'speak';
}
}
/* interrupt: abort an in-flight synthesis, or halt playback (which fires
* onended -> restore, swapping the mic back). idempotent. */
function puppetStop(){
if (!puppeting) return;
stopRequested = true;
if (currentAbort){ try { currentAbort.abort(); } catch(_){} }
if (currentSource){ try { currentSource.stop(); } catch(_){} }
setPuppet('stopping…', null);
}
/* the seal + console live in the footer block, which the parser reaches after
* this inline script — wire them once the rest of the document exists */
function wirePuppet(){
const seal = $('pi-seal');
/* 3 clicks within 800ms reveals the console. counting our own clicks (rather
* than checking event.detail === 3) means mobile taps work too — touch click
* events do not increment the native UI detail counter. desktop triple-click
* also fires 3 click events, so the same code path covers both. */
let sealClicks = 0, sealTimer = null;
if (seal) seal.addEventListener('click', () => {
try { getSelection().removeAllRanges(); } catch(_){}
if (sealTimer) clearTimeout(sealTimer);
sealClicks++;
if (sealClicks >= 3){ sealClicks = 0; revealPuppet(); return; }
sealTimer = setTimeout(() => { sealClicks = 0; }, 800);
});
const speak = $('puppet-speak'); if (speak) speak.addEventListener('click', () => { puppeting ? puppetStop() : puppetSpeak(); });
const close = $('puppet-close'); if (close) close.addEventListener('click', () => { puppetStop(); $('puppet').hidden = true; });
const tin = $('puppet-text'); if (tin) tin.addEventListener('keydown', (e) => { if (e.key === 'Enter'){ e.preventDefault(); if (!puppeting) puppetSpeak(); } });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wirePuppet);
else wirePuppet();
})();
</script>
<!-- ============================================================ -->
<!-- secret: triple-click the footer "page integrity" seal -->
<!-- ============================================================ -->
<section id="puppet" hidden style="max-width:560px;margin:1.8rem auto 0;border:1px dashed #000;padding:0.9rem 1rem">
<h2 style="display:grid;grid-template-columns:1fr auto;align-items:center;gap:0.5rem">
<span>puppet</span>
<button id="puppet-close" style="font-size:0.7rem;padding:0.15rem 0.6rem">close</button>
</h2>
<p class="note" style="margin-bottom:0.7rem">
type a line — it is synthesized at <code>speech.ai.unturf.com</code> and spoken
straight into the call. the listener hears the voice, and so do you.
</p>
<div class="row">
<label class="note" for="puppet-voice" style="flex:0 0 auto;min-width:3rem">voice</label>
<select id="puppet-voice"><option value="aria">aria</option></select>
</div>
<div class="row">
<input type="text" id="puppet-text" placeholder="say something in someone else's voice…" autocomplete="off">
<button id="puppet-speak" class="invert">speak</button>
</div>
<div class="row">
<div class="dot" id="puppet-dot"></div>
<span id="puppet-status" class="status-line">idle — not in a call yet plays locally only</span>
</div>
</section>
<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 id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-07</span><br>
md5 <span class="stamp-md5">92054b1353cdbde054776ac5ed46b531</span><br>
sha256 <span class="stamp-sha">6a3dc7206ba425b7f36e8d04b2fd78a1ad53abb7033ba5a39656071591b33a34</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>
<script>
/* shared zebra dark-mode toggle handler */
(function(){
var btn = document.getElementById('btn-theme'); if (!btn) return;
function applyLabel(){
btn.textContent = document.documentElement.classList.contains('theme-dark') ? 'light' : 'dark';
}
applyLabel();
btn.addEventListener('click', function(){
var root = document.documentElement;
var goDark = !root.classList.contains('theme-dark');
root.classList.toggle('theme-dark', goDark);
try { localStorage.setItem('zebra-theme-v1', goDark ? 'dark' : 'light'); } catch(_){}
applyLabel();
});
})();
</script>
</body>
</html>