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:
parent
4ae59be669
commit
37d34013ef
4 changed files with 561 additions and 11 deletions
393
zebra-report/host-your-own.html
Normal file
393
zebra-report/host-your-own.html
Normal 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 · self-hosted TURN + rendezvous on one edge box ·
|
||||
<a href="how-it-works.html">how it works</a> ·
|
||||
<a href="./">open the chat</a> ·
|
||||
<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 — the crypto,
|
||||
the modem, the audio — 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 — 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">▶ jump to "point your members at it"</a>
|
||||
|
||||
<h2>1 · what you are building</h2>
|
||||
<p>
|
||||
One internet-facing box (a $5–$6/mo VPS is plenty for a small community)
|
||||
running three daemons behind a TLS reverse proxy:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>coturn</strong> — 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> — 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> — 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
|
||||
— 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=<injected at deploy, see step 4>
|
||||
|
||||
<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>
|
||||
— 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‑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 — including a copy of the zebra page saved to disk and
|
||||
opened from <code>file://</code> — 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=<hash></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" —
|
||||
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 → 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>&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> — the crypto
|
||||
runs in a secure context and the <code>*</code> CORS header lets the saved file
|
||||
fetch credentials — 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 — tens of kbit/s
|
||||
per leg. With <code>bps-capacity=400000000</code> (400 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 — 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="./">▶ open the chat</a>
|
||||
|
||||
<a class="cta" href="zebra-audio.html">▶ open the voice call</a>
|
||||
|
||||
<p class="foot">
|
||||
zebra report · host your own community ·
|
||||
<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> · 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>
|
||||
|
|
@ -60,7 +60,8 @@
|
|||
|
||||
<h1>zebra report</h1>
|
||||
<p class="sub">how it works · a volume-modem chatroom over webrtc ·
|
||||
<a href="./">open the chat</a> · <a href="/">unturf</a></p>
|
||||
<a href="./">open the chat</a> ·
|
||||
<a href="host-your-own.html">host your own</a> · <a href="/">unturf</a></p>
|
||||
|
||||
<p class="lead">
|
||||
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">
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-05-29</span><br>
|
||||
md5 <span class="stamp-md5">a7363c33e5b129b38c5d385690d7622a</span><br>
|
||||
sha256 <span class="stamp-sha">4b984972c9051c9fd1f4c416e957d9926e6070423ecca67c5404231435268c51</span><br>
|
||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span>
|
||||
md5 <span class="stamp-md5">ed504d5893ed6df262ac8b3b623352a0</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><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>
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
<h1>zebra report</h1>
|
||||
<p class="sub">volume modem chatroom · e2e encrypted · webrtc carrier ·
|
||||
<a href="how-it-works.html" style="color:#555">how it works</a> ·
|
||||
<a href="host-your-own.html" style="color:#555">host your own</a> ·
|
||||
<a href="/" style="color:#555">unturf</a></p>
|
||||
|
||||
<div class="app">
|
||||
|
|
@ -1649,9 +1650,10 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
|
|||
</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">
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-05-29</span><br>
|
||||
md5 <span class="stamp-md5">3ded822c9d19eedb538d02156b0eb00b</span><br>
|
||||
sha256 <span class="stamp-sha">ec5d4dc1662fbcd570d70b9e0d0dd40e13aa804e5c5f989bec57dc9643bffe5f</span><br>
|
||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span>
|
||||
md5 <span class="stamp-md5">9dfd76a0e07db313af57a2ea310918cc</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><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>
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@
|
|||
|
||||
<h1>zebra audio</h1>
|
||||
<p class="sub">encrypted voice · webrtc · rendezvous ·
|
||||
<a href="host-your-own.html">host your own</a> ·
|
||||
<a href="/">unturf</a></p>
|
||||
|
||||
<section>
|
||||
|
|
@ -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.
|
||||
<a href="host-your-own.html"><strong>run this for your own community →</strong></a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
@ -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();
|
||||
})();
|
||||
</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 style="color:#777">page integrity</span> · built <span class="stamp-date">2026-05-29</span><br>
|
||||
md5 <span class="stamp-md5">526a8823d1d01a64db80c1ae00c5cbad</span><br>
|
||||
sha256 <span class="stamp-sha">edaddafa103af1832ea0e8f578af34e3891df77a86ed806674dc8fc285b632dd</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 id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-05-29</span><br>
|
||||
md5 <span class="stamp-md5">1e587958aedf93270535fb510aa946e7</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><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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue