Source: ~/.unfirehose/unfirehose.db (project_id=81, 4 sessions covering
2026-03-29 through 2026-04-05). Reconstructed via chronological replay
of Write/Edit tool_input on file_paths under /home/fox/zebra-report/.
stats:
files reconstructed: 20
writes baselined: all (zero missing)
edits applied: 68
edits unapplied: 8 (1 SKIP pre-baseline, 6 FAIL old_string drift, 1 AMBIGUOUS)
unapplied edits represent small drift in 6 files; baseline content for
each is intact. quality verification deferred to phase 2.
recovered tree:
CLAUDE.md, Makefile
src/{tx,rx,pulse,carrier,chat,bt}.c
include/{modem,zebra}.h
test/{functional,integration,unit}.c, test/test.h
web/{index,kernel}.html, web/blog/style.css
blog/build.py, blog/posts/{001-volume-modem,002-sse-chatroom}.md
report: /tmp/zebra_recover_report.txt
script: /tmp/zebra_recover.py
3 KiB
| Title | Date | Slug | Summary |
|---|---|---|---|
| 2026-03-29 | 001-volume-modem | PulseAudio exposes every browser tab as a named sink input. Volume is a signal. 101 dalmatians. We made a modem. |
the discovery
PulseAudio exposes each browser tab as a separate sink input. pavucontrol shows them. You can set them individually. In userland. No root. No kernel module.
That means every open tab is a controllable signal source. 0 to 100. 101 discrete levels — 101 dalmatians.
That means you can modulate at consistent baud rates. That means you have a modem.
the channel
The signal space has 101 levels but we use two — far apart for noise margin.
- MARK (idle, logic-1): 80%
- SPACE (start, logic-0): 20%
- threshold: 50%
Wire format is UART. Start bit + 8 data bits LSB-first + stop bit = 10 symbols per byte. At 10 baud that is 1 byte per second. Slow. But it works.
The receiver runs at 2x oversample. It watches for a MARK→SPACE falling edge, advances to the center of the start bit, then samples data bits at full-period intervals.
the carrier
A <video> element with a Web Audio oscillator routed through createMediaStreamDestination() keeps the tab alive as a named sink input. Without audio playing the tab disappears from PulseAudio.
const dest = ctx.createMediaStreamDestination();
osc.connect(gain);
gain.connect(dest);
video.srcObject = dest.stream;
video.play();
The browser names it Firefox or Chromium. The C clients find it by name or index.
the stack
Three C binaries built against libpulse:
- tx — reads stdin, encodes bytes as UART volume symbols, sets sink volume via
pa_context_set_sink_input_volume - rx — polls sink volume at 2x baud rate, decodes UART frames, writes bytes to stdout
- chat — two PA connections, two threads, bidirectional
Channel count is cached at startup. One PA round-trip per symbol, not two. That halved the floor latency.
the crypto
ECDH P-256 key pair. Generated once. Stored as JWK in localStorage. Never leaves the browser.
Share your public key. Paste theirs. Both sides call deriveKey. Same AES-256-GCM key on both ends. Encrypt before you transmit. Decrypt after you receive.
Proof: hi are you free friday the 13th? — decrypted OK.
the limits
10 baud. One byte per second. An encrypted message is 80+ base64 characters. That is over a minute of transmission. Workable for a proof of concept. Not workable for a conversation.
The baud ceiling is the PulseAudio round-trip latency. Each pa_context_set_sink_input_volume call waits for the server to confirm. Empirically around 50-200 Hz on a local machine. Version 2 removes this constraint entirely.
files
include/zebra.h signal constants, types, prototypes
include/modem.h inline UART encoder/decoder
src/pulse.c libpulse wrapper
src/tx.c transmitter
src/rx.c receiver
src/chat.c bidirectional chat
web/index.html browser carrier + crypto UI
make builds all three binaries. make serve starts the web UI on port 8765.