Fox 2026-06-04, after pulling CLIENT_LOG from the proxy:
stream for 25cc0aaf7eb0 autoplay blocked: ... — staying on live WebRTC
autoplay blocked: ... — tap the tile to play
Firefox Android refuses .play() on every <audio> element created
after the entry-button gesture has aged out. Both the HTTP DJ stream
AND the WebRTC fallback were silently dead — the phone heard nothing.
Two-part fix:
1. Every remoteAudio + streamAudio <audio> element is now created
with muted=true. Muted autoplay has no gesture requirement on
any browser — decoding starts the moment the src is set, the
buffer fills, and audibility is gated entirely by a later
user gesture.
2. btn-mute is enabled for listeners (was disabled because there's
no mic to mute) and re-purposed as the audio-unlock toggle. The
click is the gesture. applyAudioMute() reads a per-uuid audio-
Path map ('dj' | 'rtc') and unmutes only the canonical path for
each peer so the WebRTC duplicate stays silenced while the DJ
stream plays.
Also picked up while I was in here:
- startStream onFail clears the pubHex from streamMode so the
next auto-enrol pass retries.
- stopStream sets audioPath='rtc' instead of poking remoteAudio
directly.
369 lines
16 KiB
HTML
369 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>zebra report — how it works</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; }
|
|
.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; }
|
|
.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; }
|
|
@media (max-width: 600px) {
|
|
body { padding: 1.25rem; line-height: 1.55; }
|
|
h1 { font-size: 2.2rem; }
|
|
h2 { font-size: 1.1rem; margin: 1.8rem 0 0.7rem; }
|
|
.lead { font-size: 0.95rem; }
|
|
p, li { overflow-wrap: anywhere; }
|
|
}
|
|
|
|
/* ---- shared dark mode: pure black canvas, dim text, low light ---- */
|
|
html.theme-dark { background: #000; }
|
|
html.theme-dark body { background: #000; color: #ccc; }
|
|
html.theme-dark a { color: #9cf; }
|
|
html.theme-dark .sub, html.theme-dark .sub a, html.theme-dark .note,
|
|
html.theme-dark .foot, html.theme-dark label,
|
|
html.theme-dark .status-line { color: #888; }
|
|
html.theme-dark h2 { border-bottom-color: #333; }
|
|
html.theme-dark code, html.theme-dark .key-display {
|
|
background: #1a1a1a; border-color: #333; color: #ccc;
|
|
}
|
|
html.theme-dark .key-display:hover { background: #222; }
|
|
html.theme-dark button {
|
|
background: #000; color: #ccc; border-color: #444;
|
|
}
|
|
html.theme-dark button:hover:not(:disabled) { background: #1a1a1a; }
|
|
html.theme-dark button.invert, html.theme-dark .cta {
|
|
background: #ccc; color: #000; border-color: #ccc;
|
|
}
|
|
html.theme-dark button.invert:hover:not(:disabled),
|
|
html.theme-dark .cta:hover { background: #999; }
|
|
html.theme-dark input[type=text], html.theme-dark input[type=password],
|
|
html.theme-dark textarea, html.theme-dark select {
|
|
background: #0a0a0a; color: #ccc; border-color: #333;
|
|
}
|
|
html.theme-dark .dot { background: #000; border-color: #444; }
|
|
html.theme-dark .dot.on { background: #ccc; border-color: #ccc; }
|
|
html.theme-dark .dot.ok { background: #0a0; border-color: #0a0; }
|
|
html.theme-dark .meter { background: #000; border-color: #444; }
|
|
html.theme-dark .meter-fill { background: #ccc; }
|
|
html.theme-dark .status-line.ok { color: #6c6; }
|
|
html.theme-dark .status-line.err { color: #f66; }
|
|
html.theme-dark .log, html.theme-dark .diagram {
|
|
background: #050505; border-color: #333; color: #ccc;
|
|
}
|
|
html.theme-dark .log-line .ts { color: #666; }
|
|
html.theme-dark .log-line.sys { color: #888; font-style: italic; }
|
|
html.theme-dark .log-line.err { color: #f66; }
|
|
html.theme-dark hr { border-top-color: #222; }
|
|
html.theme-dark footer { color: #555 !important; }
|
|
html.theme-dark footer span { color: #555 !important; }
|
|
html.theme-dark footer a { color: #777 !important; }
|
|
.theme-toggle {
|
|
position: fixed; top: 0.6rem; right: 0.8rem; z-index: 9999;
|
|
background: #fff; color: #000; border: 1px solid #000;
|
|
padding: 0.2rem 0.55rem; font-family: monospace; font-size: 0.7rem;
|
|
cursor: pointer; line-height: 1;
|
|
}
|
|
.theme-toggle:hover { background: #f0f0f0; }
|
|
html.theme-dark .theme-toggle { background: #000; color: #ccc; border-color: #444; }
|
|
html.theme-dark .theme-toggle:hover { background: #1a1a1a; }
|
|
</style>
|
|
<script>
|
|
/* shared zebra dark-mode pre-paint script — default IS dark, only an
|
|
* explicit 'light' choice opts out so first-time visitors land dark
|
|
* without a white flash */
|
|
try {
|
|
var pref = localStorage.getItem('zebra-theme-v1');
|
|
if (!pref) pref = localStorage.getItem('zebra-spaces-theme-v1'); // legacy
|
|
if (pref !== 'light'){
|
|
document.documentElement.classList.add('theme-dark');
|
|
}
|
|
} catch(_){ document.documentElement.classList.add('theme-dark'); }
|
|
</script>
|
|
</head>
|
|
|
|
<body>
|
|
<button id="btn-theme" class="theme-toggle" type="button" aria-label="toggle dark mode">light</button>
|
|
|
|
|
|
<h1>zebra report</h1>
|
|
<p class="sub">how it works · a volume-modem chatroom over webrtc ·
|
|
<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
|
|
packets. Each message is encoded into the <em>loudness</em> of an audio tone,
|
|
carried inside an encrypted WebRTC voice stream, and decoded back into text on
|
|
the far side. A network observer sees an ordinary encrypted call. The chat
|
|
lives in the amplitude.
|
|
</p>
|
|
|
|
<p>
|
|
This page walks through the whole machine: how two browsers find each other
|
|
with no copy-paste, how data rides on volume, why a speech codec nearly killed
|
|
it, and the error correction that finally made it solid.
|
|
</p>
|
|
|
|
<a class="cta" href="./">▶ open the chat</a>
|
|
|
|
<h2>1 · the idea: data in amplitude, not packets</h2>
|
|
<p>
|
|
The original zebra-report channel modulates a browser tab's PulseAudio volume:
|
|
step the volume through values at a fixed clock rate and the other side reads
|
|
those steps back as data, exactly like a dial-up modem. The payload lives in a
|
|
side channel that the network stack never sees.
|
|
</p>
|
|
<p>
|
|
The browser version generates a pure 440 Hz tone locally (no microphone)
|
|
and swings its <em>output gain</em> per clock tick. That modulated tone becomes
|
|
the outbound WebRTC audio track. Wireshark sees only encrypted SRTP; the words
|
|
are in the amplitude envelope inside it.
|
|
</p>
|
|
|
|
<h2>2 · bootstrapping with no copy-paste</h2>
|
|
<p>
|
|
WebRTC needs the two peers to swap connection descriptions (SDP) before media
|
|
flows. The usual way is to copy a blob of text from one device to the other and
|
|
back. We replaced that with a tiny <strong>signaling relay</strong>:
|
|
</p>
|
|
<div class="diagram">A: rendezvous code ──▶ wss://cors-proxy.uncloseai.com/zebra-signal ◀── code :B
|
|
(relay pairs the two, forwards encrypted SDP)
|
|
|
|
both type the same code · no link · no QR · no paste</div>
|
|
<p>
|
|
Both peers type the same rendezvous code. The page derives an opaque room id
|
|
and an encryption key from that code, connects to the relay, and the relay
|
|
forwards the offer/answer between the two. The SDP is encrypted with the code
|
|
<em>before</em> it leaves the page, so the relay only ever sees ciphertext and
|
|
an opaque room id. It carries setup only; chat content never touches it.
|
|
</p>
|
|
|
|
<h2>3 · the carrier and the modem</h2>
|
|
<p>
|
|
At connection time each browser starts a 440 Hz tone and modulates its
|
|
gain. Rather than a plain on/off (one bit per tick), the modem is
|
|
<strong>multi-level</strong>: each clock tick holds the volume at one of N
|
|
levels, so a symbol carries log₂(N) bits — 4 levels = 2 bits, 16
|
|
levels = 4 bits per tick.
|
|
</p>
|
|
<p>
|
|
Each byte is framed by a minimum-level <em>start</em> symbol and a
|
|
maximum-level <em>stop</em> symbol. The receiver locks onto that high-to-low
|
|
edge, then re-derives its own low and high amplitude reference from the start
|
|
and stop of <em>every byte</em> — so the level mapping self-calibrates
|
|
continuously and tolerates drift in the channel's gain.
|
|
</p>
|
|
|
|
<h2>4 · the codec trap</h2>
|
|
<p>
|
|
The first cross-machine attempts decoded nothing. The cause was the audio
|
|
codec. WebRTC defaults to <strong>Opus</strong>, a perceptual speech codec that
|
|
re-quantizes audio in 20 ms frames and effectively normalizes loudness.
|
|
That is precisely the information our modem rides on, so Opus erased it.
|
|
</p>
|
|
<p>
|
|
Two fixes restored a clean channel:
|
|
</p>
|
|
<ul>
|
|
<li><strong>Pin the codec to G.711</strong> (<code>setCodecPreferences</code>).
|
|
G.711 is memoryless companding — it preserves the amplitude envelope
|
|
sample by sample, like the PulseAudio path, instead of smearing it.</li>
|
|
<li><strong>Use a single 440 Hz tone.</strong> The carrier had used
|
|
440 Hz left / 441 Hz right for a stereo mode, but G.711 is mono, so
|
|
the two tones downmixed into a 1 Hz <em>beat</em> — a slow
|
|
sinusoidal swell that drowned the levels. One frequency, no beat.</li>
|
|
</ul>
|
|
|
|
<h2>5 · making it reliable</h2>
|
|
<p>
|
|
Even on a clean channel, a wireless link is lossy. Three layers make the chat
|
|
dependable:
|
|
</p>
|
|
<ul>
|
|
<li><strong>Framing + CRC.</strong> Every frame carries a CRC32; a corrupted
|
|
frame is detected and dropped rather than shown as garbage.</li>
|
|
<li><strong>Delivery ACKs + outbox.</strong> A sent message is held in an
|
|
outbox — not echoed into your log — until the far side
|
|
acknowledges it (the ACK echoes the frame's CRC as a message id). Only then
|
|
does it move into the log, marked delivered. The receiver de-dupes on CRC, so
|
|
a lost ACK never double-shows a message.</li>
|
|
<li><strong>Retransmit with backoff.</strong> An un-acknowledged message is
|
|
resent, with exponential backoff so a bad stretch spaces out retries instead
|
|
of hammering the channel.</li>
|
|
</ul>
|
|
|
|
<h2>6 · error correction (the eureka)</h2>
|
|
<p>
|
|
The last problem was the worst kind: messages mostly worked, then a frame would
|
|
arrive one bit wrong, fail its CRC, get dropped, and the sender would retry
|
|
forever. The channel was good enough to <em>almost</em> work.
|
|
</p>
|
|
<p>
|
|
The fix is forward error correction. Each data byte is carried as a
|
|
<strong>Hamming(12,8)</strong> codeword — 8 data bits plus 4 parity bits
|
|
— inside one byte-frame. Any single flipped bit is located and corrected
|
|
on the far side <em>before</em> the CRC check, so a marginal channel self-heals
|
|
instead of dropping the frame.
|
|
</p>
|
|
<p>
|
|
Hamming corrects single-bit errors, but a misread volume level can flip two
|
|
bits at once. So the levels are <strong>Gray-coded</strong>: adjacent amplitude
|
|
levels differ by exactly one bit. The most common error — reading a level
|
|
as its neighbour — becomes a single-bit flip, which is exactly what
|
|
Hamming repairs. Together they turned a channel that gave up into one that
|
|
delivers.
|
|
</p>
|
|
|
|
<h2>7 · encryption & threat model</h2>
|
|
<p>
|
|
Chat content is end-to-end encrypted at the application layer, on top of
|
|
WebRTC's own SRTP encryption. Two modes:
|
|
</p>
|
|
<ul>
|
|
<li><strong>Passphrase</strong> — everyone types the same room phrase;
|
|
a 256-bit AES-GCM key is derived with PBKDF2-SHA256 (600k iterations).</li>
|
|
<li><strong>Pubkey</strong> — 1-to-1 ECDH (P-256) between two public
|
|
keys.</li>
|
|
</ul>
|
|
<p>
|
|
The encryption happens <em>before</em> anything becomes volume, so the carrier
|
|
never holds the readable text — it holds scrambled bytes. Here is the
|
|
journey of <code>"hi"</code> in passphrase mode:
|
|
</p>
|
|
<ol>
|
|
<li><strong>Plaintext.</strong> <code>"hi"</code> is two bytes of ASCII:
|
|
<code>0x68 0x69</code>.</li>
|
|
<li><strong>AES-256-GCM scrambles it.</strong> A fresh random 12-byte IV
|
|
(nonce) is generated and the two bytes are encrypted with the
|
|
passphrase-derived key, producing <code>IV (12)</code> +
|
|
<code>ciphertext (2)</code> + <code>auth tag (16)</code> =
|
|
<strong>30 bytes</strong>. The ciphertext is the same length as the plaintext
|
|
(GCM is a stream mode) and looks like random noise: GCM XORs a key+IV
|
|
keystream into your bytes, turning <code>0x68 0x69</code> into two
|
|
unpredictable ones. Because the IV is random every time, sending
|
|
<code>"hi"</code> twice yields completely different ciphertext, so no
|
|
patterns leak.</li>
|
|
<li><strong>Then it is framed, error-corrected, and modulated.</strong> A frame
|
|
header + CRC wrap the 30 bytes (~43 bytes); each byte becomes a 12-bit
|
|
Hamming codeword; each codeword becomes Gray-coded N-level volume steps on
|
|
the 440 Hz carrier.</li>
|
|
</ol>
|
|
<div class="diagram">"hi" ─▶ AES-256-GCM ─▶ IV + ciphertext + tag (30 bytes, looks random)
|
|
─▶ frame + CRC (~43 B) ─▶ Hamming(12,8) codewords ─▶ volume levels</div>
|
|
<p>
|
|
By the time it is volume, the readable <code>h</code>,<code>i</code> is gone
|
|
three layers up. Someone reading the volume locally (the same-UID PulseAudio
|
|
attack this project documents) demodulates the amplitude back to bytes and gets
|
|
30 bytes of random-looking ciphertext, not <code>"hi"</code>. Without the
|
|
passphrase the encryption is irreversible, and the auth tag means tampering
|
|
fails outright — flip one bit and decryption rejects it.
|
|
</p>
|
|
<p>
|
|
And it is doubled up: that is the <strong>application layer</strong> (AES-GCM).
|
|
WebRTC independently wraps the whole audio stream in <strong>DTLS-SRTP</strong>,
|
|
so the carrier is encrypted again in transit. Two separate keys, two separate
|
|
layers — the readable <code>"hi"</code> exists only in RAM on the two
|
|
endpoints, never on the wire and never in the volume.
|
|
</p>
|
|
<p class="note">
|
|
Protects against a network observer reading chat content. Does not protect
|
|
against another process on either endpoint recording the local audio, or an
|
|
endpoint compromise — that local audio trust boundary is the surface this
|
|
project documents.
|
|
</p>
|
|
|
|
<h2>8 · testing without two phones</h2>
|
|
<p>
|
|
The modem and protocol are validated by a Node test suite that runs the real
|
|
shipped code — no browser and no second device required
|
|
(<code>make test-web</code>). It covers the CRC and frame codec, the Hamming
|
|
and Gray logic, a full multi-level modem roundtrip through a simulated noisy
|
|
channel, and recovery of off-by-one symbol errors. Over three thousand
|
|
assertions guard every change to the wire format.
|
|
</p>
|
|
|
|
<hr>
|
|
|
|
<p>Type a rendezvous code on two devices and watch text cross on nothing but the
|
|
loudness of a tone.</p>
|
|
<a class="cta" href="./">▶ open the chat</a>
|
|
|
|
<p class="foot">
|
|
zebra report · volume modem chatroom ·
|
|
<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-06-04</span><br>
|
|
md5 <span class="stamp-md5">9870bd4e4dae3439f3238f6c7bfbb0e1</span><br>
|
|
sha256 <span class="stamp-sha">7bf54074bc06b6a9378d75cfc678aadf626c2c2e921149db5610a7e0ed86ef6a</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>
|
|
<script>
|
|
/* shared zebra dark-mode toggle handler */
|
|
(function(){
|
|
var btn = document.getElementById('btn-theme'); if (!btn) return;
|
|
function applyLabel(){
|
|
btn.textContent = document.documentElement.classList.contains('theme-dark') ? 'light' : 'dark';
|
|
}
|
|
applyLabel();
|
|
btn.addEventListener('click', function(){
|
|
var root = document.documentElement;
|
|
var goDark = !root.classList.contains('theme-dark');
|
|
root.classList.toggle('theme-dark', goDark);
|
|
try { localStorage.setItem('zebra-theme-v1', goDark ? 'dark' : 'light'); } catch(_){}
|
|
applyLabel();
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|