From b269fce2f694945655bead357acb058361296c1c Mon Sep 17 00:00:00 2001
From: Russell Ballestrini
Date: Fri, 29 May 2026 15:17:16 -0400
Subject: [PATCH] zebra-audio: secret TTS puppet console
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
web/zebra-audio.html | 161 +++++++++++++++++++++++++++++++++++++++++--
1 file changed, 157 insertions(+), 4 deletions(-)
diff --git a/web/zebra-audio.html b/web/zebra-audio.html
index 3e44977..7d9518a 100644
--- a/web/zebra-audio.html
+++ b/web/zebra-audio.html
@@ -74,6 +74,7 @@
@@ -104,6 +105,7 @@
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.
+ run this for your own community →
@@ -485,13 +487,164 @@ if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){
}
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');
+ /* 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();
})();
+
+
+
+
+
+
+ puppet
+
+
+
+ type a line — it is synthesized at speech.ai.unturf.com and spoken
+ straight into the call. the listener hears the voice, and so do you.
+