669 lines
33 KiB
HTML
669 lines
33 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;
|
|
}
|
|
select {
|
|
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
|
|
padding: 0.45rem; background: #fff; color: #000; flex: 1; min-width: 0; 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: 1rem; }
|
|
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; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>zebra audio</h1>
|
|
<p class="sub">encrypted voice · webrtc · rendezvous ·
|
|
<a href="zebra-spaces.html">spaces</a> ·
|
|
<a href="host-your-own.html">host your own</a> ·
|
|
<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.
|
|
<a href="host-your-own.html"><strong>run this for your own community →</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:flex;align-items:center;justify-content:space-between">
|
|
<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> · built <span class="stamp-date">2026-05-31</span><br>
|
|
md5 <span class="stamp-md5">d306f9a42ee33db54e69897cefec01bb</span><br>
|
|
sha256 <span class="stamp-sha">b6d6a585708f175bac7f33aca5770ba1debb239722de60cb2aef9c3bf6277872</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>
|
|
</body>
|
|
</html>
|