zebra-audio: real-time WebRTC voice-call SPA (cover front)

New single-page app: two rendezvoused partners type the same code and get a
live Opus voice call over WebRTC — direct P2P when possible, TURN relay
fallback otherwise, DTLS-SRTP encrypted end to end. Reuses the zebra-signal
rendezvous (code-encrypted SDP, zero-knowledge relay) and the ephemeral
/turn-cred credentials. Mic uses echo-cancellation/noise-suppression; mute,
hang up, live mic/remote level meters, and a direct-vs-relayed path indicator.
Deliberately NOT over the volume modem — ordinary low-latency voice, which
doubles as a plausible cover for the report channel.
This commit is contained in:
Russell Ballestrini 2026-05-28 13:57:05 -04:00
parent cc4cc8065c
commit 860afbd9e4
No known key found for this signature in database

313
web/zebra-audio.html Normal file
View file

@ -0,0 +1,313 @@
<!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: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
input[type=text] {
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
padding: 0.45rem; background: #fff; color: #000; flex: 1; min-width: 0;
}
.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; flex: 1; }
.meter-fill { height: 100%; background: #000; width: 0%; transition: width 0.06s linear; }
.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; }
</style>
</head>
<body>
<h1>zebra audio</h1>
<p class="sub">encrypted voice &nbsp;·&nbsp; webrtc &nbsp;·&nbsp; rendezvous &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="row">
<div class="dot warn" id="dot-call"></div>
<span id="call-status" class="status-line">idle</span>
<button id="btn-mute" style="margin-left:auto" 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>
<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.
</p>
</section>
<section>
<h2>levels</h2>
<div class="row">
<span class="note" style="min-width:3.5rem">you</span>
<div class="meter"><div class="meter-fill" id="meter-mic"></div></div>
</div>
<div class="row">
<span class="note" style="min-width:3.5rem">them</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;
async function getMic(){
if (micStream) return micStream;
micStream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation:true, noiseSuppression:true, autoGainControl:true }, video:false
});
return micStream;
}
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;
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()) pc.addTrack(tr, micStream);
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||'?')); } };
pc.onconnectionstatechange = () => {
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); }
else if (s==='failed'||s==='disconnected'||s==='closed'){ setPath('path: —', null); }
};
return pc;
}
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');
} 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))) }); }
async function makeOffer(){
await ensurePC();
await pc.setLocalDescription(await pc.createOffer());
await waitForIceGathering(pc);
await relaySendSDP('offer', pc.localDescription);
setStatus('offer sent — connecting', null);
}
async function onSDP(kind, json){
const desc = JSON.parse(json);
if (kind==='offer'){
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'); }
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;
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.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); }
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'); }
};
}
function hangup(){
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%';
logLine('', 'call ended');
}
let muted=false;
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':'';
}
$('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(); } });
logLine('', 'ready — type a rendezvous code and call. mic stays muted to the room until connected.');
})();
</script>
</body>
</html>