Fox 2026-06-07 telemetry on blanka-chrome ruled out the sink-routing
hypothesis from 467146a:
audio via AudioContext 085b target=0.5s ctxState=running sink=default
mesh stream swapped into worklet for 085b
...
aud.recv pkt=14622 lost=1 bytes=9117575 jitter=0.0100 level=0.000 jbuf=? lp=0.1s
50 pps stereo Opus arriving on the SFU sub PC, sink routed to the
picked device, but jbuf=? (jitterBufferEmittedCount=0) + level=0 — the
native decoder consumes nothing because no MediaStreamSource is bound
to the SFU receiver's track. fxhp-phone same room same window decodes
fine (jbuf=0.49s level=0.001), so the SFU is healthy and the defect is
per-client wiring.
Two coupled defects:
(A) tracks=0 race in handleRemoteSfuTrack mic branch. Chrome can fire
ontrack with ev.streams[0] still empty at handler-time (the live track
arrives a microtask later, or MSID-supplant merges new+dead tracks).
Caching ev.streams[0] then handing it to attachSfuTrack drops the
listener into the silent <audio> fallback — telemetry: "sfu attach
b1a7 fresh=1 tracks=0" + "meter for b1a7: MediaStream has no audio
track". Screen/camera/game already wrap ev.track in a fresh
MediaStream; mic now does the same. Single track, guaranteed live,
every time.
(B) Mesh ontrack unconditionally swaps the worklet's source to the
mesh stream — even when the mesh track is still muted (no RTP). The
SFU receiver becomes orphaned (no decoder), and if the mesh track
never unmutes the listener hears silence with no automatic SFU
restore. Gate the swap on track-not-muted: swap immediately if mesh
is already flowing, otherwise wait for 'unmute'. Add a mute-watchdog
that swaps back to the cached SFU stream after MESH_MUTE_WINDOW_MS
of mesh silence — same shape as the existing
connectionState=='failed' restore path, but driven by track-level
mute instead of PC-level failure.
Hard refresh + leave/enter worked because the fresh negotiation
delivered the mesh track already unmuted, so the eager swap landed
on a live source. Now the same swap waits for live source instead of
hoping for one.
Tests:
- handleRemoteSfuTrack: ev.streams[0] empty but ev.track live →
fresh MediaStream wrap rescues the attach (chain built, audible
path verified)
Ticket 0001 updated with the telemetry comparison and the new
diagnosis. Mesh-mute-watchdog has no dedicated test yet (the mesh
ontrack lives in connectToPeer which the listener test harness
doesn't extract) — pin in multi-peer-mesh.test.js next pass.
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-07</span><br>
|
|
md5 <span class="stamp-md5">402c3b4d6e72b8cc6692166fc4683978</span><br>
|
|
sha256 <span class="stamp-sha">5f0a1a8dafd2074081c9189d12455797157901f9e52bb7c4899d4c7b7dd7bf95</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>
|