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:
parent
cc4cc8065c
commit
860afbd9e4
1 changed files with 313 additions and 0 deletions
313
web/zebra-audio.html
Normal file
313
web/zebra-audio.html
Normal 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 · webrtc · rendezvous ·
|
||||
<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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue