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.
Fox 2026-06-06: "fedora chrome is flawless besides not able to hear
any mics it was working a few days back and nothing was changed on
the system, only thing we changed was our zebra codes."
The race: on a listener joining a room with existing speakers, the
SFU sub PC ontrack can fire BEFORE the signal-server peer-joined
event populates `members`. handleRemoteSfuTrack already does prefix
resolution at line ~4640:
let pubHex = pubHex16;
for (const [, mm] of members){
if (fh.startsWith(pubHex16)){ pubHex = fh; break; }
}
When the roster is empty, the loop finds nothing, pubHex stays the
16-char streamID prefix, and sfuStreamsByPubHex.set(pubHex, stream)
caches under that short key. peer-joined arrives later,
flushSfuStreams runs to attach what was cached — but its inner match
was strict ===:
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
mm.pubkey decodes to the FULL 64-char hex; pubHex from the cache is
the 16-char prefix; === never matches; listener stays permanently
silent for every speaker who was already in the room.
Pre-cascade this defect was masked: the old guard `!remoteAudio.has(uuid)`
was always true for worklet listeners (remoteAudio is the <audio>
fallback path only), so flushSfuStreams re-attached every cached
stream on every peer-joined — the eventual second ontrack from a
later renegotiation would land with members populated, cache key
became the full pubhex, and === matched. The 2e74b92 fix replaced
the always-true guard with `!listenerAudioNodes.has(uuid)`, which
correctly skipped re-attach but also exposed the strict-equality
matcher in the cold path.
Fix: switch flushSfuStreams' inner match from `=== pubHex` to
`fh.startsWith(pubHex)`. Symmetric with handleRemoteSfuTrack's own
prefix resolution. Works for both cases:
- cache key is full 64-char pubhex → startsWith with a full string
requires equality, so behavior is unchanged when ontrack arrived
after peer-joined (the common case).
- cache key is 16-char prefix → startsWith matches the first 16
chars of any member's full pubhex. 64 bits of prefix entropy =
astronomical collision probability.
Pinned by 29 new assertions in test/listener-audio-attach.test.js,
extracted from the live page so they cannot drift:
- 22 cover the attach FSM (chain reachability, dedup, in-place
swap, jbuf race, idempotent re-attach).
- 7 cover handleRemoteSfuTrack including the failing scenario:
"ontrack ARRIVES BEFORE peer-joined (member roster empty) →
cached + audible after flush" — fails pre-fix, passes post-fix.
Makefile gets test-listener-audio + adds it to test-all.
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.
- moderation: 'boot' button onclick now .catch'es and logs server
errors. Server now returns 'boot target not found (stale uuid?)'
instead of silently no-op'ing when the page's member roster lagged
the room — the moderator was clicking 'boot' and seeing nothing
happen because their page held a stale uuid.
- diagnostic: watchFirstFrame logs 'still black after 2500ms — no
keyframe?' on any fresh SFU video track (screen/camera/game) whose
decoder never unmutes. Pairs with the SFU's extended kfBurst so we
can tell next session which path actually broke when a camera tile
renders black.
multi-peer-mesh test harness extracts the new fn alongside
watchVideoTrackForRemoval so handleRemoteSfuTrack still runs end-to-end
in the headless sandbox.
Stamp date refresh on the other pages (no behavior change).
A row containing only a long <label> (the music-mode checkbox text
'raw mic, no echo/noise cancellation (for playing audio through it)')
got auto-column max-content sizing — which is the un-wrapped width.
The column expanded past the controls track's 360px cap and pushed a
horizontal scrollbar onto the page.
Switching grid-auto-columns to minmax(0, max-content) lets the column
shrink when the container forces it to, at which point white-space:
normal can do its wrapping work. Also added min-width: 0 on .row
itself as belt-and-suspenders for nested grid containers.
The previous .row rule unconditionally pinned column 2 at 1fr, which
stretched whichever child happened to land there. On the screenshot
that meant the 'log out' button got stretched and wrapped its label
across two lines, and the trailing note overlapped buttons it was
supposed to describe.
New rules (now also documented in CLAUDE.md as a style guide so future
authoring is consistent):
- default .row: grid-auto-columns: max-content (everything packs at
its natural width, no stretch)
- :has(> :first-child + input/select): template 'auto 1fr', input grows
- :has(> input/select:first-child): template '1fr', input fills, rest pack
- .row > .note: auto-drops to its own line under the buttons/inputs via
grid-column: 1 / -1
- .row > label: white-space: normal, so long checkbox labels wrap
Applied to zebra-spaces, chat, zebra-audio. CLAUDE.md "Web style
guide — form-row patterns" table lists every supported shape so new
rows reuse the primitive instead of inventing custom layouts.
Two fixes in one — the immediate layout bug from the screenshot (timeline
sliding into column 1 with controls eating ~80% of the viewport) and
the architectural rule that all zebra page layout uses grid.
Layout bug (was: when cameras-col gets .hidden + the global .hidden
utility's display:none !important, the grid auto-placement promoted
.timeline into column 1 and .controls into column 2 → controls took
the 1fr middle track). Fix:
- explicit grid-column: 1/2/3 on cameras-col / timeline / controls so
each child stays in its assigned column regardless of siblings going
display:none
- .cameras-col uses 'display: grid !important' to override the global
.hidden util, then only its content (h2 + #cameras) goes display:none
via separate selectors when the .hidden class is present
- controls track clamped to min(360px, 50vw) so a wide window can't
let the side panel eat the screen-share area
Grid-only refactor:
- every flex container converted: .timeline, .cameras-col, .game-tabs,
.screen-tile, .screen-meta, .tap-play, .camera-tile, .row,
.mod-actions, .invite-banner, .invite-actions, .notice-banner
- chat.html + zebra-audio.html same treatment (.row, .field-row,
.share-box .copy-row, the inline H2 style, .mode-toggle, .dot)
- inline 'style=flex:1' on inputs/meters in chat.html replaced with
'style=width:100%'
- now zero 'display: flex' / 'inline-flex' across all five zebra pages
- CLAUDE.md documents the grid-only rule under web-page authoring
All five pages — chat (zebra-audio), zebra-audio, how-it-works,
host-your-own, zebra-spaces — now ship with the same dark-mode
infrastructure: pre-paint head script, shared dark CSS block, fixed
top-right theme toggle, and one localStorage key ('zebra-theme-v1')
shared across pages so the user's choice follows them.
Dark IS the default: missing pref reads as dark, only an explicit
'light' opts out. First-time visitors land in dark without a flash.
The shared CSS covers the surfaces every page has (body, links,
buttons, inputs, dots, meter, status, log, hr, footer) so each page
looks intentional in dark without per-page tuning. zebra-spaces keeps
its richer overrides for badges + latency rows + tile metas.
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).
The SFU does not echo a publisher's stream back, so anyone sharing alone
in a room had no visual confirmation — game iframe stayed up, no tile
appeared. Render a muted local preview keyed by myKeys.pubHex on publish,
remove on unpublish. Subscribe path skips own pubHex to defend against
SFUs that do echo publishers.
Also refreshes integrity stamps on the other web pages (date drift).
- index.html, kernel.html: were missing the viewport meta entirely;
mobile fell back to the default 980px layout and the content was
scaled down. Add the standard 'width=device-width,initial-scale=1'
and centre the body with margin:0 auto so the 640px max-width is
centered instead of left-aligned on wide displays.
- host-your-own.html, how-it-works.html, zebra-audio.html, chat.html:
add a 600px breakpoint that tightens body padding to 1rem, shrinks
the h1 from 3rem to 2.2rem, and lets long URLs / pubkey hashes wrap
via overflow-wrap:anywhere on body prose. .code / .diagram already
use overflow-x:auto so internal scrolling stays bounded to the box,
never pushes the page wider than the viewport.
- chat.html: cap the QR share box's canvas (was a fixed 320px square)
with max-width:100% on phones, so 320px-wide handsets don't overflow.
- zebra-audio.html: the mute button had a fixed 6rem width to keep the
'mute'/'unmute' label from shifting the row; on narrow phones that
width forced wrapping. Drop it back to auto on small viewports.
All pages stamp+verify; the integrity footer hashes in the source
match the served files after `make stamp`.
New page: web/zebra-spaces.html. Extends the rendezvous + WebRTC mesh
model from zebra-audio (1:1) to a room of up to ~8 speakers (host + 2
co-hosts + speakers) with listeners (deferred to v0.2 for SFU fan-out).
Identity: per-browser persistent Ed25519 keypair in localStorage,
plus a per-session UUID for in-room "whose who". Password-vault
backup/restore (PBKDF2 600k + AES-GCM, matches zebra-audio's crypto)
emits a self-contained 'zspc-id-v1|...' blob.
Roles: everyone joins as listener; mods (host + co-hosts) extend
mic-invites that listeners accept/decline; host promotes to co-host;
mods demote and boot. Co-hosts cannot remove the host. Every role
transition is signed by the acting mod's Ed25519 over a canonical
input bound to room_id + epoch, so a compromised relay cannot forge
promotions, only refuse to relay them.
Pairs with cmd/zebra-spaces-signal in proxy.unturf.com.
Nav: zebra-audio and host-your-own cross-link to spaces.
Makefile: stamp target now covers zebra-spaces.html (web/chat.html
and web/how-it-works.html stamps refreshed today as a side effect).
Add web/host-your-own.html and link it from the chat and how-it-works headers
and footers. make stamp now stamps it alongside the other pages. CLAUDE.md:
turn the deploy flow into an explicit push-BOTH-repos reminder (zebra-report
source + www.unturf.com served) and list host-your-own.html as a deployed page.
- chat + zebra-audio: treat ICE 'disconnected' as recoverable (grace
before acting), auto ICE-restart on 'failed'/timeout driven by the
offerer to avoid glare, and auto-rejoin the signaling socket if it
drops mid-call. Superseded-pc guard ignores late events from a
closed connection.
- zebra-audio: input-device dropdown that hot-swaps the mic via
replaceTrack (no renegotiation, mute preserved); refreshes on
devicechange so a plugged-in interface appears mid-call.
Add an integrity footer to chat.html, zebra-audio.html, and how-it-works.html
showing the build date (2026-05-28) and the page's own MD5 + SHA-256. A file
can't hold its own hash, so web/stamp.js (make stamp) computes the hashes with
the two hash fields zeroed, then writes the real values back — self-consistent
and idempotent. To verify a served page: blank the two fields and re-hash;
confirmed it reproduces the stamped value with plain sha256sum.
Drop the hardcoded shared TURN login; fetch time-limited per-client credentials
from cors-proxy.uncloseai.com/turn-cred before each connection and build
iceServers from them. Falls back to STUN-only (direct path still works) if the
fetch fails.
After connect, read the selected ICE candidate pair from getStats() and show it
in the connect panel: a host/srflx pair => "DIRECT peer-to-peer — nobody between
you"; a relay candidate => "RELAYED through TURN (proxy.uncloseai.com sees
encrypted audio)". Lets users know whether any server sits in the media path.
Standalone blog-style page (matching chunkfive/monospace b&w UX) explaining the
whole solution: amplitude-as-data, the no-copy-paste signaling relay, the
multi-level modem, the Opus->G.711 codec fix and 440 Hz beat, the ACK/outbox
reliability layer, and the Hamming(12,8)+Gray FEC. Linked from chat.html's
header.
Add test/web-protocol.test.js — runs the real protocol code from chat.html in
Node (unit: crc/frame/ACK/HELLO codec, Hamming, Gray; integration: multi-level
modem roundtrip + FEC recovery of off-by-one symbol errors; functional: full
frame -> modem -> FEC -> assembler -> parse + ACK roundtrip). 3348 assertions.
Wired as `make test-web` (also in test-all). Lets us QA the modem without two
devices. Also: retransmit now uses exponential backoff so a lost ACK spaces out
retries instead of hammering the channel.
Each data byte is carried as a 12-bit Hamming codeword (8 data + 4 parity)
inside one modem byte-frame, so any single-bit error self-corrects instead of
failing the frame CRC and stalling the outbox on "no ack". Symbol levels are
Gray-coded so an off-by-one quantization (the dominant error) is a single-bit
flip Hamming can fix. Toggle ?fec=0 (both peers must match). Validated in Node:
all 256 bytes x 12 bit positions corrected; modem recovers 30/30 with a +/-1
symbol error per byte.
Two-panel CSS grid: setup (identity, room, connect, carrier) on the left,
chat on the right; stacks on phones (<=760px). Removes the manual offer/answer
boxes, the benchmark/handshake section, the mDNS note, the about/threat-model
prose, and the now-dead JS (share-link/QR helpers, those handlers, the inlined
QRCode library) — the relay path is the only bootstrap now. Validated headless:
clean load, two-column grid, no broken element refs, connect flow still works.
Add a reliable-delivery layer over the unreliable audio modem. A sent message
is held in an outbox (not echoed to the log) and retransmitted until the far
side ACKs it, then it moves into the log marked delivered. The DATA frame's
existing CRC32 is the message id; a new 15-byte T_ACK frame echoes it back.
Receiver ACKs every valid+decrypted frame (incl. duplicates, so a lost ACK
still stops the sender) and de-dupes on CRC so retransmits never double-show.
The carrier used 440 Hz (L) / 441 Hz (R) for the stereo Battle Toads mode, but
the modem codec is forced to mono G.711, which downmixes the two tones into a
1 Hz beat — a slow sinusoidal amplitude swell that swamps the level readings.
Use 440 Hz on both channels so the mono downmix is a clean single tone.
Generalize the browser physical layer from binary MARK/SPACE to N-level
amplitude symbols, so each baud tick carries log2(N) bits (N=2 = old binary,
N=4 = 2 bits, N=16 = 4 bits). Each byte is framed START(level 0/MIN) ..
STOP(level N-1/MAX) with the carrier idling high, so the receiver locks on the
high->low edge and self-calibrates its low/high amplitude reference every byte
(tolerant of channel gain drift). Symbols are sampled over the middle of each
window to avoid averaging across boundaries. Tunable via ?levels=N (2|4|16),
default 4. Misdecodes fail safe: a bad symbol breaks the frame CRC and the
frame is dropped rather than shown as garbage.
Roundtrip-simulated (clean + noisy channel): N=2 robust at all bauds, N=4
reliable to ~50 baud, N=16 to ~50 baud on a clean channel.
The binary carrier swung only 0.20-0.80; through a lossy codec that narrow gap
shrinks the margin between MARK and SPACE. Widen to 0.95 (near full scale, no
clip) and 0.10 (low but non-zero so the tone stays present for energy
detection), giving the adaptive-threshold decoder a cleaner split.
WebRTC defaulted to Opus, a perceptual codec that re-quantizes audio in 20ms
frames and smears the MARK/SPACE bit-edges the amplitude modem rides on — which
is why the PulseAudio C path does ~800 baud but the WebRTC path couldn't carry
frames at all. Pin the audio transceiver to memoryless G.711 (PCMU/PCMA), then
G.722, ahead of Opus via setCodecPreferences, restoring a clean amplitude
channel. Decoder resolution is now bounded by the AudioWorklet quantum (~375Hz),
not the codec.
The WebRTC Opus codec encodes in 20ms frames and smears the modem's bit
edges; at 50 baud a bit is ~one Opus frame, so frames rarely decode. Lower
baud gives more audio quanta per symbol and survives the smear. Expose the
link baud as ?baud=N (both peers must match) and lower the default to 20 so
the default path is more likely to decode. Also log the actual link baud on
HELLO instead of the advertised max (which was misleading).
The inbound UART decoder is constructed at ZEBRA_BAUD_HANDSHAKE and never
re-tuned, but chat DATA frames were sent at the negotiated/default baud (10).
HELLO frames (sent at 50) decoded fine — peers appeared and the link looked
healthy — but DATA at 10 never decoded against a 50-baud decoder, so messages
never crossed. Send DATA at ZEBRA_BAUD_HANDSHAKE to match the decoder.
Both peers type the same rendezvous code and connect automatically through
proxy.uncloseai.com — no manual offer/answer relay. The code derives an
opaque room ID and an AES key that wraps the SDP before it leaves the page,
so the relay sees only ciphertext. The existing share-link/QR exchange stays
as the offline fallback.
Also caps waitForIceGathering with a timeout: some environments never fire
the ICE 'complete' transition even after all candidates are gathered, which
previously hung offer/answer creation forever (affected the manual path too).
manual copy-paste of raw SDP was v1. this adds:
1. share-link encoding: SDP description → JSON → deflate-raw (native
CompressionStream) → base64url → URL hash. typical 2 KB SDP fits
in ~700 chars after compression. fits in any text channel.
URL format: .../zebra-report/#o=<deflated-base64> (offer)
.../zebra-report/#a=<deflated-base64> (answer)
2. QR rendering: same URL rendered as 180x180 QR using inlined
davidshimjs qrcodejs (MIT, 28 KB). offers a visual scan path
(phone scanners) without an in-browser decoder library yet.
3. auto-fill on link open: page reads location.hash on load.
#o=... → autofills remote-offer textarea, prompts user to
complete identity + audio + click "create answer"
#a=... → autofills remote-answer textarea, prompts "accept"
hash is cleared from address bar after parse so a reload doesn't
double-trigger.
4. parsers tolerate either input form: full URL with #o=/#a= hash,
or raw JSON SDP. peer A can paste back a URL or a textarea dump,
same handler.
5. tucked the raw SDP textareas behind a "show raw SDP" toggle so
the default UI is just the URL + QR. expert users still get the
raw bytes when needed.
UX flow (cross-internet, two peers):
A: enter room → start audio → "create offer" → "copy link"
→ send link to B via Signal/SMS/anywhere
B: open link → page auto-fills offer → enter room → start audio
→ "create answer" → "copy link" → send back to A
A: paste link into the answer textarea → "accept answer"
→ SRTP candidates pair → chat begins.
deferred: in-browser camera scanning of QR (needs jsQR or similar
~60 KB inlined; not on disk currently). v3.
file: 1509 lines, ~85 KB. JS syntax-clean (qrcode lib + main IIFE),
HTML balanced. CompressionStream + DecompressionStream available
in all current browsers (Chrome 80+, Firefox 113+, Safari 16.4+).
with coturn live on proxy.uncloseai.com (proxy.unturf.com:22ccf93),
ICE now offers a relay candidate path on every connection. peers
behind symmetric NAT or strict firewalls fall back to TURN instead
of failing the connection.
static creds zebra:7a4a2b1c8d9e6f5a — public by design (a static
page can't hide them); rate-limited by coturn quotas, not secrecy.
google stun stays as fallback if our stun is unreachable.
addresses two issues in the WebRTC same-LAN test:
1. "Cannot set remote answer in state stable" on retry
the offerer's pc enters stable after the first successful accept-
answer; subsequent clicks fail. add a reset-connection button that
close()s the current pc, clears all SDP textareas + status lines,
detaches inbound rx decoders, & restores the disconnected UI.
user can now retry without reloading the page.
2. silent NAT/mDNS diagnostic gap
browsers anonymize host IPs as <uuid>.local mDNS names. routers
with multicast-blocking client isolation (common on guest Wi-Fi)
silently prevent host-candidate pairing on the same LAN. add:
* a details/summary section under the rtc dot documenting the
Firefox + Chromium config knobs to disable host obfuscation;
* pc.onicecandidate logging that prints candidate type
(host/srflx/relay) + an explicit '(mdns-anonymized)' tag
for .local foundations, so the user can see WHAT is being
gathered without opening devtools;
* an err logLine on connectionState=failed pointing at the
reset button.
unchanged: protocol, frame codec, crypto, audio path, signaling
flow. UI surface gains one button + one collapsed details panel.
major architectural rework. previously chat.html modulated its own
GainNode and depended on a native zebrad daemon polling local PA
to close the receive loop. that worked single-machine multi-tab
but had no cross-laptop path without bridging external audio (voice
call, virtual sink, etc.).
new architecture: each peer's modulated audio is the outbound track
of a WebRTC peer connection. each peer decodes the inbound RTC
track in-page via an AudioWorklet energy detector + the same UART
state machine zebrad uses. cross-laptop works because WebRTC carries
the modulated audio between machines. native daemon not required.
outbound: oscillators (440/441 Hz) → modulation gain (MARK/SPACE)
→ channel merger → MediaStreamDestination → RTCPeer-
Connection.addTrack(). also → optional local monitor
gain (muted by default; toggleable for debugging).
inbound: RTCPeerConnection.ontrack → MediaStreamSource
→ AudioWorklet (per-quantum peak detection, 128 samples)
→ main thread postMessage → UartDecoder → FrameAssembler
→ onRxFrame (same handler as before).
signaling: v1 uses manual SDP paste via two textareas. role A
("start a new connection") creates an offer, waits for
gathered ICE, exposes JSON-serialized localDescription
for copy. role B ("join") pastes that, generates an
answer, gets pasted back to A. STUN: stun.l.google.com.
v2 will add a tiny signaling server for auto-pair.
threat: SRTP carries the modulated audio. Wireshark sees only
encrypted RTP packets; chat content lives in audio
amplitude transitions inside the encrypted payload.
additional app-layer AES-GCM (passphrase mode PBKDF2,
pubkey mode ECDH P-256) preserved unchanged.
zebrad daemon (src/zebrad.c) stays in the repo as the V2 disclosure
demo (a same-UID host process polling PulseAudio sink-input volumes
of an arbitrary same-UID tab) but is no longer required for chat.
preserved unchanged from prior chat.html:
* crypto modes (passphrase + pubkey), mode toggle
* sender id (random per-tab in passphrase mode; SHA-256(pubkey)[0:4] in pubkey mode)
* self-echo filter via sid match
* frame codec (OFFER/READY at 50 baud, DATA/HELLO with CRC-32)
* benchmark + handshake state machine
* peer list, chat log
* threat model + mitigation panel
* BroadcastChannel dev loopback via ?loopback=1
file size: 1258 lines, ~46 KB. JS syntax-clean (node --check).
HTML balanced.
zebrad reads @DEFAULT_MONITOR@ which mixes every PA stream on
a machine, so each browser tab hears its own carrier echoed
back. without a filter, every outbound message would render
twice in the chat log (once as 'me' at send time, once as
'peer' on echo arrival).
filter: compare frame.sid (carried in DATA + HELLO) against
our own senderIdBytes(). if equal, drop frame before any UI
work. loopback mode unaffected since BroadcastChannel never
echoes to sender by spec.
senderIdBytes returns a stable per-tab id:
* passphrase mode: random 4 bytes cached for tab lifetime
* pubkey mode: first 4 of SHA-256(pubkey), stable across reloads
without zebrad running, the page is TX-only: tabs see their own
outbound frames but nothing crosses. for UX validation while the
introspector daemon doesn't exist yet, gate a BroadcastChannel
fallback on the ?loopback=1 query param.
behavior:
* with ?loopback=1: after each txFrame completes its modulation,
the raw frame bytes are postMessage'd on a same-origin
BroadcastChannel('zebra-report-loopback'). other tabs of the
same browser subscribe & feed received bytes into onRxFrame
exactly as zebrad would. BroadcastChannel suppresses echo to
sender by spec, so the originating tab does not see itself.
* without ?loopback=1: behavior unchanged. real PA transport
stays the production path.
a yellow banner at the top of the page warns when loopback is
active: "frames cross via BroadcastChannel between same-browser
tabs, NOT via PulseAudio". no risk of mistaking dev mode for
production.
single self-contained page implementing the recovered zebra-report
protocol as a peer-to-peer chatroom whose data path is PulseAudio
sink-input state — chat content never enters an IP packet.
architecture:
page (TX) modulates audio output via Web Audio GainNode
between MARK (0.80) and SPACE (0.20). silent stereo
carrier 440/441 Hz published to PulseAudio.
zebrad separate program (companion, not in this commit):
polls PA sink-input volumes, decodes frames,
forwards to ws://127.0.0.1:7777 — the page connects
and renders received messages.
RX path the page is TX-only without zebrad running; with
it, full bidirectional chat.
crypto modes (toggle in UI):
passphrase PBKDF2-SHA256 (600k iter) → AES-GCM-256 group key.
everyone with the same phrase joins the room.
pubkey ECDH P-256 keypair per browser → AES-GCM-256
pairwise. peers added by pasting their pubkey.
both modes use Web Crypto API. no external crypto deps.
frame protocol (matches include/zebra.h):
OFFER magic(ZB) + 0x01 + baud_le(2) + xor(1) — 6B
READY magic(ZB) + 0x02 + baud_le(2) + xor(1) — 6B
HELLO magic + 0x04 + sid(4) + maxBaud_le(2)
+ handleLen(1) + handle(n) + crc32_le(4)
DATA magic + 0x03 + sid(4) + len_le(2)
+ payload(n) + crc32_le(4)
handshake:
1. user clicks "benchmark self" — page schedules 200 gain
events at 0.5ms intervals, measures avg, divides by 2 for
safety, clamps to [ZEBRA_BAUD_MIN, ZEBRA_BAUD_MAX].
2. user clicks "send OFFER" — sends OFFER frame at the
fixed handshake baud (50). also broadcasts HELLO.
3. peer's introspector decodes; their page replies with READY
at their max baud, plus their HELLO.
4. negotiated baud = min(my max, every peer's max).
5. data frames at negotiated baud.
UI sections:
identity handle + mode toggle
room passphrase OR pubkey, depending on mode
carrier start audio, live gain meter
handshake benchmark, OFFER button, baud display
receive ws://127.0.0.1:7777 status + connect
chat peer list, log, message box
about threat model, mitigation, whitepaper link
defensive framing prominent: the protocol is documented as a
research artifact demonstrating PA-IPC's open same-UID volume
read access. "what this does not protect against" enumerated.
mitigation panel cites foxhop.net/linux-audio-ipc-attack-surface.
known v1 tradeoffs:
- no CSMA: simultaneous TX from two peers will collide.
- pubkey mode broadcasts one frame per peer per message.
- GainNode scheduling has OS-level jitter — actual sustained
baud will be lower than the optimistic benchmark.
- base carrier is audible at 440/441 Hz; can be lowered or
moved ultrasonic in a follow-up.
reuses visual identity from web/index.html (chunkfive font,
monospace, black/white, no framework, no build step).
file: 1062 lines, 40 KB, JS syntax-clean (node --check), HTML
balanced.