Commit graph

21 commits

Author SHA1 Message Date
a01f1b4aac
chat.html: Hamming(12,8) FEC + Gray-coded levels on the modem
Each data byte is carried as a 12-bit Hamming codeword (8 data + 4 parity)
inside one modem byte-frame, so any single-bit error self-corrects instead of
failing the frame CRC and stalling the outbox on "no ack". Symbol levels are
Gray-coded so an off-by-one quantization (the dominant error) is a single-bit
flip Hamming can fix. Toggle ?fec=0 (both peers must match). Validated in Node:
all 256 bytes x 12 bit positions corrected; modem recovers 30/30 with a +/-1
symbol error per byte.
2026-05-28 12:41:13 -04:00
04f4076d33
chat.html: use full viewport width on desktop (drop max-width cap) 2026-05-28 12:29:10 -04:00
e8c6d8ed0a
chat.html: 50:50 setup/chat layout (CSS grid), strip redundant UI
Two-panel CSS grid: setup (identity, room, connect, carrier) on the left,
chat on the right; stacks on phones (<=760px). Removes the manual offer/answer
boxes, the benchmark/handshake section, the mDNS note, the about/threat-model
prose, and the now-dead JS (share-link/QR helpers, those handlers, the inlined
QRCode library) — the relay path is the only bootstrap now. Validated headless:
clean load, two-column grid, no broken element refs, connect flow still works.
2026-05-28 12:17:56 -04:00
b4bfb33cf7
chat.html: delivery ACKs + outbox (hold sent msgs until confirmed)
Add a reliable-delivery layer over the unreliable audio modem. A sent message
is held in an outbox (not echoed to the log) and retransmitted until the far
side ACKs it, then it moves into the log marked delivered. The DATA frame's
existing CRC32 is the message id; a new 15-byte T_ACK frame echoes it back.
Receiver ACKs every valid+decrypted frame (incl. duplicates, so a lost ACK
still stops the sender) and de-dupes on CRC so retransmits never double-show.
2026-05-28 12:00:28 -04:00
90a207b8c8
chat.html: echo sent message instantly instead of after transmit completes 2026-05-28 11:35:32 -04:00
2cd3c05fee
chat.html: single 440 Hz carrier (kill 1 Hz beat under mono codec)
The carrier used 440 Hz (L) / 441 Hz (R) for the stereo Battle Toads mode, but
the modem codec is forced to mono G.711, which downmixes the two tones into a
1 Hz beat — a slow sinusoidal amplitude swell that swamps the level readings.
Use 440 Hz on both channels so the mono downmix is a clean single tone.
2026-05-28 11:34:04 -04:00
ad78c77bfc
chat.html: multi-level volume modem (N-level symbols, ?levels=N)
Generalize the browser physical layer from binary MARK/SPACE to N-level
amplitude symbols, so each baud tick carries log2(N) bits (N=2 = old binary,
N=4 = 2 bits, N=16 = 4 bits). Each byte is framed START(level 0/MIN) ..
STOP(level N-1/MAX) with the carrier idling high, so the receiver locks on the
high->low edge and self-calibrates its low/high amplitude reference every byte
(tolerant of channel gain drift). Symbols are sampled over the middle of each
window to avoid averaging across boundaries. Tunable via ?levels=N (2|4|16),
default 4. Misdecodes fail safe: a bad symbol breaks the frame CRC and the
frame is dropped rather than shown as garbage.

Roundtrip-simulated (clean + noisy channel): N=2 robust at all bauds, N=4
reliable to ~50 baud, N=16 to ~50 baud on a clean channel.
2026-05-28 11:17:36 -04:00
a5979ee9c2
chat.html: widen MARK/SPACE swing to 0.95/0.10 for decode margin
The binary carrier swung only 0.20-0.80; through a lossy codec that narrow gap
shrinks the margin between MARK and SPACE. Widen to 0.95 (near full scale, no
clip) and 0.10 (low but non-zero so the tone stays present for energy
detection), giving the adaptive-threshold decoder a cleaner split.
2026-05-28 10:56:10 -04:00
838c3426ba
chat.html: force G.711/G.722 codec over WebRTC so the modem survives
WebRTC defaulted to Opus, a perceptual codec that re-quantizes audio in 20ms
frames and smears the MARK/SPACE bit-edges the amplitude modem rides on — which
is why the PulseAudio C path does ~800 baud but the WebRTC path couldn't carry
frames at all. Pin the audio transceiver to memoryless G.711 (PCMU/PCMA), then
G.722, ahead of Opus via setCodecPreferences, restoring a clean amplitude
channel. Decoder resolution is now bounded by the AudioWorklet quantum (~375Hz),
not the codec.
2026-05-28 10:51:55 -04:00
4e7bd3631a
chat.html: make link baud tunable (?baud=N), default 20 for Opus path
The WebRTC Opus codec encodes in 20ms frames and smears the modem's bit
edges; at 50 baud a bit is ~one Opus frame, so frames rarely decode. Lower
baud gives more audio quanta per symbol and survives the smear. Expose the
link baud as ?baud=N (both peers must match) and lower the default to 20 so
the default path is more likely to decode. Also log the actual link baud on
HELLO instead of the advertised max (which was misleading).
2026-05-28 10:47:48 -04:00
2e8683640f
chat.html: send chat data at decoder baud so messages decode
The inbound UART decoder is constructed at ZEBRA_BAUD_HANDSHAKE and never
re-tuned, but chat DATA frames were sent at the negotiated/default baud (10).
HELLO frames (sent at 50) decoded fine — peers appeared and the link looked
healthy — but DATA at 10 never decoded against a 50-baud decoder, so messages
never crossed. Send DATA at ZEBRA_BAUD_HANDSHAKE to match the decoder.
2026-05-28 10:41:38 -04:00
3b49e4848f
chat.html: point signaling relay at cors-proxy.uncloseai.com
The proxy.uncloseai.com hostname could not get a TLS cert; the relay now
lives under cors-proxy.uncloseai.com, which has a working cert.
2026-05-28 08:56:40 -04:00
3a7fd60d06
chat.html: auto-connect via signaling relay (no copy-paste handshake)
Both peers type the same rendezvous code and connect automatically through
proxy.uncloseai.com — no manual offer/answer relay. The code derives an
opaque room ID and an AES key that wraps the SDP before it leaves the page,
so the relay sees only ciphertext. The existing share-link/QR exchange stays
as the offline fallback.

Also caps waitForIceGathering with a timeout: some environments never fire
the ICE 'complete' transition even after all candidates are gathered, which
previously hung offer/answer creation forever (affected the manual path too).
2026-05-28 08:13:39 -04:00
0d1236e030 chat.html: bump QR canvas + render size 180px → 320px 2026-05-27 19:02:46 -04:00
9e67b05f2f chat.html: share-link + QR for SDP exchange (near-automated handshake)
manual copy-paste of raw SDP was v1. this adds:

1. share-link encoding: SDP description → JSON → deflate-raw (native
   CompressionStream) → base64url → URL hash. typical 2 KB SDP fits
   in ~700 chars after compression. fits in any text channel.
   URL format:  .../zebra-report/#o=<deflated-base64>    (offer)
                .../zebra-report/#a=<deflated-base64>    (answer)

2. QR rendering: same URL rendered as 180x180 QR using inlined
   davidshimjs qrcodejs (MIT, 28 KB). offers a visual scan path
   (phone scanners) without an in-browser decoder library yet.

3. auto-fill on link open: page reads location.hash on load.
     #o=... → autofills remote-offer textarea, prompts user to
              complete identity + audio + click "create answer"
     #a=... → autofills remote-answer textarea, prompts "accept"
   hash is cleared from address bar after parse so a reload doesn't
   double-trigger.

4. parsers tolerate either input form: full URL with #o=/#a= hash,
   or raw JSON SDP. peer A can paste back a URL or a textarea dump,
   same handler.

5. tucked the raw SDP textareas behind a "show raw SDP" toggle so
   the default UI is just the URL + QR. expert users still get the
   raw bytes when needed.

UX flow (cross-internet, two peers):
  A: enter room → start audio → "create offer" → "copy link"
     → send link to B via Signal/SMS/anywhere
  B: open link → page auto-fills offer → enter room → start audio
     → "create answer" → "copy link" → send back to A
  A: paste link into the answer textarea → "accept answer"
  → SRTP candidates pair → chat begins.

deferred: in-browser camera scanning of QR (needs jsQR or similar
~60 KB inlined; not on disk currently). v3.

file: 1509 lines, ~85 KB. JS syntax-clean (qrcode lib + main IIFE),
HTML balanced. CompressionStream + DecompressionStream available
in all current browsers (Chrome 80+, Firefox 113+, Safari 16.4+).
2026-05-27 18:37:18 -04:00
0ff2c788d6 chat.html: add proxy.uncloseai.com TURN to iceServers
with coturn live on proxy.uncloseai.com (proxy.unturf.com:22ccf93),
ICE now offers a relay candidate path on every connection. peers
behind symmetric NAT or strict firewalls fall back to TURN instead
of failing the connection.

static creds zebra:7a4a2b1c8d9e6f5a — public by design (a static
page can't hide them); rate-limited by coturn quotas, not secrecy.
google stun stays as fallback if our stun is unreachable.
2026-05-27 17:10:29 -04:00
202dd706f9 chat.html: add reset button + ICE candidate logging + mDNS hint
addresses two issues in the WebRTC same-LAN test:

1. "Cannot set remote answer in state stable" on retry
   the offerer's pc enters stable after the first successful accept-
   answer; subsequent clicks fail. add a reset-connection button that
   close()s the current pc, clears all SDP textareas + status lines,
   detaches inbound rx decoders, & restores the disconnected UI.
   user can now retry without reloading the page.

2. silent NAT/mDNS diagnostic gap
   browsers anonymize host IPs as <uuid>.local mDNS names. routers
   with multicast-blocking client isolation (common on guest Wi-Fi)
   silently prevent host-candidate pairing on the same LAN. add:
     * a details/summary section under the rtc dot documenting the
       Firefox + Chromium config knobs to disable host obfuscation;
     * pc.onicecandidate logging that prints candidate type
       (host/srflx/relay) + an explicit '(mdns-anonymized)' tag
       for .local foundations, so the user can see WHAT is being
       gathered without opening devtools;
     * an err logLine on connectionState=failed pointing at the
       reset button.

unchanged: protocol, frame codec, crypto, audio path, signaling
flow. UI surface gains one button + one collapsed details panel.
2026-05-27 16:38:07 -04:00
0dede3d6ca chat.html: WebRTC P2P audio + in-browser AudioWorklet decode
major architectural rework. previously chat.html modulated its own
GainNode and depended on a native zebrad daemon polling local PA
to close the receive loop. that worked single-machine multi-tab
but had no cross-laptop path without bridging external audio (voice
call, virtual sink, etc.).

new architecture: each peer's modulated audio is the outbound track
of a WebRTC peer connection. each peer decodes the inbound RTC
track in-page via an AudioWorklet energy detector + the same UART
state machine zebrad uses. cross-laptop works because WebRTC carries
the modulated audio between machines. native daemon not required.

  outbound:  oscillators (440/441 Hz) → modulation gain (MARK/SPACE)
             → channel merger → MediaStreamDestination → RTCPeer-
             Connection.addTrack().  also → optional local monitor
             gain (muted by default; toggleable for debugging).

  inbound:   RTCPeerConnection.ontrack → MediaStreamSource
             → AudioWorklet (per-quantum peak detection, 128 samples)
             → main thread postMessage → UartDecoder → FrameAssembler
             → onRxFrame (same handler as before).

  signaling: v1 uses manual SDP paste via two textareas. role A
             ("start a new connection") creates an offer, waits for
             gathered ICE, exposes JSON-serialized localDescription
             for copy. role B ("join") pastes that, generates an
             answer, gets pasted back to A.  STUN: stun.l.google.com.
             v2 will add a tiny signaling server for auto-pair.

  threat:    SRTP carries the modulated audio. Wireshark sees only
             encrypted RTP packets; chat content lives in audio
             amplitude transitions inside the encrypted payload.
             additional app-layer AES-GCM (passphrase mode PBKDF2,
             pubkey mode ECDH P-256) preserved unchanged.

zebrad daemon (src/zebrad.c) stays in the repo as the V2 disclosure
demo (a same-UID host process polling PulseAudio sink-input volumes
of an arbitrary same-UID tab) but is no longer required for chat.

preserved unchanged from prior chat.html:
  * crypto modes (passphrase + pubkey), mode toggle
  * sender id (random per-tab in passphrase mode; SHA-256(pubkey)[0:4] in pubkey mode)
  * self-echo filter via sid match
  * frame codec (OFFER/READY at 50 baud, DATA/HELLO with CRC-32)
  * benchmark + handshake state machine
  * peer list, chat log
  * threat model + mitigation panel
  * BroadcastChannel dev loopback via ?loopback=1

file size: 1258 lines, ~46 KB. JS syntax-clean (node --check).
HTML balanced.
2026-05-27 16:14:51 -04:00
dcb1c8e5a1 chat.html: drop self-echo frames at onRxFrame entry
zebrad reads @DEFAULT_MONITOR@ which mixes every PA stream on
a machine, so each browser tab hears its own carrier echoed
back. without a filter, every outbound message would render
twice in the chat log (once as 'me' at send time, once as
'peer' on echo arrival).

filter: compare frame.sid (carried in DATA + HELLO) against
our own senderIdBytes(). if equal, drop frame before any UI
work. loopback mode unaffected since BroadcastChannel never
echoes to sender by spec.

senderIdBytes returns a stable per-tab id:
  * passphrase mode: random 4 bytes cached for tab lifetime
  * pubkey mode: first 4 of SHA-256(pubkey), stable across reloads
2026-05-27 16:00:28 -04:00
5d76d713a6 web/chat.html: add ?loopback=1 dev loopback via BroadcastChannel
without zebrad running, the page is TX-only: tabs see their own
outbound frames but nothing crosses. for UX validation while the
introspector daemon doesn't exist yet, gate a BroadcastChannel
fallback on the ?loopback=1 query param.

behavior:
  * with ?loopback=1: after each txFrame completes its modulation,
    the raw frame bytes are postMessage'd on a same-origin
    BroadcastChannel('zebra-report-loopback'). other tabs of the
    same browser subscribe & feed received bytes into onRxFrame
    exactly as zebrad would. BroadcastChannel suppresses echo to
    sender by spec, so the originating tab does not see itself.
  * without ?loopback=1: behavior unchanged. real PA transport
    stays the production path.

a yellow banner at the top of the page warns when loopback is
active: "frames cross via BroadcastChannel between same-browser
tabs, NOT via PulseAudio". no risk of mistaking dev mode for
production.
2026-05-27 15:47:00 -04:00
d9b2499a2e web/chat.html: production multi-person volume-modem chatroom
single self-contained page implementing the recovered zebra-report
protocol as a peer-to-peer chatroom whose data path is PulseAudio
sink-input state — chat content never enters an IP packet.

architecture:
  page (TX)   modulates audio output via Web Audio GainNode
              between MARK (0.80) and SPACE (0.20). silent stereo
              carrier 440/441 Hz published to PulseAudio.
  zebrad      separate program (companion, not in this commit):
              polls PA sink-input volumes, decodes frames,
              forwards to ws://127.0.0.1:7777 — the page connects
              and renders received messages.
  RX path     the page is TX-only without zebrad running; with
              it, full bidirectional chat.

crypto modes (toggle in UI):
  passphrase  PBKDF2-SHA256 (600k iter) → AES-GCM-256 group key.
              everyone with the same phrase joins the room.
  pubkey      ECDH P-256 keypair per browser → AES-GCM-256
              pairwise. peers added by pasting their pubkey.
  both modes use Web Crypto API. no external crypto deps.

frame protocol (matches include/zebra.h):
  OFFER  magic(ZB) + 0x01 + baud_le(2) + xor(1)          — 6B
  READY  magic(ZB) + 0x02 + baud_le(2) + xor(1)          — 6B
  HELLO  magic + 0x04 + sid(4) + maxBaud_le(2)
         + handleLen(1) + handle(n) + crc32_le(4)
  DATA   magic + 0x03 + sid(4) + len_le(2)
         + payload(n) + crc32_le(4)

handshake:
  1. user clicks "benchmark self" — page schedules 200 gain
     events at 0.5ms intervals, measures avg, divides by 2 for
     safety, clamps to [ZEBRA_BAUD_MIN, ZEBRA_BAUD_MAX].
  2. user clicks "send OFFER" — sends OFFER frame at the
     fixed handshake baud (50). also broadcasts HELLO.
  3. peer's introspector decodes; their page replies with READY
     at their max baud, plus their HELLO.
  4. negotiated baud = min(my max, every peer's max).
  5. data frames at negotiated baud.

UI sections:
  identity   handle + mode toggle
  room       passphrase OR pubkey, depending on mode
  carrier    start audio, live gain meter
  handshake  benchmark, OFFER button, baud display
  receive    ws://127.0.0.1:7777 status + connect
  chat       peer list, log, message box
  about      threat model, mitigation, whitepaper link

defensive framing prominent: the protocol is documented as a
research artifact demonstrating PA-IPC's open same-UID volume
read access. "what this does not protect against" enumerated.
mitigation panel cites foxhop.net/linux-audio-ipc-attack-surface.

known v1 tradeoffs:
  - no CSMA: simultaneous TX from two peers will collide.
  - pubkey mode broadcasts one frame per peer per message.
  - GainNode scheduling has OS-level jitter — actual sustained
    baud will be lower than the optimistic benchmark.
  - base carrier is audible at 440/441 Hz; can be lowered or
    moved ultrasonic in a follow-up.

reuses visual identity from web/index.html (chunkfive font,
monospace, black/white, no framework, no build step).

file: 1062 lines, 40 KB, JS syntax-clean (node --check), HTML
balanced.
2026-05-27 14:54:26 -04:00