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:
commit
b77da42bbe
20 changed files with 4062 additions and 0 deletions
121
CLAUDE.md
Normal file
121
CLAUDE.md
Normal 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 **0–100** (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: ~1000–1200 baud (PA IPC ~350–400µ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 ~1–10 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
54
Makefile
Normal 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
185
blog/build.py
Normal 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">{" ".join(nav)}</nav>' if nav else ""
|
||||||
|
|
||||||
|
body = f"""<article>
|
||||||
|
<h1>{html.escape(meta["title"])}</h1>
|
||||||
|
<p class="meta">{html.escape(meta["date"])} — <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()
|
||||||
78
blog/posts/001-volume-modem.md
Normal file
78
blog/posts/001-volume-modem.md
Normal 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.
|
||||||
109
blog/posts/002-sse-chatroom.md
Normal file
109
blog/posts/002-sse-chatroom.md
Normal 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
342
include/modem.h
Normal 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
124
include/zebra.h
Normal 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 (0–100%) 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: 0–100 */
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* 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; /* 0–100 */
|
||||||
|
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 (0–100); 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
213
src/bt.c
Normal 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
188
src/carrier.c
Normal 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
200
src/chat.c
Normal 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
304
src/pulse.c
Normal 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
119
src/rx.c
Normal 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
147
src/tx.c
Normal 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
319
test/functional.c
Normal 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
152
test/integration.c
Normal 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
37
test/test.h
Normal 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
275
test/unit.c
Normal 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
118
web/blog/style.css
Normal 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
494
web/index.html
Normal 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 · <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
483
web/kernel.html
Normal 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 · stereo carrier · 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 — 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>./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 →</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 — 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue