From 695b6af58ff3476f2c86fe81d96425870dcbc4cb Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Wed, 27 May 2026 15:47:09 -0400 Subject: [PATCH] zebra-report: sync chat page, add ?loopback=1 dev mode mirrors web/chat.html from the zebra-report repo. adds a BroadcastChannel-based loopback gated on ?loopback=1 so two tabs of a same browser can round-trip frames without zebrad, useful for UX validation while a real PulseAudio introspector daemon stays in progress. yellow warning banner makes dev mode obvious when active. --- zebra-report/index.html | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/zebra-report/index.html b/zebra-report/index.html index 2589742..9f17a8c 100644 --- a/zebra-report/index.html +++ b/zebra-report/index.html @@ -244,9 +244,8 @@ 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 a WebSocket wrapper around it. + recovered rx.c handles + decoding; zebrad is the WebSocket wrapper around it.

@@ -335,6 +334,10 @@ 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'); + /* ============================================================== * * tiny utils * * ============================================================== */ @@ -631,6 +634,14 @@ async function txFrame(bytes, baud) { while (txQueue.length) { const job = txQueue.shift(); await _txOne(job.bytes, job.baud); + if (loopChan) { + try { + const ab = job.bytes.buffer.slice( + job.bytes.byteOffset, + job.bytes.byteOffset + job.bytes.byteLength); + loopChan.postMessage(ab); + } catch (_) {} + } } } finally { txBusy = false; @@ -706,10 +717,19 @@ function recomputeGroupBaud() { } /* ============================================================== * - * introspector bridge (RX path) * + * introspector bridge (RX path) + dev loopback * * ============================================================== */ 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'); @@ -1054,6 +1074,19 @@ setInterval(updateSendButton, 1000); /* try to find the introspector; quiet failure */ rxConnect(false); +if (LOOPBACK) { + const banner = document.createElement('div'); + banner.style.cssText = + 'background:#fc0;color:#000;padding:0.6rem 1rem;text-align:center;' + + 'font-size:0.8rem;font-weight:bold;border:1px solid #000;' + + 'margin-bottom:1.2rem;letter-spacing:0.03em'; + banner.textContent = + 'dev loopback active — frames cross via BroadcastChannel between ' + + 'same-browser tabs, NOT via PulseAudio. drop ?loopback=1 from the URL for production.'; + document.body.insertBefore(banner, document.body.firstChild); + logLine('sys', 'loopback mode: frames will echo to other tabs of this same browser.'); +} + logLine('sys', 'ready — start the carrier, derive a room key, then send.'); logLine('sys', 'no chat content ever enters an IP packet. wireshark blind.');