Fox 2026-06-07 telemetry on blanka-chrome ruled out the sink-routing
hypothesis from 467146a:
audio via AudioContext 085b target=0.5s ctxState=running sink=default
mesh stream swapped into worklet for 085b
...
aud.recv pkt=14622 lost=1 bytes=9117575 jitter=0.0100 level=0.000 jbuf=? lp=0.1s
50 pps stereo Opus arriving on the SFU sub PC, sink routed to the
picked device, but jbuf=? (jitterBufferEmittedCount=0) + level=0 — the
native decoder consumes nothing because no MediaStreamSource is bound
to the SFU receiver's track. fxhp-phone same room same window decodes
fine (jbuf=0.49s level=0.001), so the SFU is healthy and the defect is
per-client wiring.
Two coupled defects:
(A) tracks=0 race in handleRemoteSfuTrack mic branch. Chrome can fire
ontrack with ev.streams[0] still empty at handler-time (the live track
arrives a microtask later, or MSID-supplant merges new+dead tracks).
Caching ev.streams[0] then handing it to attachSfuTrack drops the
listener into the silent <audio> fallback — telemetry: "sfu attach
b1a7 fresh=1 tracks=0" + "meter for b1a7: MediaStream has no audio
track". Screen/camera/game already wrap ev.track in a fresh
MediaStream; mic now does the same. Single track, guaranteed live,
every time.
(B) Mesh ontrack unconditionally swaps the worklet's source to the
mesh stream — even when the mesh track is still muted (no RTP). The
SFU receiver becomes orphaned (no decoder), and if the mesh track
never unmutes the listener hears silence with no automatic SFU
restore. Gate the swap on track-not-muted: swap immediately if mesh
is already flowing, otherwise wait for 'unmute'. Add a mute-watchdog
that swaps back to the cached SFU stream after MESH_MUTE_WINDOW_MS
of mesh silence — same shape as the existing
connectionState=='failed' restore path, but driven by track-level
mute instead of PC-level failure.
Hard refresh + leave/enter worked because the fresh negotiation
delivered the mesh track already unmuted, so the eager swap landed
on a live source. Now the same swap waits for live source instead of
hoping for one.
Tests:
- handleRemoteSfuTrack: ev.streams[0] empty but ev.track live →
fresh MediaStream wrap rescues the attach (chain built, audible
path verified)
Ticket 0001 updated with the telemetry comparison and the new
diagnosis. Mesh-mute-watchdog has no dedicated test yet (the mesh
ontrack lives in connectToPeer which the listener test harness
doesn't extract) — pin in multi-peer-mesh.test.js next pass.
1775 lines
71 KiB
HTML
1775 lines
71 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>zebra report — chat</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: none; margin: 0;
|
|
}
|
|
/* two-panel layout: setup | chat, 50:50 on desktop, stacked on phone */
|
|
.app {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1.75rem;
|
|
align-items: start;
|
|
}
|
|
.panel { min-width: 0; }
|
|
@media (max-width: 760px) {
|
|
.app { grid-template-columns: 1fr; }
|
|
}
|
|
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: grid;
|
|
grid-auto-flow: column;
|
|
grid-auto-columns: minmax(0, max-content);
|
|
align-items: center; gap: 0.75rem; margin-bottom: 0.75rem;
|
|
min-width: 0;
|
|
}
|
|
.row:has(> :first-child + input[type=text]),
|
|
.row:has(> :first-child + input[type=password]),
|
|
.row:has(> :first-child + select) {
|
|
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
|
|
}
|
|
.row:has(> input[type=text]:first-child),
|
|
.row:has(> input[type=password]:first-child),
|
|
.row:has(> select:first-child) {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
.row > .note {
|
|
grid-column: 1 / -1;
|
|
margin-top: 0.05rem; line-height: 1.4;
|
|
}
|
|
.row > label { white-space: normal; }
|
|
.dot {
|
|
width: 9px; height: 9px; border-radius: 50%;
|
|
border: 1px solid #000; background: #fff;
|
|
transition: background 0.15s;
|
|
}
|
|
.dot.on { background: #000; }
|
|
.dot.warn { background: #888; }
|
|
.dot.ok { background: #060; border-color: #060; }
|
|
label, .note { font-size: 0.75rem; color: #555; }
|
|
.note { margin-top: 0.4rem; line-height: 1.5; }
|
|
input[type=text], input[type=password], textarea, select {
|
|
font-family: monospace; font-size: 0.85rem;
|
|
border: 1px solid #000; padding: 0.4rem;
|
|
background: #fff; color: #000;
|
|
}
|
|
input[type=text], input[type=password], select { width: 100%; }
|
|
textarea { width: 100%; resize: vertical; min-height: 4.5rem; }
|
|
.field-row {
|
|
display: grid; grid-template-columns: minmax(0, 1fr) max-content;
|
|
gap: 0.5rem; margin-bottom: 0.5rem;
|
|
}
|
|
.field-row > input, .field-row > textarea { width: 100%; min-width: 0; }
|
|
.key-display {
|
|
border: 1px solid #ccc; padding: 0.5rem;
|
|
font-family: monospace; font-size: 0.7rem;
|
|
word-break: break-all; cursor: pointer;
|
|
user-select: all; background: #f8f8f8;
|
|
line-height: 1.4;
|
|
}
|
|
.key-display:hover { background: #efefef; }
|
|
.status-line { font-size: 0.7rem; color: #555; }
|
|
.status-line.ok { color: #060; }
|
|
.status-line.err { color: #b00; }
|
|
.meter {
|
|
height: 12px; border: 1px solid #000; background: #fff;
|
|
position: relative; overflow: hidden;
|
|
}
|
|
.meter-fill {
|
|
height: 100%; background: #000; width: 0%;
|
|
transition: width 0.05s linear;
|
|
}
|
|
.log {
|
|
border: 1px solid #000; height: 280px; overflow-y: auto;
|
|
padding: 0.5rem; font-size: 0.78rem; line-height: 1.5;
|
|
background: #fafafa;
|
|
}
|
|
.log-line { margin-bottom: 0.25rem; word-wrap: break-word; }
|
|
.log-line .ts { color: #888; }
|
|
.log-line .from { font-weight: bold; }
|
|
.log-line.sys { color: #555; font-style: italic; }
|
|
.log-line.me { color: #000; }
|
|
.log-line.peer { color: #050; }
|
|
.log-line.err { color: #b00; }
|
|
.peer-list {
|
|
font-size: 0.75rem; color: #444;
|
|
border: 1px solid #ccc; padding: 0.5rem;
|
|
background: #f8f8f8;
|
|
}
|
|
.peer-list .peer { margin-bottom: 0.15rem; }
|
|
.peer-list .peer .h { font-weight: bold; }
|
|
.peer-list .empty { color: #999; font-style: italic; }
|
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
|
@media (max-width: 600px) {
|
|
body { padding: 1.25rem; }
|
|
.grid-2 { grid-template-columns: 1fr; }
|
|
}
|
|
code {
|
|
background: #f0f0f0; padding: 0 0.2rem;
|
|
font-size: 0.78rem; border: 1px solid #ddd;
|
|
}
|
|
details > summary {
|
|
cursor: pointer; font-size: 0.8rem;
|
|
padding: 0.3rem 0; user-select: none;
|
|
}
|
|
details[open] > summary { margin-bottom: 0.5rem; }
|
|
.mode-toggle {
|
|
display: inline-grid; grid-auto-flow: column; grid-auto-columns: max-content;
|
|
border: 1px solid #000;
|
|
font-size: 0.78rem; user-select: none;
|
|
}
|
|
.mode-toggle > label {
|
|
padding: 0.35rem 0.8rem; cursor: pointer;
|
|
color: #000;
|
|
}
|
|
.mode-toggle > input { display: none; }
|
|
.mode-toggle > input:checked + label { background: #000; color: #fff; }
|
|
.mode-panel { display: none; }
|
|
.mode-panel.active { display: block; }
|
|
.rtc-role { margin-top: 0.6rem; padding: 0.5rem; border: 1px dashed #999; }
|
|
.rtc-role h3 { font-size: 0.85rem; margin-bottom: 0.4rem; font-weight: normal; font-family: monospace; }
|
|
.step { font-size: 0.7rem; color: #777; margin: 0.5rem 0 0.2rem; }
|
|
.share-box {
|
|
margin: 0.4rem 0;
|
|
display: grid;
|
|
grid-template-columns: 1fr auto;
|
|
gap: 0.5rem;
|
|
align-items: start;
|
|
}
|
|
.share-box .url-side { min-width: 0; }
|
|
.share-box .qr-canvas {
|
|
width: 320px; height: 320px;
|
|
border: 1px solid #ccc; background: #fff;
|
|
display: grid; place-items: center;
|
|
font-size: 0.65rem; color: #999;
|
|
}
|
|
.share-box input { width: 100%; font-size: 0.7rem; }
|
|
.share-box .copy-row {
|
|
display: grid; grid-template-columns: minmax(0, 1fr) max-content;
|
|
gap: 0.4rem; margin-top: 0.3rem;
|
|
}
|
|
.share-box .copy-row button { font-size: 0.75rem; padding: 0.25rem 0.7rem; }
|
|
@media (max-width: 600px) {
|
|
.share-box { grid-template-columns: 1fr; }
|
|
/* cap the QR canvas so it never pushes the page wider than the
|
|
* viewport on narrow phones (320px and up) */
|
|
.share-box .qr-canvas { justify-self: center; max-width: 100%; }
|
|
h1 { font-size: 2.2rem; }
|
|
}
|
|
|
|
/* ---- shared dark mode: pure black canvas, dim text, low light ---- */
|
|
html.theme-dark { background: #000; }
|
|
html.theme-dark body { background: #000; color: #ccc; }
|
|
html.theme-dark a { color: #9cf; }
|
|
html.theme-dark .sub, html.theme-dark .sub a, html.theme-dark .note,
|
|
html.theme-dark .foot, html.theme-dark label,
|
|
html.theme-dark .status-line { color: #888; }
|
|
html.theme-dark h2 { border-bottom-color: #333; }
|
|
html.theme-dark code, html.theme-dark .key-display {
|
|
background: #1a1a1a; border-color: #333; color: #ccc;
|
|
}
|
|
html.theme-dark .key-display:hover { background: #222; }
|
|
html.theme-dark button {
|
|
background: #000; color: #ccc; border-color: #444;
|
|
}
|
|
html.theme-dark button:hover:not(:disabled) { background: #1a1a1a; }
|
|
html.theme-dark button.invert, html.theme-dark .cta {
|
|
background: #ccc; color: #000; border-color: #ccc;
|
|
}
|
|
html.theme-dark button.invert:hover:not(:disabled),
|
|
html.theme-dark .cta:hover { background: #999; }
|
|
html.theme-dark input[type=text], html.theme-dark input[type=password],
|
|
html.theme-dark textarea, html.theme-dark select {
|
|
background: #0a0a0a; color: #ccc; border-color: #333;
|
|
}
|
|
html.theme-dark .dot { background: #000; border-color: #444; }
|
|
html.theme-dark .dot.on { background: #ccc; border-color: #ccc; }
|
|
html.theme-dark .dot.ok { background: #0a0; border-color: #0a0; }
|
|
html.theme-dark .meter { background: #000; border-color: #444; }
|
|
html.theme-dark .meter-fill { background: #ccc; }
|
|
html.theme-dark .status-line.ok { color: #6c6; }
|
|
html.theme-dark .status-line.err { color: #f66; }
|
|
html.theme-dark .log, html.theme-dark .diagram {
|
|
background: #050505; border-color: #333; color: #ccc;
|
|
}
|
|
html.theme-dark .log-line .ts { color: #666; }
|
|
html.theme-dark .log-line.sys { color: #888; font-style: italic; }
|
|
html.theme-dark .log-line.err { color: #f66; }
|
|
html.theme-dark hr { border-top-color: #222; }
|
|
html.theme-dark footer { color: #555 !important; }
|
|
html.theme-dark footer span { color: #555 !important; }
|
|
html.theme-dark footer a { color: #777 !important; }
|
|
.theme-toggle {
|
|
position: fixed; top: 0.6rem; right: 0.8rem; z-index: 9999;
|
|
background: #fff; color: #000; border: 1px solid #000;
|
|
padding: 0.2rem 0.55rem; font-family: monospace; font-size: 0.7rem;
|
|
cursor: pointer; line-height: 1;
|
|
}
|
|
.theme-toggle:hover { background: #f0f0f0; }
|
|
html.theme-dark .theme-toggle { background: #000; color: #ccc; border-color: #444; }
|
|
html.theme-dark .theme-toggle:hover { background: #1a1a1a; }
|
|
</style>
|
|
<script>
|
|
/* shared zebra dark-mode pre-paint script — default IS dark, only an
|
|
* explicit 'light' choice opts out so first-time visitors land dark
|
|
* without a white flash */
|
|
try {
|
|
var pref = localStorage.getItem('zebra-theme-v1');
|
|
if (!pref) pref = localStorage.getItem('zebra-spaces-theme-v1'); // legacy
|
|
if (pref !== 'light'){
|
|
document.documentElement.classList.add('theme-dark');
|
|
}
|
|
} catch(_){ document.documentElement.classList.add('theme-dark'); }
|
|
</script>
|
|
</head>
|
|
|
|
<body>
|
|
<button id="btn-theme" class="theme-toggle" type="button" aria-label="toggle dark mode">light</button>
|
|
|
|
|
|
<h1>zebra report</h1>
|
|
<p class="sub">volume modem chatroom · e2e encrypted · webrtc carrier ·
|
|
<a href="how-it-works.html" style="color:#555">how it works</a> ·
|
|
<a href="host-your-own.html" style="color:#555">host your own</a> ·
|
|
<a href="/" style="color:#555">unturf</a></p>
|
|
|
|
<div class="app">
|
|
|
|
<div class="panel">
|
|
|
|
<section>
|
|
<h2>identity</h2>
|
|
<div class="row">
|
|
<label for="handle-in">handle:</label>
|
|
<input type="text" id="handle-in" style="width:100%; min-width:0" placeholder="your handle">
|
|
<span class="status-line" id="id-status"></span>
|
|
</div>
|
|
<div class="row" style="margin-top:0.5rem">
|
|
<span class="mode-toggle">
|
|
<input type="radio" name="mode" value="passphrase" id="m-pp" checked>
|
|
<label for="m-pp">passphrase</label>
|
|
<input type="radio" name="mode" value="pubkey" id="m-pk">
|
|
<label for="m-pk">pubkey</label>
|
|
</span>
|
|
<span class="note">group · or 1-to-1 ECDH</span>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================ -->
|
|
<section id="sec-passphrase" class="mode-panel active">
|
|
<h2>room (passphrase mode)</h2>
|
|
<div class="row">
|
|
<input type="password" id="passphrase-in" placeholder="room passphrase — everyone in the room types the same one"
|
|
style="width:100%; min-width:0">
|
|
</div>
|
|
<div class="row">
|
|
<button id="btn-pp-join" class="invert">enter room</button>
|
|
<span class="status-line" id="pp-status">no room key</span>
|
|
</div>
|
|
<p class="note">
|
|
PBKDF2-SHA256 (600k iter) of passphrase + literal salt "zebra-report-v1"
|
|
→ 256-bit AES-GCM group key.
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================================================ -->
|
|
<section id="sec-pubkey" class="mode-panel">
|
|
<h2>room (pubkey mode)</h2>
|
|
<p class="note" style="margin-bottom:0.5rem">your public key (click to copy):</p>
|
|
<div class="key-display" id="pub-display">generating…</div>
|
|
<div class="row" style="margin-top:0.5rem">
|
|
<button id="btn-regen">regenerate</button>
|
|
<span class="status-line" id="key-status"></span>
|
|
</div>
|
|
<div class="field-row" style="margin-top:0.5rem">
|
|
<input type="text" id="peer-key-in" placeholder="paste a peer's public key…">
|
|
<button id="btn-pk-add">add peer</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- carrier -->
|
|
<section>
|
|
<h2>carrier</h2>
|
|
<div class="row">
|
|
<div class="dot" id="dot-audio"></div>
|
|
<button id="btn-audio">start audio</button>
|
|
<label id="audio-status" class="note">offline</label>
|
|
<span class="note" style="margin-left:auto"><label><input type="checkbox" id="monitor-toggle"> monitor</label></span>
|
|
</div>
|
|
<div class="row" style="margin-top:0.4rem">
|
|
<span class="note" style="min-width:3rem">tx</span>
|
|
<div class="meter" style="width:100%"><div class="meter-fill" id="meter-tx"></div></div>
|
|
<span class="note" id="gain-label" style="min-width:3rem; text-align:right">0%</span>
|
|
</div>
|
|
<div class="row">
|
|
<span class="note" style="min-width:3rem">rx</span>
|
|
<div class="meter" style="width:100%"><div class="meter-fill" id="meter-rx"></div></div>
|
|
<span class="note" id="rx-label" style="min-width:3rem; text-align:right">—</span>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- connect (signaling relay) -->
|
|
<section>
|
|
<h2>connect</h2>
|
|
<div class="field-row">
|
|
<input type="text" id="rdv-code" placeholder="rendezvous code — both peers type the same thing">
|
|
<button id="btn-relay-connect" class="invert">connect</button>
|
|
</div>
|
|
<div class="row" style="margin-top:0.3rem">
|
|
<div class="dot warn" id="dot-relay"></div>
|
|
<span class="status-line" id="relay-status">not connected</span>
|
|
<button id="btn-relay-disconnect" style="margin-left:auto" disabled>disconnect</button>
|
|
</div>
|
|
<div class="row" style="margin-top:0.3rem">
|
|
<div class="dot warn" id="dot-rtc"></div>
|
|
<span id="rtc-status" class="note">peer link: idle</span>
|
|
<button id="btn-reset-rtc" style="margin-left:auto">reset</button>
|
|
</div>
|
|
<div class="row" style="margin-top:0.3rem">
|
|
<div class="dot" id="dot-path"></div>
|
|
<span id="path-status" class="status-line">path: not connected</span>
|
|
</div>
|
|
<p class="note" style="margin-top:0.5rem">
|
|
both peers type the same code; SDP is exchanged automatically via the relay
|
|
(encrypted with the code). chat content rides the audio carrier.
|
|
</p>
|
|
</section>
|
|
|
|
</div><!-- /panel (setup) -->
|
|
|
|
<div class="panel">
|
|
|
|
<section style="margin-bottom:0">
|
|
<h2>chat</h2>
|
|
<p class="note" style="margin-bottom:0.3rem">peers in room:</p>
|
|
<div class="peer-list" id="peer-list" style="margin-bottom:0.6rem">
|
|
<div class="empty">none yet</div>
|
|
</div>
|
|
<div class="log" id="log" style="height:48vh"></div>
|
|
<div class="field-row" style="margin-top:0.6rem">
|
|
<input type="text" id="msg-in" placeholder="type a message and press enter">
|
|
<button id="btn-send" disabled>send</button>
|
|
</div>
|
|
<div id="outbox-wrap" style="display:none; margin-top:0.4rem">
|
|
<p class="note" style="margin-bottom:0.2rem">outbox — awaiting delivery:</p>
|
|
<div class="peer-list" id="outbox"></div>
|
|
</div>
|
|
</section>
|
|
|
|
</div><!-- /panel (chat) -->
|
|
|
|
</div><!-- /app -->
|
|
|
|
<!-- ============================================================ -->
|
|
<!-- JS -->
|
|
<!-- ============================================================ -->
|
|
<script>
|
|
(async () => {
|
|
|
|
/* ============================================================== *
|
|
* constants (mirror include/zebra.h) *
|
|
* ============================================================== */
|
|
const ZEBRA_VOL_MARK = 0.95; /* near full-scale (no clip) for max margin */
|
|
const ZEBRA_VOL_SPACE = 0.10; /* low but non-zero so the tone stays present */
|
|
|
|
/* Multi-level encoding: each baud tick holds the volume at one of N levels, so
|
|
* a symbol carries log2(N) bits (N=2 -> 1 bit, the old binary scheme; N=16 -> 4
|
|
* bits). Tunable via ?levels=N. N must be a power of 2 that divides 8 cleanly. */
|
|
const _LEVELS_PARAM = parseInt(new URLSearchParams(location.search).get('levels'), 10);
|
|
const ZEBRA_LEVELS = [2, 4, 16].includes(_LEVELS_PARAM) ? _LEVELS_PARAM : 4;
|
|
const ZEBRA_BITS_PER_SYM = Math.log2(ZEBRA_LEVELS); /* 1 | 2 | 4 */
|
|
/* map a level index 0..N-1 onto the usable amplitude band [SPACE, MARK] */
|
|
function ampForLevel(L) {
|
|
return ZEBRA_VOL_SPACE + (L / (ZEBRA_LEVELS - 1)) * (ZEBRA_VOL_MARK - ZEBRA_VOL_SPACE);
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* forward error correction — Hamming(12,8) single-bit correct *
|
|
* *
|
|
* Each data byte becomes a 12-bit codeword (8 data + 4 parity) *
|
|
* carried inside one modem byte-frame, so any single flipped bit *
|
|
* self-corrects on the far side instead of failing the frame CRC. *
|
|
* Toggle with ?fec=0 (both peers must match). Combined with Gray- *
|
|
* coded levels below, an off-by-one quantization is a single-bit *
|
|
* flip — exactly what Hamming repairs. *
|
|
* ============================================================== */
|
|
const ZEBRA_FEC = new URLSearchParams(location.search).get('fec') !== '0';
|
|
const ZEBRA_CW_BITS = ZEBRA_FEC ? 12 : 8; /* bits carried per byte-frame */
|
|
const ZEBRA_SYMS_PER_CW = ZEBRA_CW_BITS / ZEBRA_BITS_PER_SYM;
|
|
const _HAM_DATA_POS = [3, 5, 6, 7, 9, 10, 11, 12]; /* 1-indexed data positions */
|
|
function hammingEncode(byte) {
|
|
const bit = new Array(13).fill(0);
|
|
for (let i = 0; i < 8; i++) bit[_HAM_DATA_POS[i]] = (byte >> i) & 1;
|
|
for (const p of [1, 2, 4, 8]) {
|
|
let par = 0;
|
|
for (let pos = 1; pos <= 12; pos++) if (pos !== p && (pos & p)) par ^= bit[pos];
|
|
bit[p] = par;
|
|
}
|
|
let code = 0;
|
|
for (let pos = 1; pos <= 12; pos++) code |= bit[pos] << (pos - 1);
|
|
return code;
|
|
}
|
|
function hammingDecode(code) {
|
|
const bit = new Array(13).fill(0);
|
|
for (let pos = 1; pos <= 12; pos++) bit[pos] = (code >> (pos - 1)) & 1;
|
|
let syndrome = 0;
|
|
for (const p of [1, 2, 4, 8]) {
|
|
let par = 0;
|
|
for (let pos = 1; pos <= 12; pos++) if (pos & p) par ^= bit[pos];
|
|
if (par) syndrome |= p;
|
|
}
|
|
if (syndrome >= 1 && syndrome <= 12) bit[syndrome] ^= 1; /* correct single-bit error */
|
|
let byte = 0;
|
|
for (let i = 0; i < 8; i++) byte |= bit[_HAM_DATA_POS[i]] << i;
|
|
return byte;
|
|
}
|
|
|
|
/* Gray map: physical level <-> symbol value, so an off-by-one level error is a
|
|
* single-bit value error (which Hamming then corrects). */
|
|
const grayVal2Phys = new Array(ZEBRA_LEVELS);
|
|
const grayPhys2Val = new Array(ZEBRA_LEVELS);
|
|
for (let L = 0; L < ZEBRA_LEVELS; L++) {
|
|
const v = L ^ (L >> 1); /* value carried by physical level L */
|
|
grayPhys2Val[L] = v;
|
|
grayVal2Phys[v] = L;
|
|
}
|
|
/* Link baud for the whole audio modem (handshake + data). Tunable via ?baud=N
|
|
* because the WebRTC Opus path encodes in 20ms frames and smears bit edges:
|
|
* lower baud = more audio quanta per symbol = survives the smear, but slower.
|
|
* Both peers must use the same value (same default, or same ?baud=). */
|
|
const _BAUD_PARAM = parseInt(new URLSearchParams(location.search).get('baud'), 10);
|
|
const ZEBRA_BAUD_HANDSHAKE = (_BAUD_PARAM >= 1 && _BAUD_PARAM <= 1000) ? _BAUD_PARAM : 20;
|
|
const ZEBRA_BAUD_MIN = 1;
|
|
const ZEBRA_BAUD_MAX = 100000;
|
|
const ZEBRA_BAUD_DEFAULT = 10;
|
|
|
|
const HS_MAGIC = [0x5A, 0x42];
|
|
const T_OFFER = 0x01;
|
|
const T_READY = 0x02;
|
|
const T_DATA = 0x03;
|
|
const T_HELLO = 0x04;
|
|
const T_ACK = 0x05;
|
|
const HS_FRAME_LEN = 6;
|
|
|
|
const PBKDF2_SALT = new TextEncoder().encode('zebra-report-v1');
|
|
const PBKDF2_ITER = 600000;
|
|
|
|
const LOOPBACK = new URLSearchParams(location.search).has('loopback');
|
|
|
|
/* STUN is free + needs no auth; TURN credentials are fetched fresh per
|
|
* connection from /turn-cred (time-limited HMAC, so each client gets its own
|
|
* coturn quota instead of sharing one static login). rtcConfig starts STUN-only
|
|
* and is upgraded with TURN once the creds arrive — if the fetch fails we still
|
|
* connect directly / via STUN, just without the relay fallback. */
|
|
const TURN_CRED_URL = new URLSearchParams(location.search).get('turncred')
|
|
|| 'https://cors-proxy.uncloseai.com/turn-cred';
|
|
let rtcConfig = {
|
|
iceServers: [{ urls: ['stun:proxy.uncloseai.com:3478', 'stun:stun.l.google.com:19302'] }]
|
|
};
|
|
async function refreshTurnCred() {
|
|
try {
|
|
const c = await (await fetch(TURN_CRED_URL, { cache: 'no-store' })).json();
|
|
if (c && c.credential && c.uris) {
|
|
rtcConfig = {
|
|
iceServers: [
|
|
{ urls: c.stun || ['stun:proxy.uncloseai.com:3478'] },
|
|
{ urls: c.uris, username: c.username, credential: c.credential }
|
|
]
|
|
};
|
|
logLine('sys', 'TURN credentials fetched (relay fallback available)');
|
|
}
|
|
} catch (e) {
|
|
logLine('sys', 'no TURN credentials — direct/STUN only (' + e.message + ')');
|
|
}
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* tiny utils *
|
|
* ============================================================== */
|
|
const $ = (id) => document.getElementById(id);
|
|
function b64(buf) {
|
|
const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
let s = ''; for (const b of u8) s += String.fromCharCode(b);
|
|
return btoa(s);
|
|
}
|
|
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('');
|
|
}
|
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
|
|
const CRC32_TABLE = (() => {
|
|
const t = new Uint32Array(256);
|
|
for (let i = 0; i < 256; i++) {
|
|
let c = i;
|
|
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
|
t[i] = c >>> 0;
|
|
}
|
|
return t;
|
|
})();
|
|
function crc32(bytes) {
|
|
let c = 0xFFFFFFFF;
|
|
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 *
|
|
* ============================================================== */
|
|
const logEl = $('log');
|
|
function logLine(kind, html) {
|
|
const div = document.createElement('div');
|
|
div.className = 'log-line ' + (kind || 'sys');
|
|
const ts = new Date().toLocaleTimeString();
|
|
div.innerHTML = `<span class="ts">${ts}</span> ${html}`;
|
|
logEl.appendChild(div);
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* crypto *
|
|
* ============================================================== */
|
|
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']);
|
|
return crypto.subtle.deriveKey(
|
|
{ name: 'PBKDF2', salt: PBKDF2_SALT, iterations: PBKDF2_ITER, hash: 'SHA-256' },
|
|
baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
}
|
|
|
|
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']);
|
|
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 p = localStorage.getItem(LS_PRIV), pub = localStorage.getItem(LS_PUB);
|
|
if (p && pub) {
|
|
try {
|
|
const privateKey = await crypto.subtle.importKey(
|
|
'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, []);
|
|
return crypto.subtle.deriveKey(
|
|
{ name: 'ECDH', public: peerPub }, myPrivKey,
|
|
{ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
}
|
|
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(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), ct = bytes.slice(12);
|
|
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
|
|
return new TextDecoder().decode(pt);
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* sender id *
|
|
* ============================================================== */
|
|
async function senderIdBytes() {
|
|
if (currentMode() === 'pubkey' && myPubB64) {
|
|
const h = await crypto.subtle.digest('SHA-256', unb64(myPubB64));
|
|
return new Uint8Array(h).slice(0, 4);
|
|
}
|
|
if (!senderIdBytes._cached) {
|
|
senderIdBytes._cached = crypto.getRandomValues(new Uint8Array(4));
|
|
}
|
|
return senderIdBytes._cached;
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* frame codec *
|
|
* ============================================================== */
|
|
function xorChecksum(bytes) {
|
|
let x = 0; for (const b of bytes) x ^= b; return x & 0xFF;
|
|
}
|
|
function buildHandshakeFrame(type, baudVal) {
|
|
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) {
|
|
const sid = await senderIdBytes();
|
|
const len = payloadBytes.length;
|
|
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;
|
|
const body = new Uint8Array(head.length + len);
|
|
body.set(head); body.set(payloadBytes, head.length);
|
|
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;
|
|
return out;
|
|
}
|
|
async function buildHelloFrame(handle, maxBaud) {
|
|
const sid = await senderIdBytes();
|
|
const hb = new TextEncoder().encode(handle.slice(0, 40));
|
|
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;
|
|
body[9] = hb.length;
|
|
body.set(hb, 10);
|
|
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;
|
|
return out;
|
|
}
|
|
/* ACK frame: magic(2) type(1) sid(4) ackCrc(4) crc(4) = 15 bytes.
|
|
* ackCrc is the CRC32 of the DATA frame being acknowledged (its message id). */
|
|
async function buildAckFrame(ackCrc) {
|
|
const sid = await senderIdBytes();
|
|
const body = new Uint8Array(11);
|
|
body[0] = HS_MAGIC[0]; body[1] = HS_MAGIC[1]; body[2] = T_ACK;
|
|
body.set(sid, 3);
|
|
body[7] = ackCrc & 0xFF; body[8] = (ackCrc >> 8) & 0xFF;
|
|
body[9] = (ackCrc >> 16) & 0xFF; body[10] = (ackCrc >>> 24) & 0xFF;
|
|
const crc = crc32(body);
|
|
const out = new Uint8Array(15);
|
|
out.set(body);
|
|
out[11] = crc & 0xFF; out[12] = (crc >> 8) & 0xFF;
|
|
out[13] = (crc >> 16) & 0xFF; out[14] = (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;
|
|
const type = bytes[2];
|
|
if (type === T_OFFER || type === T_READY) {
|
|
if (bytes.length < HS_FRAME_LEN) return null;
|
|
if (xorChecksum(bytes.slice(0, 5)) !== bytes[5]) return { type, error: 'xor' };
|
|
const baud = bytes[3] | (bytes[4] << 8);
|
|
return { type, baud };
|
|
}
|
|
if (type === T_DATA) {
|
|
if (bytes.length < 13) return null;
|
|
const sid = bytes.slice(3, 7);
|
|
const len = bytes[7] | (bytes[8] << 8);
|
|
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);
|
|
if ((got >>> 0) !== crc32(frameNoCrc)) return { type, error: 'crc' };
|
|
return { type, sid, payload, crc: (got >>> 0) };
|
|
}
|
|
if (type === T_ACK) {
|
|
if (bytes.length < 15) return null;
|
|
const sid = bytes.slice(3, 7);
|
|
const ackCrc = (bytes[7] | (bytes[8] << 8) | (bytes[9] << 16) | (bytes[10] << 24)) >>> 0;
|
|
const got = (bytes[11] | (bytes[12] << 8) | (bytes[13] << 16) | (bytes[14] << 24)) >>> 0;
|
|
if (got !== crc32(bytes.slice(0, 11))) return { type, error: 'crc' };
|
|
return { type, sid, ackCrc };
|
|
}
|
|
if (type === T_HELLO) {
|
|
if (bytes.length < 10) return null;
|
|
const sid = bytes.slice(3, 7);
|
|
const maxBaud = bytes[7] | (bytes[8] << 8);
|
|
const hlen = bytes[9];
|
|
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);
|
|
if ((got >>> 0) !== crc32(frameNoCrc)) return { type, error: 'crc' };
|
|
return { type, sid, maxBaud, handle };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* audio carrier + outbound RTC stream *
|
|
* ============================================================== */
|
|
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 = [];
|
|
|
|
const meterFill = $('meter-tx');
|
|
const gainLabel = $('gain-label');
|
|
function _meterTick() {
|
|
if (!carrierOn) return;
|
|
const g = gainL ? gainL.gain.value : 0;
|
|
meterFill.style.width = Math.round(g * 100) + '%';
|
|
gainLabel.textContent = Math.round(g * 100) + '%';
|
|
requestAnimationFrame(_meterTick);
|
|
}
|
|
|
|
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;
|
|
/* same freq on both channels: the codec is mono (G.711), so a 440/441 split
|
|
* would downmix into a 1 Hz beat that swamps the amplitude levels. */
|
|
oscR.type = 'sine'; oscR.frequency.value = 440;
|
|
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 } */
|
|
|
|
/* Multi-level demodulator. Each byte arrives framed as:
|
|
* START(level 0 = MIN) | symsPerByte data symbols | STOP(level N-1 = MAX)
|
|
* with the carrier idling at MAX between bytes, so every byte begins with a
|
|
* high->low edge we can lock onto. After collecting a full byte window we read
|
|
* the amplitude at each symbol centre, take START as the low reference and STOP
|
|
* as the high reference, and quantise the data symbols against that span — so
|
|
* the level mapping self-calibrates every byte and tolerates channel gain
|
|
* drift. N=2 collapses to the original binary UART. */
|
|
class MultiLevelDecoder {
|
|
constructor(baud, sampleRate, levels) {
|
|
this.sampleRate = sampleRate;
|
|
this.levels = levels;
|
|
this.bps = Math.log2(levels);
|
|
this.cwSyms = ZEBRA_CW_BITS / this.bps; /* symbols per codeword (8 or 12 bits) */
|
|
this.frameSyms = this.cwSyms + 2; /* + START + STOP */
|
|
this.setBaud(baud);
|
|
this.state = 'hunt';
|
|
this.prevAbove = true;
|
|
this.peak = 0;
|
|
this.peakDecay = Math.pow(0.5, 1.0 / (2.0 * sampleRate)); /* 2s half-life */
|
|
this.amps = [];
|
|
this.q = 0;
|
|
}
|
|
setBaud(b) {
|
|
this.baud = b;
|
|
this.sps = this.sampleRate / b;
|
|
this.windowQ = Math.ceil(this.frameSyms * this.sps);
|
|
}
|
|
/* average the middle ~40% of symbol i's window so we never average across a
|
|
* symbol boundary (which corrupts the level at small samples-per-symbol) */
|
|
_symAmp(i) {
|
|
const lo = Math.max(0, Math.floor((i + 0.3) * this.sps));
|
|
const hi = Math.min(this.amps.length - 1, Math.ceil((i + 0.7) * this.sps));
|
|
let sum = 0, n = 0;
|
|
for (let q = lo; q <= hi; q++) { sum += this.amps[q]; n++; }
|
|
return n ? sum / n : this.amps[this.amps.length - 1];
|
|
}
|
|
_decodeByte() {
|
|
const N1 = this.levels - 1;
|
|
const lowRef = this._symAmp(0); /* START */
|
|
const highRef = this._symAmp(this.frameSyms - 1); /* STOP */
|
|
const span = highRef - lowRef;
|
|
if (span < 0.03) return null; /* not a real START..STOP frame */
|
|
let cw = 0;
|
|
for (let j = 0; j < this.cwSyms; j++) {
|
|
const amp = this._symAmp(1 + j);
|
|
let phys = Math.round((amp - lowRef) / span * N1);
|
|
if (phys < 0) phys = 0; else if (phys > N1) phys = N1;
|
|
cw |= (grayPhys2Val[phys] << (j * this.bps)); /* un-Gray: level -> value */
|
|
}
|
|
return ZEBRA_FEC ? hammingDecode(cw) : cw; /* correct single-bit error */
|
|
}
|
|
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 above = e >= this.peak * 0.50;
|
|
if (this.state === 'hunt') {
|
|
if (this.prevAbove && !above) { /* high->low edge = START of a byte */
|
|
this.state = 'collect';
|
|
this.amps = [e];
|
|
this.q = 1;
|
|
}
|
|
} else {
|
|
this.amps.push(e);
|
|
this.q++;
|
|
if (this.q >= this.windowQ) {
|
|
const out = this._decodeByte();
|
|
this.state = 'hunt';
|
|
this.prevAbove = true; /* STOP was high; next START edge will fire */
|
|
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_ACK) {
|
|
if (this.buf.length < 15) return null;
|
|
const frame = new Uint8Array(this.buf.slice(0, 15));
|
|
this.buf.splice(0, 15);
|
|
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 MultiLevelDecoder(ZEBRA_BAUD_HANDSHAKE, effRate, ZEBRA_LEVELS);
|
|
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 });
|
|
const frameSyms = ZEBRA_SYMS_PER_CW + 2;
|
|
const dataBps = Math.round(ZEBRA_BAUD_HANDSHAKE * 8 / frameSyms);
|
|
logLine('sys', `inbound track attached — decoder live (${ZEBRA_LEVELS} levels, `
|
|
+ `${ZEBRA_BAUD_HANDSHAKE} baud, FEC ${ZEBRA_FEC ? 'on' : 'off'} ≈ ${dataBps} data bit/s)`);
|
|
}
|
|
|
|
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 });
|
|
if (txBusy) return;
|
|
txBusy = true;
|
|
try {
|
|
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;
|
|
}
|
|
}
|
|
|
|
async function _txOne(bytes, baud) {
|
|
const period = 1.0 / baud;
|
|
const N1 = ZEBRA_LEVELS - 1;
|
|
let t = audioCtx.currentTime + 0.02;
|
|
/* preamble: idle high (MAX) so the first START is a clean high->low edge */
|
|
for (let i = 0; i < 10; i++) { _scheduleGain(ZEBRA_VOL_MARK, t); t += period; }
|
|
for (const byte of bytes) {
|
|
const cw = ZEBRA_FEC ? hammingEncode(byte) : byte; /* 12 or 8 bits */
|
|
_scheduleGain(ampForLevel(0), t); t += period; /* START = MIN */
|
|
for (let j = 0; j < ZEBRA_SYMS_PER_CW; j++) { /* codeword, LSB-first */
|
|
const v = (cw >> (j * ZEBRA_BITS_PER_SYM)) & N1;
|
|
_scheduleGain(ampForLevel(grayVal2Phys[v]), t); t += period; /* Gray: value -> level */
|
|
}
|
|
_scheduleGain(ampForLevel(N1), t); t += period; /* STOP = MAX */
|
|
}
|
|
for (let i = 0; i < 5; i++) { _scheduleGain(ZEBRA_VOL_MARK, t); t += period; }
|
|
_scheduleGain(ZEBRA_VOL_MARK, t);
|
|
await sleep(Math.max(0, (t - audioCtx.currentTime) * 1000) + 30);
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* handshake state *
|
|
* ============================================================== */
|
|
let myMaxBaud = null;
|
|
let negotiatedBaud = null;
|
|
function recomputeGroupBaud() {
|
|
let m = myMaxBaud || ZEBRA_BAUD_DEFAULT;
|
|
for (const p of peers.values()) if (p.maxBaud && p.maxBaud < m) m = p.maxBaud;
|
|
negotiatedBaud = m;
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* loopback (same-browser dev shortcut, ?loopback=1) *
|
|
* ============================================================== */
|
|
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);
|
|
};
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* reliable delivery: outbox (pending until ACK) + retransmit *
|
|
* ============================================================== */
|
|
const msgPending = new Map(); /* frameCrc -> { handle, text, frame, tries, failed, timer } */
|
|
const rxSeenCrc = new Set(); /* recently received DATA crcs, for de-dup */
|
|
const MAX_TX_TRIES = 5;
|
|
|
|
function frameCrcOf(f) {
|
|
const n = f.length;
|
|
return (f[n-4] | (f[n-3] << 8) | (f[n-2] << 16) | (f[n-1] << 24)) >>> 0;
|
|
}
|
|
/* rough on-air time for a frame of byteLen bytes at the current link settings */
|
|
function estimateTxMs(byteLen) {
|
|
const ticks = 10 + byteLen * (2 + ZEBRA_SYMS_PER_CW) + 5; /* preamble + frame + trailer */
|
|
return ticks / ZEBRA_BAUD_HANDSHAKE * 1000 + 80;
|
|
}
|
|
function renderOutbox() {
|
|
const wrap = $('outbox-wrap'), el = $('outbox');
|
|
if (!wrap || !el) return;
|
|
if (msgPending.size === 0) { wrap.style.display = 'none'; el.innerHTML = ''; return; }
|
|
wrap.style.display = '';
|
|
el.innerHTML = '';
|
|
for (const [, m] of msgPending) {
|
|
const row = document.createElement('div');
|
|
row.className = 'peer';
|
|
const status = m.failed ? '✗ no ack (giving up)'
|
|
: (m.tries > 1 ? `sending… (try ${m.tries})` : 'sending…');
|
|
row.innerHTML = `<span style="color:${m.failed ? '#b00' : '#888'}">`
|
|
+ `⧗ ${escapeHtml(m.text)} — ${status}</span>`;
|
|
el.appendChild(row);
|
|
}
|
|
}
|
|
function scheduleRetransmit(crc) {
|
|
const entry = msgPending.get(crc);
|
|
if (!entry) return;
|
|
const base = estimateTxMs(entry.frame.length) + estimateTxMs(15) + 4000; /* data + ack + margin */
|
|
const wait = base * Math.pow(1.8, entry.tries - 1); /* exponential backoff — don't hammer */
|
|
entry.timer = setTimeout(async () => {
|
|
const e = msgPending.get(crc);
|
|
if (!e) return; /* already ACKed */
|
|
if (e.tries >= MAX_TX_TRIES) { e.failed = true; renderOutbox(); return; }
|
|
e.tries++; renderOutbox();
|
|
try { await txFrame(e.frame, ZEBRA_BAUD_HANDSHAKE); } catch (_) {}
|
|
scheduleRetransmit(crc);
|
|
}, wait);
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* frame handler *
|
|
* ============================================================== */
|
|
async function onRxFrame(bytes) {
|
|
const f = parseFrame(bytes);
|
|
if (!f) return;
|
|
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 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`);
|
|
await txFrame(buildHandshakeFrame(T_READY, myMaxBaud || ZEBRA_BAUD_DEFAULT), ZEBRA_BAUD_HANDSHAKE);
|
|
return;
|
|
}
|
|
if (f.type === T_READY) {
|
|
logLine('sys', `rx: READY from peer — ${f.baud} baud`);
|
|
return;
|
|
}
|
|
if (f.type === T_HELLO) {
|
|
const sid = hex(f.sid);
|
|
const seen = peers.has(sid);
|
|
let entry = peers.get(sid) || {};
|
|
entry.handle = f.handle; entry.maxBaud = f.maxBaud;
|
|
peers.set(sid, entry);
|
|
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_ACK) {
|
|
const entry = msgPending.get(f.ackCrc);
|
|
if (entry) {
|
|
if (entry.timer) clearTimeout(entry.timer);
|
|
msgPending.delete(f.ackCrc);
|
|
logLine('me', `<span class="from">${escapeHtml(entry.handle)}:</span> `
|
|
+ `${escapeHtml(entry.text)} <span style="color:#060">✓</span>`);
|
|
renderOutbox();
|
|
}
|
|
return;
|
|
}
|
|
if (f.type === T_DATA) {
|
|
const sid = hex(f.sid);
|
|
let pt = null;
|
|
if (currentMode() === 'passphrase') {
|
|
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 {
|
|
const entry = peers.get(sid);
|
|
if (entry && entry.sharedKey) {
|
|
try { pt = await aesDecrypt(entry.sharedKey, f.payload); } catch (_) {}
|
|
}
|
|
if (pt === null) { logLine('err', `rx: data from ${sid.slice(0,8)} (no key)`); return; }
|
|
}
|
|
/* received + decrypted OK → ACK it (even duplicates, so a retransmit stops
|
|
* when an earlier ACK was lost) */
|
|
try { await txFrame(await buildAckFrame(f.crc), ZEBRA_BAUD_HANDSHAKE); } catch (_) {}
|
|
if (rxSeenCrc.has(f.crc)) return; /* duplicate: re-ACKed, don't re-display */
|
|
rxSeenCrc.add(f.crc);
|
|
if (rxSeenCrc.size > 300) rxSeenCrc.clear();
|
|
const handle = (peers.get(sid) || {}).handle || sid.slice(0,8);
|
|
logLine('peer', `<span class="from">${escapeHtml(handle)}:</span> ${escapeHtml(pt)}`);
|
|
}
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* peer list render *
|
|
* ============================================================== */
|
|
function renderPeerList() {
|
|
const el = $('peer-list');
|
|
el.innerHTML = '';
|
|
if (peers.size === 0) { el.innerHTML = '<div class="empty">none yet</div>'; return; }
|
|
for (const [sid, p] of peers) {
|
|
const row = document.createElement('div');
|
|
row.className = 'peer';
|
|
row.innerHTML = `<span class="h">${escapeHtml(p.handle || '?')}</span>
|
|
<span style="color:#888"> · sid ${sid.slice(0,8)} · ${p.maxBaud || '?'} baud</span>`;
|
|
el.appendChild(row);
|
|
}
|
|
}
|
|
|
|
async function announceHello() {
|
|
const handle = $('handle-in').value.trim() || 'anon';
|
|
if (!myMaxBaud) myMaxBaud = ZEBRA_BAUD_DEFAULT;
|
|
const hello = await buildHelloFrame(handle, myMaxBaud);
|
|
await txFrame(hello, ZEBRA_BAUD_HANDSHAKE);
|
|
logLine('sys', `tx: HELLO as ${escapeHtml(handle)} at ${ZEBRA_BAUD_HANDSHAKE} baud (link)`);
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* WebRTC *
|
|
* ============================================================== */
|
|
let pc = null;
|
|
const dotRtc = $('dot-rtc');
|
|
const rtcStatus = $('rtc-status');
|
|
|
|
function ensurePC() {
|
|
if (pc) return pc;
|
|
if (!carrierOn) throw new Error('start carrier first');
|
|
pc = new RTCPeerConnection(rtcConfig);
|
|
for (const tr of outboundStream.getTracks()) pc.addTrack(tr, outboundStream);
|
|
/* Force a memoryless codec (G.711 PCMU/PCMA, else G.722) ahead of Opus.
|
|
* Opus is a perceptual codec that re-quantizes in 20ms frames and smears the
|
|
* MARK/SPACE bit-edges this modem rides on; G.711 companding is per-sample,
|
|
* preserving the carrier envelope like the PulseAudio path. */
|
|
try {
|
|
const caps = (RTCRtpSender.getCapabilities && RTCRtpSender.getCapabilities('audio')) || null;
|
|
if (caps && caps.codecs) {
|
|
const rank = (mime) => {
|
|
const m = (mime || '').toLowerCase();
|
|
if (m.endsWith('/pcmu')) return 0;
|
|
if (m.endsWith('/pcma')) return 1;
|
|
if (m.endsWith('/g722')) return 2;
|
|
if (m.includes('opus')) return 99;
|
|
return 50;
|
|
};
|
|
const ordered = caps.codecs.slice().sort((a, b) => rank(a.mimeType) - rank(b.mimeType));
|
|
for (const t of pc.getTransceivers()) {
|
|
if (t.setCodecPreferences) { try { t.setCodecPreferences(ordered); } catch (_) {} }
|
|
}
|
|
logLine('sys', 'codec preference: G.711/G.722 ahead of Opus (clean amplitude)');
|
|
}
|
|
} catch (_) {}
|
|
const selfPc = pc; /* snapshot so a superseded pc's late events are ignored */
|
|
pc.ontrack = (ev) => {
|
|
const stream = ev.streams[0] || new MediaStream([ev.track]);
|
|
attachInboundTrack(stream, ev.track.id);
|
|
};
|
|
pc.onicecandidate = (ev) => {
|
|
if (!ev.candidate) return;
|
|
const c = ev.candidate.candidate;
|
|
const tm = c.match(/typ\s+(\S+)/);
|
|
const typ = tm ? tm[1] : '?';
|
|
const mdns = c.includes('.local') ? ' (mdns-anonymized)' : '';
|
|
logLine('sys', `ice candidate: ${typ}${mdns}`);
|
|
};
|
|
pc.onconnectionstatechange = () => {
|
|
if (selfPc !== pc) return; /* event from a superseded pc — ignore */
|
|
const s = pc.connectionState;
|
|
rtcStatus.textContent = 'connection: ' + s;
|
|
if (s === 'connected') {
|
|
dotRtc.className = 'dot ok';
|
|
clearRtcRecover(); rtcReconnectTries = 0;
|
|
logLine('sys', 'webrtc connected');
|
|
announceHello().catch(() => {});
|
|
setTimeout(reportTransport, 1200);
|
|
} else if (s === 'disconnected') {
|
|
dotRtc.className = 'dot warn';
|
|
setPath('path: reconnecting…', null);
|
|
logLine('sys', 'webrtc disconnected — waiting for ICE to recover');
|
|
clearRtcRecover();
|
|
rtcRecoverTimer = setTimeout(attemptRtcRestart, 5000); /* often self-heals */
|
|
} else if (s === 'failed') {
|
|
dotRtc.className = 'dot warn';
|
|
setPath('path: reconnecting…', null);
|
|
logLine('sys', 'webrtc failed — restarting ICE');
|
|
attemptRtcRestart();
|
|
} else if (s === 'closed') {
|
|
dotRtc.className = 'dot';
|
|
clearRtcRecover();
|
|
setPath('path: not connected', null);
|
|
}
|
|
};
|
|
pc.oniceconnectionstatechange = () => {
|
|
if (selfPc !== pc) return;
|
|
rtcStatus.textContent = 'ice: ' + pc.iceConnectionState;
|
|
};
|
|
return pc;
|
|
}
|
|
|
|
function setPath(msg, kind) {
|
|
const el = $('path-status'); if (el) { el.textContent = msg; el.className = 'status-line' + (kind ? ' ' + kind : ''); }
|
|
const dot = $('dot-path'); if (dot) dot.className = 'dot' + (kind === 'ok' ? ' ok' : kind === 'err' ? ' warn' : '');
|
|
}
|
|
/* Read the live ICE candidate pair so users can see whether anything sits
|
|
* between them: a direct host/srflx pair = peer-to-peer, nobody in the audio
|
|
* path; a 'relay' candidate = the TURN server is forwarding their (encrypted)
|
|
* audio. */
|
|
async function reportTransport() {
|
|
if (!pc) return;
|
|
try {
|
|
const stats = await pc.getStats();
|
|
let pair = null;
|
|
stats.forEach(r => { if (r.type === 'candidate-pair' && r.nominated && r.state === 'succeeded') pair = r; });
|
|
if (!pair) stats.forEach(r => { if (!pair && r.type === 'candidate-pair' && r.state === 'succeeded') pair = r; });
|
|
if (!pair) { setPath('path: connecting…', null); return; }
|
|
const loc = stats.get(pair.localCandidateId) || {}, rem = stats.get(pair.remoteCandidateId) || {};
|
|
const relayed = loc.candidateType === 'relay' || rem.candidateType === 'relay';
|
|
if (relayed) {
|
|
setPath('path: RELAYED through TURN — a server forwards your (encrypted) audio', 'err');
|
|
logLine('sys', 'transport: relayed via TURN (proxy.uncloseai.com is in the path; audio stays encrypted)');
|
|
logLine('sys', 'to go DIRECT (nobody between): disable mDNS host masking, then reset + reconnect — '
|
|
+ 'Firefox about:config media.peerconnection.ice.obfuscate_host_addresses=false; '
|
|
+ 'Chromium chrome://flags #enable-webrtc-hide-local-ips-with-mdns = Disabled');
|
|
} else {
|
|
setPath(`path: DIRECT peer-to-peer — nobody between you (${loc.candidateType || '?'}/${rem.candidateType || '?'})`, 'ok');
|
|
logLine('sys', `transport: direct peer-to-peer (${loc.candidateType || '?'}/${rem.candidateType || '?'})`);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
function resetConnection() {
|
|
if (typeof disconnectRelay === 'function') disconnectRelay();
|
|
clearRtcRecover(); rtcReconnectTries = 0;
|
|
if (pc) {
|
|
try { pc.close(); } catch (_) {}
|
|
pc = null;
|
|
}
|
|
for (const [, d] of rxDecoders) {
|
|
try { d.node.disconnect(); } catch (_) {}
|
|
try { d.audio.pause(); d.audio.srcObject = null; } catch (_) {}
|
|
}
|
|
rxDecoders.clear();
|
|
rtcStatus.textContent = 'peer link: idle';
|
|
dotRtc.className = 'dot warn';
|
|
setPath('path: not connected', null);
|
|
logLine('sys', 'connection reset — ready to retry');
|
|
}
|
|
|
|
$('btn-reset-rtc').addEventListener('click', resetConnection);
|
|
|
|
/* Resolve when ICE gathering completes, OR after timeoutMs — whichever comes
|
|
* first. Some environments (and some TURN configs) never fire the 'complete'
|
|
* state transition even after every candidate has been gathered, which would
|
|
* otherwise hang offer/answer creation forever. On timeout we proceed with
|
|
* whatever candidates are already in the local description. */
|
|
function waitForIceGathering(p, timeoutMs = 6000) {
|
|
return new Promise(resolve => {
|
|
if (p.iceGatheringState === 'complete') return resolve();
|
|
let done = false;
|
|
const finish = () => {
|
|
if (done) return;
|
|
done = true;
|
|
p.removeEventListener('icegatheringstatechange', h);
|
|
clearTimeout(timer);
|
|
resolve();
|
|
};
|
|
const h = () => { if (p.iceGatheringState === 'complete') finish(); };
|
|
p.addEventListener('icegatheringstatechange', h);
|
|
const timer = setTimeout(finish, timeoutMs);
|
|
});
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* signaling relay — auto-connect, no copy-paste *
|
|
* *
|
|
* both peers type the same rendezvous code. it derives (a) an *
|
|
* opaque room id the relay routes on and (b) an AES key that wraps *
|
|
* the SDP before it leaves the page. the relay pairs the two peers *
|
|
* (first = offerer, second = answerer) and blindly forwards the *
|
|
* ciphertext. it never sees plaintext SDP or the code. *
|
|
* ============================================================== */
|
|
const SIGNAL_URL = new URLSearchParams(location.search).get('signal')
|
|
|| 'wss://cors-proxy.uncloseai.com/zebra-signal';
|
|
const SIGNAL_SALT = new TextEncoder().encode('zebra-signal-v1');
|
|
|
|
let relayWS = null;
|
|
let relayRole = null;
|
|
let relayKey = null;
|
|
let relayWantOpen = false; /* true from connect until the user disconnects */
|
|
let relayReconnectTimer = null; /* auto-rejoin the signaling socket if it drops */
|
|
let rtcRecoverTimer = null; /* grace timer before forcing an ICE restart */
|
|
let rtcReconnectTries = 0;
|
|
|
|
async function deriveSignalRoom(code) {
|
|
const h = await crypto.subtle.digest('SHA-256',
|
|
new TextEncoder().encode('zebra-signal-room|' + code));
|
|
return hex(h); /* 64 hex chars — matches relay's room id charset */
|
|
}
|
|
async function deriveSignalKey(code) {
|
|
const baseKey = await crypto.subtle.importKey(
|
|
'raw', new TextEncoder().encode(code), 'PBKDF2', false, ['deriveKey']);
|
|
return crypto.subtle.deriveKey(
|
|
{ name: 'PBKDF2', salt: SIGNAL_SALT, iterations: PBKDF2_ITER, hash: 'SHA-256' },
|
|
baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
}
|
|
function relaySet(msg, cls) {
|
|
const el = $('relay-status');
|
|
el.textContent = msg;
|
|
el.className = 'status-line' + (cls ? ' ' + cls : '');
|
|
}
|
|
function relaySend(obj) {
|
|
if (relayWS && relayWS.readyState === WebSocket.OPEN) relayWS.send(JSON.stringify(obj));
|
|
}
|
|
async function relaySendSDP(kind, desc) {
|
|
const ct = await aesEncrypt(relayKey, JSON.stringify(desc));
|
|
relaySend({ type: 'sdp', kind, data: b64(ct) });
|
|
}
|
|
async function relayMakeOffer(iceRestart = false) {
|
|
ensurePC();
|
|
const off = await pc.createOffer(iceRestart ? { iceRestart: true } : undefined);
|
|
await pc.setLocalDescription(off);
|
|
await waitForIceGathering(pc);
|
|
await relaySendSDP('offer', pc.localDescription);
|
|
relaySet(iceRestart ? 'reconnecting — new offer sent' : 'offer sent — waiting for answer', 'ok');
|
|
}
|
|
/* Relay-path ICE recovery. A transient blip drops us to 'disconnected' (usually
|
|
* self-heals) or 'failed' (needs an ICE restart). The offerer owns restarts so
|
|
* the two sides don't both re-offer at once (glare); the answerer waits for the
|
|
* new offer to arrive over the relay and answers it on its existing pc. */
|
|
function clearRtcRecover() { if (rtcRecoverTimer) { clearTimeout(rtcRecoverTimer); rtcRecoverTimer = null; } }
|
|
async function attemptRtcRestart() {
|
|
clearRtcRecover();
|
|
if (!pc || pc.connectionState === 'connected') { rtcReconnectTries = 0; return; }
|
|
if (!relayWS || relayWS.readyState !== WebSocket.OPEN) return; /* WS rejoin re-offers */
|
|
if (relayRole !== 'offerer') { relaySet('reconnecting — waiting for peer'); return; }
|
|
if (rtcReconnectTries >= 6) { relaySet('reconnect failed — click "reset" then retry', 'err'); return; }
|
|
rtcReconnectTries++;
|
|
try {
|
|
await refreshTurnCred();
|
|
if (pc.setConfiguration) { try { pc.setConfiguration(rtcConfig); } catch (_) {} }
|
|
await relayMakeOffer(true);
|
|
logLine('sys', `ice restart attempt ${rtcReconnectTries}`);
|
|
} catch (e) { logLine('err', 'ice restart failed: ' + e.message); }
|
|
if (!pc || pc.connectionState !== 'connected') {
|
|
rtcRecoverTimer = setTimeout(attemptRtcRestart, Math.min(2000 * rtcReconnectTries, 10000));
|
|
}
|
|
}
|
|
async function onRelaySDP(kind, descJSON) {
|
|
const desc = JSON.parse(descJSON);
|
|
if (kind === 'offer') {
|
|
ensurePC();
|
|
await pc.setRemoteDescription(desc);
|
|
const ans = await pc.createAnswer();
|
|
await pc.setLocalDescription(ans);
|
|
await waitForIceGathering(pc);
|
|
await relaySendSDP('answer', pc.localDescription);
|
|
relaySet('answer sent — connecting', 'ok');
|
|
} else if (kind === 'answer') {
|
|
if (!pc) return;
|
|
await pc.setRemoteDescription(desc);
|
|
relaySet('answer received — connecting', 'ok');
|
|
}
|
|
}
|
|
async function connectRelay() {
|
|
const code = $('rdv-code').value.trim();
|
|
if (!code) { relaySet('enter a rendezvous code first', 'err'); return; }
|
|
if (relayWS) { relaySet('already connected — disconnect first'); return; }
|
|
if (!carrierOn) {
|
|
try {
|
|
await startCarrier();
|
|
$('dot-audio').className = 'dot on';
|
|
$('audio-status').textContent = 'online';
|
|
$('btn-audio').disabled = true;
|
|
logLine('sys', 'carrier auto-started for relay connect');
|
|
} catch (e) { relaySet('carrier failed: ' + e.message, 'err'); return; }
|
|
}
|
|
await refreshTurnCred(); /* fetch fresh ephemeral TURN creds before the PC */
|
|
try {
|
|
relayKey = await deriveSignalKey(code);
|
|
const room = await deriveSignalRoom(code);
|
|
relayWantOpen = true;
|
|
relaySet('connecting to relay…');
|
|
openRelaySocket(room);
|
|
} catch (e) {
|
|
relaySet('connect failed: ' + e.message, 'err');
|
|
if (relayWS) { try { relayWS.close(); } catch (_) {} relayWS = null; }
|
|
}
|
|
}
|
|
function openRelaySocket(room) {
|
|
relayWS = new WebSocket(SIGNAL_URL + '?room=' + encodeURIComponent(room));
|
|
relayWS.onopen = () => {
|
|
$('dot-relay').className = 'dot ok';
|
|
$('btn-relay-connect').disabled = true;
|
|
$('btn-relay-disconnect').disabled = false;
|
|
logLine('sys', 'relay connected — waiting for peer');
|
|
};
|
|
relayWS.onclose = () => {
|
|
$('dot-relay').className = 'dot warn';
|
|
relayWS = null; relayRole = null;
|
|
if (relayWantOpen) {
|
|
$('btn-relay-disconnect').disabled = false;
|
|
relaySet('relay dropped — reconnecting…');
|
|
if (relayReconnectTimer) clearTimeout(relayReconnectTimer);
|
|
relayReconnectTimer = setTimeout(() => { if (relayWantOpen) openRelaySocket(room); }, 1500);
|
|
} else {
|
|
$('btn-relay-connect').disabled = false;
|
|
$('btn-relay-disconnect').disabled = true;
|
|
relaySet('relay disconnected');
|
|
}
|
|
};
|
|
relayWS.onerror = () => relaySet('relay connection error', 'err');
|
|
relayWS.onmessage = async (ev) => {
|
|
let m; try { m = JSON.parse(ev.data); } catch (_) { return; }
|
|
if (m.type === 'welcome') {
|
|
relayRole = m.role;
|
|
relaySet(`in room as ${m.role} — ${m.peers} peer(s) present`);
|
|
logLine('sys', `relay role: ${m.role} (${m.peers} present)`);
|
|
/* pc already exists ⇒ this is a re-join after a drop: ICE-restart offer */
|
|
if (relayRole === 'offerer' && m.peers >= 2) await relayMakeOffer(!!pc);
|
|
} else if (m.type === 'peer-joined') {
|
|
logLine('sys', 'peer joined the room');
|
|
if (relayRole === 'offerer') await relayMakeOffer(!!pc);
|
|
} else if (m.type === 'peer-left') {
|
|
logLine('sys', 'peer left the room');
|
|
relaySet('peer left — waiting for a peer');
|
|
} else if (m.type === 'sdp') {
|
|
try {
|
|
const pt = await aesDecrypt(relayKey, unb64(m.data));
|
|
await onRelaySDP(m.kind, pt);
|
|
} catch (e) {
|
|
logLine('err', 'relay sdp failed (wrong code?): ' + e.message);
|
|
relaySet('sdp exchange failed — codes must match', 'err');
|
|
}
|
|
} else if (m.type === 'error') {
|
|
relaySet('relay: ' + m.message, 'err');
|
|
logLine('err', 'relay: ' + m.message);
|
|
}
|
|
};
|
|
}
|
|
function disconnectRelay() {
|
|
relayWantOpen = false;
|
|
if (relayReconnectTimer) { clearTimeout(relayReconnectTimer); relayReconnectTimer = null; }
|
|
clearRtcRecover();
|
|
if (relayWS) { const w = relayWS; relayWS = null; try { w.close(); } catch (_) {} }
|
|
$('dot-relay').className = 'dot warn';
|
|
$('btn-relay-connect').disabled = false;
|
|
$('btn-relay-disconnect').disabled = true;
|
|
relaySet('not connected to relay');
|
|
}
|
|
$('btn-relay-connect').addEventListener('click', connectRelay);
|
|
$('btn-relay-disconnect').addEventListener('click', disconnectRelay);
|
|
$('rdv-code').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') { e.preventDefault(); connectRelay(); }
|
|
});
|
|
|
|
/* ============================================================== *
|
|
* mode toggle *
|
|
* ============================================================== */
|
|
function currentMode() {
|
|
const r = document.querySelector('input[name="mode"]:checked');
|
|
return r ? r.value : 'passphrase';
|
|
}
|
|
function showModePanel() {
|
|
const m = currentMode();
|
|
$('sec-passphrase').classList.toggle('active', m === 'passphrase');
|
|
$('sec-pubkey').classList.toggle('active', m === 'pubkey');
|
|
}
|
|
for (const r of document.querySelectorAll('input[name="mode"]')) {
|
|
r.addEventListener('change', () => { showModePanel(); updateSendButton(); });
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* identity / passphrase / pubkey UI *
|
|
* ============================================================== */
|
|
const handleIn = $('handle-in');
|
|
handleIn.value = localStorage.getItem(LS_HANDLE) || '';
|
|
handleIn.addEventListener('change', () => {
|
|
localStorage.setItem(LS_HANDLE, handleIn.value.trim());
|
|
});
|
|
|
|
$('btn-pp-join').addEventListener('click', async () => {
|
|
const p = $('passphrase-in').value;
|
|
if (!p) { $('pp-status').textContent = 'enter a passphrase first'; return; }
|
|
$('pp-status').textContent = 'deriving (~600k iter)…';
|
|
try {
|
|
groupKey = await deriveGroupKey(p);
|
|
$('pp-status').textContent = 'room key ready';
|
|
$('pp-status').className = 'status-line ok';
|
|
logLine('sys', 'room key derived');
|
|
updateSendButton();
|
|
if (carrierOn && pc && pc.connectionState === 'connected') await announceHello();
|
|
} catch (e) {
|
|
$('pp-status').textContent = 'derive failed: ' + e.message;
|
|
$('pp-status').className = 'status-line err';
|
|
}
|
|
});
|
|
|
|
async function initPubkey() {
|
|
const kp = await loadOrGenKeypair();
|
|
myPriv = kp.privateKey; myPubB64 = kp.pubB64;
|
|
$('pub-display').textContent = myPubB64;
|
|
}
|
|
initPubkey();
|
|
|
|
$('pub-display').addEventListener('click', () => {
|
|
navigator.clipboard.writeText(myPubB64 || '').then(() => {
|
|
$('key-status').textContent = 'copied'; $('key-status').className = 'status-line ok';
|
|
setTimeout(() => { $('key-status').textContent = ''; }, 1500);
|
|
});
|
|
});
|
|
$('btn-regen').addEventListener('click', async () => {
|
|
if (!confirm('regenerate keys?')) return;
|
|
const kp = await genKeypair();
|
|
myPriv = kp.privateKey; myPubB64 = kp.pubB64;
|
|
$('pub-display').textContent = myPubB64;
|
|
for (const p of peers.values()) delete p.sharedKey;
|
|
$('key-status').textContent = 'new keys'; $('key-status').className = 'status-line ok';
|
|
setTimeout(() => { $('key-status').textContent = ''; }, 2000);
|
|
});
|
|
$('btn-pk-add').addEventListener('click', async () => {
|
|
const peerB64 = $('peer-key-in').value.trim();
|
|
if (!peerB64) return;
|
|
try {
|
|
const sk = await deriveShared(myPriv, peerB64);
|
|
const sidBytes = new Uint8Array(await crypto.subtle.digest('SHA-256', unb64(peerB64)));
|
|
const sid = hex(sidBytes.slice(0, 4));
|
|
const entry = peers.get(sid) || {};
|
|
entry.sharedKey = sk;
|
|
entry.handle = entry.handle || ('peer-' + sid.slice(0,4));
|
|
peers.set(sid, entry);
|
|
renderPeerList();
|
|
$('peer-key-in').value = '';
|
|
logLine('sys', `pubkey peer added: ${sid.slice(0,8)}`);
|
|
updateSendButton();
|
|
} catch (e) {
|
|
logLine('err', 'add peer failed: ' + e.message);
|
|
}
|
|
});
|
|
|
|
/* ============================================================== *
|
|
* carrier UI *
|
|
* ============================================================== */
|
|
$('btn-audio').addEventListener('click', async () => {
|
|
if (carrierOn) return;
|
|
try {
|
|
await startCarrier();
|
|
$('dot-audio').className = 'dot on';
|
|
$('audio-status').textContent = 'online';
|
|
$('btn-audio').disabled = true;
|
|
logLine('sys', 'carrier started — modulated 440 Hz');
|
|
} catch (e) {
|
|
logLine('err', 'carrier failed: ' + e.message);
|
|
}
|
|
});
|
|
|
|
/* ============================================================== *
|
|
* send UI *
|
|
* ============================================================== */
|
|
function updateSendButton() {
|
|
const mode = currentMode();
|
|
const haveKey = mode === 'passphrase' ? !!groupKey
|
|
: Array.from(peers.values()).some(p => p.sharedKey);
|
|
$('btn-send').disabled = !(carrierOn && haveKey);
|
|
}
|
|
|
|
async function sendCurrentMsg() {
|
|
const msg = $('msg-in').value;
|
|
if (!msg) return;
|
|
if (!carrierOn) { logLine('err', 'start carrier first'); return; }
|
|
const mode = currentMode();
|
|
if (mode === 'passphrase' && !groupKey) { logLine('err', 'no room key'); return; }
|
|
if (mode === 'pubkey' && !Array.from(peers.values()).some(p => p.sharedKey)) {
|
|
logLine('err', 'no pubkey peers ready'); return;
|
|
}
|
|
/* data travels at ZEBRA_BAUD_HANDSHAKE to match the inbound decoder */
|
|
const baud = ZEBRA_BAUD_HANDSHAKE;
|
|
const handle = handleIn.value.trim() || 'me';
|
|
$('msg-in').value = '';
|
|
/* don't echo to the log yet — hold in the outbox until the far side ACKs,
|
|
* retransmitting until then. the message moves into the log on its ACK. */
|
|
try {
|
|
const frames = [];
|
|
if (mode === 'passphrase') {
|
|
frames.push(await buildDataFrame(await aesEncrypt(groupKey, msg)));
|
|
} else {
|
|
for (const p of peers.values()) {
|
|
if (p.sharedKey) frames.push(await buildDataFrame(await aesEncrypt(p.sharedKey, msg)));
|
|
}
|
|
}
|
|
for (const frame of frames) {
|
|
const crc = frameCrcOf(frame);
|
|
msgPending.set(crc, { handle, text: msg, frame, tries: 1, failed: false, timer: null });
|
|
renderOutbox();
|
|
await txFrame(frame, baud);
|
|
scheduleRetransmit(crc);
|
|
}
|
|
} catch (e) {
|
|
logLine('err', 'send failed: ' + e.message);
|
|
}
|
|
}
|
|
|
|
$('btn-send').addEventListener('click', sendCurrentMsg);
|
|
$('msg-in').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendCurrentMsg(); }
|
|
});
|
|
|
|
/* ============================================================== *
|
|
* boot *
|
|
* ============================================================== */
|
|
showModePanel();
|
|
updateSendButton();
|
|
setInterval(updateSendButton, 1000);
|
|
|
|
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 WebRTC. drop ?loopback=1 for real cross-machine path.';
|
|
document.body.insertBefore(banner, document.body.firstChild);
|
|
logLine('sys', 'loopback mode: frames will echo to other tabs of this same browser.');
|
|
}
|
|
|
|
logLine('sys', 'ready — set a room passphrase, type a rendezvous code, connect, then chat.');
|
|
logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry plaintext.');
|
|
|
|
})();
|
|
</script>
|
|
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
|
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-06-07</span><br>
|
|
md5 <span class="stamp-md5">c9fee1030b030fb1277cb89607ef7d8d</span><br>
|
|
sha256 <span class="stamp-sha">12c4070297b0d4d671e84d18891451289968f14b12fc2309f4dc85bef47528be</span><br>
|
|
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
|
|
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
|
|
</footer>
|
|
<script>
|
|
/* shared zebra dark-mode toggle handler */
|
|
(function(){
|
|
var btn = document.getElementById('btn-theme'); if (!btn) return;
|
|
function applyLabel(){
|
|
btn.textContent = document.documentElement.classList.contains('theme-dark') ? 'light' : 'dark';
|
|
}
|
|
applyLabel();
|
|
btn.addEventListener('click', function(){
|
|
var root = document.documentElement;
|
|
var goDark = !root.classList.contains('theme-dark');
|
|
root.classList.toggle('theme-dark', goDark);
|
|
try { localStorage.setItem('zebra-theme-v1', goDark ? 'dark' : 'light'); } catch(_){}
|
|
applyLabel();
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|