zebra-report/web/zebra-audio.html
Russell Ballestrini d106bd224e
zebra-audio: mic on/off icons via CSS grid; stop button reflow
Lay out the controls and level rows with CSS grid and give the mute button a
fixed width, so toggling mute/unmute no longer shifts the buttons. Replace the
text "muted" badge with a mic-icon set (green open mic / red slashed mic) shown
next to "you" and "them", so both players can see at a glance whose mic is open
or closed. Verified both directions headless.
2026-05-28 15:09:24 -04:00

360 lines
18 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: 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; }
.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; }
</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="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>
<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="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;
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); partnerKnown=true; setMicIcons(); sendMicState(); }
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 sel=null;
/* the authoritative active pair, per the transport */
stats.forEach(r=>{ if(r.type==='transport'&&r.selectedCandidatePairId) sel=stats.get(r.selectedCandidatePairId); });
if (!sel) stats.forEach(r=>{ if(r.type==='candidate-pair'&&r.state==='succeeded'&&(r.nominated||r.selected)) sel=r; });
if (!sel){ setPath('path: connecting…', null); return; }
const loc=stats.get(sel.localCandidateId)||{}, rem=stats.get(sel.remoteCandidateId)||{};
const la=(loc.address||loc.ip||'?')+':'+(loc.port||'?'), ra=(rem.address||rem.ip||'?')+':'+(rem.port||'?');
const relayed = loc.candidateType==='relay'||rem.candidateType==='relay';
/* show the real endpoints so it's verifiable: host = the two devices' IPs;
* relay = the TURN server's IP (142.93.73.64) appears on at least one side. */
setPath('path: '+(relayed?'RELAYED via TURN':'DIRECT P2P')+' — you '+la
+' ↔ peer '+ra+' ('+(loc.candidateType||'?')+'/'+(rem.candidateType||'?')+')', relayed?'err':'ok');
logLine('', 'active pair: '+la+' ['+(loc.candidateType||'?')+'] ↔ '+ra+' ['+(rem.candidateType||'?')+']');
} catch(_){}
}
function relaySend(obj){ if (relayWS && relayWS.readyState===1) relayWS.send(JSON.stringify(obj)); }
async function relaySendSDP(kind, desc){ relaySend({ type:'sdp', kind, data: b64(await aesEncrypt(relayKey, JSON.stringify(desc))) }); }
function freshPC(){ if (pc){ try{pc.close();}catch(_){} pc=null; } }
async function makeOffer(){
freshPC(); /* always (re)negotiate on a clean connection */
await ensurePC();
await pc.setLocalDescription(await pc.createOffer());
await waitForIceGathering(pc);
await relaySendSDP('offer', pc.localDescription);
setStatus('offer sent — connecting', null);
}
async function onSDP(kind, json){
const desc = JSON.parse(json);
if (kind==='offer'){
freshPC(); /* every incoming offer = a fresh connection */
await ensurePC();
await pc.setRemoteDescription(desc);
await pc.setLocalDescription(await pc.createAnswer());
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(); }
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; }
/* 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(){
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(); } });
logLine('', 'ready — type a rendezvous code and call. mic stays muted to the room until connected.');
})();
</script>
</body>
</html>