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.
Add a "music mode" toggle that re-acquires the mic with echo cancellation,
noise suppression, and auto-gain OFF (so music/audio passes through instead of
being treated as noise and pumped), tags the track contentHint='music' so the
Opus encoder drops speech optimizations (DTX etc.), and raises the send bitrate.
Switchable mid-call via replaceTrack — hot-swaps the track with no
renegotiation, preserving mute state. Verified mid-call switch stays connected
both directions.
Lay out the controls and level rows with CSS grid and give the mute button a
fixed width, so toggling mute/unmute no longer shifts the buttons. Replace the
text "muted" badge with a mic-icon set (green open mic / red slashed mic) shown
next to "you" and "them", so both players can see at a glance whose mic is open
or closed. Verified both directions headless.
Each peer signals its mute state over the rendezvous channel (encrypted with
the room code, so the relay never learns it) on connect and on every toggle.
A "muted" badge now shows on both your own and the partner's level meter, so
both players can see who is muted. Verified both directions headless.
Reconnect: on "partner left", tear down the stale peer connection but stay in
the room, and have whichever peer is already present send the offer when the
other (re)joins — so a partner can leave and rejoin with no refresh, regardless
of who left. Every (re)negotiation runs on a fresh RTCPeerConnection.
Path indicator now reads the transport's selected candidate pair and prints the
actual local/remote IP:port + types, so DIRECT vs RELAYED is verifiable (host =
the two devices' IPs; relay = the TURN server's IP). The mic un-masks the real
host candidate, which is why a voice call reaches direct P2P on a LAN where the
no-mic chat fell back to TURN.
New single-page app: two rendezvoused partners type the same code and get a
live Opus voice call over WebRTC — direct P2P when possible, TURN relay
fallback otherwise, DTLS-SRTP encrypted end to end. Reuses the zebra-signal
rendezvous (code-encrypted SDP, zero-knowledge relay) and the ephemeral
/turn-cred credentials. Mic uses echo-cancellation/noise-suppression; mute,
hang up, live mic/remote level meters, and a direct-vs-relayed path indicator.
Deliberately NOT over the volume modem — ordinary low-latency voice, which
doubles as a plausible cover for the report channel.
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
single C file (~620 LOC), no third-party deps beyond libpulse.
closes a receive loop for web/chat.html: a browser tab cannot read
another tab's PulseAudio state, so each peer runs zebrad locally
to forward decoded frames to ws://127.0.0.1:7777.
components inline:
* SHA-1 (RFC 3174) + base64 encode — for the WebSocket handshake;
verified against the canonical RFC 6455 example
(key dGhlIHNhbXBsZSBub25jZQ== → accept s3pPLMBiTxaQ9kYGzzhZRbK+xOo=)
* CRC-32 (IEEE 802.3) — verifies DATA + HELLO frame payloads
* UART symbol decoder with adaptive threshold:
running peak with 2-second half-life decay, threshold = 50% of peak,
falling-edge start detect, sample at 1.5..8.5 × sps from edge
* frame assembler: magic-sync ZB, type dispatch OFFER/READY/DATA/HELLO,
overflow + corrupt-length guards, resync on bad checksum
* embedded WebSocket server: TCP listener on 127.0.0.1:7777,
handshake responder, binary frames only (opcode 0x82), no masking
(server→client), any inbound data closes the client (browser
auto-reconnects); max 16 concurrent clients
* pulseaudio: pa_mainloop integration, record stream on @DEFAULT_MONITOR@
by default, PA_SAMPLE_FLOAT32LE mono @ 8 kHz, 10 ms fragsize, peak
energy extracted from |abs(sample)|
adaptive baud: starts at ZEBRA_BAUD_HANDSHAKE (50). On valid READY frame
locks in negotiated baud from payload. Matches the chat.html send path.
binds 127.0.0.1 only — never reachable from the LAN, even though the
chat.html page itself may be served over HTTPS from a public domain.
smoke tested: PA connect + stream ready + WS handshake (RFC example
verified) + clean shutdown. -Wall -Wextra clean.
Makefile: zebrad now part of `make all`; clean target updated.
README: new section documenting it.
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.
aligned with `make clean` — ignores compiled binaries (tx/rx/chat/bt/
carrier, test/{unit,integration,functional}), object files, and the
generated blog output under web/blog/.
recovered code verified at this commit:
make all : 5 binaries built, no -Wall/-Wextra warnings
test/unit : 84/84 passed
test/integration : 17/17 passed (benchmark: 836 baud measured)
test/functional : 8/10 passed
- t_loopback_fixed_baud (50 baud) ok — "ZEBRA" tx->rx round-trip
- t_autoneg_pipeline FAIL: autoneg handshake succeeds; data
transmission at benchmarked baud (1994) times out. cause is
environmental — RX polling under loadavg 2.0 can't sustain
sub-millisecond symbol timing on a non-RT kernel. not a defect
in the recovered code; same path passes at fixed 50 baud.
quality verification:
- confirmed all 8 unapplied edits in phase 1 also failed in their
original sessions (tool-result is_error=1, "String to replace
not found"). Our reconstruction is faithful to live execution.
gaps closed:
- include/modem.h: re-ran replay with cat>>heredoc handling in
chronological position. BATTLE TOADS dual-channel stereo UART
block (4495 bytes, 102 lines) now appended at correct point in
timeline. Two previously-failing edits now apply against the
post-append baseline. unapplied edits dropped 8 -> 6.
- web/fonts/: chunkfive-regular-webfont.{woff,woff2} restored
from live source /home/fox/git/www.unturf.com/css/chunkfive/.
HTML+CSS @font-face references now resolve.
remaining unapplied edits (6) confirmed legitimate live-session
failures, not reconstruction artifacts.
internal references audit:
- all #include directives resolve within recovered tree
- all font url() references resolve to recovered web/fonts/
- no other Bash file-creation ops target zebra-report
final tree: 22 files, ~160KB.