Find a file
Russell Ballestrini 1ed5263fbc
zebra-spaces: session fingerprint + sharper camera-error breadcrumbs
Telemetry for tickets 0001 (fedora chrome silence) and 0002 (fedora
firefox cannot share camera). Every logLine call already ships to
zebra-spaces-signal via client-log; these two additions thicken the
diagnostic payload at the points each ticket needs.

1. ONE-SHOT SESSION FINGERPRINT on entry. After the 'joined as ...'
   line we now emit:

     session: ua=chrome/desktop ctx={sr=48000 baseLat=0.0107
       sinkSupp=1 sink=default} devs={out=4 in=3 cam=2}
       picked={mic=abc12345 cam=def67890 spk=}

   - ua family + mobile/desktop bucket (the bug shape often falls
     cleanly along browser-family lines: fxhp-phone-firefox vs
     fedora-chrome are different problem classes)
   - audioCtx sampleRate, baseLatency, setSinkId support, current
     sinkId — directly answers the 0001 hypothesis: if sink ≠ the
     picked spk= shortprefix, we know the audioCtx isn't routed to
     the user's chosen device even though the dropdown says so.
   - device counts (audiooutput / audioinput / videoinput) — tells
     us if a saved-deviceId is pointed at a device that's gone (0002).
   - 8-char prefix of the saved mic/cam/spk deviceIds — enough to
     correlate with the picker selection without leaking the full id.

   Wrapped + caught so a hostile UA can't break entry. Empty fields
   shown as 'n/a' / ''.

2. CAMERA-OPEN ERROR now logs e.name + constraints summary instead
   of bare e.message. Firefox returns NotFoundError vs
   OverconstrainedError vs NotAllowedError vs NotReadableError with
   nearly identical .message strings — name disambiguates the actual
   failure mode (device gone vs constraint mismatch vs permission
   denied vs camera held by another app). Constraints summary
   includes the resolution we asked for and the first 8 chars of the
   saved deviceId so a saved-id mismatch is obvious on inspection.

   AUTO-RECOVERY for OverconstrainedError+saved-deviceId: retry once
   without the deviceId constraint so the user gets default camera
   instead of a permanent fail. The most likely 0002 cause is a
   saved cameraDeviceId pointing at a removed/renamed device, and
   making the user re-pick from the dropdown after every system
   reboot would be hostile.
2026-06-07 11:27:24 -04:00
blog phase 1: unfirehose reconstruction from session JSONL ingest 2026-05-27 13:51:14 -04:00
docs/tickets docs: add tickets dir + first two open defects 2026-06-07 11:24:17 -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 zebra-spaces: route audioCtx to user-picked sink (kill fedora-chrome silence) 2026-06-06 19:27:35 -04:00
web zebra-spaces: session fingerprint + sharper camera-error breadcrumbs 2026-06-07 11:27:24 -04:00
.gitignore add .gitignore for build artifacts 2026-05-27 14:03:06 -04:00
CLAUDE.md CLAUDE.md: audio jitter buffering section — userland AudioWorklet, not browser hints 2026-06-04 16:59:37 -04:00
Makefile zebra-spaces: clean shrink-retarget (drop to target, skip-couple video) 2026-06-06 14:55:03 -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.