Each peer signals its mute state over the rendezvous channel (encrypted with the room code, so the relay never learns it) on connect and on every toggle. A "muted" badge now shows on both your own and the partner's level meter, so both players can see who is muted. Verified both directions headless.
351 lines
16 KiB
HTML
351 lines
16 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; 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>
|
|
<span class="note" id="you-state" style="min-width:4.5rem; text-align:right"></span>
|
|
</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>
|
|
<span class="note" id="them-state" style="min-width:4.5rem; text-align:right"></span>
|
|
</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); setMicLabels(); 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'); }
|
|
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='';
|
|
setPartnerMic(false); setMicLabels();
|
|
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; $('btn-mute').textContent='mute'; $('btn-mute').className=''; setMicLabels();
|
|
logLine('', 'call ended');
|
|
}
|
|
let muted=false, partnerMuted=false;
|
|
function setMicLabels(){
|
|
$('you-state').textContent = muted ? 'muted' : '';
|
|
$('them-state').textContent = partnerMuted ? 'muted' : '';
|
|
}
|
|
function setPartnerMic(m){ partnerMuted=m; setMicLabels(); 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':'';
|
|
setMicLabels();
|
|
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>
|