- derives a 256-bit AES-GCM key via PBKDF2-SHA256 (600k iterations) from the
- passphrase + the literal string "zebra-report-v1" as salt. Anyone with the
- same phrase joins the same room.
+ PBKDF2-SHA256 (600k iter) of passphrase + literal salt "zebra-report-v1"
+ → 256-bit AES-GCM group key.
@@ -185,8 +187,6 @@
-
add one peer at a time. each add derives a 1-to-1 shared key
- and lets you exchange messages with that peer.
@@ -196,17 +196,68 @@
- mic stays off · transmit only
+
+
+
- live gain:
+ tx gain:
0%
+
+ rx energy:
+
+ —
+
- a silent stereo carrier is published to PulseAudio. Each frame modulates the
- GainNode between MARK (80%) and SPACE (20%) per bit. A local introspector
- (see “receive” below) polls PA sink-input state and decodes.
+ a 440/441 Hz stereo carrier is generated locally. each frame swings the
+ output gain MARK (0.80) ↔ SPACE (0.20) per bit. the modulated audio is the
+ outbound WebRTC track for every connected peer. mic stays off.
+
+
+
+
+
+
peer connection (WebRTC)
+
+
+ disconnected — start audio first, then pick a role below
+
+
+
+
role A · start a new connection
+
step 1: click, then copy the offer to your peer (any out-of-band channel):
+
+
+
+
+
+
step 4: paste your peer's answer here:
+
+
+
+
+
+
+
+
+
role B · join an existing connection
+
step 2: paste your peer's offer here:
+
+
+
+
+
+
step 3: copy your answer to peer:
+
+
+
+
+ each browser modulates its own outbound audio track (no mic). incoming peer
+ audio is decoded in-page via AudioWorklet, no native daemon
+ required. Wireshark sees only encrypted SRTP — chat content lives in
+ audio amplitude transitions inside that stream.
@@ -218,7 +269,7 @@
not yet measured
-
+
idle
@@ -226,26 +277,9 @@
—
- OFFER and READY frames travel at fixed ZEBRA_BAUD_HANDSHAKE=50.
- Each peer measures its own scheduling jitter, broadcasts its max baud, and
- the room settles on min(all peers).
-
-
-
-
-
-
receive (introspector bridge)
-
-
- no introspector — running TX-only
-
-
-
- expects zebrad at ws://127.0.0.1:7777. The bridge
- polls PulseAudio sink-input volumes via PA IPC and pushes decoded frames here.
- Without it the page transmits but cannot receive. The
- recovered rx.c handles
- decoding; zebrad is the WebSocket wrapper around it.
+ OFFER and READY frames travel at fixed ZEBRA_BAUD_HANDSHAKE = 50.
+ each peer measures its own scheduling jitter, broadcasts a max baud, room
+ settles on min(all).
@@ -274,34 +308,33 @@
about
- This page is the “production” zebra-report protocol:
- a covert peer-to-peer chatroom whose data path is PulseAudio sink-input
- state. Chat content never enters an IP packet — Wireshark sees only the
- websocket between this browser tab and the local zebrad bridge,
- and even that traffic is post-encryption ciphertext + frame metadata.
+ The protocol's data path is audio amplitude inside an encrypted WebRTC
+ SRTP stream. The signaling channel (SDP / ICE) is exchanged once at
+ connection setup — it contains no chat content. Every subsequent
+ bit travels as a MARK/SPACE swing in the carrier audio.
threat model · what this protects against · what it doesn't
- Protects against: a network-level observer (Wireshark, IDS,
- DPI middlebox, ISP) seeing chat content. None of it crosses the network.
+ Protects against: a network-level observer (Wireshark,
+ IDS, DPI) reading chat content. The bits are inside the SRTP audio
+ payload, which is end-to-end encrypted by WebRTC, then additionally
+ encrypted at the app layer (AES-GCM via passphrase or ECDH).
Does not protect against:
- (1) other same-UID processes that can poll PulseAudio — this is the
- entire attack surface zebra-report demonstrates;
+ (1) other same-UID processes that can poll PulseAudio or record the
+ monitor source on either peer's machine — that is the
+ attack surface this whole project documents;
(2) an endpoint compromise of either peer's machine;
- (3) traffic analysis against the encrypted ciphertext over many sessions;
- (4) anyone able to run a microphone in earshot of the speakers (n/a here —
- this protocol's bytes don't ride on speaker audio energy).
+ (3) traffic-analysis correlation against the encrypted SRTP flow.
- Mitigation for defenders: require PulseAudio clients to
- hold a capability before reading peer sink-input volumes. The current
- design grants any same-UID process unrestricted read access — that's
- the published defect this whole project documents. See
-
- foxhop.net/linux-audio-ipc-attack-surface for the full whitepaper.
+ Mitigation for defenders: require a capability for
+ same-UID PulseAudio reads. The current Linux audio IPC trust model
+ grants every same-UID process unrestricted access — the defect
+ whitepaper documents this.
@@ -315,29 +348,29 @@
/* ============================================================== *
* constants (mirror include/zebra.h) *
* ============================================================== */
-const ZEBRA_VOL_MARK = 0.80; /* logic-1 / idle / stop bit */
-const ZEBRA_VOL_SPACE = 0.20; /* logic-0 / start bit */
-const ZEBRA_VOL_THRESHOLD = 0.50; /* decision boundary */
+const ZEBRA_VOL_MARK = 0.80;
+const ZEBRA_VOL_SPACE = 0.20;
const ZEBRA_BAUD_HANDSHAKE = 50;
const ZEBRA_BAUD_MIN = 1;
const ZEBRA_BAUD_MAX = 100000;
const ZEBRA_BAUD_DEFAULT = 10;
-const HS_MAGIC = [0x5A, 0x42]; /* 'Z' 'B' */
+const HS_MAGIC = [0x5A, 0x42];
const T_OFFER = 0x01;
const T_READY = 0x02;
-const T_DATA = 0x03; /* extension: data frame */
-const T_HELLO = 0x04; /* extension: peer announcement */
+const T_DATA = 0x03;
+const T_HELLO = 0x04;
const HS_FRAME_LEN = 6;
-const WS_URL = 'ws://127.0.0.1:7777';
const PBKDF2_SALT = new TextEncoder().encode('zebra-report-v1');
const PBKDF2_ITER = 600000;
-/* dev loopback: bypass PulseAudio, ride BroadcastChannel between tabs.
- * gated on ?loopback=1. real PA transport stays the production path. */
const LOOPBACK = new URLSearchParams(location.search).has('loopback');
+const RTC_CONFIG = {
+ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
+};
+
/* ============================================================== *
* tiny utils *
* ============================================================== */
@@ -350,11 +383,10 @@ function b64(buf) {
function unb64(s) { return Uint8Array.from(atob(s), c => c.charCodeAt(0)); }
function hex(buf) {
const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
- return Array.from(u8).map(b => b.toString(16).padStart(2,'0')).join('');
+ return Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join('');
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
-/* CRC-32 (IEEE 802.3 polynomial 0xEDB88320, init 0xFFFFFFFF, xorout 0xFFFFFFFF) */
const CRC32_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
@@ -369,6 +401,10 @@ function crc32(bytes) {
for (let i = 0; i < bytes.length; i++) c = CRC32_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
+function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, ch =>
+ ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch]));
+}
/* ============================================================== *
* logging *
@@ -384,95 +420,71 @@ function logLine(kind, html) {
}
/* ============================================================== *
- * crypto: passphrase mode (PBKDF2 → AES-GCM) *
+ * crypto *
* ============================================================== */
-let groupKey = null; /* in passphrase mode */
-let myPriv = null; /* in pubkey mode */
-let myPubB64 = null; /* in pubkey mode */
-let peers = new Map(); /* senderId(hex) → { handle, sharedKey, lastSeen, maxBaud } */
+let groupKey = null; /* passphrase mode */
+let myPriv = null; /* pubkey mode */
+let myPubB64 = null; /* pubkey mode */
+let peers = new Map(); /* sid(hex) → { handle, sharedKey, maxBaud } */
async function deriveGroupKey(phrase) {
const baseKey = await crypto.subtle.importKey(
- 'raw', new TextEncoder().encode(phrase),
- 'PBKDF2', false, ['deriveKey']
- );
+ 'raw', new TextEncoder().encode(phrase), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: PBKDF2_SALT, iterations: PBKDF2_ITER, hash: 'SHA-256' },
- baseKey,
- { name: 'AES-GCM', length: 256 },
- false, ['encrypt', 'decrypt']
- );
+ baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}
-/* ============================================================== *
- * crypto: pubkey mode (ECDH P-256 → AES-GCM) *
- * ============================================================== */
const LS_PRIV = 'zebra_chat_privkey';
const LS_PUB = 'zebra_chat_pubkey';
const LS_HANDLE = 'zebra_chat_handle';
async function genKeypair() {
const kp = await crypto.subtle.generateKey(
- { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits']
- );
+ { 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) {
+ const p = localStorage.getItem(LS_PRIV), pub = localStorage.getItem(LS_PUB);
+ if (p && pub) {
try {
const privateKey = await crypto.subtle.importKey(
- 'jwk', JSON.parse(privStr),
- { name: 'ECDH', namedCurve: 'P-256' },
- true, ['deriveKey', 'deriveBits']
- );
- return { privateKey, pubB64: pubStr };
+ 'jwk', JSON.parse(p), { name: 'ECDH', namedCurve: 'P-256' },
+ true, ['deriveKey', 'deriveBits']);
+ return { privateKey, pubB64: pub };
} catch (_) {}
}
return genKeypair();
}
-
async function deriveShared(myPrivKey, peerPubB64) {
const peerPub = await crypto.subtle.importKey(
- 'raw', unb64(peerPubB64),
- { name: 'ECDH', namedCurve: 'P-256' },
- false, []
- );
+ 'raw', unb64(peerPubB64), { name: 'ECDH', namedCurve: 'P-256' }, false, []);
return crypto.subtle.deriveKey(
- { name: 'ECDH', public: peerPub },
- myPrivKey,
- { name: 'AES-GCM', length: 256 },
- false, ['encrypt', 'decrypt']
- );
+ { name: 'ECDH', public: peerPub }, myPrivKey,
+ { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}
-
-async function aesEncrypt(key, plaintextStr) {
+async function aesEncrypt(key, str) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await crypto.subtle.encrypt(
- { name: 'AES-GCM', iv }, key, new TextEncoder().encode(plaintextStr)
- ));
+ { name: 'AES-GCM', iv }, key, new TextEncoder().encode(str)));
const out = new Uint8Array(12 + ct.length);
out.set(iv); out.set(ct, 12);
return out;
}
async function aesDecrypt(key, bytes) {
- const iv = bytes.slice(0, 12);
- const ct = bytes.slice(12);
+ const iv = bytes.slice(0, 12), ct = bytes.slice(12);
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
return new TextDecoder().decode(pt);
}
/* ============================================================== *
- * sender id: 4-byte digest of identity material *
+ * sender id *
* ============================================================== */
async function senderIdBytes() {
- /* in passphrase mode: random-per-tab; in pubkey mode: first 4 of SHA-256(pub) */
if (currentMode() === 'pubkey' && myPubB64) {
const h = await crypto.subtle.digest('SHA-256', unb64(myPubB64));
return new Uint8Array(h).slice(0, 4);
@@ -489,21 +501,17 @@ async function senderIdBytes() {
function xorChecksum(bytes) {
let x = 0; for (const b of bytes) x ^= b; return x & 0xFF;
}
-
function buildHandshakeFrame(type, baudVal) {
- /* magic(2) + type(1) + baud_le(2) + xor(1) = 6 bytes */
const b = new Uint8Array(HS_FRAME_LEN);
b[0] = HS_MAGIC[0]; b[1] = HS_MAGIC[1]; b[2] = type;
b[3] = baudVal & 0xFF; b[4] = (baudVal >> 8) & 0xFF;
b[5] = xorChecksum(b.slice(0, 5));
return b;
}
-
async function buildDataFrame(payloadBytes) {
- /* magic(2) + T_DATA + sender(4) + len_le(2) + payload(n) + crc32_le(4) */
const sid = await senderIdBytes();
const len = payloadBytes.length;
- const head = new Uint8Array(2 + 1 + 4 + 2);
+ const head = new Uint8Array(9);
head[0] = HS_MAGIC[0]; head[1] = HS_MAGIC[1]; head[2] = T_DATA;
head.set(sid, 3);
head[7] = len & 0xFF; head[8] = (len >> 8) & 0xFF;
@@ -512,18 +520,14 @@ async function buildDataFrame(payloadBytes) {
const crc = crc32(body);
const out = new Uint8Array(body.length + 4);
out.set(body);
- out[body.length+0] = crc & 0xFF;
- out[body.length+1] = (crc >> 8) & 0xFF;
- out[body.length+2] = (crc >> 16) & 0xFF;
- out[body.length+3] = (crc >> 24) & 0xFF;
+ out[body.length+0] = crc & 0xFF; out[body.length+1] = (crc >> 8) & 0xFF;
+ out[body.length+2] = (crc >> 16) & 0xFF; out[body.length+3] = (crc >> 24) & 0xFF;
return out;
}
-
async function buildHelloFrame(handle, maxBaud) {
- /* magic(2) + T_HELLO + sender(4) + maxBaud_le(2) + handleLen(1) + handle(n) + crc(4) */
const sid = await senderIdBytes();
const hb = new TextEncoder().encode(handle.slice(0, 40));
- const body = new Uint8Array(3 + 4 + 2 + 1 + hb.length);
+ const body = new Uint8Array(10 + hb.length);
body[0] = HS_MAGIC[0]; body[1] = HS_MAGIC[1]; body[2] = T_HELLO;
body.set(sid, 3);
body[7] = maxBaud & 0xFF; body[8] = (maxBaud >> 8) & 0xFF;
@@ -532,13 +536,10 @@ async function buildHelloFrame(handle, maxBaud) {
const crc = crc32(body);
const out = new Uint8Array(body.length + 4);
out.set(body);
- out[body.length+0] = crc & 0xFF;
- out[body.length+1] = (crc >> 8) & 0xFF;
- out[body.length+2] = (crc >> 16) & 0xFF;
- out[body.length+3] = (crc >> 24) & 0xFF;
+ out[body.length+0] = crc & 0xFF; out[body.length+1] = (crc >> 8) & 0xFF;
+ out[body.length+2] = (crc >> 16) & 0xFF; out[body.length+3] = (crc >> 24) & 0xFF;
return out;
}
-
function parseFrame(bytes) {
if (bytes.length < 3) return null;
if (bytes[0] !== HS_MAGIC[0] || bytes[1] !== HS_MAGIC[1]) return null;
@@ -556,9 +557,8 @@ function parseFrame(bytes) {
if (bytes.length < 9 + len + 4) return null;
const payload = bytes.slice(9, 9 + len);
const frameNoCrc = bytes.slice(0, 9 + len);
- const got = bytes[9+len] | (bytes[9+len+1] << 8) | (bytes[9+len+2] << 16) | (bytes[9+len+3] << 24);
- const calc = crc32(frameNoCrc);
- if ((got >>> 0) !== (calc >>> 0)) return { type, error: 'crc' };
+ const got = bytes[9+len] | (bytes[9+len+1] << 8) | (bytes[9+len+2] << 16) | (bytes[9+len+3] << 24);
+ if ((got >>> 0) !== crc32(frameNoCrc)) return { type, error: 'crc' };
return { type, sid, payload };
}
if (type === T_HELLO) {
@@ -569,49 +569,26 @@ function parseFrame(bytes) {
if (bytes.length < 10 + hlen + 4) return null;
const handle = new TextDecoder().decode(bytes.slice(10, 10 + hlen));
const frameNoCrc = bytes.slice(0, 10 + hlen);
- const got = bytes[10+hlen] | (bytes[10+hlen+1] << 8) | (bytes[10+hlen+2] << 16) | (bytes[10+hlen+3] << 24);
- const calc = crc32(frameNoCrc);
- if ((got >>> 0) !== (calc >>> 0)) return { type, error: 'crc' };
+ const got = bytes[10+hlen] | (bytes[10+hlen+1] << 8) | (bytes[10+hlen+2] << 16) | (bytes[10+hlen+3] << 24);
+ if ((got >>> 0) !== crc32(frameNoCrc)) return { type, error: 'crc' };
return { type, sid, maxBaud, handle };
}
return null;
}
/* ============================================================== *
- * audio carrier + gain modulator *
+ * audio carrier + outbound RTC stream *
* ============================================================== */
-let audioCtx = null;
+let audioCtx = null;
+let oscL = null, oscR = null;
let gainL = null, gainR = null;
+let merger = null;
+let monitorGain = null; /* local audible (off by default) */
+let txStreamDest = null; /* outbound media track for RTC */
+let outboundStream = null;
let carrierOn = false;
let txBusy = false;
-let txQueue = []; /* { bytes, baud } */
-
-function _scheduleGain(value, atTime) {
- gainL.gain.setValueAtTime(value, atTime);
- gainR.gain.setValueAtTime(value, atTime);
-}
-
-async function startCarrier() {
- if (carrierOn) return;
- audioCtx = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'playback' });
- if (audioCtx.state === 'suspended') await audioCtx.resume();
- const oscL = audioCtx.createOscillator();
- const oscR = audioCtx.createOscillator();
- oscL.type = 'sine'; oscR.type = 'sine';
- oscL.frequency.value = 440; oscR.frequency.value = 441;
- gainL = audioCtx.createGain();
- gainR = audioCtx.createGain();
- gainL.gain.value = ZEBRA_VOL_MARK;
- gainR.gain.value = ZEBRA_VOL_MARK;
- const merger = audioCtx.createChannelMerger(2);
- oscL.connect(gainL); gainL.connect(merger, 0, 0);
- oscR.connect(gainR); gainR.connect(merger, 0, 1);
- merger.connect(audioCtx.destination);
- oscL.start(); oscR.start();
- carrierOn = true;
- /* live gain meter */
- _meterTick();
-}
+let txQueue = [];
const meterFill = $('meter-tx');
const gainLabel = $('gain-label');
@@ -623,8 +600,210 @@ function _meterTick() {
requestAnimationFrame(_meterTick);
}
-/* transmit a byte array via volume modulation.
- * frame layout per bit: SPACE(start) + 8 data bits LSB first + MARK(stop). */
+function _scheduleGain(v, t) {
+ gainL.gain.setValueAtTime(v, t);
+ gainR.gain.setValueAtTime(v, t);
+}
+
+async function startCarrier() {
+ if (carrierOn) return;
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'interactive' });
+ if (audioCtx.state === 'suspended') await audioCtx.resume();
+ oscL = audioCtx.createOscillator(); oscR = audioCtx.createOscillator();
+ oscL.type = 'sine'; oscL.frequency.value = 440;
+ oscR.type = 'sine'; oscR.frequency.value = 441;
+ gainL = audioCtx.createGain(); gainR = audioCtx.createGain();
+ gainL.gain.value = ZEBRA_VOL_MARK;
+ gainR.gain.value = ZEBRA_VOL_MARK;
+ merger = audioCtx.createChannelMerger(2);
+ oscL.connect(gainL); gainL.connect(merger, 0, 0);
+ oscR.connect(gainR); gainR.connect(merger, 0, 1);
+
+ /* outbound: route to RTC media stream destination */
+ txStreamDest = audioCtx.createMediaStreamDestination();
+ merger.connect(txStreamDest);
+ outboundStream = txStreamDest.stream;
+
+ /* local monitor (muted by default) */
+ monitorGain = audioCtx.createGain();
+ monitorGain.gain.value = 0;
+ merger.connect(monitorGain);
+ monitorGain.connect(audioCtx.destination);
+
+ oscL.start(); oscR.start();
+ carrierOn = true;
+ _meterTick();
+ await setupWorkletDecoder();
+}
+
+$('monitor-toggle').addEventListener('change', (e) => {
+ if (monitorGain) monitorGain.gain.value = e.target.checked ? 0.04 : 0;
+});
+
+/* ============================================================== *
+ * AudioWorklet: per-quantum peak detector *
+ * ============================================================== */
+const WORKLET_CODE = `
+class EnergyDetector extends AudioWorkletProcessor {
+ process(inputs) {
+ const input = inputs[0];
+ if (!input || !input.length || !input[0]) return true;
+ const ch = input[0];
+ let peak = 0;
+ for (let i = 0; i < ch.length; i++) {
+ const a = Math.abs(ch[i]);
+ if (a > peak) peak = a;
+ }
+ this.port.postMessage(peak);
+ return true;
+ }
+}
+registerProcessor('energy-detector', EnergyDetector);
+`;
+
+async function setupWorkletDecoder() {
+ const url = URL.createObjectURL(new Blob([WORKLET_CODE], { type: 'application/javascript' }));
+ try {
+ await audioCtx.audioWorklet.addModule(url);
+ } catch (e) {
+ logLine('err', 'audio worklet add failed: ' + e.message);
+ return;
+ }
+}
+
+/* one decoder per inbound RTC track (peer) */
+const rxDecoders = new Map(); /* trackId → { uart, asm, node } */
+
+class UartDecoder {
+ constructor(baud, sampleRate) {
+ this.sampleRate = sampleRate;
+ this.setBaud(baud);
+ this.state = 'hunt';
+ this.byte = 0; this.bitIdx = 0; this.curSample = 0;
+ this.prevAbove = true;
+ this.peak = 0;
+ this.peakDecay = Math.pow(0.5, 1.0 / (2.0 * sampleRate)); /* 2s half-life */
+ }
+ setBaud(b) { this.baud = b; this.sps = this.sampleRate / b; }
+ push(e) {
+ this.peak *= this.peakDecay;
+ if (e > this.peak) this.peak = e;
+ if (this.peak < 0.02) { this.state = 'hunt'; this.prevAbove = true; return null; }
+ const thr = this.peak * 0.50;
+ const above = e >= thr;
+ if (this.state === 'hunt') {
+ if (this.prevAbove && !above) {
+ this.state = 'sample'; this.curSample = 1; this.byte = 0; this.bitIdx = 0;
+ }
+ } else {
+ this.curSample++;
+ const center = (1.5 + this.bitIdx) * this.sps;
+ if (this.curSample >= center) {
+ const bit = above ? 1 : 0;
+ this.byte |= (bit << this.bitIdx);
+ this.bitIdx++;
+ if (this.bitIdx === 8) {
+ const out = this.byte;
+ this.state = 'hunt'; this.prevAbove = true;
+ return out;
+ }
+ }
+ }
+ this.prevAbove = above;
+ return null;
+ }
+}
+
+class FrameAssembler {
+ constructor() { this.buf = []; }
+ push(byte) {
+ this.buf.push(byte);
+ while (this.buf.length >= 2 && (this.buf[0] !== HS_MAGIC[0] || this.buf[1] !== HS_MAGIC[1])) {
+ this.buf.shift();
+ }
+ if (this.buf.length < 3) return null;
+ const t = this.buf[2];
+ if (t === T_OFFER || t === T_READY) {
+ if (this.buf.length < 6) return null;
+ const frame = new Uint8Array(this.buf.slice(0, 6));
+ this.buf.splice(0, 6);
+ return frame;
+ }
+ if (t === T_DATA) {
+ if (this.buf.length < 9) return null;
+ const ln = this.buf[7] | (this.buf[8] << 8);
+ if (ln > 512) { this.buf.splice(0, 2); return null; }
+ const need = 9 + ln + 4;
+ if (this.buf.length < need) return null;
+ const frame = new Uint8Array(this.buf.slice(0, need));
+ this.buf.splice(0, need);
+ return frame;
+ }
+ if (t === T_HELLO) {
+ if (this.buf.length < 10) return null;
+ const hlen = this.buf[9];
+ if (hlen > 64) { this.buf.splice(0, 2); return null; }
+ const need = 10 + hlen + 4;
+ if (this.buf.length < need) return null;
+ const frame = new Uint8Array(this.buf.slice(0, need));
+ this.buf.splice(0, need);
+ return frame;
+ }
+ this.buf.splice(0, 2);
+ return null;
+ }
+}
+
+const rxMeterFill = $('meter-rx');
+const rxLabel = $('rx-label');
+let lastRxPeak = 0;
+function _rxMeterTick() {
+ rxMeterFill.style.width = Math.round(lastRxPeak * 100) + '%';
+ rxLabel.textContent = lastRxPeak > 0.001
+ ? Math.round(lastRxPeak * 100) + '%' : '—';
+ requestAnimationFrame(_rxMeterTick);
+}
+requestAnimationFrame(_rxMeterTick);
+
+async function attachInboundTrack(stream, trackId) {
+ if (!audioCtx) return;
+ /* must attach to a (muted) audio element for the WebRTC stack to deliver */
+ const a = new Audio();
+ a.srcObject = stream;
+ a.muted = true;
+ a.autoplay = true;
+ try { await a.play(); } catch (_) {}
+
+ const src = audioCtx.createMediaStreamSource(stream);
+ const node = new AudioWorkletNode(audioCtx, 'energy-detector');
+ src.connect(node);
+ /* effective decoder rate: sampleRate / 128 (one peak per audio quantum) */
+ const effRate = audioCtx.sampleRate / 128;
+ const uart = new UartDecoder(ZEBRA_BAUD_HANDSHAKE, effRate);
+ const asm = new FrameAssembler();
+ node.port.onmessage = (ev) => {
+ const peak = ev.data;
+ lastRxPeak = peak;
+ const byte = uart.push(peak);
+ if (byte === null) return;
+ const frame = asm.push(byte);
+ if (frame) onRxFrame(frame);
+ };
+ rxDecoders.set(trackId, { uart, asm, node, audio: a });
+ logLine('sys', 'inbound track attached — decoder live');
+}
+
+function detachInboundTrack(trackId) {
+ const d = rxDecoders.get(trackId);
+ if (!d) return;
+ try { d.node.disconnect(); } catch (_) {}
+ try { d.audio.pause(); d.audio.srcObject = null; } catch (_) {}
+ rxDecoders.delete(trackId);
+}
+
+/* ============================================================== *
+ * transmit *
+ * ============================================================== */
async function txFrame(bytes, baud) {
if (!carrierOn) throw new Error('carrier not started');
txQueue.push({ bytes, baud });
@@ -636,9 +815,7 @@ async function txFrame(bytes, baud) {
await _txOne(job.bytes, job.baud);
if (loopChan) {
try {
- const ab = job.bytes.buffer.slice(
- job.bytes.byteOffset,
- job.bytes.byteOffset + job.bytes.byteLength);
+ const ab = job.bytes.buffer.slice(job.bytes.byteOffset, job.bytes.byteOffset + job.bytes.byteLength);
loopChan.postMessage(ab);
} catch (_) {}
}
@@ -650,49 +827,31 @@ async function txFrame(bytes, baud) {
async function _txOne(bytes, baud) {
const period = 1.0 / baud;
- let t = audioCtx.currentTime + 0.02; /* small lead-in */
- /* MARK preamble — 10 symbols at this baud */
- for (let i = 0; i < 10; i++) {
+ let t = audioCtx.currentTime + 0.02;
+ for (let i = 0; i < 10; i++) { _scheduleGain(ZEBRA_VOL_MARK, t); t += period; }
+ for (const byte of bytes) {
+ _scheduleGain(ZEBRA_VOL_SPACE, t); t += period;
+ for (let bit = 0; bit < 8; bit++) {
+ _scheduleGain(((byte >> bit) & 1) ? ZEBRA_VOL_MARK : ZEBRA_VOL_SPACE, t);
+ t += period;
+ }
_scheduleGain(ZEBRA_VOL_MARK, t); t += period;
}
- for (const byte of bytes) {
- _scheduleGain(ZEBRA_VOL_SPACE, t); t += period; /* start bit */
- for (let bit = 0; bit < 8; bit++) {
- const v = ((byte >> bit) & 1) ? ZEBRA_VOL_MARK : ZEBRA_VOL_SPACE;
- _scheduleGain(v, t); t += period;
- }
- _scheduleGain(ZEBRA_VOL_MARK, t); t += period; /* stop bit */
- }
- /* trailer */
for (let i = 0; i < 5; i++) { _scheduleGain(ZEBRA_VOL_MARK, t); t += period; }
- /* return to idle */
_scheduleGain(ZEBRA_VOL_MARK, t);
- /* sleep until scheduled time has elapsed (best-effort) */
- const waitMs = Math.max(0, (t - audioCtx.currentTime) * 1000) + 30;
- await sleep(waitMs);
+ await sleep(Math.max(0, (t - audioCtx.currentTime) * 1000) + 30);
}
-/* ============================================================== *
- * benchmark: how fast can WE schedule gain changes? *
- * ============================================================== */
async function benchmarkSelf() {
if (!carrierOn) await startCarrier();
const N = 200;
const t0 = performance.now();
let t = audioCtx.currentTime;
- for (let i = 0; i < N; i++) {
- _scheduleGain(i & 1 ? ZEBRA_VOL_MARK : ZEBRA_VOL_SPACE, t);
- t += 0.0005;
- }
- /* let it drain */
+ for (let i = 0; i < N; i++) { _scheduleGain(i & 1 ? ZEBRA_VOL_MARK : ZEBRA_VOL_SPACE, t); t += 0.0005; }
await sleep(Math.max(0, (t - audioCtx.currentTime) * 1000));
const t1 = performance.now();
- /* settle back to MARK */
_scheduleGain(ZEBRA_VOL_MARK, audioCtx.currentTime + 0.01);
- const elapsedMs = t1 - t0;
- const avgUs = (elapsedMs * 1000) / N;
- /* practical baud: cap at 1 / (avg_seconds_per_symbol).
- * keep generous safety margin (2x) for jitter. */
+ const avgUs = (t1 - t0) * 1000 / N;
const rawBaud = 1e6 / avgUs;
let baud = Math.floor(rawBaud / 2);
baud = Math.max(ZEBRA_BAUD_MIN, Math.min(ZEBRA_BAUD_MAX, baud));
@@ -704,154 +863,87 @@ async function benchmarkSelf() {
* ============================================================== */
let myMaxBaud = null;
let negotiatedBaud = null;
-const hsState = $('hs-status');
const baudLabel = $('baud-label');
-
function recomputeGroupBaud() {
let m = myMaxBaud || ZEBRA_BAUD_DEFAULT;
- for (const p of peers.values()) {
- if (p.maxBaud && p.maxBaud < m) m = p.maxBaud;
- }
+ for (const p of peers.values()) if (p.maxBaud && p.maxBaud < m) m = p.maxBaud;
negotiatedBaud = m;
baudLabel.textContent = m + ' baud';
}
/* ============================================================== *
- * introspector bridge (RX path) + dev loopback *
+ * loopback (same-browser dev shortcut, ?loopback=1) *
* ============================================================== */
-let ws = null;
-let wsReconnectTimer = null;
let loopChan = null;
if (LOOPBACK) {
loopChan = new BroadcastChannel('zebra-report-loopback');
loopChan.onmessage = (ev) => {
- const buf = ev.data instanceof ArrayBuffer
- ? new Uint8Array(ev.data) : new Uint8Array(ev.data);
- onRxFrame(buf);
- };
-}
-const dotRx = $('dot-rx');
-const rxStatus = $('rx-status');
-
-function rxConnect(manual) {
- if (ws) try { ws.close(); } catch (_) {}
- try {
- ws = new WebSocket(WS_URL);
- } catch (e) {
- rxStatus.textContent = 'cannot construct websocket: ' + e.message;
- return;
- }
- ws.binaryType = 'arraybuffer';
- rxStatus.textContent = 'connecting…';
- dotRx.className = 'dot warn';
- ws.onopen = () => {
- rxStatus.textContent = 'connected to zebrad — RX live';
- dotRx.className = 'dot on';
- };
- ws.onclose = (ev) => {
- rxStatus.textContent = 'disconnected (' + ev.code + ') — ' +
- (manual ? 'press connect to retry' : 'retrying…');
- dotRx.className = 'dot';
- if (!manual) wsReconnectTimer = setTimeout(() => rxConnect(false), 5000);
- };
- ws.onerror = () => {
- rxStatus.textContent = 'no introspector at ' + WS_URL + ' — running TX-only';
- };
- ws.onmessage = (ev) => {
- /* zebrad pushes decoded frames as raw binary bytes */
- const buf = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : null;
- if (!buf) return;
+ const buf = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array(ev.data);
onRxFrame(buf);
};
}
+/* ============================================================== *
+ * frame handler *
+ * ============================================================== */
async function onRxFrame(bytes) {
const f = parseFrame(bytes);
if (!f) return;
- if (f.error) {
- logLine('err', `rx: malformed frame (type=${f.type}, ${f.error})`);
- return;
- }
- /* same-machine PA mix echoes our own carrier back to us. drop any frame
- * whose sender id matches our own so we don't double-render. loopback
- * mode is unaffected because BroadcastChannel never echoes to sender. */
+ if (f.error) { logLine('err', `rx: malformed (${f.type}, ${f.error})`); return; }
+ /* drop self-echoes (own sid) */
if (f.sid) {
const mine = await senderIdBytes();
- let isSelf = true;
- for (let i = 0; i < 4; i++) if (f.sid[i] !== mine[i]) { isSelf = false; break; }
- if (isSelf) return;
+ let self = true;
+ for (let i = 0; i < 4; i++) if (f.sid[i] !== mine[i]) { self = false; break; }
+ if (self) return;
}
if (f.type === T_OFFER) {
logLine('sys', `rx: OFFER from peer — ${f.baud} baud`);
- /* respond with READY at handshake baud */
await txFrame(buildHandshakeFrame(T_READY, myMaxBaud || ZEBRA_BAUD_DEFAULT), ZEBRA_BAUD_HANDSHAKE);
- /* peer baud noted; will recompute on HELLO */
return;
}
if (f.type === T_READY) {
logLine('sys', `rx: READY from peer — ${f.baud} baud`);
- /* peer told us their max baud; treat it as their hello-baud */
- /* defer further until HELLO arrives carrying handle + sid */
return;
}
if (f.type === T_HELLO) {
const sid = hex(f.sid);
- const existed = peers.has(sid);
+ const seen = peers.has(sid);
let entry = peers.get(sid) || {};
- entry.handle = f.handle;
- entry.maxBaud = f.maxBaud;
- entry.lastSeen = Date.now();
+ entry.handle = f.handle; entry.maxBaud = f.maxBaud;
peers.set(sid, entry);
- recomputeGroupBaud();
- renderPeerList();
- logLine('sys', existed ?
- `peer ${escapeHtml(f.handle)} updated (baud ${f.maxBaud})` :
- `peer ${escapeHtml(f.handle)} joined (baud ${f.maxBaud}, sid ${sid.slice(0,8)})`);
- /* on first contact, send our own HELLO too so they learn us */
- if (!existed) await announceHello();
+ recomputeGroupBaud(); renderPeerList();
+ logLine('sys', seen
+ ? `peer ${escapeHtml(f.handle)} updated (baud ${f.maxBaud})`
+ : `peer ${escapeHtml(f.handle)} joined (baud ${f.maxBaud}, sid ${sid.slice(0,8)})`);
+ if (!seen) await announceHello();
return;
}
if (f.type === T_DATA) {
const sid = hex(f.sid);
let pt = null;
if (currentMode() === 'passphrase') {
- if (!groupKey) { logLine('err', 'rx: data frame but no room key'); return; }
- try { pt = await aesDecrypt(groupKey, f.payload); }
- catch (e) { logLine('err', `rx: decrypt failed (${e.message})`); return; }
+ if (!groupKey) { logLine('err', 'rx: data but no room key'); return; }
+ try { pt = await aesDecrypt(groupKey, f.payload); } catch (e) { logLine('err', `rx: decrypt failed (${e.message})`); return; }
} else {
- /* pubkey mode: try each peer's shared key */
- let entry = peers.get(sid);
+ const entry = peers.get(sid);
if (entry && entry.sharedKey) {
- try { pt = await aesDecrypt(entry.sharedKey, f.payload); }
- catch (_) { pt = null; }
- }
- if (pt === null) {
- logLine('err', `rx: data from sid=${sid.slice(0,8)} (no key for this peer)`);
- return;
+ try { pt = await aesDecrypt(entry.sharedKey, f.payload); } catch (_) {}
}
+ if (pt === null) { logLine('err', `rx: data from ${sid.slice(0,8)} (no key)`); return; }
}
const handle = (peers.get(sid) || {}).handle || sid.slice(0,8);
logLine('peer', `${escapeHtml(handle)}: ${escapeHtml(pt)}`);
- return;
}
}
-function escapeHtml(s) {
- return String(s).replace(/[&<>"']/g, ch => ({
- '&':'&','<':'<','>':'>','"':'"',"'":'''
- }[ch]));
-}
-
/* ============================================================== *
* peer list render *
* ============================================================== */
function renderPeerList() {
const el = $('peer-list');
el.innerHTML = '';
- if (peers.size === 0) {
- el.innerHTML = '