zebra-report: secret TTS puppet console + host-your-own page

Deploy zebra-audio puppet console (triple-click footer seal: type -> TTS at
speech.ai.unturf.com -> into the call, send/stop) and the new host-your-own
page, plus nav links from chat (index) and how-it-works.
This commit is contained in:
russell@unturf.com 2026-05-29 15:18:00 -04:00
parent 4ae59be669
commit 37d34013ef
No known key found for this signature in database
4 changed files with 561 additions and 11 deletions

View file

@ -0,0 +1,393 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>zebra report — host your own community</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: 820px; margin: 0 auto; line-height: 1.6;
}
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: 2.5rem;
letter-spacing: 0.05em; text-transform: uppercase;
}
.sub a { color: #555; }
h2 {
font-family: 'chunkfiveregular', serif;
font-size: 1.3rem; font-weight: normal;
border-bottom: 1px solid #000;
padding-bottom: 0.3rem; margin: 2.4rem 0 0.9rem;
}
p { margin-bottom: 0.9rem; }
ul, ol { margin: 0 0 0.9rem 1.4rem; }
li { margin-bottom: 0.35rem; }
code {
background: #f0f0f0; padding: 0 0.2rem;
font-size: 0.85em; border: 1px solid #ddd;
}
.lead { font-size: 1.05rem; }
.note { font-size: 0.8rem; color: #555; }
.code {
border: 1px solid #000; background: #fafafa;
padding: 0.9rem 1rem; font-size: 0.78rem; line-height: 1.5;
overflow-x: auto; white-space: pre; margin: 1rem 0;
}
.code b { font-weight: bold; }
.diagram {
border: 1px solid #000; background: #fafafa;
padding: 1rem; font-size: 0.72rem; line-height: 1.45;
overflow-x: auto; white-space: pre; margin: 1rem 0;
}
hr { border: none; border-top: 1px solid #ddd; margin: 2.5rem 0; }
.foot { font-size: 0.75rem; color: #777; margin-top: 2rem; }
.cta {
display: inline-block; margin: 0.4rem 0;
background: #000; color: #fff; border: 1px solid #000;
padding: 0.5rem 1.2rem; text-decoration: none; font-size: 0.9rem;
}
.cta:hover { background: #333; }
</style>
</head>
<body>
<h1>host your own</h1>
<p class="sub">run your own zebra community &nbsp;·&nbsp; self-hosted TURN + rendezvous on one edge box &nbsp;·&nbsp;
<a href="how-it-works.html">how it works</a> &nbsp;·&nbsp;
<a href="./">open the chat</a> &nbsp;·&nbsp;
<a href="/">unturf</a></p>
<p class="lead">
The zebra pages (the <a href="./">chat</a> and the <a href="zebra-audio.html">voice
call</a>) need exactly two things from a server: a <strong>rendezvous relay</strong>
so two browsers can find each other, and a <strong>TURN server</strong> so they
can still connect when both sit behind NAT. Everything else &mdash; the crypto,
the modem, the audio &mdash; runs in the browser. This page hands you the whole
back end so you can run it for your own community on a single small box.
</p>
<p>
This is the exact infrastructure behind <code>www.unturf.com/zebra-report</code>,
written out so you can stand up your own. It is a gift &mdash; reproduce it,
fork it, harden it. Your members then point the existing pages at your servers
with two URL parameters; nothing about the client needs to change.
</p>
<a class="cta" href="#point">&#9654; jump to "point your members at it"</a>
<h2>1 · what you are building</h2>
<p>
One internet-facing box (a $5&ndash;$6/mo VPS is plenty for a small community)
running three daemons behind a TLS reverse proxy:
</p>
<ul>
<li><strong>coturn</strong> &mdash; the STUN/TURN server. STUN tells a browser
its public address; TURN relays the encrypted media when a direct path is
impossible. This is the part that makes calls work across mobile networks and
strict NATs.</li>
<li><strong>a rendezvous relay</strong> &mdash; a tiny WebSocket service that
pairs two browsers in a "room" and forwards their encrypted connection setup.
It never sees plaintext: the room id is an opaque hash and the setup data is
encrypted with the shared code before it ever reaches the server.</li>
<li><strong>a credential mint</strong> &mdash; a single HTTP endpoint that hands
each browser a short-lived TURN username/password, so every user draws on
their own quota instead of sharing one static login.</li>
</ul>
<p class="note">
The mint and the relay are the same small program here, but they are independent
&mdash; split them if you like. coturn is off-the-shelf.
</p>
<h2>2 · the shape of it</h2>
<div class="diagram"> browser A rendezvous (wss, encrypted SDP) browser B
┌──────────┐ ◄──────────────────────────────────────────────────────► ┌──────────┐
│ zebra │ │ zebra │
│ page │ ──┐ ┌── │ page │
└──────────┘ │ GET /turn-cred (https) → short-lived HMAC cred │ └──────────┘
▲ │ │ ▲
│ └────────────────────────┐ ┌────────────────┘ │
│ media (DTLS-SRTP, encrypted) ▼ ▼ media (DTLS-SRTP) │
│ ┌─────────────────────────────┐ │
└─────────────────────────►│ coturn TURN/STUN :3478 │◄──────────────┘
│ relay UDP 49152-50151 │
┌──────────────────────────────┴─────────────────────────────┴───────────────┐
│ your edge box Caddy (auto-TLS) │
│ wss://you/zebra-signal ─► relay :8090 │
│ https://you/turn-cred ─► mint :8090 │
└──────────────────────────────────────────────────────────────────────────────┘</div>
<p>
When the network allows it, the two browsers talk <strong>directly</strong> and
coturn never touches the media. coturn is the fallback that guarantees a
connection; the rendezvous relay is only used for the few hundred bytes of
setup, then sits idle.
</p>
<h2>3 · coturn — the TURN relay</h2>
<p>
Install it (<code>apt install coturn</code> on Debian/Ubuntu) and replace
<code>/etc/turnserver.conf</code> with this. Swap in your box's public IP and a
DNS name you control:
</p>
<div class="code"><b># /etc/turnserver.conf</b>
external-ip=<b>YOUR.PUBLIC.IP</b>
relay-ip=<b>YOUR.PUBLIC.IP</b>
listening-port=3478
realm=<b>turn.example.com</b>
<b># relay allocation range — open these UDP ports in your firewall too</b>
min-port=49152
max-port=50151
<b># time-limited credentials: the mint computes HMAC-SHA1(secret, expiry).</b>
<b># the secret is appended below at deploy and never committed.</b>
use-auth-secret
# static-auth-secret=&lt;injected at deploy, see step 4&gt;
<b># abuse quotas — per ephemeral user, so they stay tight as you scale</b>
total-quota=2000
user-quota=6
bps-capacity=400000000
max-bps=2000000
stale-nonce=600
fingerprint
no-cli
no-loopback-peers
no-multicast-peers
log-file=/var/log/coturn/coturn.log
simple-log</div>
<p>
Run it under systemd as an unprivileged user (all ports are above 1024, so no
special capabilities are needed):
</p>
<div class="code"><b># /etc/systemd/system/coturn.service</b>
[Unit]
Description=coturn TURN/STUN relay for WebRTC NAT traversal
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=turnserver
Group=turnserver
ExecStart=/usr/bin/turnserver -c /etc/turnserver.conf
Restart=on-failure
RestartSec=5
LogsDirectory=coturn
LogsDirectoryMode=0750
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target</div>
<p class="note">
Firewall: allow inbound <code>UDP 3478</code> (and <code>TCP 3478</code> if you
offer TCP relay) plus the whole <code>UDP 49152-50151</code> range. On a cloud
provider, that means a firewall rule, not just <code>ufw</code>.
</p>
<h2>4 · the shared secret</h2>
<p>
coturn and the mint share one secret. The mint signs each ephemeral credential
with it; coturn validates against the same value. <strong>Generate your own</strong>
&mdash; never reuse anyone else's, never print it, never commit it:
</p>
<div class="code"><b># run once, as root, on the box</b>
umask 077
openssl rand -hex 32 > /etc/zebra-turn-secret
chmod 600 /etc/zebra-turn-secret
SECRET=$(cat /etc/zebra-turn-secret)
<b># wire it into coturn</b>
sed -i '/^static-auth-secret=/d' /etc/turnserver.conf
printf 'static-auth-secret=%s\n' "$SECRET" >> /etc/turnserver.conf
<b># and into the mint's environment</b>
printf 'ZEBRA_TURN_SECRET=%s\n' "$SECRET" > /etc/zebra-signal.env
chmod 640 /etc/zebra-signal.env</div>
<p class="note">
Generate once and persist it: rotating the secret invalidates every credential
already handed out, dropping live calls. Keep the file <code>600</code>, owned
by root.
</p>
<h2>5 · minting credentials — <code>/turn-cred</code></h2>
<p>
This is the only non-obvious piece, and it is tiny. coturn's
<code>use-auth-secret</code> mode accepts any username whose value is a future
unix timestamp, with the password being
<code>base64(HMAC&#8209;SHA1(secret, username))</code>. So the endpoint just
stamps an expiry and signs it. In Go:
</p>
<div class="code"><b>const turnTTL = 12 * 3600 // seconds a credential stays valid</b>
func turnCred(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*") <b>// browsers (and file:// copies) can fetch</b>
w.Header().Set("Content-Type", "application/json")
secret := os.Getenv("ZEBRA_TURN_SECRET")
if secret == "" { http.Error(w, `{"error":"turn unavailable"}`, 503); return }
username := strconv.FormatInt(time.Now().Unix()+turnTTL, 10)
mac := hmac.New(sha1.New, []byte(secret))
mac.Write([]byte(username))
json.NewEncoder(w).Encode(map[string]any{
"username": username,
"credential": base64.StdEncoding.EncodeToString(mac.Sum(nil)),
"ttl": turnTTL,
"stun": []string{"stun:<b>turn.example.com</b>:3478"},
"uris": []string{
"turn:<b>turn.example.com</b>:3478?transport=udp",
"turn:<b>turn.example.com</b>:3478?transport=tcp",
},
})
}</div>
<p>
That <code>Access-Control-Allow-Origin: *</code> matters: it is what lets a
browser on any page &mdash; including a copy of the zebra page saved to disk and
opened from <code>file://</code> &mdash; fetch a credential. The credential is
short-lived and per-user, so handing it out openly is by design.
</p>
<h2>6 · the rendezvous relay</h2>
<p>
The relay is a stateless WebSocket server, a few hundred lines of standard
library, no database. Its whole job:
</p>
<ul>
<li>A browser connects to <code>/zebra-signal?room=&lt;hash&gt;</code>. The room
is a SHA-256 of the shared code, so the server learns nothing about the code.</li>
<li>The first peer in a room is told it is the offerer; the second is the
answerer. (Or "whoever is already present offers when the other joins" &mdash;
either rule works, as long as it is deterministic.)</li>
<li>Every message a peer sends is forwarded verbatim to the other peer in the
same room. The payload is the WebRTC offer/answer, <strong>already encrypted</strong>
in the browser with a key derived from the shared code (PBKDF2 &rarr; AES-GCM).
The relay forwards ciphertext it cannot read.</li>
<li>The server sends periodic WebSocket pings so idle calls don't get reaped by
intermediaries, and drops a room when both peers leave.</li>
</ul>
<p>
Run it under systemd as an unprivileged user, reading the secret from the env
file written in step 4:
</p>
<div class="code"><b># /etc/systemd/system/zebra-signal.service</b>
[Unit]
Description=zebra-signal — WebRTC rendezvous relay + TURN credential mint
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
Environment=ZEBRA_SIGNAL_ADDR=:8090
EnvironmentFile=-/etc/zebra-signal.env
ExecStart=/usr/local/bin/zebra-signal
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
[Install]
WantedBy=multi-user.target</div>
<h2>7 · TLS + reverse proxy</h2>
<p>
Browsers require <code>wss://</code> (TLS) for WebSockets and a secure context
for the crypto, so put a reverse proxy in front that terminates TLS. With
<a href="https://caddyserver.com">Caddy</a> you get automatic certificates and
the config is four lines:
</p>
<div class="code"><b># Caddyfile</b>
turn.example.com {
handle /zebra-signal* { reverse_proxy localhost:8090 }
handle /turn-cred { reverse_proxy localhost:8090 }
}</div>
<p>
Caddy fetches a Let's Encrypt certificate on first request. The WebSocket
upgrade is proxied transparently; the <code>*</code> CORS header set by the mint
passes straight through. That is the entire edge.
</p>
<h2 id="point">8 · point your members at it</h2>
<p>
Now the payoff: <strong>nobody needs a modified page.</strong> The published
zebra pages read two URL parameters and fall back to the unturf servers only if
they are absent. Send your community a link with your own endpoints:
</p>
<div class="code">https://www.unturf.com/zebra-report/zebra-audio.html<b>?signal=</b>wss://turn.example.com/zebra-signal<b>&amp;turncred=</b>https://turn.example.com/turn-cred</div>
<p>
Or host the page yourself (it is a single self-contained HTML file) and serve it
from the same box. Either way, the call is established through <em>your</em>
relay and, when needed, relayed through <em>your</em> coturn. The same two
parameters work on the text chat (<code>index.html</code>) and the voice call
(<code>zebra-audio.html</code>).
</p>
<p class="note">
Saved a copy to disk? It still works from <code>file://</code> &mdash; the crypto
runs in a secure context and the <code>*</code> CORS header lets the saved file
fetch credentials &mdash; as long as your relay and TURN server are reachable.
</p>
<h2>9 · how many users can it carry</h2>
<p>
The rendezvous relay is nearly free: it moves a few hundred bytes per call setup
and then idles, so a tiny box pairs thousands of rooms. <strong>coturn is the
ceiling</strong>, and only for relayed calls (direct peer-to-peer calls cost it
nothing). Each relayed voice call is bidirectional audio &mdash; tens of kbit/s
per leg. With <code>bps-capacity=400000000</code> (400&nbsp;Mbit/s) the limit is
whatever your VPS's actual uplink and monthly transfer allow, long before coturn
itself strains.
</p>
<p>
The <code>user-quota</code> and <code>total-quota</code> lines cap concurrent
allocations to blunt abuse. Raise <code>total-quota</code> as you grow; keep
<code>user-quota</code> small (a handful of allocations per credential is plenty
for one call). Because credentials are per-user and expire, a leaked one is
worthless within hours.
</p>
<hr>
<h2>10 · it's a gift</h2>
<p>
This stack is open intellectual capital &mdash; take it and run a community the
unturf servers will never see or meter. Patch it, harden it, pass it on. Every
box that runs its own relay makes the whole mesh more resilient and less
centralised, which is the entire point.
</p>
<a class="cta" href="./">&#9654; open the chat</a>
&nbsp;
<a class="cta" href="zebra-audio.html">&#9654; open the voice call</a>
<p class="foot">
zebra report &middot; host your own community &middot;
<a href="/" style="color:#777">unturf</a>
</p>
<footer style="max-width:820px;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>
md5 <span class="stamp-md5">521949cd4653a08a47afd841f53addc1</span><br>
sha256 <span class="stamp-sha">81bbc00c63ed4343885ab2dd575355da92ff6c0bc683d693e36a06e6015b6241</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=</span>
</footer>
</body>
</html>

View file

@ -60,7 +60,8 @@
<h1>zebra report</h1> <h1>zebra report</h1>
<p class="sub">how it works &nbsp;·&nbsp; a volume-modem chatroom over webrtc &nbsp;·&nbsp; <p class="sub">how it works &nbsp;·&nbsp; a volume-modem chatroom over webrtc &nbsp;·&nbsp;
<a href="./">open the chat</a> &nbsp;·&nbsp; <a href="/">unturf</a></p> <a href="./">open the chat</a> &nbsp;·&nbsp;
<a href="host-your-own.html">host your own</a> &nbsp;·&nbsp; <a href="/">unturf</a></p>
<p class="lead"> <p class="lead">
Zebra report is a two-person chatroom where your words never travel as network Zebra report is a two-person chatroom where your words never travel as network
@ -267,9 +268,10 @@
<footer style="max-width:820px;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:820px;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 style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-05-29</span><br>
md5 <span class="stamp-md5">a7363c33e5b129b38c5d385690d7622a</span><br> md5 <span class="stamp-md5">ed504d5893ed6df262ac8b3b623352a0</span><br>
sha256 <span class="stamp-sha">4b984972c9051c9fd1f4c416e957d9926e6070423ecca67c5404231435268c51</span><br> sha256 <span class="stamp-sha">96d2a30370bbe173ad805d0e90a80fce816314879a56ccd7d1ad1ec6e48dbb71</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>

View file

@ -169,6 +169,7 @@
<h1>zebra report</h1> <h1>zebra report</h1>
<p class="sub">volume modem chatroom &nbsp;·&nbsp; e2e encrypted &nbsp;·&nbsp; webrtc carrier &nbsp;·&nbsp; <p class="sub">volume modem chatroom &nbsp;·&nbsp; e2e encrypted &nbsp;·&nbsp; webrtc carrier &nbsp;·&nbsp;
<a href="how-it-works.html" style="color:#555">how it works</a> &nbsp;·&nbsp; <a href="how-it-works.html" style="color:#555">how it works</a> &nbsp;·&nbsp;
<a href="host-your-own.html" style="color:#555">host your own</a> &nbsp;·&nbsp;
<a href="/" style="color:#555">unturf</a></p> <a href="/" style="color:#555">unturf</a></p>
<div class="app"> <div class="app">
@ -1649,9 +1650,10 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
</script> </script>
<footer style="max-width:820px;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:820px;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 style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-05-29</span><br>
md5 <span class="stamp-md5">3ded822c9d19eedb538d02156b0eb00b</span><br> md5 <span class="stamp-md5">9dfd76a0e07db313af57a2ea310918cc</span><br>
sha256 <span class="stamp-sha">ec5d4dc1662fbcd570d70b9e0d0dd40e13aa804e5c5f989bec57dc9643bffe5f</span><br> sha256 <span class="stamp-sha">e268adb6caac732ebc0dcf3ed4b635957a987225f7d9f9e99eb9b797d12ba0fe</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>

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>