zebra-audio: secret TTS puppet console

Triple-click the footer integrity seal to reveal a hidden console: type a
line, pick one of the cloned voices, and it is synthesized at
speech.ai.unturf.com (/v1/audio/speech, no key, CORS open) and played into the
call — swapped onto the outbound WebRTC track so the listener hears the voice,
and to local output so you do too. The speak button doubles as stop: aborts an
in-flight synthesis or halts playback, then swaps the live mic back.
This commit is contained in:
Russell Ballestrini 2026-05-29 15:17:16 -04:00
parent 162b4b2917
commit b269fce2f6
No known key found for this signature in database

View file

@ -74,6 +74,7 @@
<h1>zebra audio</h1> <h1>zebra audio</h1>
<p class="sub">encrypted voice &nbsp;·&nbsp; webrtc &nbsp;·&nbsp; rendezvous &nbsp;·&nbsp; <p class="sub">encrypted voice &nbsp;·&nbsp; webrtc &nbsp;·&nbsp; rendezvous &nbsp;·&nbsp;
<a href="host-your-own.html">host your own</a> &nbsp;·&nbsp;
<a href="/">unturf</a></p> <a href="/">unturf</a></p>
<section> <section>
@ -104,6 +105,7 @@
encrypted (DTLS-SRTP), peer-to-peer when the network allows, relayed through 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 a TURN server otherwise. the rendezvous server only sees an opaque room id
and code-encrypted setup data. and code-encrypted setup data.
&nbsp;<a href="host-your-own.html"><strong>run this for your own community &rarr;</strong></a>
</p> </p>
</section> </section>
@ -485,13 +487,164 @@ if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){
} }
refreshMicList(); /* populate up front (labels fill in after mic permission) */ 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.'); 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');
/* native triple-click = three clicks on the same node (event.detail === 3) */
if (seal) seal.addEventListener('click', (e) => {
if (e.detail === 3){ try { getSelection().removeAllRanges(); } catch(_){} revealPuppet(); }
});
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> </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"> <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 style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-05-29</span><br> <span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-05-29</span><br>
md5 <span class="stamp-md5">526a8823d1d01a64db80c1ae00c5cbad</span><br> md5 <span class="stamp-md5">1e587958aedf93270535fb510aa946e7</span><br>
sha256 <span class="stamp-sha">edaddafa103af1832ea0e8f578af34e3891df77a86ed806674dc8fc285b632dd</span><br> sha256 <span class="stamp-sha">ba1975bd8011a139f8dec8a868cc218e2aeb09a969831343f02564524412f726</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span> <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> </footer>
</body> </body>
</html> </html>