Mobile browsers refuse to autoplay a <video> carrying an unmuted audio track without a user gesture; the whole element stays paused, so the video pixels never render either. The phone listener saw a black tile even with a healthy track flowing. Start the remote tile muted so it autoplays everywhere; expose an 'unmute audio' button in the meta bar that toggles. The button click counts as the gesture, so audio kicks in on demand. Local preview never offers unmute (would feed back into the publisher's own mic).
284 lines
13 KiB
HTML
284 lines
13 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; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<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-02</span><br>
|
|
md5 <span class="stamp-md5">866696ff17414e6a79b9118d5cec1242</span><br>
|
|
sha256 <span class="stamp-sha">a99c9cda25bfaaa2691f62ce7c97d1ea4cd8bb36f39271db675af147598f1638</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>
|