zebra-report/blog/posts/001-volume-modem.md
Russell Ballestrini b77da42bbe phase 1: unfirehose reconstruction from session JSONL ingest
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
2026-05-27 13:51:14 -04:00

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.