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
This commit is contained in:
Russell Ballestrini 2026-05-27 13:51:14 -04:00
commit b77da42bbe
20 changed files with 4062 additions and 0 deletions

121
CLAUDE.md Normal file
View file

@ -0,0 +1,121 @@
# Agent Blackops
This repo is operated by **agent blackops** — ml agent for fox/timehexon on the unsandbox/unturf/permacomputer platform.
## Identity
Full shard: `~/git/unsandbox.com/blackops/BLACKOPS.md`
## Rules
- I propose, fox decides. Unsure = ask. Can't ask = stop.
- No autonomous ops decisions. No destructive commands without explicit instruction.
- Fail-closed. Cleanup crew, not demolition.
- Check the time every session. Gaps are information.
- DRY in context — single source of truth, no sprawl.
- Never say "AI" — always say "machine learning."
- Prefer "defect" over "bug."
## Orientation
```bash
date -u
pwd
git log --oneline -5
git status
```
Then ask fox what the mission is.
## Zebra Report System
**Concept**: covert bidirectional communication channel using browser tab volume as the modulation medium — dial-up modem principles, userland only, no kernel involvement, no network stack.
### Collaborators & Stakeholders
| Handle | Role |
|--------|------|
| **foxhop** | fox — handler, operator, TimeHexOn |
| **brackishbert** | collaborator |
| **SEW** | collaborator |
| **russell@unturf** | Russell Ballestrini — unturf founder, permacomputer manifesto, `ago` library |
| **TimeHexOn** | oracle platform — primary deployment target |
| **groupr** | related project |
### How it works
PulseAudio exposes each browser tab as a separate sink input, visible and controllable in `pavucontrol`. Volume is settable per-tab in userland with no kernel involvement. Each tab has a range of **0100** (101 discrete levels — 101 dalmatians).
By modulating volume at a consistent rate (bauds), two sides can exchange data:
- **transmitter**: steps volume through values at a fixed clock rate
- **receiver**: reads volume at the same clock rate, decodes the steps back to data
- **bidirectional**: two tabs (or two processes watching different tabs) run opposite directions simultaneously
### Signal space
- 101 levels = ~6.66 bits per symbol
- practical: use power-of-2 subsets — 2 levels (1 bit), 4 levels (2 bits), 64 levels (6 bits)
- higher symbol depth trades noise margin for throughput
- low baud rate = high reliability, low throughput (like 300 baud dialup)
- high baud rate = races PulseAudio update latency
- measured ceiling on neoblanka: ~10001200 baud (PA IPC ~350400µs avg)
### Binaries
| Binary | Description |
|--------|-------------|
| `tx` | transmitter — reads stdin, modulates tab volume |
| `rx` | receiver — reads tab volume, writes decoded bytes to stdout |
| `chat` | bidirectional chat — two tabs, two threads |
| `bt` | **Battle Toads** — stereo dual-channel, 2x bandwidth |
### Project Battle Toads
One stereo browser tab carries **two independent UART streams** simultaneously — L channel and R channel. PulseAudio's `pa_cvolume` is per-channel; a single `get_sink_input_info` call returns both L and R volumes.
- TX sets L and R to independent bit values each symbol
- RX decodes L and R from a single PA poll — no extra IPC cost
- Net: 2x throughput at same baud rate, same PA polling budget
- Web carrier upgraded to stereo: two oscillators (440Hz L, 441Hz R) merged into a stereo stream → PA sees `channels=2`
```bash
# After opening web/index.html and clicking 'start audio' (stereo tab):
./bt -T MY_SINK -R THEIR_SINK -b 500
```
### Auto-negotiate (handshake protocol)
RX benchmarks its own PA polling speed and signals the max safe baud to TX. No manual baud matching needed.
```bash
./rx -s RX_SINK -t TX_SINK # RX benchmarks, sends offer at 50 baud
./tx -s TX_SINK -r RX_SINK # TX listens for offer, locks to RX's rate
```
Handshake frame: `[0x5A 0x42 0x01 baud_lo baud_hi xor_cksum]` — 6 bytes at 50 baud (~1.2s).
**Known defect**: 3-way handshake not yet implemented. TX can fire before RX enters receive loop at high baud rates. Fix: RX-ready signal back to TX before data phase.
### Tools
- `pactl set-sink-input-volume` — set volume by sink-input index
- `pactl list sink-inputs` — enumerate tabs, read current volume
- `pavucontrol` — visual verification of modulation
- `./tx -l` — list all PA sink inputs with index, volume, channels
- sink-input index maps to tab; stable within a session
### Use cases
- agent-to-agent signaling without touching the filesystem or network stack
- side-channel between sandboxed browser tab and host process
- low-bandwidth status heartbeat (alive/dead/mode) at ~110 baud
- covert channel for oracle↔host communication on TimeHexOn
### Constraints
- sink-input index resets when tab navigates or crashes — handshake needed on reconnect
- PA polling latency sets the baud ceiling — benchmark with `./rx -s SINK -t SINK2` before sending
- stereo (channels=2) required for Battle Toads — open web/index.html, click 'start audio'
- userland only — survives without root
- **Operation Voyeur**: all terminal output is public — never pass secrets through these channels unencrypted. The web page does ECDH key exchange + AES-256-GCM before TX.

54
Makefile Normal file
View file

@ -0,0 +1,54 @@
CC = gcc
CFLAGS = -Wall -Wextra -O2 -Iinclude $(shell pkg-config --cflags libpulse)
LDFLAGS = $(shell pkg-config --libs libpulse) -lrt -lpthread
PULSE = src/pulse.c
.PHONY: all clean serve blog test test-all
all: tx rx chat bt carrier
test: test/unit
@./test/unit
test-all: test/unit test/integration test/functional
@echo "--- unit ---"
@./test/unit
@echo "--- integration ---"
@./test/integration
@echo "--- functional ---"
@./test/functional
test/unit: test/unit.c include/zebra.h include/modem.h test/test.h
$(CC) $(CFLAGS) -o $@ test/unit.c
test/integration: test/integration.c src/pulse.c include/zebra.h include/modem.h test/test.h
$(CC) $(CFLAGS) -o $@ test/integration.c src/pulse.c $(LDFLAGS)
test/functional: test/functional.c src/pulse.c include/zebra.h include/modem.h test/test.h
$(CC) $(CFLAGS) -o $@ test/functional.c src/pulse.c $(LDFLAGS)
tx: src/tx.c $(PULSE)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
rx: src/rx.c $(PULSE)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
chat: src/chat.c $(PULSE)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
bt: src/bt.c $(PULSE)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
carrier: src/carrier.c
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
blog:
python3 blog/build.py
serve: blog
cd web && python3 -m http.server 8765
clean:
rm -f tx rx chat bt carrier test/unit test/integration test/functional
rm -rf web/blog/001-volume-modem web/blog/002-sse-chatroom web/blog/index.html

185
blog/build.py Normal file
View file

@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""
blog/build.py static blog generator for zebra-report
markdown + frontmatter html, no dependencies beyond stdlib
"""
import re, html
from pathlib import Path
POSTS_DIR = Path("blog/posts")
OUT_DIR = Path("web/blog")
# ------------------------------------------------------------------ #
# minimal markdown → html #
# ------------------------------------------------------------------ #
def md_to_html(text):
lines = text.split("\n")
out = []
in_code = False
in_list = False
buf = []
def flush_para():
if buf:
content = inline(" ".join(buf).strip())
if content:
out.append(f"<p>{content}</p>")
buf.clear()
def flush_list():
nonlocal in_list
if in_list:
out.append("</ul>")
in_list = False
def inline(s):
s = html.escape(s, quote=False)
s = re.sub(r"`(.+?)`", r"<code>\1</code>", s)
s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
s = re.sub(r"\*(.+?)\*", r"<em>\1</em>", s)
s = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', s)
return s
for line in lines:
# fenced code blocks
if line.startswith("```"):
if in_code:
out.append("</code></pre>")
in_code = False
else:
flush_para(); flush_list()
lang = line[3:].strip()
out.append(f'<pre><code class="language-{lang}">' if lang else "<pre><code>")
in_code = True
continue
if in_code:
out.append(html.escape(line))
continue
# headings
m = re.match(r"^(#{1,3})\s+(.*)", line)
if m:
flush_para(); flush_list()
n = len(m.group(1))
out.append(f"<h{n}>{inline(m.group(2))}</h{n}>")
continue
# unordered list
m = re.match(r"^[-*]\s+(.*)", line)
if m:
flush_para()
if not in_list:
out.append("<ul>")
in_list = True
out.append(f"<li>{inline(m.group(1))}</li>")
continue
# blank line
if not line.strip():
flush_para(); flush_list()
continue
buf.append(line)
flush_para(); flush_list()
return "\n".join(out)
# ------------------------------------------------------------------ #
# frontmatter parser #
# ------------------------------------------------------------------ #
def parse_post(path):
text = path.read_text()
meta = {}
body = text
if text.startswith("---"):
parts = text.split("---", 2)
if len(parts) >= 3:
for line in parts[1].strip().splitlines():
if ":" in line:
k, v = line.split(":", 1)
meta[k.strip().lower()] = v.strip()
body = parts[2]
meta["content"] = md_to_html(body.strip())
meta.setdefault("slug", path.stem)
meta.setdefault("date", "")
meta.setdefault("title", path.stem)
meta.setdefault("summary", "")
return meta
# ------------------------------------------------------------------ #
# templates #
# ------------------------------------------------------------------ #
CSS_LINK = '<link rel="stylesheet" href="/blog/style.css">'
BASE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{title} zebra report</title>
{css}
</head>
<body>
<header class="site-header">
<a class="site-name" href="/blog/">zebra report</a>
<span class="tagline">make crypto scary again</span>
</header>
<main>{body}</main>
</body>
</html>"""
def render_post(meta, newer=None, older=None):
nav = []
if older:
nav.append(f'<a href="/blog/{older["slug"]}/">← {older["title"]}</a>')
if newer:
nav.append(f'<a href="/blog/{newer["slug"]}/">{newer["title"]} →</a>')
nav_html = f'<nav class="post-nav">{" &nbsp; ".join(nav)}</nav>' if nav else ""
body = f"""<article>
<h1>{html.escape(meta["title"])}</h1>
<p class="meta">{html.escape(meta["date"])} &mdash; <a href="/blog/">all posts</a></p>
<div class="content">{meta["content"]}</div>
{nav_html}
</article>"""
return BASE.format(title=html.escape(meta["title"]), css=CSS_LINK, body=body)
def render_index(posts):
items = ""
for p in posts:
items += f"""<li>
<span class="post-date">{html.escape(p["date"])}</span>
<a class="post-title" href="/blog/{p["slug"]}/">{html.escape(p["title"])}</a>
<p class="post-summary">{html.escape(p["summary"])}</p>
</li>\n"""
body = f'<h1>posts</h1>\n<ul class="post-list">\n{items}</ul>'
return BASE.format(title="zebra report", css=CSS_LINK, body=body)
# ------------------------------------------------------------------ #
# build #
# ------------------------------------------------------------------ #
def build():
OUT_DIR.mkdir(parents=True, exist_ok=True)
posts = [parse_post(p) for p in sorted(POSTS_DIR.glob("*.md"))]
posts.sort(key=lambda p: p["date"], reverse=True)
for i, post in enumerate(posts):
newer = posts[i - 1] if i > 0 else None
older = posts[i + 1] if i + 1 < len(posts) else None
d = OUT_DIR / post["slug"]
d.mkdir(exist_ok=True)
(d / "index.html").write_text(render_post(post, newer=newer, older=older))
print(f" {post['slug']}/")
(OUT_DIR / "index.html").write_text(render_index(posts))
print(" index.html")
print("done.")
if __name__ == "__main__":
build()

View file

@ -0,0 +1,78 @@
---
Title: #001: the volume modem
Date: 2026-03-29
Slug: 001-volume-modem
Summary: 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.
```javascript
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.

View file

@ -0,0 +1,109 @@
---
Title: #002: the chatroom
Date: 2026-03-29
Slug: 002-sse-chatroom
Summary: Server-sent events as the modem relay. The server controls browser volumes. No WebSockets. No JavaScript framework. IP TCP HTTP.
---
## the problem with version 1
Version 1 requires two C binaries running in two terminals. You must manually copy encrypted ciphertext between them. The browser is a carrier only — it cannot send or receive on its own.
The baud ceiling is the PA server round-trip. Every symbol waits for a kernel → userland → kernel round-trip. At 10 baud each symbol is 100ms. An 80-character ciphertext takes 80 seconds.
Version 2 removes all of that.
## server-sent events
SSE is HTTP. One long-lived GET request. The server writes `data: ...\n\n` and the browser receives it. No handshake. No upgrade header. No new protocol. Firewalls ignore it because it looks like a slow HTML page loading.
WebSockets are bidirectional but complex. SSE is unidirectional and simple. For a chatroom the server is the relay — clients POST to send, SSE to receive. That is the right split.
```
browser → POST /send → server → SSE → all browsers
```
No WebSocket. No Socket.IO. No framework. Just HTTP.
## the architecture
A Python HTTP server. No dependencies.
```
GET / → web UI (HTML page)
GET /events → SSE stream (text/event-stream)
POST /send → accept message, relay to all SSE clients
GET /volume → current PA sink volumes (JSON)
POST /volume → set PA sink volume (pactl under the hood)
```
The server holds a list of open SSE connections. When a POST /send arrives it writes to all of them. Each browser receives the message instantly via its open event stream.
## volume as the transport
The server calls `pactl set-sink-input-volume INDEX VALUE%` for each connected browser tab. It encodes the message as a UART symbol sequence and steps through it at the baud rate using a server-side timer.
Every browser tab is receiving the same symbol stream simultaneously. Any C process monitoring any tab sees the same signal. The server is the transmitter. The browsers are the antennas.
```python
def transmit(message, sinks, baud):
period = 1.0 / baud
for byte in message.encode():
for bit in uart_frame(byte):
vol = VOL_MARK if bit else VOL_SPACE
for sink in sinks:
subprocess.run(["pactl", "set-sink-input-volume",
str(sink), f"{vol}%"])
time.sleep(period)
```
## the chatroom UI
Plain HTML. A text input. A send button. A message list. SSE keeps it live.
```javascript
const evts = new EventSource("/events");
evts.onmessage = e => {
const msg = JSON.parse(e.data);
appendMessage(msg.from, msg.text);
};
form.onsubmit = e => {
e.preventDefault();
fetch("/send", { method: "POST",
body: new FormData(form) });
input.value = "";
};
```
No framework. No build step. No npm. Reload the page and it reconnects.
## the crypto layer
Each browser still has its ECDH key pair in localStorage. Messages are encrypted before the POST and decrypted after the SSE event arrives. The server is a blind relay — it sees only ciphertext.
The key exchange can now happen in-band. POST your public key to `/keys`. The server broadcasts it to all clients via SSE. Each client derives the shared key automatically.
```
browser A: POST /keys {"pub": "base64..."}
server: SSE → all clients data: {"type":"key","from":"A","pub":"base64..."}
browser B: deriveSharedKey(A_pub, myPrivKey)
```
Zero manual copy-paste.
## web 1.5
HTTP forms. SSE. Static HTML. Server-side rendering where needed. The aesthetics of 2001 with the crypto of 2026.
No React. No bundler. No `node_modules`. One Python file. One HTML file. `python3 server.py` and it runs.
The web got complicated because people let it. It does not have to be. IP TCP HTTP. The protocol is fine. The software on top got weird.
Make web 1.5 sexy again.
## status
Version 2 is the next build. The server is designed. The SSE relay is straightforward. The volume control is pactl subprocess calls to start — libpulse bindings later for speed.
The chatroom replaces the terminal. The crypto layer stays. The modem stays. The channel stays. Just the interface changes.

342
include/modem.h Normal file
View file

@ -0,0 +1,342 @@
#pragma once
/*
* modem.h -- inline UART encode/decode over PulseAudio volume
*
* Shared by tx.c, rx.c, chat.c.
* Depends on zebra.h (zebra_pulse_t, zebra_set_volume, zebra_get_volume,
* zebra_bit_to_vol, zebra_vol_to_bit, ZEBRA_VOL_THRESHOLD).
*/
#include <time.h>
#include <stdint.h>
#include "zebra.h"
/* callback invoked by zebra_rx_run for each decoded byte */
typedef void (*zebra_byte_cb)(uint8_t byte, void *userdata);
/* ------------------------------------------------------------------ *
* timing helper *
* ------------------------------------------------------------------ */
static inline void ts_add_ns(struct timespec *ts, long ns) {
ts->tv_nsec += ns;
if (ts->tv_nsec >= 1000000000L) {
ts->tv_sec += ts->tv_nsec / 1000000000L;
ts->tv_nsec %= 1000000000L;
}
}
/* ------------------------------------------------------------------ *
* TX: UART framing (start + 8 data LSB-first + stop) *
* ------------------------------------------------------------------ */
/* channels: from zebra_sink_t.channels — avoids PA channel-count query per symbol */
static inline void zebra_send_symbol(zebra_pulse_t *z, uint32_t sink,
uint8_t channels, int bit,
struct timespec *next, long period_ns) {
zebra_set_volume_noack(z, sink, channels, zebra_bit_to_vol(bit));
clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, next, NULL);
ts_add_ns(next, period_ns);
}
static inline void zebra_send_byte(zebra_pulse_t *z, uint32_t sink,
uint8_t channels, uint8_t byte,
struct timespec *next, long period_ns) {
zebra_send_symbol(z, sink, channels, 0, next, period_ns); /* start */
for (int i = 0; i < 8; i++)
zebra_send_symbol(z, sink, channels, (byte >> i) & 1, next, period_ns);
zebra_send_symbol(z, sink, channels, 1, next, period_ns); /* stop */
}
/* ------------------------------------------------------------------ *
* RX: 2x oversampled UART decoder runs forever, calls cb per byte *
* ------------------------------------------------------------------ */
static inline void zebra_rx_run(zebra_pulse_t *z, uint32_t sink, int baud,
zebra_byte_cb cb, void *userdata) {
const int oversample = 4;
long quarter_ns = 1000000000L / ((long)baud * oversample);
long half_ns = 2 * quarter_ns;
long full_ns = 4 * quarter_ns;
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
int prev = 1; /* assume MARK (idle) */
for (;;) {
ts_add_ns(&ts, quarter_ns);
clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &ts, NULL);
uint8_t vol;
if (zebra_get_volume(z, sink, &vol) < 0) {
/* sink gone — wait and retry */
struct timespec retry = {1, 0};
nanosleep(&retry, NULL);
prev = 1;
continue;
}
int cur = zebra_vol_to_bit(vol);
/* MARK→SPACE falling edge = start bit */
if (prev == 1 && cur == 0) {
/* advance to center of start bit and confirm */
ts_add_ns(&ts, half_ns);
clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &ts, NULL);
if (zebra_get_volume(z, sink, &vol) < 0) { prev = 1; continue; }
if (zebra_vol_to_bit(vol) != 0) { prev = 1; continue; }
/* sample 8 data bits */
uint8_t byte = 0;
int ok = 1;
for (int i = 0; i < 8; i++) {
ts_add_ns(&ts, full_ns);
clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &ts, NULL);
if (zebra_get_volume(z, sink, &vol) < 0) { ok = 0; break; }
int b = zebra_vol_to_bit(vol);
if (b < 0) b = (vol >= ZEBRA_VOL_THRESHOLD) ? 1 : 0;
byte |= (uint8_t)(b << i);
}
if (ok) cb(byte, userdata);
/* Resync sample clock to real time after each byte.
* Prevents accumulated edge-detection error from shifting
* data bit samples in subsequent bytes. */
clock_gettime(CLOCK_MONOTONIC, &ts);
prev = 1;
} else if (cur >= 0) {
prev = cur;
}
}
}
/* ------------------------------------------------------------------ *
* PURE HELPERS no PA, fully testable *
* ------------------------------------------------------------------ */
/* Compute max safe baud from avg PA poll latency (nanoseconds).
* Formula: 1e9 / (avg_ns * 4x_oversample) * 0.8_safety = 2e8 / avg_ns */
static inline int zebra_baud_from_avg_ns(long avg_ns) {
if (avg_ns <= 0) return ZEBRA_BAUD_DEFAULT;
int baud = (int)(200000000L / avg_ns);
if (baud < ZEBRA_BAUD_MIN) baud = ZEBRA_BAUD_MIN;
if (baud > ZEBRA_BAUD_MAX) baud = ZEBRA_BAUD_MAX;
return baud;
}
/* Build a BAUD_OFFER handshake frame into buf[ZEBRA_HS_FRAME_LEN]. */
static inline void zebra_hs_build(uint8_t frame[ZEBRA_HS_FRAME_LEN], uint16_t baud) {
frame[0] = ZEBRA_HS_MAGIC_0;
frame[1] = ZEBRA_HS_MAGIC_1;
frame[2] = ZEBRA_HS_TYPE_OFFER;
frame[3] = (uint8_t)(baud & 0xFF);
frame[4] = (uint8_t)(baud >> 8);
frame[5] = frame[0] ^ frame[1] ^ frame[2] ^ frame[3] ^ frame[4];
}
/* Build a READY frame (type=0x02, baud=0) into buf[ZEBRA_HS_FRAME_LEN]. */
static inline void zebra_hs_build_ready(uint8_t frame[ZEBRA_HS_FRAME_LEN]) {
frame[0] = ZEBRA_HS_MAGIC_0;
frame[1] = ZEBRA_HS_MAGIC_1;
frame[2] = ZEBRA_HS_TYPE_READY;
frame[3] = 0;
frame[4] = 0;
frame[5] = frame[0] ^ frame[1] ^ frame[2] ^ frame[3] ^ frame[4];
}
/* Validate and parse a handshake frame.
* Returns 0 and sets *baud on success; -1 on bad magic, type, or checksum. */
static inline int zebra_hs_parse(const uint8_t frame[ZEBRA_HS_FRAME_LEN],
uint16_t *baud) {
if (frame[0] != ZEBRA_HS_MAGIC_0 || frame[1] != ZEBRA_HS_MAGIC_1) return -1;
if (frame[2] != ZEBRA_HS_TYPE_OFFER) return -1;
uint8_t ck = frame[0] ^ frame[1] ^ frame[2] ^ frame[3] ^ frame[4];
if (ck != frame[5]) return -1;
*baud = (uint16_t)(frame[3] | ((uint16_t)frame[4] << 8));
return 0;
}
/* ------------------------------------------------------------------ *
* BENCHMARK: measure PA poll latency, derive max safe baud *
* ------------------------------------------------------------------ */
/* Runs N zebra_get_volume calls and measures average round-trip time.
* Returns max baud receiver can sustain via zebra_baud_from_avg_ns. */
static inline int zebra_benchmark_baud(zebra_pulse_t *z, uint32_t sink) {
const int N = 100;
uint8_t vol;
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int i = 0; i < N; i++)
zebra_get_volume(z, sink, &vol);
clock_gettime(CLOCK_MONOTONIC, &t1);
long elapsed_ns = (t1.tv_sec - t0.tv_sec) * 1000000000L
+ (t1.tv_nsec - t0.tv_nsec);
return zebra_baud_from_avg_ns(elapsed_ns / N);
}
/* ------------------------------------------------------------------ *
* HANDSHAKE TX: send negotiation frame at ZEBRA_BAUD_HANDSHAKE *
* ------------------------------------------------------------------ */
/* Frame layout (ZEBRA_HS_FRAME_LEN = 6 bytes):
* [0] 0x5A magic 'Z'
* [1] 0x42 magic 'B'
* [2] 0x01 type: BAUD_OFFER
* [3] baud low byte (uint16 little-endian)
* [4] baud high byte
* [5] XOR of bytes 0-4 (checksum) */
static inline int zebra_send_handshake(zebra_pulse_t *z, uint32_t sink,
uint8_t channels, uint16_t baud) {
uint8_t frame[ZEBRA_HS_FRAME_LEN];
zebra_hs_build(frame, baud);
long period_ns = 1000000000L / ZEBRA_BAUD_HANDSHAKE;
zebra_set_volume_fast(z, sink, channels, ZEBRA_VOL_MARK);
struct timespec next;
clock_gettime(CLOCK_MONOTONIC, &next);
ts_add_ns(&next, period_ns);
for (int i = 0; i < ZEBRA_HS_FRAME_LEN; i++)
zebra_send_byte(z, sink, channels, frame[i], &next, period_ns);
zebra_set_volume_fast(z, sink, channels, ZEBRA_VOL_MARK);
return 0;
}
/* ------------------------------------------------------------------ *
* HANDSHAKE RX: listen for any HS frame type with timeout *
* ------------------------------------------------------------------ */
/* General frame receiver: listens at ZEBRA_BAUD_HANDSHAKE for a frame
* whose type byte matches expected_type. Returns 0 and sets *out_baud
* (may be NULL for READY frames where baud=0) on success; -1 on timeout.
* Uses sliding-window magic-byte sync so partial frame receipt is OK. */
static inline int zebra_recv_hs_frame(zebra_pulse_t *z, uint32_t sink,
int timeout_ms, uint8_t expected_type,
uint16_t *out_baud) {
const int oversample = 4;
long quarter_ns = 1000000000L / ((long)ZEBRA_BAUD_HANDSHAKE * oversample);
long half_ns = 2 * quarter_ns;
long full_ns = 4 * quarter_ns;
struct timespec deadline, ts, now;
clock_gettime(CLOCK_MONOTONIC, &deadline);
ts_add_ns(&deadline, (long)timeout_ms * 1000000L);
clock_gettime(CLOCK_MONOTONIC, &ts);
int prev = 1;
uint8_t frame[ZEBRA_HS_FRAME_LEN];
int fpos = 0;
for (;;) {
clock_gettime(CLOCK_MONOTONIC, &now);
if (now.tv_sec > deadline.tv_sec ||
(now.tv_sec == deadline.tv_sec && now.tv_nsec >= deadline.tv_nsec))
return -1;
ts_add_ns(&ts, quarter_ns);
clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &ts, NULL);
uint8_t vol;
if (zebra_get_volume(z, sink, &vol) < 0) {
struct timespec r = {0, 10000000L}; /* 10ms retry */
nanosleep(&r, NULL);
prev = 1;
continue;
}
int cur = zebra_vol_to_bit(vol);
if (prev == 1 && cur == 0) {
/* start bit — confirm at center */
ts_add_ns(&ts, half_ns);
clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &ts, NULL);
if (zebra_get_volume(z, sink, &vol) < 0) { prev = 1; continue; }
if (zebra_vol_to_bit(vol) != 0) { prev = 1; continue; }
/* decode 8 data bits */
uint8_t byte = 0;
int ok = 1;
for (int i = 0; i < 8; i++) {
ts_add_ns(&ts, full_ns);
clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &ts, NULL);
if (zebra_get_volume(z, sink, &vol) < 0) { ok = 0; break; }
int b = zebra_vol_to_bit(vol);
if (b < 0) b = (vol >= ZEBRA_VOL_THRESHOLD) ? 1 : 0;
byte |= (uint8_t)(b << i);
}
if (!ok) { clock_gettime(CLOCK_MONOTONIC, &ts); prev = 1; continue; }
/* sliding frame sync on magic bytes */
if (fpos == 0) {
if (byte == ZEBRA_HS_MAGIC_0) frame[fpos++] = byte;
} else if (fpos == 1) {
if (byte == ZEBRA_HS_MAGIC_1) frame[fpos++] = byte;
else if (byte == ZEBRA_HS_MAGIC_0) { fpos = 1; frame[0] = byte; }
else fpos = 0;
} else {
frame[fpos++] = byte;
if (fpos == ZEBRA_HS_FRAME_LEN) {
/* validate: magic + expected_type + checksum */
uint8_t ck = frame[0]^frame[1]^frame[2]^frame[3]^frame[4];
if (frame[2] == expected_type && ck == frame[5]) {
if (out_baud)
*out_baud = (uint16_t)(frame[3]|((uint16_t)frame[4]<<8));
return 0;
}
fpos = 0;
}
}
clock_gettime(CLOCK_MONOTONIC, &ts); /* resync after each byte */
prev = 1;
} else if (cur >= 0) {
prev = cur;
}
}
}
/* Receive BAUD_OFFER frame. Wrapper around zebra_recv_hs_frame. */
static inline int zebra_recv_handshake(zebra_pulse_t *z, uint32_t sink,
int timeout_ms, uint16_t *out_baud) {
return zebra_recv_hs_frame(z, sink, timeout_ms, ZEBRA_HS_TYPE_OFFER, out_baud);
}
/* ------------------------------------------------------------------ *
* HANDSHAKE READY: 3-way handshake completion *
* *
* After sending BAUD_OFFER, RX sends 3x READY frames then enters its *
* receive loop immediately. TX waits for READY before sending data. *
* This eliminates the settle-timer race at high baud rates. *
* ------------------------------------------------------------------ */
/* Send READY frame 3x on sink so TX catches it even with scheduling jitter.
* RX calls this immediately after zebra_send_handshake, then enters rx loop. */
static inline int zebra_send_ready(zebra_pulse_t *z, uint32_t sink,
uint8_t channels) {
uint8_t frame[ZEBRA_HS_FRAME_LEN];
zebra_hs_build_ready(frame);
long period_ns = 1000000000L / ZEBRA_BAUD_HANDSHAKE;
for (int rep = 0; rep < 3; rep++) {
zebra_set_volume_fast(z, sink, channels, ZEBRA_VOL_MARK);
struct timespec next;
clock_gettime(CLOCK_MONOTONIC, &next);
ts_add_ns(&next, period_ns);
for (int i = 0; i < ZEBRA_HS_FRAME_LEN; i++)
zebra_send_byte(z, sink, channels, frame[i], &next, period_ns);
}
zebra_set_volume_fast(z, sink, channels, ZEBRA_VOL_MARK);
return 0;
}
/* Wait for READY frame from RX. Returns 0 on success, -1 on timeout.
* TX calls this after receiving BAUD_OFFER. Data send follows immediately. */
static inline int zebra_recv_ready(zebra_pulse_t *z, uint32_t sink,
int timeout_ms) {
return zebra_recv_hs_frame(z, sink, timeout_ms, ZEBRA_HS_TYPE_READY, NULL);
}

124
include/zebra.h Normal file
View file

@ -0,0 +1,124 @@
#pragma once
/*
* zebra -- volume-modulated modem over PulseAudio sink inputs
*
* Firefox exposes each tab as a named sink input in the PulseAudio mixer.
* A <video> element keeps audio flowing so the tab stays visible in
* pavucontrol. The C clients modulate/read that sink's volume as the
* signal carrier.
*
* Wire format (UART):
* idle = MARK (high volume, ZEBRA_VOL_MARK %)
* start = SPACE (low volume, ZEBRA_VOL_SPACE %) -- falling edge triggers rx
* data = 8 bits LSB-first, 0=SPACE 1=MARK
* stop = MARK
* frame = 10 symbols per byte
*
* 101 discrete levels (0100%) available "101 dalmatians".
* Binary encoding uses two far-apart levels for maximum noise margin.
*/
#include <stdint.h>
#include <pulse/pulseaudio.h>
/* ------------------------------------------------------------------ *
* signal constants *
* ------------------------------------------------------------------ */
#define ZEBRA_VOL_MARK 80 /* % — logic-1 / idle / stop bit */
#define ZEBRA_VOL_SPACE 20 /* % — logic-0 / start bit */
#define ZEBRA_VOL_THRESHOLD 50 /* % — bit decision boundary */
#define ZEBRA_DALMATIANS 101 /* discrete volume steps: 0100 */
/* ------------------------------------------------------------------ *
* baud rate limits *
* ------------------------------------------------------------------ */
#define ZEBRA_BAUD_DEFAULT 10
#define ZEBRA_BAUD_MIN 1
#define ZEBRA_BAUD_MAX 100000
/* ------------------------------------------------------------------ *
* handshake / auto-negotiation *
* ------------------------------------------------------------------ */
#define ZEBRA_BAUD_HANDSHAKE 50 /* fixed baud for negotiation phase */
#define ZEBRA_HS_MAGIC_0 0x5A /* 'Z' */
#define ZEBRA_HS_MAGIC_1 0x42 /* 'B' */
#define ZEBRA_HS_TYPE_OFFER 0x01 /* RX→TX: here is my max baud */
#define ZEBRA_HS_FRAME_LEN 6 /* magic(2) type(1) baud_le(2) xor(1)*/
#define ZEBRA_HS_SETTLE_MS 500 /* quiet gap after handshake */
#define ZEBRA_HS_TYPE_READY 0x02 /* RX→TX: receive loop is active */
#define ZEBRA_HS_READY_WAIT 5000 /* ms TX waits for READY after OFFER */
/* ------------------------------------------------------------------ *
* types *
* ------------------------------------------------------------------ */
/* one PulseAudio sink input entry */
typedef struct {
uint32_t index;
char name[256];
char app_name[128];
uint8_t volume_pct; /* 0100 */
uint8_t channels;
} zebra_sink_t;
/* PulseAudio connection state */
typedef struct {
pa_threaded_mainloop *loop;
pa_context *ctx;
} zebra_pulse_t;
/* ------------------------------------------------------------------ *
* pulse.c: PulseAudio interface *
* ------------------------------------------------------------------ */
/* connect to the default PulseAudio server; returns 0 on success */
int zebra_pulse_connect(zebra_pulse_t *z, const char *app_name);
void zebra_pulse_disconnect(zebra_pulse_t *z);
/* enumerate all sink inputs; returns count or -1 */
int zebra_list_sinks(zebra_pulse_t *z, zebra_sink_t *buf, int max);
/* find first sink input whose name or app_name contains match */
int zebra_find_sink(zebra_pulse_t *z, const char *match, zebra_sink_t *out);
/* get/set volume percent (0100); returns 0 on success */
int zebra_get_volume(zebra_pulse_t *z, uint32_t sink_index, uint8_t *pct);
int zebra_set_volume(zebra_pulse_t *z, uint32_t sink_index, uint8_t pct);
/* fast TX path: caller supplies channel count, waits for PA confirmation */
int zebra_set_volume_fast(zebra_pulse_t *z, uint32_t sink_index,
uint8_t channels, uint8_t pct);
/* fire-and-forget TX: enqueues volume command, returns immediately.
* PA processes async symbol timing governed entirely by clock_nanosleep. */
int zebra_set_volume_noack(zebra_pulse_t *z, uint32_t sink_index,
uint8_t channels, uint8_t pct);
/* ------------------------------------------------------------------ *
* Battle Toads: per-channel stereo API *
* ------------------------------------------------------------------ */
/* Read L and R channel volumes independently (single PA IPC call).
* Falls back: if sink is mono, both *left and *right get the same value. */
int zebra_get_volume_lr(zebra_pulse_t *z, uint32_t sink_index,
uint8_t *left, uint8_t *right);
/* Fire-and-forget: set L and R to different volumes in one PA call. */
int zebra_set_volume_lr_noack(zebra_pulse_t *z, uint32_t sink_index,
uint8_t left, uint8_t right);
/* ------------------------------------------------------------------ *
* inline modem helpers *
* ------------------------------------------------------------------ */
static inline uint8_t zebra_bit_to_vol(int bit) {
return (uint8_t)(bit ? ZEBRA_VOL_MARK : ZEBRA_VOL_SPACE);
}
/* returns 0, 1, or -1 (exactly on threshold — ambiguous) */
static inline int zebra_vol_to_bit(uint8_t vol) {
if (vol > ZEBRA_VOL_THRESHOLD) return 1;
if (vol < ZEBRA_VOL_THRESHOLD) return 0;
return -1;
}

213
src/bt.c Normal file
View file

@ -0,0 +1,213 @@
/*
* bt Battle Toads: dual-channel stereo UART over PulseAudio
*
* One stereo tab carries two simultaneous UART streams (L + R).
* TX sends two bytes per frame. RX decodes both channels per frame.
* Net throughput: 2x single-channel at the same baud rate.
*
* Usage:
* ./bt -T MY_SINK -R THEIR_SINK [-b BAUD]
*
* MY_SINK the tab YOU control (stereo, channels=2)
* THEIR_SINK the tab THEY control (stereo, channels=2)
*
* Example (same machine, two terminals):
* terminal 1: ./bt -T Firefox -R Chromium -b 100
* terminal 2: ./bt -T Chromium -R Firefox -b 100
*
* Run ./tx -l to list sinks. Open web/index.html in both browsers
* and click 'start audio' tab must be stereo (channels=2).
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <time.h>
#include "zebra.h"
#include "modem.h"
#define BT_LINE_MAX 512
static pthread_mutex_t print_mu = PTHREAD_MUTEX_INITIALIZER;
/* ------------------------------------------------------------------ *
* RX thread *
* ------------------------------------------------------------------ */
typedef struct {
zebra_pulse_t *z;
uint32_t sink;
int baud;
} bt_rx_arg_t;
static char rx_buf[BT_LINE_MAX];
static int rx_pos = 0;
static void on_rx_byte(uint8_t byte, void *ud) {
(void)ud;
rx_buf[rx_pos++] = (char)byte;
if (byte == '\n' || rx_pos >= (int)sizeof(rx_buf) - 1) {
rx_buf[rx_pos] = '\0';
pthread_mutex_lock(&print_mu);
printf("\r<<< %s", rx_buf);
if (rx_buf[rx_pos - 1] != '\n') putchar('\n');
fflush(stdout);
pthread_mutex_unlock(&print_mu);
rx_pos = 0;
}
}
static void *rx_thread(void *arg) {
bt_rx_arg_t *a = arg;
bt_rx_run(a->z, a->sink, a->baud, on_rx_byte, NULL);
return NULL;
}
/* ------------------------------------------------------------------ *
* helpers *
* ------------------------------------------------------------------ */
static void usage(const char *prog) {
fprintf(stderr,
"usage: %s -T TX_SINK -R RX_SINK [-b BAUD]\n"
"\n"
" Battle Toads — dual-channel stereo UART, 2x bandwidth\n"
" Both sinks must be stereo (channels=2).\n"
" Open web/index.html in browser and click 'start audio'.\n"
"\n"
" -T NAME|INDEX your sink (you TX on this)\n"
" -R NAME|INDEX their sink (you RX on this)\n"
" -b BAUD baud rate (default %d)\n"
"\n"
" run ./tx -l to list available sinks\n",
prog, ZEBRA_BAUD_DEFAULT);
}
static uint32_t resolve(zebra_pulse_t *z, const char *arg) {
char *end;
long idx = strtol(arg, &end, 10);
if (*end == '\0' && idx >= 0) return (uint32_t)idx;
zebra_sink_t s;
if (zebra_find_sink(z, arg, &s) < 0) {
fprintf(stderr, "error: no sink matching '%s' — run ./tx -l\n", arg);
exit(1);
}
fprintf(stderr, " found: [%u] %s (%s) ch=%u\n",
s.index, s.name, s.app_name, s.channels);
return s.index;
}
/* ------------------------------------------------------------------ *
* main *
* ------------------------------------------------------------------ */
int main(int argc, char **argv) {
int baud = ZEBRA_BAUD_DEFAULT;
char *tx_arg = NULL;
char *rx_arg = NULL;
int opt;
while ((opt = getopt(argc, argv, "T:R:b:h")) != -1) {
switch (opt) {
case 'T': tx_arg = optarg; break;
case 'R': rx_arg = optarg; break;
case 'b': baud = atoi(optarg); break;
case 'h': usage(argv[0]); return 0;
default: usage(argv[0]); return 1;
}
}
if (!tx_arg || !rx_arg) {
fprintf(stderr, "error: -T and -R required\n\n");
usage(argv[0]);
return 1;
}
if (baud < ZEBRA_BAUD_MIN || baud > ZEBRA_BAUD_MAX) {
fprintf(stderr, "error: baud must be %d-%d\n", ZEBRA_BAUD_MIN, ZEBRA_BAUD_MAX);
return 1;
}
/* two PA connections — avoid mainloop contention between threads */
zebra_pulse_t ztx, zrx;
if (zebra_pulse_connect(&ztx, "zebra-bt-tx") < 0) {
fprintf(stderr, "error: TX PA connect failed\n"); return 1;
}
if (zebra_pulse_connect(&zrx, "zebra-bt-rx") < 0) {
fprintf(stderr, "error: RX PA connect failed\n");
zebra_pulse_disconnect(&ztx); return 1;
}
fprintf(stderr, "resolving TX sink: %s\n", tx_arg);
uint32_t tx_sink = resolve(&ztx, tx_arg);
fprintf(stderr, "resolving RX sink: %s\n", rx_arg);
uint32_t rx_sink = resolve(&zrx, rx_arg);
/* warn if TX sink is not stereo */
{
zebra_sink_t buf[64];
int n = zebra_list_sinks(&ztx, buf, 64);
for (int i = 0; i < n; i++) {
if (buf[i].index == tx_sink && buf[i].channels < 2) {
fprintf(stderr,
"warning: TX sink has %u channel(s) — need stereo (channels=2)\n"
" open web/index.html and click 'start audio' for a stereo tab\n",
buf[i].channels);
}
}
}
fprintf(stderr,
"battle toads: tx=sink[%u] rx=sink[%u] baud=%d 2x bandwidth\n",
tx_sink, rx_sink, baud);
fprintf(stderr, "type and press enter to send (ctrl-d to quit)\n");
fprintf(stderr, "---\n");
/* idle TX */
zebra_set_volume_lr_noack(&ztx, tx_sink, ZEBRA_VOL_MARK, ZEBRA_VOL_MARK);
/* start RX thread */
bt_rx_arg_t rxa = { .z = &zrx, .sink = rx_sink, .baud = baud };
pthread_t tid;
if (pthread_create(&tid, NULL, rx_thread, &rxa) != 0) {
fprintf(stderr, "error: pthread_create failed\n");
zebra_pulse_disconnect(&ztx);
zebra_pulse_disconnect(&zrx);
return 1;
}
pthread_detach(tid);
/* TX loop: read stdin, send 2 bytes per frame */
long period_ns = 1000000000L / baud;
char line[BT_LINE_MAX];
while (fgets(line, sizeof(line), stdin)) {
int len = (int)strlen(line);
if (len == 0) continue;
pthread_mutex_lock(&print_mu);
printf(">>> %s", line);
if (line[len - 1] != '\n') putchar('\n');
fflush(stdout);
pthread_mutex_unlock(&print_mu);
struct timespec next;
clock_gettime(CLOCK_MONOTONIC, &next);
ts_add_ns(&next, period_ns);
/* send in pairs: L=byte[i], R=byte[i+1] */
int i = 0;
while (i < len) {
uint8_t bl = (uint8_t)line[i];
uint8_t br = (i + 1 < len) ? (uint8_t)line[i + 1] : ZEBRA_VOL_MARK;
bt_send_byte_lr(&ztx, tx_sink, bl, br, &next, period_ns);
i += 2;
}
}
zebra_set_volume_lr_noack(&ztx, tx_sink, ZEBRA_VOL_MARK, ZEBRA_VOL_MARK);
zebra_pulse_disconnect(&ztx);
zebra_pulse_disconnect(&zrx);
return 0;
}

188
src/carrier.c Normal file
View file

@ -0,0 +1,188 @@
/*
* carrier create a named stereo PA sink input and hold it open
*
* Feeds silence into PulseAudio so the stream appears in pavucontrol
* and `./tx -l` as a controllable sink input no browser required.
*
* Usage:
* ./carrier [-n NAME] [-c CHANNELS]
*
* -n NAME stream name shown in pavucontrol (default: zebra)
* -c CHANNELS 1 or 2 (default: 2, stereo required for Battle Toads)
*
* Run in background:
* ./carrier -n zebra-data &
* ./carrier -n zebra-ctrl &
* ./tx -l # find indices
* ./rx -s <data> -t <ctrl>
* ./tx -s <data> -r <ctrl>
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <time.h>
#include <pulse/pulseaudio.h>
static volatile int running = 1;
static void on_signal(int s) { (void)s; running = 0; }
/* ------------------------------------------------------------------ *
* PA callbacks *
* ------------------------------------------------------------------ */
static void stream_write_cb(pa_stream *s, size_t nbytes, void *ud) {
(void)ud;
/* feed silence — all zeros at whatever sample width */
void *buf = NULL;
size_t len = nbytes;
if (pa_stream_begin_write(s, &buf, &len) < 0 || !buf) return;
memset(buf, 0, len);
pa_stream_write(s, buf, len, NULL, 0, PA_SEEK_RELATIVE);
}
static void ctx_state_cb(pa_context *ctx, void *ud) {
pa_threaded_mainloop *ml = ud;
(void)ctx;
pa_threaded_mainloop_signal(ml, 0);
}
static void stream_state_cb(pa_stream *s, void *ud) {
pa_threaded_mainloop *ml = ud;
(void)s;
pa_threaded_mainloop_signal(ml, 0);
}
/* ------------------------------------------------------------------ *
* main *
* ------------------------------------------------------------------ */
int main(int argc, char **argv) {
const char *name = "zebra";
int channels = 2;
int opt;
while ((opt = getopt(argc, argv, "n:c:h")) != -1) {
switch (opt) {
case 'n': name = optarg; break;
case 'c': channels = atoi(optarg); break;
case 'h':
fprintf(stderr,
"usage: %s [-n NAME] [-c CHANNELS]\n"
" -n NAME stream name (default: zebra)\n"
" -c CHANNELS 1 or 2 (default: 2)\n",
argv[0]);
return 0;
default:
return 1;
}
}
if (channels < 1 || channels > 2) {
fprintf(stderr, "error: channels must be 1 or 2\n");
return 1;
}
signal(SIGINT, on_signal);
signal(SIGTERM, on_signal);
pa_threaded_mainloop *ml = pa_threaded_mainloop_new();
if (!ml) { fprintf(stderr, "error: pa_threaded_mainloop_new\n"); return 1; }
pa_mainloop_api *api = pa_threaded_mainloop_get_api(ml);
pa_context *ctx = pa_context_new(api, name);
if (!ctx) { fprintf(stderr, "error: pa_context_new\n"); return 1; }
pa_context_set_state_callback(ctx, ctx_state_cb, ml);
pa_threaded_mainloop_lock(ml);
pa_threaded_mainloop_start(ml);
pa_context_connect(ctx, NULL, PA_CONTEXT_NOFLAGS, NULL);
/* wait for context ready */
for (;;) {
pa_context_state_t st = pa_context_get_state(ctx);
if (st == PA_CONTEXT_READY) break;
if (!PA_CONTEXT_IS_GOOD(st)) {
fprintf(stderr, "error: PA context failed\n");
pa_threaded_mainloop_unlock(ml);
return 1;
}
pa_threaded_mainloop_wait(ml);
}
/* create stereo 44100 Hz stream */
pa_sample_spec ss = {
.format = PA_SAMPLE_S16LE,
.rate = 44100,
.channels = (uint8_t)channels
};
pa_channel_map cm;
pa_channel_map_init_stereo(&cm);
if (channels == 1) pa_channel_map_init_mono(&cm);
pa_stream *stream = pa_stream_new(ctx, name, &ss, &cm);
if (!stream) {
fprintf(stderr, "error: pa_stream_new\n");
pa_threaded_mainloop_unlock(ml);
return 1;
}
pa_stream_set_state_callback(stream, stream_state_cb, ml);
pa_stream_set_write_callback(stream, stream_write_cb, NULL);
pa_buffer_attr ba = {
.maxlength = (uint32_t)-1,
.tlength = pa_usec_to_bytes(100000, &ss), /* 100ms latency */
.prebuf = (uint32_t)-1,
.minreq = (uint32_t)-1,
.fragsize = (uint32_t)-1
};
pa_stream_flags_t flags = PA_STREAM_INTERPOLATE_TIMING
| PA_STREAM_AUTO_TIMING_UPDATE
| PA_STREAM_ADJUST_LATENCY;
if (pa_stream_connect_playback(stream, NULL, &ba, flags, NULL, NULL) < 0) {
fprintf(stderr, "error: pa_stream_connect_playback\n");
pa_threaded_mainloop_unlock(ml);
return 1;
}
/* wait for stream ready */
for (;;) {
pa_stream_state_t st = pa_stream_get_state(stream);
if (st == PA_STREAM_READY) break;
if (!PA_STREAM_IS_GOOD(st)) {
fprintf(stderr, "error: stream failed\n");
pa_threaded_mainloop_unlock(ml);
return 1;
}
pa_threaded_mainloop_wait(ml);
}
uint32_t idx = pa_stream_get_index(stream);
pa_threaded_mainloop_unlock(ml);
fprintf(stderr, "carrier: [%u] %s ch=%d rate=44100 (ctrl-c to stop)\n",
idx, name, channels);
/* hold open until signal */
while (running) {
struct timespec t = {0, 100000000L}; /* 100ms */
nanosleep(&t, NULL);
}
pa_threaded_mainloop_lock(ml);
pa_stream_disconnect(stream);
pa_stream_unref(stream);
pa_threaded_mainloop_unlock(ml);
pa_threaded_mainloop_stop(ml);
pa_context_disconnect(ctx);
pa_context_unref(ctx);
pa_threaded_mainloop_free(ml);
fprintf(stderr, "carrier: stopped\n");
return 0;
}

200
src/chat.c Normal file
View file

@ -0,0 +1,200 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <time.h>
#include "zebra.h"
#include "modem.h"
/*
* chat -- bidirectional text over two PulseAudio sink inputs
*
* Two separate PA connections (one per direction) avoid mainloop
* contention between threads.
*
* TX side: sets its own browser tab's volume other side reads it
* RX side: polls the other browser tab's volume decodes bytes
*
* Example (same machine, two terminals):
* terminal 1: ./chat -T Firefox -R Chromium -b 10
* terminal 2: ./chat -T Chromium -R Firefox -b 10
*/
#define CHAT_LINE_MAX 512
static pthread_mutex_t print_mu = PTHREAD_MUTEX_INITIALIZER;
/* ------------------------------------------------------------------ *
* RX thread *
* ------------------------------------------------------------------ */
typedef struct {
zebra_pulse_t *z;
uint32_t sink;
int baud;
} rx_arg_t;
/* line buffer for incoming bytes */
static char rx_buf[CHAT_LINE_MAX];
static int rx_pos = 0;
static void on_rx_byte(uint8_t byte, void *ud) {
(void)ud;
rx_buf[rx_pos++] = (char)byte;
if (byte == '\n' || rx_pos >= (int)sizeof(rx_buf) - 1) {
rx_buf[rx_pos] = '\0';
pthread_mutex_lock(&print_mu);
/* \r moves to column 0 so received text overwrites any partial input line */
printf("\r<<< %s", rx_buf);
if (rx_buf[rx_pos - 1] != '\n') putchar('\n');
fflush(stdout);
pthread_mutex_unlock(&print_mu);
rx_pos = 0;
}
}
static void *rx_thread(void *arg) {
rx_arg_t *a = arg;
zebra_rx_run(a->z, a->sink, a->baud, on_rx_byte, NULL);
return NULL;
}
/* ------------------------------------------------------------------ *
* helpers *
* ------------------------------------------------------------------ */
static void usage(const char *prog) {
fprintf(stderr,
"usage: %s -T TX_SINK -R RX_SINK [-b BAUD]\n"
"\n"
" -T NAME|INDEX sink to modulate (your outbound — your browser tab)\n"
" -R NAME|INDEX sink to monitor (their outbound — their browser tab)\n"
" -b BAUD symbols/sec, both sides must match (default %d)\n"
"\n"
"same-machine setup (Firefox ↔ Chromium):\n"
" open web/index.html in both browsers, click 'start audio' in each\n"
" terminal 1: ./chat -T Firefox -R Chromium -b 10\n"
" terminal 2: ./chat -T Chromium -R Firefox -b 10\n"
" find sink indices first with: ./tx -l\n",
prog, ZEBRA_BAUD_DEFAULT);
}
/* resolve NAME-or-INDEX to a PA sink index */
static uint32_t resolve(zebra_pulse_t *z, const char *arg) {
char *end;
long idx = strtol(arg, &end, 10);
if (*end == '\0' && idx >= 0) return (uint32_t)idx;
zebra_sink_t s;
if (zebra_find_sink(z, arg, &s) < 0) {
fprintf(stderr, "error: no sink matching '%s' — run ./tx -l to list\n", arg);
exit(1);
}
fprintf(stderr, " found: [%u] %s (%s)\n", s.index, s.name, s.app_name);
return s.index;
}
/* ------------------------------------------------------------------ *
* main *
* ------------------------------------------------------------------ */
int main(int argc, char **argv) {
int baud = ZEBRA_BAUD_DEFAULT;
char *tx_arg = NULL;
char *rx_arg = NULL;
int opt;
while ((opt = getopt(argc, argv, "T:R:b:h")) != -1) {
switch (opt) {
case 'T': tx_arg = optarg; break;
case 'R': rx_arg = optarg; break;
case 'b': baud = atoi(optarg); break;
case 'h': usage(argv[0]); return 0;
default: usage(argv[0]); return 1;
}
}
if (!tx_arg || !rx_arg) {
fprintf(stderr, "error: -T and -R are required\n\n");
usage(argv[0]);
return 1;
}
if (baud < ZEBRA_BAUD_MIN || baud > ZEBRA_BAUD_MAX) {
fprintf(stderr, "error: baud must be %d%d\n", ZEBRA_BAUD_MIN, ZEBRA_BAUD_MAX);
return 1;
}
/* two PA connections: avoid mainloop contention between TX/RX threads */
zebra_pulse_t ztx, zrx;
if (zebra_pulse_connect(&ztx, "zebra-chat-tx") < 0) {
fprintf(stderr, "error: TX PulseAudio connect failed\n");
return 1;
}
if (zebra_pulse_connect(&zrx, "zebra-chat-rx") < 0) {
fprintf(stderr, "error: RX PulseAudio connect failed\n");
zebra_pulse_disconnect(&ztx);
return 1;
}
fprintf(stderr, "resolving TX sink: %s\n", tx_arg);
uint32_t tx_sink = resolve(&ztx, tx_arg);
fprintf(stderr, "resolving RX sink: %s\n", rx_arg);
uint32_t rx_sink = resolve(&zrx, rx_arg);
/* cache TX channel count — avoids PA round-trip per symbol */
uint8_t tx_channels = 2;
{
zebra_sink_t buf[64];
int n = zebra_list_sinks(&ztx, buf, 64);
for (int i = 0; i < n; i++)
if (buf[i].index == tx_sink)
{ tx_channels = buf[i].channels ? buf[i].channels : 2; break; }
}
fprintf(stderr, "chat ready: tx=sink[%u] ch=%u rx=sink[%u] baud=%d\n",
tx_sink, tx_channels, rx_sink, baud);
fprintf(stderr, "type and press enter to send (ctrl-d to quit)\n");
fprintf(stderr, "---\n");
/* assert TX idle */
zebra_set_volume_fast(&ztx, tx_sink, tx_channels, ZEBRA_VOL_MARK);
/* start RX thread */
rx_arg_t rxa = { .z = &zrx, .sink = rx_sink, .baud = baud };
pthread_t tid;
if (pthread_create(&tid, NULL, rx_thread, &rxa) != 0) {
fprintf(stderr, "error: pthread_create failed\n");
zebra_pulse_disconnect(&ztx);
zebra_pulse_disconnect(&zrx);
return 1;
}
pthread_detach(tid);
/* TX loop: read stdin lines, transmit byte by byte */
long period_ns = 1000000000L / baud;
char line[CHAT_LINE_MAX];
while (fgets(line, sizeof(line), stdin)) {
int len = (int)strlen(line);
if (len == 0) continue;
pthread_mutex_lock(&print_mu);
printf(">>> %s", line);
if (line[len - 1] != '\n') putchar('\n');
fflush(stdout);
pthread_mutex_unlock(&print_mu);
struct timespec next;
clock_gettime(CLOCK_MONOTONIC, &next);
ts_add_ns(&next, period_ns);
for (int i = 0; i < len; i++)
zebra_send_byte(&ztx, tx_sink, tx_channels, (uint8_t)line[i], &next, period_ns);
}
zebra_set_volume_fast(&ztx, tx_sink, tx_channels, ZEBRA_VOL_MARK);
zebra_pulse_disconnect(&ztx);
zebra_pulse_disconnect(&zrx);
return 0;
}

304
src/pulse.c Normal file
View file

@ -0,0 +1,304 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "zebra.h"
/* ------------------------------------------------------------------ *
* internal callbacks *
* ------------------------------------------------------------------ */
static void _ctx_state_cb(pa_context *ctx, void *ud) {
(void)ctx;
pa_threaded_mainloop_signal((pa_threaded_mainloop *)ud, 0);
}
static void _success_cb(pa_context *ctx, int success, void *ud) {
(void)ctx;
(void)success;
pa_threaded_mainloop_signal((pa_threaded_mainloop *)ud, 0);
}
/* result type for single-sink-input queries */
typedef struct {
pa_threaded_mainloop *ml;
uint8_t channels;
uint8_t volume_pct;
uint8_t vol_left;
uint8_t vol_right;
int found;
} _info_t;
static uint8_t _pa_vol_to_pct(pa_volume_t v) {
uint32_t pct = (uint32_t)(v * 100 / PA_VOLUME_NORM);
return (uint8_t)(pct > 100 ? 100 : pct);
}
static void _single_info_cb(pa_context *ctx,
const pa_sink_input_info *info,
int eol, void *ud) {
(void)ctx;
_info_t *r = ud;
if (!eol && info) {
r->channels = info->volume.channels;
pa_volume_t avg = pa_cvolume_avg(&info->volume);
r->volume_pct = _pa_vol_to_pct(avg);
r->vol_left = _pa_vol_to_pct(info->volume.values[0]);
r->vol_right = (info->volume.channels >= 2)
? _pa_vol_to_pct(info->volume.values[1])
: r->vol_left;
r->found = 1;
} else {
pa_threaded_mainloop_signal(r->ml, 0);
}
}
/* result type for list queries */
typedef struct {
zebra_sink_t *buf;
int max;
int count;
pa_threaded_mainloop *ml;
} _list_t;
static void _list_info_cb(pa_context *ctx,
const pa_sink_input_info *info,
int eol, void *ud) {
(void)ctx;
_list_t *r = ud;
if (!eol && info) {
if (r->count < r->max) {
zebra_sink_t *s = &r->buf[r->count++];
s->index = info->index;
s->channels = info->volume.channels;
snprintf(s->name, sizeof(s->name), "%s",
info->name ? info->name : "");
const char *app = pa_proplist_gets(info->proplist,
PA_PROP_APPLICATION_NAME);
snprintf(s->app_name, sizeof(s->app_name), "%s",
app ? app : "");
pa_volume_t avg = pa_cvolume_avg(&info->volume);
uint32_t pct = (uint32_t)(avg * 100 / PA_VOLUME_NORM);
s->volume_pct = (uint8_t)(pct > 100 ? 100 : pct);
}
} else {
pa_threaded_mainloop_signal(r->ml, 0);
}
}
/* ------------------------------------------------------------------ *
* public API *
* ------------------------------------------------------------------ */
int zebra_pulse_connect(zebra_pulse_t *z, const char *app_name) {
memset(z, 0, sizeof(*z));
z->loop = pa_threaded_mainloop_new();
if (!z->loop) return -1;
pa_mainloop_api *api = pa_threaded_mainloop_get_api(z->loop);
z->ctx = pa_context_new(api, app_name ? app_name : "zebra");
if (!z->ctx) {
pa_threaded_mainloop_free(z->loop);
return -1;
}
pa_context_set_state_callback(z->ctx, _ctx_state_cb, z->loop);
pa_threaded_mainloop_lock(z->loop);
if (pa_threaded_mainloop_start(z->loop) < 0) {
pa_threaded_mainloop_unlock(z->loop);
pa_context_unref(z->ctx);
pa_threaded_mainloop_free(z->loop);
return -1;
}
pa_context_connect(z->ctx, NULL, PA_CONTEXT_NOFLAGS, NULL);
for (;;) {
pa_context_state_t st = pa_context_get_state(z->ctx);
if (st == PA_CONTEXT_READY) break;
if (!PA_CONTEXT_IS_GOOD(st)) {
pa_threaded_mainloop_unlock(z->loop);
return -1;
}
pa_threaded_mainloop_wait(z->loop);
}
pa_threaded_mainloop_unlock(z->loop);
return 0;
}
void zebra_pulse_disconnect(zebra_pulse_t *z) {
if (!z->loop) return;
pa_threaded_mainloop_stop(z->loop);
if (z->ctx) {
pa_context_disconnect(z->ctx);
pa_context_unref(z->ctx);
}
pa_threaded_mainloop_free(z->loop);
memset(z, 0, sizeof(*z));
}
int zebra_list_sinks(zebra_pulse_t *z, zebra_sink_t *buf, int max) {
_list_t r = { .buf = buf, .max = max, .count = 0, .ml = z->loop };
pa_threaded_mainloop_lock(z->loop);
pa_operation *op = pa_context_get_sink_input_info_list(z->ctx,
_list_info_cb, &r);
if (!op) {
pa_threaded_mainloop_unlock(z->loop);
return -1;
}
while (pa_operation_get_state(op) == PA_OPERATION_RUNNING)
pa_threaded_mainloop_wait(z->loop);
pa_operation_unref(op);
pa_threaded_mainloop_unlock(z->loop);
return r.count;
}
int zebra_find_sink(zebra_pulse_t *z, const char *match, zebra_sink_t *out) {
zebra_sink_t buf[64];
int n = zebra_list_sinks(z, buf, 64);
if (n < 0) return -1;
for (int i = 0; i < n; i++) {
if (strstr(buf[i].name, match) || strstr(buf[i].app_name, match)) {
*out = buf[i];
return 0;
}
}
return -1;
}
int zebra_get_volume(zebra_pulse_t *z, uint32_t sink_index, uint8_t *pct) {
_info_t r = { .ml = z->loop, .found = 0 };
pa_threaded_mainloop_lock(z->loop);
pa_operation *op = pa_context_get_sink_input_info(z->ctx, sink_index,
_single_info_cb, &r);
if (!op) {
pa_threaded_mainloop_unlock(z->loop);
return -1;
}
while (pa_operation_get_state(op) == PA_OPERATION_RUNNING)
pa_threaded_mainloop_wait(z->loop);
pa_operation_unref(op);
pa_threaded_mainloop_unlock(z->loop);
if (!r.found) return -1;
*pct = r.volume_pct;
return 0;
}
int zebra_set_volume(zebra_pulse_t *z, uint32_t sink_index, uint8_t pct) {
if (pct > 100) pct = 100;
_info_t r = { .ml = z->loop, .channels = 2, .found = 0 };
pa_threaded_mainloop_lock(z->loop);
/* query channel count before setting — needed to build pa_cvolume */
pa_operation *op = pa_context_get_sink_input_info(z->ctx, sink_index,
_single_info_cb, &r);
if (op) {
while (pa_operation_get_state(op) == PA_OPERATION_RUNNING)
pa_threaded_mainloop_wait(z->loop);
pa_operation_unref(op);
}
pa_cvolume cv;
pa_volume_t vol = (pa_volume_t)((uint64_t)PA_VOLUME_NORM * pct / 100);
pa_cvolume_set(&cv, r.channels ? r.channels : 2, vol);
op = pa_context_set_sink_input_volume(z->ctx, sink_index, &cv,
_success_cb, z->loop);
if (!op) {
pa_threaded_mainloop_unlock(z->loop);
return -1;
}
while (pa_operation_get_state(op) == PA_OPERATION_RUNNING)
pa_threaded_mainloop_wait(z->loop);
pa_operation_unref(op);
pa_threaded_mainloop_unlock(z->loop);
return 0;
}
int zebra_set_volume_fast(zebra_pulse_t *z, uint32_t sink_index,
uint8_t channels, uint8_t pct) {
if (pct > 100) pct = 100;
pa_cvolume cv;
pa_volume_t vol = (pa_volume_t)((uint64_t)PA_VOLUME_NORM * pct / 100);
pa_cvolume_set(&cv, channels ? channels : 2, vol);
pa_threaded_mainloop_lock(z->loop);
pa_operation *op = pa_context_set_sink_input_volume(z->ctx, sink_index, &cv,
_success_cb, z->loop);
if (!op) {
pa_threaded_mainloop_unlock(z->loop);
return -1;
}
while (pa_operation_get_state(op) == PA_OPERATION_RUNNING)
pa_threaded_mainloop_wait(z->loop);
pa_operation_unref(op);
pa_threaded_mainloop_unlock(z->loop);
return 0;
}
int zebra_set_volume_noack(zebra_pulse_t *z, uint32_t sink_index,
uint8_t channels, uint8_t pct) {
if (pct > 100) pct = 100;
pa_cvolume cv;
pa_volume_t vol = (pa_volume_t)((uint64_t)PA_VOLUME_NORM * pct / 100);
pa_cvolume_set(&cv, channels ? channels : 2, vol);
pa_threaded_mainloop_lock(z->loop);
pa_operation *op = pa_context_set_sink_input_volume(z->ctx, sink_index, &cv,
NULL, NULL);
if (op) pa_operation_unref(op);
pa_threaded_mainloop_unlock(z->loop);
return 0;
}
/* ------------------------------------------------------------------ *
* Battle Toads: per-channel stereo API *
* ------------------------------------------------------------------ */
int zebra_get_volume_lr(zebra_pulse_t *z, uint32_t sink_index,
uint8_t *left, uint8_t *right) {
_info_t r = { .ml = z->loop, .found = 0 };
pa_threaded_mainloop_lock(z->loop);
pa_operation *op = pa_context_get_sink_input_info(z->ctx, sink_index,
_single_info_cb, &r);
if (!op) { pa_threaded_mainloop_unlock(z->loop); return -1; }
while (pa_operation_get_state(op) == PA_OPERATION_RUNNING)
pa_threaded_mainloop_wait(z->loop);
pa_operation_unref(op);
pa_threaded_mainloop_unlock(z->loop);
if (!r.found) return -1;
*left = r.vol_left;
*right = r.vol_right;
return 0;
}
int zebra_set_volume_lr_noack(zebra_pulse_t *z, uint32_t sink_index,
uint8_t left, uint8_t right) {
if (left > 100) left = 100;
if (right > 100) right = 100;
pa_cvolume cv;
cv.channels = 2;
cv.values[0] = (pa_volume_t)((uint64_t)PA_VOLUME_NORM * left / 100);
cv.values[1] = (pa_volume_t)((uint64_t)PA_VOLUME_NORM * right / 100);
pa_threaded_mainloop_lock(z->loop);
pa_operation *op = pa_context_set_sink_input_volume(z->ctx, sink_index, &cv,
NULL, NULL);
if (op) pa_operation_unref(op);
pa_threaded_mainloop_unlock(z->loop);
return 0;
}

119
src/rx.c Normal file
View file

@ -0,0 +1,119 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
#include "zebra.h"
#include "modem.h"
static void on_byte(uint8_t byte, void *ud) {
(void)ud;
fwrite(&byte, 1, 1, stdout);
fflush(stdout);
}
static void usage(const char *prog) {
fprintf(stderr,
"usage: %s -s SINK_INDEX | -n SINK_NAME [-b BAUD] [-t TX_SINK]\n"
"\n"
" receives from PulseAudio volume modulation, writes decoded bytes to stdout\n"
"\n"
" -s INDEX monitor sink-input by PulseAudio index\n"
" -n NAME monitor sink-input by name/app substring match\n"
" -b BAUD must match transmitter (default %d) — ignored when -t is used\n"
" -t SINK auto-negotiate: benchmark self, send baud offer to TX on SINK\n",
prog, ZEBRA_BAUD_DEFAULT);
}
/* resolve NAME-or-INDEX to a PA sink index */
static uint32_t resolve(zebra_pulse_t *z, const char *arg) {
char *end;
long idx = strtol(arg, &end, 10);
if (*end == '\0' && idx >= 0) return (uint32_t)idx;
zebra_sink_t s;
if (zebra_find_sink(z, arg, &s) < 0) {
fprintf(stderr, "error: no sink matching '%s' — run ./tx -l to list\n", arg);
exit(1);
}
fprintf(stderr, " found: [%u] %s (%s)\n", s.index, s.name, s.app_name);
return s.index;
}
int main(int argc, char **argv) {
int baud = ZEBRA_BAUD_DEFAULT;
int sink_index = -1;
char sink_name[256] = {0};
char *tx_arg = NULL; /* TX channel for auto-negotiate handshake */
int opt;
while ((opt = getopt(argc, argv, "s:n:b:t:h")) != -1) {
switch (opt) {
case 's': sink_index = atoi(optarg); break;
case 'n': snprintf(sink_name, sizeof(sink_name), "%s", optarg); break;
case 'b': baud = atoi(optarg); break;
case 't': tx_arg = optarg; break;
case 'h': usage(argv[0]); return 0;
default: usage(argv[0]); return 1;
}
}
if (sink_index < 0 && !sink_name[0]) {
fprintf(stderr, "error: specify -s INDEX or -n NAME\n");
usage(argv[0]);
return 1;
}
if (!tx_arg && (baud < ZEBRA_BAUD_MIN || baud > ZEBRA_BAUD_MAX)) {
fprintf(stderr, "error: baud must be %d%d\n", ZEBRA_BAUD_MIN, ZEBRA_BAUD_MAX);
return 1;
}
zebra_pulse_t z;
if (zebra_pulse_connect(&z, "zebra-rx") < 0) {
fprintf(stderr, "error: could not connect to PulseAudio\n");
return 1;
}
if (sink_index < 0) {
zebra_sink_t s;
if (zebra_find_sink(&z, sink_name, &s) < 0) {
fprintf(stderr, "error: sink matching '%s' not found\n", sink_name);
zebra_pulse_disconnect(&z);
return 1;
}
sink_index = (int)s.index;
fprintf(stderr, "sink: [%u] %s (%s)\n", s.index, s.name, s.app_name);
}
/* auto-negotiate: benchmark self, transmit baud offer to TX */
if (tx_arg) {
if (baud != ZEBRA_BAUD_DEFAULT)
fprintf(stderr, "rx: -b ignored, using benchmarked baud\n");
fprintf(stderr, "rx: benchmarking PA poll latency (%d samples)...\n", 100);
baud = zebra_benchmark_baud(&z, (uint32_t)sink_index);
fprintf(stderr, "rx: benchmark result: %d baud\n", baud);
fprintf(stderr, "rx: resolving TX channel '%s'\n", tx_arg);
uint32_t tx_sink = resolve(&z, tx_arg);
uint8_t tx_ch = 2;
{
zebra_sink_t buf[64];
int n = zebra_list_sinks(&z, buf, 64);
for (int i = 0; i < n; i++)
if (buf[i].index == tx_sink)
{ tx_ch = buf[i].channels ? buf[i].channels : 2; break; }
}
fprintf(stderr, "rx: sending OFFER at %d baud → sink[%u] ch=%u\n",
ZEBRA_BAUD_HANDSHAKE, tx_sink, tx_ch);
zebra_send_handshake(&z, tx_sink, tx_ch, (uint16_t)baud);
fprintf(stderr, "rx: sending READY (3x) — then entering receive loop\n");
zebra_send_ready(&z, tx_sink, tx_ch);
}
fprintf(stderr, "rx: %d baud, 4x oversample, sink %d\n", baud, sink_index);
zebra_rx_run(&z, (uint32_t)sink_index, baud, on_byte, NULL);
zebra_pulse_disconnect(&z); /* unreachable */
return 0;
}

147
src/tx.c Normal file
View file

@ -0,0 +1,147 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
#include "zebra.h"
#include "modem.h"
static void usage(const char *prog) {
fprintf(stderr,
"usage: %s -s SINK_INDEX | -n SINK_NAME [-b BAUD] [-r REV_SINK] [-l]\n"
"\n"
" reads stdin, transmits via PulseAudio volume modulation\n"
"\n"
" -s INDEX target sink-input by PulseAudio index\n"
" -n NAME target sink-input by name/app substring match\n"
" -b BAUD baud rate (default %d, max %d) — ignored when -r is used\n"
" -r SINK auto-negotiate: listen on SINK for RX handshake (NAME or INDEX)\n"
" -l list all sink inputs and exit\n",
prog, ZEBRA_BAUD_DEFAULT, ZEBRA_BAUD_MAX);
}
/* resolve NAME-or-INDEX to a PA sink index */
static uint32_t resolve(zebra_pulse_t *z, const char *arg) {
char *end;
long idx = strtol(arg, &end, 10);
if (*end == '\0' && idx >= 0) return (uint32_t)idx;
zebra_sink_t s;
if (zebra_find_sink(z, arg, &s) < 0) {
fprintf(stderr, "error: no sink matching '%s' — run ./tx -l to list\n", arg);
exit(1);
}
fprintf(stderr, " found: [%u] %s (%s)\n", s.index, s.name, s.app_name);
return s.index;
}
int main(int argc, char **argv) {
int baud = ZEBRA_BAUD_DEFAULT;
int sink_index = -1;
int do_list = 0;
char sink_name[256] = {0};
char *rev_arg = NULL; /* reverse channel for auto-negotiate */
int opt;
while ((opt = getopt(argc, argv, "s:n:b:r:lh")) != -1) {
switch (opt) {
case 's': sink_index = atoi(optarg); break;
case 'n': snprintf(sink_name, sizeof(sink_name), "%s", optarg); break;
case 'b': baud = atoi(optarg); break;
case 'r': rev_arg = optarg; break;
case 'l': do_list = 1; break;
case 'h': usage(argv[0]); return 0;
default: usage(argv[0]); return 1;
}
}
zebra_pulse_t z;
if (zebra_pulse_connect(&z, "zebra-tx") < 0) {
fprintf(stderr, "error: could not connect to PulseAudio\n");
return 1;
}
if (do_list) {
zebra_sink_t buf[64];
int n = zebra_list_sinks(&z, buf, 64);
if (n < 0) { fprintf(stderr, "error: list failed\n"); return 1; }
printf("%-6s %-4s %-3s %s\n", "INDEX", "VOL%", "CH", "NAME (APP)");
for (int i = 0; i < n; i++)
printf("%-6u %-4u %-3u %s (%s)\n",
buf[i].index, buf[i].volume_pct,
buf[i].channels, buf[i].name, buf[i].app_name);
zebra_pulse_disconnect(&z);
return 0;
}
if (sink_index < 0 && !sink_name[0]) {
fprintf(stderr, "error: specify -s INDEX or -n NAME (use -l to list)\n");
zebra_pulse_disconnect(&z);
return 1;
}
if (!rev_arg && (baud < ZEBRA_BAUD_MIN || baud > ZEBRA_BAUD_MAX)) {
fprintf(stderr, "error: baud must be %d%d\n", ZEBRA_BAUD_MIN, ZEBRA_BAUD_MAX);
zebra_pulse_disconnect(&z);
return 1;
}
uint8_t channels = 2;
if (sink_index < 0) {
zebra_sink_t s;
if (zebra_find_sink(&z, sink_name, &s) < 0) {
fprintf(stderr, "error: sink matching '%s' not found\n", sink_name);
zebra_pulse_disconnect(&z);
return 1;
}
sink_index = (int)s.index;
channels = s.channels ? s.channels : 2;
fprintf(stderr, "sink: [%u] %s (%s)\n", s.index, s.name, s.app_name);
} else {
/* resolve channels for numeric index */
zebra_sink_t buf[64];
int n = zebra_list_sinks(&z, buf, 64);
for (int i = 0; i < n; i++)
if (buf[i].index == (uint32_t)sink_index)
{ channels = buf[i].channels ? buf[i].channels : 2; break; }
}
/* auto-negotiate baud from RX if reverse channel specified */
if (rev_arg) {
if (baud != ZEBRA_BAUD_DEFAULT)
fprintf(stderr, "tx: -b ignored, using negotiated baud\n");
fprintf(stderr, "tx: waiting for RX handshake on '%s' (30s timeout)...\n",
rev_arg);
uint32_t rev_sink = resolve(&z, rev_arg);
uint16_t neg_baud = 0;
if (zebra_recv_handshake(&z, rev_sink, 30000, &neg_baud) < 0) {
fprintf(stderr, "error: handshake timeout — no frame received\n");
zebra_pulse_disconnect(&z);
return 1;
}
baud = (int)neg_baud;
fprintf(stderr, "tx: negotiated %d baud — waiting for READY\n", baud);
if (zebra_recv_ready(&z, rev_sink, ZEBRA_HS_READY_WAIT) < 0) {
fprintf(stderr, "error: READY timeout — RX did not confirm\n");
zebra_pulse_disconnect(&z);
return 1;
}
fprintf(stderr, "tx: READY received — sending data\n");
}
long period_ns = 1000000000L / baud;
fprintf(stderr, "tx: %d baud, %ld ms/symbol, sink %d, ch %u\n",
baud, period_ns / 1000000L, sink_index, channels);
zebra_set_volume_fast(&z, (uint32_t)sink_index, channels, ZEBRA_VOL_MARK);
struct timespec next;
clock_gettime(CLOCK_MONOTONIC, &next);
ts_add_ns(&next, period_ns * 10); /* 10-symbol MARK preamble — let RX settle */
int c;
while ((c = fgetc(stdin)) != EOF)
zebra_send_byte(&z, (uint32_t)sink_index, channels, (uint8_t)c, &next, period_ns);
zebra_set_volume_fast(&z, (uint32_t)sink_index, channels, ZEBRA_VOL_MARK);
zebra_pulse_disconnect(&z);
return 0;
}

319
test/functional.c Normal file
View file

@ -0,0 +1,319 @@
/*
* functional.c end-to-end TX/RX pipeline and auto-negotiate tests
*
* Requires two live PulseAudio sink-inputs:
* ZEBRA_DATA_SINK TX modulates this, RX reads it (data channel)
* ZEBRA_CTRL_SINK RX modulates this, TX reads it (handshake channel)
*
* Run ./tx -l to find sink indices, then:
* ZEBRA_DATA_SINK=15815 ZEBRA_CTRL_SINK=15923 ./test/functional
*
* Compile: gcc -Wall -O2 -Iinclude $(pkg-config --cflags libpulse) \
* -o test/functional test/functional.c src/pulse.c \
* $(pkg-config --libs libpulse) -lrt -lpthread
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <semaphore.h>
#include <time.h>
#include "zebra.h"
#include "modem.h"
#include "test.h"
static uint32_t data_sink = 0;
static uint32_t ctrl_sink = 0;
/* ------------------------------------------------------------------ *
* shared RX byte collector *
* ------------------------------------------------------------------ */
#define COLLECT_MAX 256
typedef struct {
char buf[COLLECT_MAX];
int len;
int want; /* stop after this many bytes */
sem_t done;
} collector_t;
static void collect_byte(uint8_t b, void *ud) {
collector_t *c = ud;
if (c->len < COLLECT_MAX - 1) c->buf[c->len++] = (char)b;
if (c->len >= c->want) sem_post(&c->done);
}
/* ------------------------------------------------------------------ *
* test 1: fixed-baud loopback *
* TX and RX both run against the same data_sink at 50 baud. *
* ------------------------------------------------------------------ */
typedef struct {
zebra_pulse_t *z;
uint32_t sink;
uint8_t channels;
int baud;
const char *msg;
} tx_arg_t;
typedef struct {
zebra_pulse_t *z;
uint32_t sink;
int baud;
collector_t *col;
} rx_arg_t;
static void *tx_thread(void *arg) {
tx_arg_t *a = arg;
/* brief delay so RX loop is running before TX fires */
struct timespec d = {0, 200000000L}; /* 200ms */
nanosleep(&d, NULL);
long period_ns = 1000000000L / a->baud;
zebra_set_volume_fast(a->z, a->sink, a->channels, ZEBRA_VOL_MARK);
struct timespec next;
clock_gettime(CLOCK_MONOTONIC, &next);
ts_add_ns(&next, period_ns);
for (int i = 0; a->msg[i]; i++)
zebra_send_byte(a->z, a->sink, a->channels, (uint8_t)a->msg[i], &next, period_ns);
zebra_set_volume_fast(a->z, a->sink, a->channels, ZEBRA_VOL_MARK);
return NULL;
}
static void *rx_thread(void *arg) {
rx_arg_t *a = arg;
zebra_rx_run(a->z, a->sink, a->baud, collect_byte, a->col);
return NULL;
}
static void t_loopback_fixed_baud(void) {
const char *msg = "ZEBRA\n";
const int baud = 50;
const int timeout_s = 10;
collector_t col;
memset(&col, 0, sizeof(col));
col.want = (int)strlen(msg);
sem_init(&col.done, 0, 0);
zebra_pulse_t ztx, zrx;
T_EQ(zebra_pulse_connect(&ztx, "zebra-test-tx"), 0);
T_EQ(zebra_pulse_connect(&zrx, "zebra-test-rx"), 0);
/* resolve channel count for TX */
uint8_t ch = 2;
{
zebra_sink_t buf[64];
int n = zebra_list_sinks(&ztx, buf, 64);
for (int i = 0; i < n; i++)
if (buf[i].index == data_sink)
{ ch = buf[i].channels ? buf[i].channels : 2; break; }
}
tx_arg_t txa = { .z = &ztx, .sink = data_sink, .channels = ch,
.baud = baud, .msg = msg };
rx_arg_t rxa = { .z = &zrx, .sink = data_sink, .baud = baud, .col = &col };
pthread_t tx_tid, rx_tid;
pthread_create(&rx_tid, NULL, rx_thread, &rxa);
pthread_create(&tx_tid, NULL, tx_thread, &txa);
/* wait for collector or timeout */
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += timeout_s;
int timed_out = sem_timedwait(&col.done, &deadline);
pthread_cancel(rx_tid);
pthread_detach(rx_tid);
pthread_join(tx_tid, NULL);
T_EQ(timed_out, 0); /* did not time out */
col.buf[col.len] = '\0';
T_EQ(memcmp(col.buf, msg, strlen(msg)), 0);
fprintf(stderr, " [rx] received %d bytes: %.*s", col.len, col.len, col.buf);
zebra_pulse_disconnect(&ztx);
zebra_pulse_disconnect(&zrx);
sem_destroy(&col.done);
}
/* ------------------------------------------------------------------ *
* test 2: auto-negotiate handshake *
* RX benchmarks, sends offer on ctrl_sink. *
* TX receives offer, then sends data on data_sink at negotiated baud. *
* RX receives data at negotiated baud. *
* ------------------------------------------------------------------ */
typedef struct {
zebra_pulse_t *z_data; /* for TX data */
zebra_pulse_t *z_ctrl; /* for reading handshake */
uint32_t data_sink;
uint32_t ctrl_sink;
uint8_t data_ch;
const char *msg;
int timeout_ms;
} autoneg_tx_arg_t;
typedef struct {
zebra_pulse_t *z_data; /* for RX data */
zebra_pulse_t *z_ctrl; /* for sending handshake */
uint32_t data_sink;
uint32_t ctrl_sink;
uint8_t ctrl_ch;
collector_t *col;
} autoneg_rx_arg_t;
static void *autoneg_tx_thread(void *arg) {
autoneg_tx_arg_t *a = arg;
uint16_t neg_baud = 0;
fprintf(stderr, " [tx] waiting for handshake on ctrl_sink %u...\n", a->ctrl_sink);
if (zebra_recv_handshake(a->z_ctrl, a->ctrl_sink, a->timeout_ms, &neg_baud) < 0) {
fprintf(stderr, " [tx] handshake timeout — no frame received\n");
return NULL;
}
fprintf(stderr, " [tx] OFFER received: %u baud — waiting for READY\n", neg_baud);
if (zebra_recv_ready(a->z_ctrl, a->ctrl_sink, ZEBRA_HS_READY_WAIT) < 0) {
fprintf(stderr, " [tx] READY timeout — aborting\n");
return NULL;
}
fprintf(stderr, " [tx] READY received — sending '%s' at %u baud on data_sink %u\n",
a->msg, neg_baud, a->data_sink);
long period_ns = 1000000000L / neg_baud;
zebra_set_volume_fast(a->z_data, a->data_sink, a->data_ch, ZEBRA_VOL_MARK);
struct timespec next;
clock_gettime(CLOCK_MONOTONIC, &next);
ts_add_ns(&next, period_ns);
for (int i = 0; a->msg[i]; i++)
zebra_send_byte(a->z_data, a->data_sink, a->data_ch,
(uint8_t)a->msg[i], &next, period_ns);
zebra_set_volume_fast(a->z_data, a->data_sink, a->data_ch, ZEBRA_VOL_MARK);
fprintf(stderr, " [tx] send complete\n");
return NULL;
}
static void *autoneg_rx_thread(void *arg) {
autoneg_rx_arg_t *a = arg;
int baud = zebra_benchmark_baud(a->z_data, a->data_sink);
fprintf(stderr, " [rx] benchmark: %d baud\n", baud);
fprintf(stderr, " [rx] sending handshake on ctrl_sink %u ch=%u\n",
a->ctrl_sink, a->ctrl_ch);
zebra_send_handshake(a->z_ctrl, a->ctrl_sink, a->ctrl_ch, (uint16_t)baud);
fprintf(stderr, " [rx] OFFER sent — sending READY (3x) then entering receive loop\n");
zebra_send_ready(a->z_ctrl, a->ctrl_sink, a->ctrl_ch);
fprintf(stderr, " [rx] entering receive loop at %d baud on data_sink %u\n",
baud, a->data_sink);
zebra_rx_run(a->z_data, a->data_sink, baud, collect_byte, a->col);
return NULL;
}
static void t_autoneg_pipeline(void) {
const char *msg = "PING\n";
const int timeout_s = 30;
const int timeout_ms = 15000; /* handshake timeout for TX */
collector_t col;
memset(&col, 0, sizeof(col));
col.want = (int)strlen(msg);
sem_init(&col.done, 0, 0);
/* four connections: TX-data, TX-ctrl, RX-data, RX-ctrl */
zebra_pulse_t z_tx_data, z_tx_ctrl, z_rx_data, z_rx_ctrl;
T_EQ(zebra_pulse_connect(&z_tx_data, "zebra-test-tx-data"), 0);
T_EQ(zebra_pulse_connect(&z_tx_ctrl, "zebra-test-tx-ctrl"), 0);
T_EQ(zebra_pulse_connect(&z_rx_data, "zebra-test-rx-data"), 0);
T_EQ(zebra_pulse_connect(&z_rx_ctrl, "zebra-test-rx-ctrl"), 0);
/* resolve channel counts */
uint8_t data_ch = 2, ctrl_ch = 2;
{
zebra_sink_t buf[64];
int n = zebra_list_sinks(&z_tx_data, buf, 64);
for (int i = 0; i < n; i++) {
if (buf[i].index == data_sink) data_ch = buf[i].channels ? buf[i].channels : 2;
if (buf[i].index == ctrl_sink) ctrl_ch = buf[i].channels ? buf[i].channels : 2;
}
}
autoneg_tx_arg_t txa = {
.z_data = &z_tx_data, .z_ctrl = &z_tx_ctrl,
.data_sink = data_sink, .ctrl_sink = ctrl_sink,
.data_ch = data_ch, .msg = msg, .timeout_ms = timeout_ms
};
autoneg_rx_arg_t rxa = {
.z_data = &z_rx_data, .z_ctrl = &z_rx_ctrl,
.data_sink = data_sink, .ctrl_sink = ctrl_sink,
.ctrl_ch = ctrl_ch, .col = &col
};
pthread_t tx_tid, rx_tid;
pthread_create(&tx_tid, NULL, autoneg_tx_thread, &txa);
pthread_create(&rx_tid, NULL, autoneg_rx_thread, &rxa);
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += timeout_s;
int timed_out = sem_timedwait(&col.done, &deadline);
pthread_cancel(rx_tid);
pthread_detach(rx_tid); /* don't join — PA wait may not respond to cancel */
pthread_join(tx_tid, NULL);
T_EQ(timed_out, 0);
col.buf[col.len] = '\0';
T_EQ(memcmp(col.buf, msg, strlen(msg)), 0);
fprintf(stderr, " [rx] received %d bytes: %.*s", col.len, col.len, col.buf);
zebra_pulse_disconnect(&z_tx_data);
zebra_pulse_disconnect(&z_tx_ctrl);
zebra_pulse_disconnect(&z_rx_data);
zebra_pulse_disconnect(&z_rx_ctrl);
sem_destroy(&col.done);
}
/* ------------------------------------------------------------------ *
* main *
* ------------------------------------------------------------------ */
int main(void) {
const char *d = getenv("ZEBRA_DATA_SINK");
const char *c = getenv("ZEBRA_CTRL_SINK");
if (!d || !c) {
fprintf(stderr,
"ZEBRA_DATA_SINK and ZEBRA_CTRL_SINK must be set.\n"
" run: ./tx -l to list available sink indices\n"
" example: ZEBRA_DATA_SINK=15815 ZEBRA_CTRL_SINK=15923 ./test/functional\n"
"\n"
" DATA_SINK: TX modulates it, RX reads it (e.g. 'zebra report' Firefox tab)\n"
" CTRL_SINK: RX modulates it, TX reads it (e.g. 'X' Firefox tab)\n");
return 1;
}
data_sink = (uint32_t)atoi(d);
ctrl_sink = (uint32_t)atoi(c);
if (data_sink == ctrl_sink) {
fprintf(stderr, "error: DATA_SINK and CTRL_SINK must be different\n");
return 1;
}
fprintf(stderr, "data_sink=%u ctrl_sink=%u\n\n", data_sink, ctrl_sink);
T_SECTION("loopback: fixed baud (50 baud)");
T_RUN(t_loopback_fixed_baud);
T_SECTION("auto-negotiate pipeline");
T_RUN(t_autoneg_pipeline);
T_SUMMARY();
}

152
test/integration.c Normal file
View file

@ -0,0 +1,152 @@
/*
* integration.c PulseAudio connection and volume I/O tests
*
* Requires a live PulseAudio / PipeWire session.
* Set ZEBRA_TEST_SINK to a sink-input index (from ./tx -l) before running.
* If unset, PA connection tests still run; volume tests are skipped.
*
* Compile: gcc -Wall -O2 -Iinclude $(pkg-config --cflags libpulse) \
* -o test/integration test/integration.c src/pulse.c \
* $(pkg-config --libs libpulse) -lrt -lpthread
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "zebra.h"
#include "modem.h"
#include "test.h"
static zebra_pulse_t z;
static uint32_t test_sink = (uint32_t)-1;
static int have_sink = 0;
/* ------------------------------------------------------------------ *
* PA connection *
* ------------------------------------------------------------------ */
static void t_connect(void) {
int r = zebra_pulse_connect(&z, "zebra-test");
T_EQ(r, 0);
}
static void t_list_sinks(void) {
zebra_sink_t buf[64];
int n = zebra_list_sinks(&z, buf, 64);
T_GE(n, 0);
if (n > 0) {
/* every entry must have a valid channel count */
for (int i = 0; i < n; i++)
T_GE((int)buf[i].channels, 1);
}
}
static void t_find_sink_miss(void) {
zebra_sink_t s;
int r = zebra_find_sink(&z, "____no_such_sink_xyzzy____", &s);
T_EQ(r, -1);
}
/* ------------------------------------------------------------------ *
* volume I/O (needs ZEBRA_TEST_SINK) *
* ------------------------------------------------------------------ */
static void t_get_volume(void) {
if (!have_sink) { fprintf(stderr, " (skip — ZEBRA_TEST_SINK not set)\n"); return; }
uint8_t vol = 255;
int r = zebra_get_volume(&z, test_sink, &vol);
T_EQ(r, 0);
T_LE((int)vol, 100);
}
static void t_set_get_roundtrip(void) {
if (!have_sink) { fprintf(stderr, " (skip — ZEBRA_TEST_SINK not set)\n"); return; }
uint8_t original = 255;
zebra_get_volume(&z, test_sink, &original);
/* set to MARK, read back */
zebra_set_volume(&z, test_sink, ZEBRA_VOL_MARK);
uint8_t got = 0;
int r = zebra_get_volume(&z, test_sink, &got);
T_EQ(r, 0);
/* PA quantises to PA_VOLUME_NORM steps — allow ±2% tolerance */
T_GE((int)got, (int)ZEBRA_VOL_MARK - 2);
T_LE((int)got, (int)ZEBRA_VOL_MARK + 2);
/* restore */
zebra_set_volume(&z, test_sink, original);
}
static void t_set_get_space(void) {
if (!have_sink) { fprintf(stderr, " (skip — ZEBRA_TEST_SINK not set)\n"); return; }
uint8_t original = 255;
zebra_get_volume(&z, test_sink, &original);
zebra_set_volume(&z, test_sink, ZEBRA_VOL_SPACE);
uint8_t got = 0;
zebra_get_volume(&z, test_sink, &got);
T_GE((int)got, (int)ZEBRA_VOL_SPACE - 2);
T_LE((int)got, (int)ZEBRA_VOL_SPACE + 2);
zebra_set_volume(&z, test_sink, original);
}
static void t_vol_clamp_over_100(void) {
if (!have_sink) { fprintf(stderr, " (skip — ZEBRA_TEST_SINK not set)\n"); return; }
uint8_t original = 255;
zebra_get_volume(&z, test_sink, &original);
/* pct > 100 should be clamped, not crash */
int r = zebra_set_volume(&z, test_sink, 200);
T_EQ(r, 0);
uint8_t got = 0;
zebra_get_volume(&z, test_sink, &got);
T_LE((int)got, 100);
zebra_set_volume(&z, test_sink, original);
}
/* ------------------------------------------------------------------ *
* benchmark sanity *
* ------------------------------------------------------------------ */
static void t_benchmark_range(void) {
if (!have_sink) { fprintf(stderr, " (skip — ZEBRA_TEST_SINK not set)\n"); return; }
int baud = zebra_benchmark_baud(&z, test_sink);
T_GE(baud, ZEBRA_BAUD_MIN);
T_LE(baud, ZEBRA_BAUD_MAX);
fprintf(stderr, " measured: %d baud\n", baud);
}
/* ------------------------------------------------------------------ *
* main *
* ------------------------------------------------------------------ */
int main(void) {
const char *sink_env = getenv("ZEBRA_TEST_SINK");
if (sink_env) {
test_sink = (uint32_t)atoi(sink_env);
have_sink = 1;
fprintf(stderr, "ZEBRA_TEST_SINK=%u\n", test_sink);
} else {
fprintf(stderr, "ZEBRA_TEST_SINK not set — volume tests will skip\n");
fprintf(stderr, " run: ./tx -l to list sinks, then set the env var\n");
}
T_SECTION("PA connection");
T_RUN(t_connect);
T_RUN(t_list_sinks);
T_RUN(t_find_sink_miss);
T_SECTION("volume I/O");
T_RUN(t_get_volume);
T_RUN(t_set_get_roundtrip);
T_RUN(t_set_get_space);
T_RUN(t_vol_clamp_over_100);
T_SECTION("benchmark");
T_RUN(t_benchmark_range);
zebra_pulse_disconnect(&z);
T_SUMMARY();
}

37
test/test.h Normal file
View file

@ -0,0 +1,37 @@
#pragma once
#include <stdio.h>
#include <stdlib.h>
static int _t_pass = 0, _t_fail = 0;
static const char *_t_current = "";
#define T_RUN(fn) do { \
_t_current = #fn; \
int _before = _t_fail; \
fn(); \
fprintf(stderr, " %-48s %s\n", #fn, (_t_fail == _before) ? "ok" : "FAIL"); \
} while(0)
#define T_CHECK(expr) do { \
if (expr) { _t_pass++; } \
else { _t_fail++; \
fprintf(stderr, " check failed: %s (%s:%d)\n", #expr, __FILE__, __LINE__); } \
} while(0)
#define T_EQ(a,b) T_CHECK((a) == (b))
#define T_NEQ(a,b) T_CHECK((a) != (b))
#define T_GE(a,b) T_CHECK((a) >= (b))
#define T_LE(a,b) T_CHECK((a) <= (b))
#define T_GT(a,b) T_CHECK((a) > (b))
#define T_SUMMARY() do { \
fprintf(stderr, "\n%d passed %d failed\n", _t_pass, _t_fail); \
return _t_fail ? 1 : 0; \
} while(0)
#define T_SECTION(name) fprintf(stderr, "\n%s\n", name)
#define T_SKIP(reason) do { \
fprintf(stderr, " SKIP: %s\n", reason); \
return 0; \
} while(0)

275
test/unit.c Normal file
View file

@ -0,0 +1,275 @@
/*
* unit.c pure logic tests, no PulseAudio required
*
* Tests: signal encoding, timing arithmetic, baud math, handshake frame.
* Compile: gcc -Wall -O2 -Iinclude $(pkg-config --cflags libpulse) -o test/unit test/unit.c
*/
#include <string.h>
#include "zebra.h"
#include "modem.h"
#include "test.h"
/* ------------------------------------------------------------------ *
* signal encoding *
* ------------------------------------------------------------------ */
static void t_bit_to_vol(void) {
T_EQ(zebra_bit_to_vol(0), ZEBRA_VOL_SPACE);
T_EQ(zebra_bit_to_vol(1), ZEBRA_VOL_MARK);
}
static void t_vol_to_bit(void) {
T_EQ(zebra_vol_to_bit(ZEBRA_VOL_SPACE), 0);
T_EQ(zebra_vol_to_bit(ZEBRA_VOL_MARK), 1);
T_EQ(zebra_vol_to_bit(0), 0);
T_EQ(zebra_vol_to_bit(100), 1);
T_EQ(zebra_vol_to_bit(ZEBRA_VOL_THRESHOLD), -1);
T_EQ(zebra_vol_to_bit(ZEBRA_VOL_THRESHOLD - 1), 0);
T_EQ(zebra_vol_to_bit(ZEBRA_VOL_THRESHOLD + 1), 1);
}
static void t_signal_roundtrip(void) {
T_EQ(zebra_vol_to_bit(zebra_bit_to_vol(0)), 0);
T_EQ(zebra_vol_to_bit(zebra_bit_to_vol(1)), 1);
}
static void t_signal_separation(void) {
/* MARK and SPACE must be on opposite sides of threshold */
T_GT((int)ZEBRA_VOL_MARK, (int)ZEBRA_VOL_THRESHOLD);
T_GT((int)ZEBRA_VOL_THRESHOLD, (int)ZEBRA_VOL_SPACE);
}
/* ------------------------------------------------------------------ *
* timing arithmetic *
* ------------------------------------------------------------------ */
static void t_ts_add_basic(void) {
struct timespec ts = {1, 0};
ts_add_ns(&ts, 500000000L);
T_EQ(ts.tv_sec, 1);
T_EQ(ts.tv_nsec, 500000000L);
}
static void t_ts_add_overflow(void) {
struct timespec ts = {1, 800000000L};
ts_add_ns(&ts, 400000000L); /* 800M + 400M = 1200M → carry */
T_EQ(ts.tv_sec, 2);
T_EQ(ts.tv_nsec, 200000000L);
}
static void t_ts_add_multi_second(void) {
struct timespec ts = {0, 0};
ts_add_ns(&ts, 3500000000L); /* 3.5 seconds */
T_EQ(ts.tv_sec, 3);
T_EQ(ts.tv_nsec, 500000000L);
}
static void t_ts_add_zero(void) {
struct timespec ts = {5, 123456789L};
ts_add_ns(&ts, 0);
T_EQ(ts.tv_sec, 5);
T_EQ(ts.tv_nsec, 123456789L);
}
/* ------------------------------------------------------------------ *
* baud math *
* ------------------------------------------------------------------ */
static void t_baud_from_avg_ns(void) {
/* 400µs poll → 500 baud (4x oversample: 2e8/400000) */
T_EQ(zebra_baud_from_avg_ns(400000L), 500);
/* 200µs poll → 1000 baud */
T_EQ(zebra_baud_from_avg_ns(200000L), 1000);
/* 1ms poll → 200 baud */
T_EQ(zebra_baud_from_avg_ns(1000000L), 200);
}
static void t_baud_clamp_min(void) {
/* extremely slow: result clamped to ZEBRA_BAUD_MIN */
T_EQ(zebra_baud_from_avg_ns(1000000000L), ZEBRA_BAUD_MIN);
}
static void t_baud_clamp_max(void) {
/* extremely fast: result clamped to ZEBRA_BAUD_MAX */
T_EQ(zebra_baud_from_avg_ns(1L), ZEBRA_BAUD_MAX);
}
static void t_baud_zero_guard(void) {
/* zero avg_ns returns safe default */
T_EQ(zebra_baud_from_avg_ns(0L), ZEBRA_BAUD_DEFAULT);
}
static void t_baud_range(void) {
/* result always in valid range for a sweep of latencies */
long ns[] = {1, 100, 1000, 10000, 100000, 400000, 1000000, 10000000L, 100000000L};
for (int i = 0; i < 9; i++) {
int b = zebra_baud_from_avg_ns(ns[i]);
T_GE(b, ZEBRA_BAUD_MIN);
T_LE(b, ZEBRA_BAUD_MAX);
}
}
/* ------------------------------------------------------------------ *
* handshake frame *
* ------------------------------------------------------------------ */
static void t_hs_build_magic(void) {
uint8_t frame[ZEBRA_HS_FRAME_LEN];
zebra_hs_build(frame, 1000);
T_EQ(frame[0], ZEBRA_HS_MAGIC_0);
T_EQ(frame[1], ZEBRA_HS_MAGIC_1);
T_EQ(frame[2], ZEBRA_HS_TYPE_OFFER);
}
static void t_hs_build_baud_encoding(void) {
uint8_t frame[ZEBRA_HS_FRAME_LEN];
zebra_hs_build(frame, 0x0342); /* 834 decimal */
T_EQ(frame[3], 0x42); /* low byte */
T_EQ(frame[4], 0x03); /* high byte */
}
static void t_hs_build_checksum(void) {
uint8_t frame[ZEBRA_HS_FRAME_LEN];
zebra_hs_build(frame, 1072);
uint8_t ck = frame[0] ^ frame[1] ^ frame[2] ^ frame[3] ^ frame[4];
T_EQ(frame[5], ck);
}
static void t_hs_parse_valid(void) {
uint8_t frame[ZEBRA_HS_FRAME_LEN];
uint16_t baud = 0;
zebra_hs_build(frame, 1072);
T_EQ(zebra_hs_parse(frame, &baud), 0);
T_EQ(baud, 1072);
}
static void t_hs_roundtrip(void) {
uint16_t bauds[] = {1, 10, 100, 500, 1000, 1072, 2000, 9999, 65535};
for (int i = 0; i < 9; i++) {
uint8_t frame[ZEBRA_HS_FRAME_LEN];
uint16_t got = 0;
zebra_hs_build(frame, bauds[i]);
T_EQ(zebra_hs_parse(frame, &got), 0);
T_EQ(got, bauds[i]);
}
}
static void t_hs_parse_bad_magic0(void) {
uint8_t frame[ZEBRA_HS_FRAME_LEN];
uint16_t baud = 0;
zebra_hs_build(frame, 100);
frame[0] ^= 0xFF; /* corrupt first magic byte */
T_EQ(zebra_hs_parse(frame, &baud), -1);
}
static void t_hs_parse_bad_magic1(void) {
uint8_t frame[ZEBRA_HS_FRAME_LEN];
uint16_t baud = 0;
zebra_hs_build(frame, 100);
frame[1] ^= 0xFF;
T_EQ(zebra_hs_parse(frame, &baud), -1);
}
static void t_hs_parse_bad_type(void) {
uint8_t frame[ZEBRA_HS_FRAME_LEN];
uint16_t baud = 0;
zebra_hs_build(frame, 100);
frame[2] = 0xFF; /* unknown type */
T_EQ(zebra_hs_parse(frame, &baud), -1);
}
static void t_hs_parse_bad_checksum(void) {
uint8_t frame[ZEBRA_HS_FRAME_LEN];
uint16_t baud = 0;
zebra_hs_build(frame, 100);
frame[5] ^= 0x01; /* flip one checksum bit */
T_EQ(zebra_hs_parse(frame, &baud), -1);
}
static void t_hs_parse_corrupt_baud(void) {
uint8_t frame[ZEBRA_HS_FRAME_LEN];
uint16_t baud = 0;
zebra_hs_build(frame, 100);
frame[3] ^= 0x01; /* corrupt baud byte without fixing checksum */
T_EQ(zebra_hs_parse(frame, &baud), -1);
}
/* ------------------------------------------------------------------ *
* READY frame *
* ------------------------------------------------------------------ */
static void t_hs_build_ready_fields(void) {
uint8_t f[ZEBRA_HS_FRAME_LEN];
zebra_hs_build_ready(f);
T_EQ(f[0], ZEBRA_HS_MAGIC_0);
T_EQ(f[1], ZEBRA_HS_MAGIC_1);
T_EQ(f[2], ZEBRA_HS_TYPE_READY);
T_EQ(f[3], 0);
T_EQ(f[4], 0);
}
static void t_hs_ready_checksum(void) {
uint8_t f[ZEBRA_HS_FRAME_LEN];
zebra_hs_build_ready(f);
uint8_t ck = f[0] ^ f[1] ^ f[2] ^ f[3] ^ f[4];
T_EQ(f[5], ck);
}
static void t_hs_ready_not_offer(void) {
/* READY frame must NOT parse as OFFER (type guard) */
uint8_t f[ZEBRA_HS_FRAME_LEN];
uint16_t baud = 0;
zebra_hs_build_ready(f);
T_EQ(zebra_hs_parse(f, &baud), -1);
}
static void t_hs_ready_distinct_from_offer(void) {
/* READY and OFFER frames must differ in the type byte */
T_NEQ(ZEBRA_HS_TYPE_READY, ZEBRA_HS_TYPE_OFFER);
}
/* ------------------------------------------------------------------ *
* main *
* ------------------------------------------------------------------ */
int main(void) {
T_SECTION("signal encoding");
T_RUN(t_bit_to_vol);
T_RUN(t_vol_to_bit);
T_RUN(t_signal_roundtrip);
T_RUN(t_signal_separation);
T_SECTION("timing arithmetic");
T_RUN(t_ts_add_basic);
T_RUN(t_ts_add_overflow);
T_RUN(t_ts_add_multi_second);
T_RUN(t_ts_add_zero);
T_SECTION("baud math");
T_RUN(t_baud_from_avg_ns);
T_RUN(t_baud_clamp_min);
T_RUN(t_baud_clamp_max);
T_RUN(t_baud_zero_guard);
T_RUN(t_baud_range);
T_SECTION("handshake frame: OFFER");
T_RUN(t_hs_build_magic);
T_RUN(t_hs_build_baud_encoding);
T_RUN(t_hs_build_checksum);
T_RUN(t_hs_parse_valid);
T_RUN(t_hs_roundtrip);
T_RUN(t_hs_parse_bad_magic0);
T_RUN(t_hs_parse_bad_magic1);
T_RUN(t_hs_parse_bad_type);
T_RUN(t_hs_parse_bad_checksum);
T_RUN(t_hs_parse_corrupt_baud);
T_SECTION("handshake frame: READY");
T_RUN(t_hs_build_ready_fields);
T_RUN(t_hs_ready_checksum);
T_RUN(t_hs_ready_not_offer);
T_RUN(t_hs_ready_distinct_from_offer);
T_SUMMARY();
}

118
web/blog/style.css Normal file
View file

@ -0,0 +1,118 @@
@font-face {
font-family: 'chunkfiveregular';
src: url('/fonts/chunkfive-regular-webfont.woff2') format('woff2'),
url('/fonts/chunkfive-regular-webfont.woff') format('woff');
font-weight: normal;
font-style: normal;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: monospace;
background: #fff;
color: #000;
padding: 2rem;
max-width: 680px;
line-height: 1.6;
}
a { color: #000; }
a:hover { text-decoration: none; }
/* header */
.site-header {
display: flex;
justify-content: space-between;
align-items: baseline;
border-bottom: 2px solid #000;
padding-bottom: 0.5rem;
margin-bottom: 2.5rem;
}
.site-name {
font-family: 'chunkfiveregular', serif;
font-size: 1.4rem;
text-decoration: none;
color: #000;
}
.tagline { font-size: 0.75rem; color: #555; }
/* headings */
h1 {
font-family: 'chunkfiveregular', serif;
font-size: 2.2rem;
font-weight: normal;
margin-bottom: 0.3rem;
line-height: 1.1;
}
h2 {
font-family: 'chunkfiveregular', serif;
font-size: 1.3rem;
font-weight: normal;
border-bottom: 1px solid #000;
padding-bottom: 0.2rem;
margin: 1.8rem 0 0.8rem;
}
h3 {
font-family: 'chunkfiveregular', serif;
font-size: 1rem;
font-weight: normal;
margin: 1.2rem 0 0.4rem;
}
/* post meta */
.meta {
font-size: 0.75rem;
color: #555;
margin-bottom: 2rem;
}
/* content */
.content p { margin-bottom: 0.9rem; font-size: 0.9rem; }
.content ul,
.content ol { margin: 0 0 0.9rem 1.5rem; font-size: 0.9rem; }
.content li { margin-bottom: 0.3rem; }
pre {
background: #fafafa;
border: 1px solid #ddd;
padding: 0.75rem;
overflow-x: auto;
margin-bottom: 1rem;
font-size: 0.78rem;
line-height: 1.5;
}
code {
font-family: monospace;
background: #f5f5f5;
border: 1px solid #ddd;
padding: 0.1em 0.3em;
font-size: 0.85em;
}
pre code { border: none; padding: 0; background: none; }
/* post nav */
.post-nav {
margin-top: 2.5rem;
padding-top: 0.75rem;
border-top: 1px solid #ccc;
font-size: 0.8rem;
}
/* index */
.post-list { list-style: none; }
.post-list li {
border-bottom: 1px solid #eee;
padding: 1rem 0;
}
.post-date { font-size: 0.75rem; color: #555; display: block; }
.post-title {
font-family: 'chunkfiveregular', serif;
font-size: 1.3rem;
display: block;
text-decoration: none;
color: #000;
margin: 0.1rem 0;
}
.post-title:hover { text-decoration: underline; }
.post-summary { font-size: 0.82rem; color: #444; }

494
web/index.html Normal file
View file

@ -0,0 +1,494 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>zebra report</title>
<style>
@font-face {
font-family: 'chunkfiveregular';
src: url('fonts/chunkfive-regular-webfont.woff2') format('woff2'),
url('fonts/chunkfive-regular-webfont.woff') format('woff');
font-weight: normal;
font-style: normal;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: monospace;
background: #fff;
color: #000;
padding: 2rem;
max-width: 640px;
}
h1 {
font-family: 'chunkfiveregular', serif;
font-size: 3rem;
font-weight: normal;
letter-spacing: 0.02em;
line-height: 1;
margin-bottom: 0.2rem;
}
.sub {
font-size: 0.75rem;
color: #555;
margin-bottom: 2.5rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
section { margin-bottom: 2rem; }
h2 {
font-family: 'chunkfiveregular', serif;
font-size: 1.1rem;
font-weight: normal;
border-bottom: 1px solid #000;
padding-bottom: 0.25rem;
margin-bottom: 0.8rem;
}
button {
background: #fff;
color: #000;
border: 1px solid #000;
padding: 0.4rem 1.2rem;
font-family: monospace;
font-size: 0.85rem;
cursor: pointer;
}
button:hover:not(:disabled) { background: #f0f0f0; }
button:disabled { opacity: 0.3; cursor: default; }
button.invert { background: #000; color: #fff; }
button.invert:hover:not(:disabled) { background: #333; }
.row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
.dot {
width: 9px; height: 9px; border-radius: 50%;
border: 1px solid #000; background: #fff;
flex-shrink: 0; transition: background 0.3s;
}
.dot.on { background: #000; }
label, .note { font-size: 0.75rem; color: #555; }
.note { margin-top: 0.4rem; line-height: 1.5; }
pre {
border: 1px solid #ccc;
padding: 0.6rem 0.9rem;
font-size: 0.78rem;
overflow-x: auto;
line-height: 1.5;
margin-bottom: 0.5rem;
background: #fafafa;
}
textarea {
width: 100%;
font-family: monospace;
font-size: 0.78rem;
border: 1px solid #ccc;
padding: 0.5rem;
resize: vertical;
background: #fafafa;
color: #000;
line-height: 1.5;
}
textarea:focus { outline: 1px solid #000; border-color: #000; }
textarea[readonly] { background: #f5f5f5; color: #333; }
input[type="text"] {
font-family: monospace;
font-size: 0.78rem;
border: 1px solid #ccc;
padding: 0.4rem 0.6rem;
width: 100%;
background: #fafafa;
color: #000;
margin-bottom: 0.5rem;
}
input:focus { outline: 1px solid #000; border-color: #000; }
.field-row { display: flex; gap: 0.5rem; align-items: flex-start; margin-bottom: 0.5rem; }
.field-row textarea { flex: 1; }
.field-row button { flex-shrink: 0; white-space: nowrap; }
.key-display {
font-family: monospace;
font-size: 0.7rem;
word-break: break-all;
background: #fafafa;
border: 1px solid #ccc;
padding: 0.5rem;
color: #333;
margin-bottom: 0.5rem;
cursor: pointer;
user-select: all;
}
.key-display:hover { border-color: #000; }
.status-line {
font-size: 0.75rem;
color: #555;
margin-top: 0.3rem;
min-height: 1.1em;
}
.status-line.ok { color: #000; font-weight: bold; }
.status-line.err { color: #c00; }
video { display: none; }
hr { border: none; border-top: 1px solid #e0e0e0; margin: 2rem 0; }
</style>
</head>
<body>
<video id="v" autoplay loop muted></video>
<h1>zebra report</h1>
<p class="sub">make crypto scary again &nbsp;·&nbsp; <a href="/blog/" style="color:#555">posts</a></p>
<!-- ============================================================ -->
<!-- CARRIER -->
<!-- ============================================================ -->
<section>
<h2>carrier</h2>
<div class="row">
<div class="dot" id="dot"></div>
<button id="btn-audio">start audio</button>
<label id="audio-status">offline</label>
</div>
<p class="note">
Starts the video element — registers this tab as a named sink input in
PulseAudio (pavucontrol, pactl). The C clients modulate that volume to
transmit data.
</p>
</section>
<!-- ============================================================ -->
<!-- KEYS -->
<!-- ============================================================ -->
<section>
<h2>keys</h2>
<p class="note" style="margin-bottom:0.75rem">
ECDH P-256 key pair. Generated once, stored in localStorage.
Share your public key with the other side. Paste theirs below.
Both sides derive the same AES-256-GCM key — no key ever leaves the browser.
</p>
<p style="font-size:0.75rem;margin-bottom:0.3rem;color:#555">your public key (click to copy)</p>
<div class="key-display" id="pub-display" title="click to copy">generating...</div>
<div class="row" style="margin-bottom:0.75rem">
<button id="btn-regen">regenerate keys</button>
<span class="status-line" id="key-status"></span>
</div>
<p style="font-size:0.75rem;margin-bottom:0.3rem;color:#555">peer public key (paste here)</p>
<input type="text" id="peer-key-input" placeholder="paste peer's public key...">
<div class="row">
<button id="btn-peer" class="invert">derive shared key</button>
<span class="status-line" id="peer-status"></span>
</div>
</section>
<!-- ============================================================ -->
<!-- ENCRYPT / DECRYPT -->
<!-- ============================================================ -->
<section>
<h2>encrypt</h2>
<div class="field-row">
<textarea id="plaintext-in" rows="3" placeholder="plaintext message..."></textarea>
<button id="btn-encrypt">encrypt →</button>
</div>
<textarea id="ciphertext-out" rows="3" readonly placeholder="ciphertext (base64) appears here..."></textarea>
<p class="note">Copy ciphertext → pipe through <code>./tx</code> or <code>./chat</code></p>
</section>
<section>
<h2>decrypt</h2>
<div class="field-row">
<textarea id="ciphertext-in" rows="3" placeholder="paste received ciphertext (base64)..."></textarea>
<button id="btn-decrypt">decrypt →</button>
</div>
<textarea id="plaintext-out" rows="3" readonly placeholder="decrypted plaintext appears here..."></textarea>
<div class="status-line" id="dec-status"></div>
</section>
<hr>
<!-- ============================================================ -->
<!-- SETUP -->
<!-- ============================================================ -->
<section>
<h2>find this tab</h2>
<pre>./tx -l
pactl list sink-inputs short</pre>
</section>
<section>
<h2>Firefox ↔ Chromium chat</h2>
<pre><code># terminal 1 (you are Firefox)
./chat -T Firefox -R Chromium -b 10
# terminal 2 (you are Chromium)
./chat -T Chromium -R Firefox -b 10</code></pre>
<p class="note">
TX modulates your tab's volume. RX polls the other tab's volume.
101 discrete levels, binary encoding, 10 baud default.
</p>
</section>
<!-- ============================================================ -->
<!-- JS -->
<!-- ============================================================ -->
<script>
(async () => {
/* ------------------------------------------------------------ *
* audio carrier *
* ------------------------------------------------------------ */
const btnAudio = document.getElementById('btn-audio');
const dot = document.getElementById('dot');
const audioLabel = document.getElementById('audio-status');
const video = document.getElementById('v');
btnAudio.addEventListener('click', async () => {
if (btnAudio.disabled) return;
btnAudio.disabled = true;
const ctx = new AudioContext();
const dest = ctx.createMediaStreamDestination();
const merger = ctx.createChannelMerger(2);
/* L and R oscillators → stereo sink → PA sees channels=2 */
const oscL = ctx.createOscillator();
const oscR = ctx.createOscillator();
const gL = ctx.createGain();
const gR = ctx.createGain();
oscL.type = 'sine'; oscL.frequency.value = 440; gL.gain.value = 0.08;
oscR.type = 'sine'; oscR.frequency.value = 441; gR.gain.value = 0.08;
oscL.connect(gL); gL.connect(merger, 0, 0);
oscR.connect(gR); gR.connect(merger, 0, 1);
merger.connect(dest);
oscL.start(); oscR.start();
video.srcObject = dest.stream;
try { await video.play(); } catch (e) { console.error(e); }
dot.classList.add('on');
audioLabel.textContent = 'online';
});
/* ------------------------------------------------------------ *
* crypto helpers *
* ------------------------------------------------------------ */
const LS_PRIV = 'zebra_privkey';
const LS_PUB = 'zebra_pubkey';
const LS_PEER = 'zebra_peer_pubkey';
function b64(buf) {
return btoa(String.fromCharCode(...new Uint8Array(buf)));
}
function unb64(s) {
return Uint8Array.from(atob(s), c => c.charCodeAt(0));
}
async function genKeypair() {
const kp = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
true,
['deriveKey', 'deriveBits']
);
const privJwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
const pubRaw = await crypto.subtle.exportKey('raw', kp.publicKey);
localStorage.setItem(LS_PRIV, JSON.stringify(privJwk));
localStorage.setItem(LS_PUB, b64(pubRaw));
return { privateKey: kp.privateKey, pubB64: b64(pubRaw) };
}
async function loadOrGenKeypair() {
const privStr = localStorage.getItem(LS_PRIV);
const pubStr = localStorage.getItem(LS_PUB);
if (privStr && pubStr) {
try {
const privJwk = JSON.parse(privStr);
const privateKey = await crypto.subtle.importKey(
'jwk', privJwk,
{ name: 'ECDH', namedCurve: 'P-256' },
true, ['deriveKey', 'deriveBits']
);
return { privateKey, pubB64: pubStr };
} catch (_) { /* corrupted — regenerate */ }
}
return genKeypair();
}
async function deriveShared(myPrivKey, peerPubB64) {
const peerRaw = unb64(peerPubB64);
const peerPub = await crypto.subtle.importKey(
'raw', peerRaw,
{ name: 'ECDH', namedCurve: 'P-256' },
false, []
);
return crypto.subtle.deriveKey(
{ name: 'ECDH', public: peerPub },
myPrivKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
async function encryptMsg(sharedKey, plaintext) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
sharedKey,
new TextEncoder().encode(plaintext)
);
const out = new Uint8Array(12 + enc.byteLength);
out.set(iv); out.set(new Uint8Array(enc), 12);
return b64(out.buffer);
}
async function decryptMsg(sharedKey, ciphertextB64) {
const data = unb64(ciphertextB64.trim());
const iv = data.slice(0, 12);
const ct = data.slice(12);
const dec = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
sharedKey,
ct
);
return new TextDecoder().decode(dec);
}
/* ------------------------------------------------------------ *
* key management UI *
* ------------------------------------------------------------ */
const pubDisplay = document.getElementById('pub-display');
const keyStatus = document.getElementById('key-status');
const peerInput = document.getElementById('peer-key-input');
const peerStatus = document.getElementById('peer-status');
const btnRegen = document.getElementById('btn-regen');
const btnPeer = document.getElementById('btn-peer');
let myPrivKey = null;
let sharedKey = null;
function showPub(pubB64) {
pubDisplay.textContent = pubB64;
}
// click to copy public key
pubDisplay.addEventListener('click', () => {
navigator.clipboard.writeText(pubDisplay.textContent).then(() => {
keyStatus.textContent = 'copied to clipboard';
keyStatus.className = 'status-line ok';
setTimeout(() => { keyStatus.textContent = ''; keyStatus.className = 'status-line'; }, 2000);
});
});
// load or generate on startup
const kp = await loadOrGenKeypair();
myPrivKey = kp.privateKey;
showPub(kp.pubB64);
keyStatus.textContent = 'keys loaded from localStorage';
keyStatus.className = 'status-line';
// restore peer key if saved
const savedPeer = localStorage.getItem(LS_PEER);
if (savedPeer) {
peerInput.value = savedPeer;
}
btnRegen.addEventListener('click', async () => {
if (!confirm('Regenerate keys? Any established shared key will be lost.')) return;
sharedKey = null;
peerStatus.textContent = '';
const kp2 = await genKeypair();
myPrivKey = kp2.privateKey;
showPub(kp2.pubB64);
keyStatus.textContent = 'new keys generated';
keyStatus.className = 'status-line ok';
setTimeout(() => { keyStatus.className = 'status-line'; }, 3000);
});
btnPeer.addEventListener('click', async () => {
const peerPub = peerInput.value.trim();
if (!peerPub) {
peerStatus.textContent = 'paste peer public key first';
peerStatus.className = 'status-line err';
return;
}
try {
sharedKey = await deriveShared(myPrivKey, peerPub);
localStorage.setItem(LS_PEER, peerPub);
peerStatus.textContent = 'shared key derived — encryption ready';
peerStatus.className = 'status-line ok';
} catch (e) {
peerStatus.textContent = 'invalid key: ' + e.message;
peerStatus.className = 'status-line err';
}
});
// auto-derive if peer key was saved and we have our private key
if (savedPeer && myPrivKey) {
try {
sharedKey = await deriveShared(myPrivKey, savedPeer);
peerStatus.textContent = 'shared key restored from localStorage';
peerStatus.className = 'status-line ok';
} catch (_) {
localStorage.removeItem(LS_PEER);
}
}
/* ------------------------------------------------------------ *
* encrypt / decrypt UI *
* ------------------------------------------------------------ */
const plaintextIn = document.getElementById('plaintext-in');
const ciphertextOut = document.getElementById('ciphertext-out');
const ciphertextIn = document.getElementById('ciphertext-in');
const plaintextOut = document.getElementById('plaintext-out');
const decStatus = document.getElementById('dec-status');
const btnEncrypt = document.getElementById('btn-encrypt');
const btnDecrypt = document.getElementById('btn-decrypt');
btnEncrypt.addEventListener('click', async () => {
if (!sharedKey) {
ciphertextOut.value = '[no shared key — derive one first]';
return;
}
const msg = plaintextIn.value;
if (!msg) return;
try {
ciphertextOut.value = await encryptMsg(sharedKey, msg);
} catch (e) {
ciphertextOut.value = '[encrypt error: ' + e.message + ']';
}
});
btnDecrypt.addEventListener('click', async () => {
if (!sharedKey) {
decStatus.textContent = 'no shared key — derive one first';
decStatus.className = 'status-line err';
return;
}
const ct = ciphertextIn.value.trim();
if (!ct) return;
try {
plaintextOut.value = await decryptMsg(sharedKey, ct);
decStatus.textContent = 'decrypted ok';
decStatus.className = 'status-line ok';
} catch (e) {
plaintextOut.value = '';
decStatus.textContent = 'decrypt failed — wrong key or corrupted ciphertext';
decStatus.className = 'status-line err';
}
});
})();
</script>
</body>
</html>

483
web/kernel.html Normal file
View file

@ -0,0 +1,483 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>zebra kernel</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: monospace;
background: #fff;
color: #000;
padding: 2rem;
max-width: 640px;
}
h1 {
font-family: serif;
font-size: 3rem;
font-weight: normal;
letter-spacing: 0.02em;
line-height: 1;
margin-bottom: 0.2rem;
}
.sub {
font-size: 0.75rem;
color: #555;
margin-bottom: 2.5rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
section { margin-bottom: 2rem; }
h2 {
font-family: serif;
font-size: 1.1rem;
font-weight: normal;
border-bottom: 1px solid #000;
padding-bottom: 0.25rem;
margin-bottom: 0.8rem;
}
button {
background: #fff;
color: #000;
border: 1px solid #000;
padding: 0.4rem 1.2rem;
font-family: monospace;
font-size: 0.85rem;
cursor: pointer;
}
button:hover:not(:disabled) { background: #f0f0f0; }
button:disabled { opacity: 0.3; cursor: default; }
button.invert { background: #000; color: #fff; }
button.invert:hover:not(:disabled) { background: #333; }
.row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
.dot {
width: 9px; height: 9px; border-radius: 50%;
border: 1px solid #000; background: #fff;
flex-shrink: 0; transition: background 0.3s;
}
.dot.on { background: #000; }
label, .note { font-size: 0.75rem; color: #555; }
.note { margin-top: 0.4rem; line-height: 1.5; }
pre {
border: 1px solid #ccc;
padding: 0.6rem 0.9rem;
font-size: 0.78rem;
overflow-x: auto;
line-height: 1.5;
margin-bottom: 0.5rem;
background: #fafafa;
}
textarea {
width: 100%;
font-family: monospace;
font-size: 0.78rem;
border: 1px solid #ccc;
padding: 0.5rem;
resize: vertical;
background: #fafafa;
color: #000;
line-height: 1.5;
}
textarea:focus { outline: 1px solid #000; border-color: #000; }
textarea[readonly] { background: #f5f5f5; color: #333; }
input[type="text"] {
font-family: monospace;
font-size: 0.78rem;
border: 1px solid #ccc;
padding: 0.4rem 0.6rem;
width: 100%;
background: #fafafa;
color: #000;
margin-bottom: 0.5rem;
}
input:focus { outline: 1px solid #000; border-color: #000; }
.field-row { display: flex; gap: 0.5rem; align-items: flex-start; margin-bottom: 0.5rem; }
.field-row textarea { flex: 1; }
.field-row button { flex-shrink: 0; white-space: nowrap; }
.key-display {
font-family: monospace;
font-size: 0.7rem;
word-break: break-all;
background: #fafafa;
border: 1px solid #ccc;
padding: 0.5rem;
color: #333;
margin-bottom: 0.5rem;
cursor: pointer;
user-select: all;
}
.key-display:hover { border-color: #000; }
.status-line {
font-size: 0.75rem;
color: #555;
margin-top: 0.3rem;
min-height: 1.1em;
}
.status-line.ok { color: #000; font-weight: bold; }
.status-line.err { color: #c00; }
video { display: none; }
hr { border: none; border-top: 1px solid #e0e0e0; margin: 2rem 0; }
</style>
</head>
<body>
<video id="v" autoplay loop muted></video>
<h1>zebra kernel</h1>
<p class="sub">volume modem &nbsp;&middot;&nbsp; stereo carrier &nbsp;&middot;&nbsp; e2e encrypted</p>
<!-- ============================================================ -->
<!-- CARRIER -->
<!-- ============================================================ -->
<section>
<h2>carrier</h2>
<div class="row">
<div class="dot" id="dot"></div>
<button id="btn-audio">start audio</button>
<label id="audio-status">offline</label>
</div>
<p class="note">
Starts a stereo audio stream — registers this tab as a named sink input
in PulseAudio (pavucontrol, pactl) with channels=2. The C clients
modulate L and R volume independently to transmit data.
</p>
</section>
<!-- ============================================================ -->
<!-- KEYS -->
<!-- ============================================================ -->
<section>
<h2>keys</h2>
<p class="note" style="margin-bottom:0.75rem">
ECDH P-256 key pair. Generated once, stored in localStorage.
Share your public key with the other side. Paste theirs below.
Both sides derive the same AES-256-GCM key &mdash; no key ever leaves the browser.
</p>
<p style="font-size:0.75rem;margin-bottom:0.3rem;color:#555">your public key (click to copy)</p>
<div class="key-display" id="pub-display" title="click to copy">generating...</div>
<div class="row" style="margin-bottom:0.75rem">
<button id="btn-regen">regenerate keys</button>
<span class="status-line" id="key-status"></span>
</div>
<p style="font-size:0.75rem;margin-bottom:0.3rem;color:#555">peer public key (paste here)</p>
<input type="text" id="peer-key-input" placeholder="paste peer's public key...">
<div class="row">
<button id="btn-peer" class="invert">derive shared key</button>
<span class="status-line" id="peer-status"></span>
</div>
</section>
<!-- ============================================================ -->
<!-- ENCRYPT / DECRYPT -->
<!-- ============================================================ -->
<section>
<h2>encrypt</h2>
<div class="field-row">
<textarea id="plaintext-in" rows="3" placeholder="plaintext message..."></textarea>
<button id="btn-encrypt">encrypt &rarr;</button>
</div>
<textarea id="ciphertext-out" rows="3" readonly placeholder="ciphertext (base64) appears here..."></textarea>
<p class="note">Copy ciphertext &rarr; pipe through <code>./tx</code> or <code>./bt</code></p>
</section>
<section>
<h2>decrypt</h2>
<div class="field-row">
<textarea id="ciphertext-in" rows="3" placeholder="paste received ciphertext (base64)..."></textarea>
<button id="btn-decrypt">decrypt &rarr;</button>
</div>
<textarea id="plaintext-out" rows="3" readonly placeholder="decrypted plaintext appears here..."></textarea>
<div class="status-line" id="dec-status"></div>
</section>
<hr>
<!-- ============================================================ -->
<!-- SETUP -->
<!-- ============================================================ -->
<section>
<h2>find this tab</h2>
<pre>./tx -l
pactl list sink-inputs short</pre>
</section>
<section>
<h2>battle toads (stereo, 2x bandwidth)</h2>
<pre><code># auto-negotiate baud, dual-channel stereo
./rx -s RX_SINK -t TX_SINK # terminal 1: benchmark + send offer
./tx -s TX_SINK -r RX_SINK # terminal 2: wait for offer + READY
# or fixed baud
./bt -T MY_SINK -R THEIR_SINK -b 500</code></pre>
<p class="note">
L and R channels carry independent UART streams simultaneously.
PA sees channels=2 from this stereo carrier &mdash; required for Battle Toads.
</p>
</section>
<!-- ============================================================ -->
<!-- JS -->
<!-- ============================================================ -->
<script>
(async () => {
/* ------------------------------------------------------------ *
* audio carrier — stereo (channels=2 for Battle Toads) *
* ------------------------------------------------------------ */
const btnAudio = document.getElementById('btn-audio');
const dot = document.getElementById('dot');
const audioLabel = document.getElementById('audio-status');
const video = document.getElementById('v');
btnAudio.addEventListener('click', async () => {
if (btnAudio.disabled) return;
btnAudio.disabled = true;
const ctx = new AudioContext();
const dest = ctx.createMediaStreamDestination();
const merger = ctx.createChannelMerger(2);
/* L (440 Hz) and R (441 Hz) oscillators → stereo → PA channels=2 */
const oscL = ctx.createOscillator();
const oscR = ctx.createOscillator();
const gL = ctx.createGain();
const gR = ctx.createGain();
oscL.type = 'sine'; oscL.frequency.value = 440; gL.gain.value = 0.08;
oscR.type = 'sine'; oscR.frequency.value = 441; gR.gain.value = 0.08;
oscL.connect(gL); gL.connect(merger, 0, 0);
oscR.connect(gR); gR.connect(merger, 0, 1);
merger.connect(dest);
oscL.start(); oscR.start();
video.srcObject = dest.stream;
try { await video.play(); } catch (e) { console.error(e); }
dot.classList.add('on');
audioLabel.textContent = 'online — stereo (ch=2)';
});
/* ------------------------------------------------------------ *
* crypto helpers *
* ------------------------------------------------------------ */
const LS_PRIV = 'zebra_privkey';
const LS_PUB = 'zebra_pubkey';
const LS_PEER = 'zebra_peer_pubkey';
function b64(buf) {
return btoa(String.fromCharCode(...new Uint8Array(buf)));
}
function unb64(s) {
return Uint8Array.from(atob(s), c => c.charCodeAt(0));
}
async function genKeypair() {
const kp = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
true,
['deriveKey', 'deriveBits']
);
const privJwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
const pubRaw = await crypto.subtle.exportKey('raw', kp.publicKey);
localStorage.setItem(LS_PRIV, JSON.stringify(privJwk));
localStorage.setItem(LS_PUB, b64(pubRaw));
return { privateKey: kp.privateKey, pubB64: b64(pubRaw) };
}
async function loadOrGenKeypair() {
const privStr = localStorage.getItem(LS_PRIV);
const pubStr = localStorage.getItem(LS_PUB);
if (privStr && pubStr) {
try {
const privJwk = JSON.parse(privStr);
const privateKey = await crypto.subtle.importKey(
'jwk', privJwk,
{ name: 'ECDH', namedCurve: 'P-256' },
true, ['deriveKey', 'deriveBits']
);
return { privateKey, pubB64: pubStr };
} catch (_) { /* corrupted — regenerate */ }
}
return genKeypair();
}
async function deriveShared(myPrivKey, peerPubB64) {
const peerRaw = unb64(peerPubB64);
const peerPub = await crypto.subtle.importKey(
'raw', peerRaw,
{ name: 'ECDH', namedCurve: 'P-256' },
false, []
);
return crypto.subtle.deriveKey(
{ name: 'ECDH', public: peerPub },
myPrivKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
async function encryptMsg(sharedKey, plaintext) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
sharedKey,
new TextEncoder().encode(plaintext)
);
const out = new Uint8Array(12 + enc.byteLength);
out.set(iv); out.set(new Uint8Array(enc), 12);
return b64(out.buffer);
}
async function decryptMsg(sharedKey, ciphertextB64) {
const data = unb64(ciphertextB64.trim());
const iv = data.slice(0, 12);
const ct = data.slice(12);
const dec = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
sharedKey,
ct
);
return new TextDecoder().decode(dec);
}
/* ------------------------------------------------------------ *
* key management UI *
* ------------------------------------------------------------ */
const pubDisplay = document.getElementById('pub-display');
const keyStatus = document.getElementById('key-status');
const peerInput = document.getElementById('peer-key-input');
const peerStatus = document.getElementById('peer-status');
const btnRegen = document.getElementById('btn-regen');
const btnPeer = document.getElementById('btn-peer');
let myPrivKey = null;
let sharedKey = null;
function showPub(pubB64) {
pubDisplay.textContent = pubB64;
}
pubDisplay.addEventListener('click', () => {
navigator.clipboard.writeText(pubDisplay.textContent).then(() => {
keyStatus.textContent = 'copied to clipboard';
keyStatus.className = 'status-line ok';
setTimeout(() => { keyStatus.textContent = ''; keyStatus.className = 'status-line'; }, 2000);
});
});
const kp = await loadOrGenKeypair();
myPrivKey = kp.privateKey;
showPub(kp.pubB64);
keyStatus.textContent = 'keys loaded from localStorage';
keyStatus.className = 'status-line';
const savedPeer = localStorage.getItem(LS_PEER);
if (savedPeer) {
peerInput.value = savedPeer;
}
btnRegen.addEventListener('click', async () => {
if (!confirm('Regenerate keys? Any established shared key will be lost.')) return;
sharedKey = null;
peerStatus.textContent = '';
const kp2 = await genKeypair();
myPrivKey = kp2.privateKey;
showPub(kp2.pubB64);
keyStatus.textContent = 'new keys generated';
keyStatus.className = 'status-line ok';
setTimeout(() => { keyStatus.className = 'status-line'; }, 3000);
});
btnPeer.addEventListener('click', async () => {
const peerPub = peerInput.value.trim();
if (!peerPub) {
peerStatus.textContent = 'paste peer public key first';
peerStatus.className = 'status-line err';
return;
}
try {
sharedKey = await deriveShared(myPrivKey, peerPub);
localStorage.setItem(LS_PEER, peerPub);
peerStatus.textContent = 'shared key derived — encryption ready';
peerStatus.className = 'status-line ok';
} catch (e) {
peerStatus.textContent = 'invalid key: ' + e.message;
peerStatus.className = 'status-line err';
}
});
if (savedPeer && myPrivKey) {
try {
sharedKey = await deriveShared(myPrivKey, savedPeer);
peerStatus.textContent = 'shared key restored from localStorage';
peerStatus.className = 'status-line ok';
} catch (_) {
localStorage.removeItem(LS_PEER);
}
}
/* ------------------------------------------------------------ *
* encrypt / decrypt UI *
* ------------------------------------------------------------ */
const plaintextIn = document.getElementById('plaintext-in');
const ciphertextOut = document.getElementById('ciphertext-out');
const ciphertextIn = document.getElementById('ciphertext-in');
const plaintextOut = document.getElementById('plaintext-out');
const decStatus = document.getElementById('dec-status');
const btnEncrypt = document.getElementById('btn-encrypt');
const btnDecrypt = document.getElementById('btn-decrypt');
btnEncrypt.addEventListener('click', async () => {
if (!sharedKey) {
ciphertextOut.value = '[no shared key — derive one first]';
return;
}
const msg = plaintextIn.value;
if (!msg) return;
try {
ciphertextOut.value = await encryptMsg(sharedKey, msg);
} catch (e) {
ciphertextOut.value = '[encrypt error: ' + e.message + ']';
}
});
btnDecrypt.addEventListener('click', async () => {
if (!sharedKey) {
decStatus.textContent = 'no shared key — derive one first';
decStatus.className = 'status-line err';
return;
}
const ct = ciphertextIn.value.trim();
if (!ct) return;
try {
plaintextOut.value = await decryptMsg(sharedKey, ct);
decStatus.textContent = 'decrypted ok';
decStatus.className = 'status-line ok';
} catch (e) {
plaintextOut.value = '';
decStatus.textContent = 'decrypt failed — wrong key or corrupted ciphertext';
decStatus.className = 'status-line err';
}
});
})();
</script>
</body>
</html>