diff --git a/web/chat.html b/web/chat.html
index f368240..a6e9313 100644
--- a/web/chat.html
+++ b/web/chat.html
@@ -334,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 *
* ============================================================== */
@@ -630,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;
@@ -705,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');
@@ -1053,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.');