add zebrad: pulseaudio→websocket introspector for chat.html

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.
This commit is contained in:
Russell Ballestrini 2026-05-27 15:57:22 -04:00
parent 5d76d713a6
commit c8a755513f
3 changed files with 719 additions and 4 deletions

View file

@ -41,7 +41,7 @@ early with a clear message.
## Targets
### `make all`five binaries
### `make all`six binaries
| Binary | Source | Role |
|-----------|----------------|------|
@ -50,6 +50,7 @@ early with a clear message.
| `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/
@ -107,6 +108,28 @@ 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`.
```bash
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: