Find a file
Russell Ballestrini 0dede3d6ca chat.html: WebRTC P2P audio + in-browser AudioWorklet decode
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.
2026-05-27 16:14:51 -04:00
blog phase 1: unfirehose reconstruction from session JSONL ingest 2026-05-27 13:51:14 -04:00
include phase 2: gap analysis — cat>> append + binary asset recovery 2026-05-27 13:54:19 -04:00
src add zebrad: pulseaudio→websocket introspector for chat.html 2026-05-27 15:57:22 -04:00
test phase 1: unfirehose reconstruction from session JSONL ingest 2026-05-27 13:51:14 -04:00
web chat.html: WebRTC P2P audio + in-browser AudioWorklet decode 2026-05-27 16:14:51 -04:00
.gitignore add .gitignore for build artifacts 2026-05-27 14:03:06 -04:00
CLAUDE.md phase 1: unfirehose reconstruction from session JSONL ingest 2026-05-27 13:51:14 -04:00
Makefile add zebrad: pulseaudio→websocket introspector for chat.html 2026-05-27 15:57:22 -04:00
README.md add zebrad: pulseaudio→websocket introspector for chat.html 2026-05-27 15:57:22 -04:00

zebra-report

Covert peer-to-peer chat over PulseAudio sink-input volume. Userland only. No kernel module. No network packets carry the chat content. Public domain.

Build & run

Single entry point: make. Every workflow goes through a target — never invoke gcc or python3 by hand.

make            # builds all five binaries (default target = `all`)
make test       # builds and runs unit tests (pure logic, no PA needed)
make test-all   # unit + integration + functional (PA + sink-inputs required)
make blog       # rebuilds the static blog under web/blog/
make serve      # builds blog, serves web/ on http://127.0.0.1:8765
make clean      # removes binaries and generated blog output

make is the contract. If a workflow isn't a target, add the target before adding the workflow.

Dependencies

gcc, make, pkg-config        # build chain
libpulse-dev                 # PulseAudio client lib (pkg-config: libpulse)
python3                      # blog builder + dev server

Ubuntu / Debian:

sudo apt install build-essential pkg-config libpulse-dev python3

make checks libpulse via pkg-config --cflags libpulse and pkg-config --libs libpulse; if pkg-config can't find it, the build fails early with a clear message.

Targets

make all — six binaries

Binary Source Role
tx src/tx.c reads stdin, transmits via volume modulation
rx src/rx.c reads target sink-input volumes, decodes to stdout
chat src/chat.c bidirectional tx+rx, line-based chat UI
bt src/bt.c "battle toads" dual-channel stereo UART (2× throughput)
carrier src/carrier.c publishes a silent PA sink so volume reads have something to read
zebrad src/zebrad.c PA→WS introspector for web/chat.html

All link against libpulse, librt, and libpthread. Headers come from include/zebra.h (protocol constants) and include/modem.h (inline encode/ decode + benchmark).

make test — unit tests

test/unit                    pure logic, no PA, no audio

Covers: signal encoding (bit_to_vol / vol_to_bit), timing arithmetic (ts_add_ns with overflow), baud math (baud_from_avg_ns), handshake frame build/parse for both OFFER and READY (magic, checksum, corruption).

Currently: 84 cases pass. Required to be green before any commit.

make test-all — integration + functional

Integration and functional tests need a running PulseAudio daemon and real sink-inputs to operate on. They are not run by make test by default.

# integration tests need ONE sink-input
ZEBRA_TEST_SINK=<sink_input_index> ./test/integration

# functional tests need TWO sink-inputs (data + ctrl channels)
ZEBRA_DATA_SINK=<idx> ZEBRA_CTRL_SINK=<idx> ./test/functional

Find sink-input indices with pactl list sink-inputs short — the first column is the index. The test/integration benchmark sub-test reports measured baud for the current host.

To avoid disrupting real audio applications, spawn dedicated silent sink-inputs as test targets:

paplay --raw --format=s16le --rate=44100 --channels=1 \
       --stream-name=zebra-test /dev/zero &
# then use `pactl list short sink-inputs` to find this stream's index

Currently: 17/17 integration pass; 8/10 functional pass. The two functional failures are timing-bound on non-realtime kernels at the auto-negotiated baud and are not code defects (the same data path passes at fixed 50 baud).

make blog & make serve

make blog runs python3 blog/build.py, which reads Markdown sources from blog/posts/ and writes static HTML into web/blog/. make serve rebuilds the blog and serves web/ on port 8765 for local preview.

The chat UI (web/chat.html) is plain static HTML and works directly under make serve — open http://127.0.0.1:8765/chat.html.

make zebrad — PulseAudio → WebSocket introspector

web/chat.html modulates audio output via Web Audio GainNode (mic stays off). A browser tab cannot read another tab's PA state, so to close the receive loop each peer runs zebrad. Decoded frames are forwarded over a local WebSocket; chat.html auto-connects to ws://127.0.0.1:7777.

make zebrad
./zebrad --verbose                  # default: @DEFAULT_MONITOR@, port 7777
./zebrad --source <name> --port 7777

Single C file, no third-party deps beyond libpulse. Embedded WebSocket server: SHA-1 + base64 inline, server→client binary frames only (RFC 6455 opcode 0x82); any inbound data closes the socket (browser auto-reconnects). Bound to 127.0.0.1 only — never accessible from the LAN.

Adaptive baud: starts at ZEBRA_BAUD_HANDSHAKE (50), watches for a READY frame, locks in the negotiated rate from its payload. Peak tracker has 2-second half-life decay so threshold adapts to room volume.

make clean

Removes:

tx rx chat bt carrier
test/unit test/integration test/functional
web/blog/index.html
web/blog/001-volume-modem/
web/blog/002-sse-chatroom/

Source files, headers, fonts, recovered binaries on disk, and committed artifacts under web/ (other than the generated blog) are not touched.

Layout

include/        protocol constants + inline encode/decode/benchmark
src/            five binaries: tx, rx, chat, bt (battle toads), carrier
test/           unit (pure), integration (PA needed), functional (full chain)
blog/           markdown sources + python build
web/            static site: index, kernel, chat, fonts, generated blog
Makefile        every workflow lives here
CLAUDE.md       agent operating rules for this repo

License

Public domain. Patches gratefully accepted via merge request at git.unturf.com/engineering/unturf/zebra-report.