www.unturf.com/zebra-report/zebra-audio.html
2026-06-03 20:57:18 -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-04</span><br>
md5 <span class="stamp-md5">704bc6f8c294b01704add1b59ec23d69</span><br>
sha256 <span class="stamp-sha">06b739c56ebdfab90591ea4c5e8d8cb6b5cd854c02f526753f724857c518a64e</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>