- Block is now terminal: any 'blocked' signal-server error or 403 from
the SFU calls handleBlocked() which shows a clear notice, stops the
WS reconnect loop, tears mesh + SFU + mic, and unlocks the enter
button. Stale UI no longer spins trying to rejoin a room the user
was kicked from. Reset on a fresh joinSpace.
- Share URL: after joining, sec-share shows a copyable link with
?code=… embedded; opening that link autofills the rendezvous code
field. QR canvas placeholder is in place; encoder lands in the next
push (defer to keep this commit small).
- host-your-own.html gains section 8: zebra-spaces (multi-party rooms)
describing both Go services that make spaces work — the
zebra-spaces-signal authority-chain rendezvous and the Pion-based
zebra-spaces-sfu audio fan-out. Includes the two Caddyfile routes
(with flush_interval -1 for SSE), the NAT1To1 env var, and the
single UDP mux port — so someone wanting to host their own community
can stand the whole stack up. Sections 9-11 renumbered.
Three issues fixed together:
1. Mobile horizontal scroll: dynamically-appended <audio> sinks render
their default ~300px control strip on Firefox mobile, pushing the
page wider than the viewport. Hide them globally with
'audio { display: none }' — they're just sinks for the WebRTC track,
no UI required.
2. Don't use literal 50/50 split: page grid is now
'minmax(0, 1fr) minmax(0, 520px)' so the controls cap at 520px and
the timeline soaks the remainder. min-width:0 on both children so
they shrink without forcing an outer scrollbar.
3. Mobile order: on phones the controls were stacked ABOVE the
timeline placeholder; fox wants them first. Use 'order: 1' on
.controls and 'order: 2' on .timeline inside the 800px breakpoint.
4. Host rejoin audio: when a host leaves and rejoins, Pion frequently
reuses the same transceiver on the SFU side, so 'ontrack' doesn't
fire a second time on the listener — but the listener already tore
down the audio element on peer-left. The audio plays into nothing.
Replace the pending-tracks queue with sfuStreamsByPubHex (keyed by
the publisher's pubkey, which is the streamID). The cached
MediaStream survives the host's session uuid changing across
leave/rejoin. On peer-joined / host-promoted / state we look up the
cached stream by pubkey and attach to the fresh audio element.
Also call a.play() after the srcObject swap so the autoplay policy
doesn't quietly drop the resumed playback.
Race when the host rejoined during their 30s grace window: the SFU
renegotiation that delivered the new mic track arrived over SSE BEFORE
the signal-server's peer-joined for the rejoined host. ontrack's
streams[0].id (= host's pubkey hex) had no matching member yet, so
the track was silently discarded — the listener saw no audio.
Cache the unmatched stream by pubkey hex and flush whenever the
roster updates (peer-joined, host-promoted, state). When the rejoined
host's peer-joined arrives, the queued stream pops out and attaches
to their row. Cleared on sfuUnsubscribe so stale streams don't carry
across rejoins.
space-closing flips the top status line to a red 'host left — space
closing in Ns' warning. Nothing was clearing it when the host actually
came back during grace (peer-joined with role=host) or when a senior
cohost auto-promoted (host-promoted). Both paths now reset the status
line to 'connected as <my role>' so the warning doesn't stick after
the rescue.
mic-state broadcasts only inform OTHER peers, so the local view of our
own member row stayed muted=false forever — the green mic-on icon
never flipped to the red cross when the host (or any speaker) muted.
Set members.get(myUUID).muted before sendMicState so renderRoom
reflects it immediately on our own row.
- index.html, kernel.html: were missing the viewport meta entirely;
mobile fell back to the default 980px layout and the content was
scaled down. Add the standard 'width=device-width,initial-scale=1'
and centre the body with margin:0 auto so the 640px max-width is
centered instead of left-aligned on wide displays.
- host-your-own.html, how-it-works.html, zebra-audio.html, chat.html:
add a 600px breakpoint that tightens body padding to 1rem, shrinks
the h1 from 3rem to 2.2rem, and lets long URLs / pubkey hashes wrap
via overflow-wrap:anywhere on body prose. .code / .diagram already
use overflow-x:auto so internal scrolling stays bounded to the box,
never pushes the page wider than the viewport.
- chat.html: cap the QR share box's canvas (was a fixed 320px square)
with max-width:100% on phones, so 320px-wide handsets don't overflow.
- zebra-audio.html: the mute button had a fixed 6rem width to keep the
'mute'/'unmute' label from shifting the row; on narrow phones that
width forced wrapping. Drop it back to auto on small viewports.
All pages stamp+verify; the integrity footer hashes in the source
match the served files after `make stamp`.
The 'give the mic' button (visible only when the listener has raised
their hand) now sends grant-mic, which the server promotes directly
without waiting for accept-mic. The 'invite mic' button (cold invite)
still triggers the existing mic-invite + accept dance.
Also fold in the earlier sfuPublish diagnostic logging so silent
returns surface in the page log.
Listeners now hear all speakers via a Pion-based audio-only SFU on
proxy.uncloseai.com. Speakers publish to it; listeners subscribe and
get one PC carrying every speaker's track. Mesh remains the
low-latency speaker<->speaker path; SFU is the broadcast fan-out.
Wiring:
- sfuPublish/Unpublish for speaker role transitions
- sfuSubscribe/Unsubscribe for listener role transitions
- onRoleEntered + onRoleChanged + leave hooks
- ontrack on the subscribe PC maps streams[0].id (= publisher pubkey
hex, set as the SFU TrackLocal StreamID) -> room member uuid; audio
element + meter attach to the matched member row
- SSE renegotiation: SFU pushes offer when speakers come/go; browser
answers via POST /answer
- applyMicMode now hot-swaps the SFU publish sender's track too, so
voice/music mode toggles apply over the SFU just like the mesh
Notice banner — fox flagged that booted/demoted users had no visible
signal. Added a #sec-notice section with .notice-banner (warn for
boot/demote-to-listener, info for promote). Boot also sets
wantConnected=false to prevent the WS auto-reconnect loop from
rejoining into a boot loop.
Two real defects in the meter wiring:
- the local mic was never fed to its meter — only remote streams were
- renderRoom replaces every member's meter DOM element, so the old
meterFor tick loop (which captured the OLD fill via closure) exited
on the next frame and the bar froze forever
Replace with a per-uuid meterCtl Map that holds the analyser + buffer
once and re-reads members.get(uuid)._meterEl fresh every animation
frame. Cleanup is automatic when the uuid drops from members (peer-
left, boot) or explicitly via stopMeter (dropMic, applyMicMode).
applyMicMode re-wires the local meter against the freshly acquired
stream since the old MediaStreamSource dies with the old track.
The grid was packing badge | handle | pubkey | mic | meter | buttons on
one line, which got crowded with three or four actions visible. Drop
mod-actions to a second grid row spanning the full width, indented
under the handle column. Hides itself entirely when empty so rows with
nothing to act on don't reserve dead space.
The control panel (identity, space, room, log) moves to a right column
occupying 50% of the page width. Left column is reserved for a timeline
to come; for now it shows a one-line placeholder. Stacks to single
column below 800px so mobile stays usable. Footer spans full width.
They were hidden behind the speaker role, so listeners (and the host
during the brief window before mic permission resolves) could not
disable echo/noise cancellation. Make both rows always visible; the
preference is picked up by micConstraints() the next time getMic runs,
so toggling before becoming a speaker is honored.
test/zebra-spaces.test.js — pure Node, four tiers:
1. pure protocol parity: extracts sigJoin/sigAction directly from
web/zebra-spaces.html (so the test tracks the shipped page),
compares produced bytes against fixtures pinned to the Go-side
unit tests in proxy.unturf.com/cmd/zebra-spaces-signal/main_test.go.
If JS drifts from Go by one byte the test fails — exactly the
silent break that would kill promotions in production.
2. ed25519 sign/verify: WebCrypto Ed25519 round-trip + tamper detection,
the same crypto stack the page uses for signed role transitions.
3. vault round-trip: PBKDF2 600k + AES-GCM, mirrors vaultExport/Import
in the page. Verifies wrong-password rejection.
4. live server (optional): if ZEBRA_SPACES_BINARY is set, launches the
relay, dials over real WebSocket, drives full join -> mic-invite ->
accept flow using browser APIs end to end.
Makefile: 'test-zebra-spaces' target auto-builds the relay binary
from ../proxy.unturf.com when present so the live tier runs without
manual setup. 'test-all' now includes it.
New page: web/zebra-spaces.html. Extends the rendezvous + WebRTC mesh
model from zebra-audio (1:1) to a room of up to ~8 speakers (host + 2
co-hosts + speakers) with listeners (deferred to v0.2 for SFU fan-out).
Identity: per-browser persistent Ed25519 keypair in localStorage,
plus a per-session UUID for in-room "whose who". Password-vault
backup/restore (PBKDF2 600k + AES-GCM, matches zebra-audio's crypto)
emits a self-contained 'zspc-id-v1|...' blob.
Roles: everyone joins as listener; mods (host + co-hosts) extend
mic-invites that listeners accept/decline; host promotes to co-host;
mods demote and boot. Co-hosts cannot remove the host. Every role
transition is signed by the acting mod's Ed25519 over a canonical
input bound to room_id + epoch, so a compromised relay cannot forge
promotions, only refuse to relay them.
Pairs with cmd/zebra-spaces-signal in proxy.unturf.com.
Nav: zebra-audio and host-your-own cross-link to spaces.
Makefile: stamp target now covers zebra-spaces.html (web/chat.html
and web/how-it-works.html stamps refreshed today as a side effect).
Trigger was event.detail === 3, the native UI triple-click counter, which
mouse triple-clicks increment but mobile taps do not, so the easter egg
was unreachable on phones. Count 3 clicks within 800ms ourselves; desktop
triple-click still satisfies it (still 3 click events), and finger taps
now reveal the console.
reportPath used to print 'you A:B ↔ peer C:D' in path-status and the log,
exposing both participants' real IP addresses to anyone glancing at the
screen. Show DIRECT vs RELAYED and the candidate types
(host/srflx/relay) only; addresses are gone. CLAUDE.md gains a
'Web UI privacy — never display peer IPs' rule so this does not regress.
Add web/host-your-own.html and link it from the chat and how-it-works headers
and footers. make stamp now stamps it alongside the other pages. CLAUDE.md:
turn the deploy flow into an explicit push-BOTH-repos reminder (zebra-report
source + www.unturf.com served) and list host-your-own.html as a deployed page.
Triple-click the footer integrity seal to reveal a hidden console: type a
line, pick one of the cloned voices, and it is synthesized at
speech.ai.unturf.com (/v1/audio/speech, no key, CORS open) and played into the
call — swapped onto the outbound WebRTC track so the listener hears the voice,
and to local output so you do too. The speak button doubles as stop: aborts an
in-flight synthesis or halts playback, then swaps the live mic back.
- chat + zebra-audio: treat ICE 'disconnected' as recoverable (grace
before acting), auto ICE-restart on 'failed'/timeout driven by the
offerer to avoid glare, and auto-rejoin the signaling socket if it
drops mid-call. Superseded-pc guard ignores late events from a
closed connection.
- zebra-audio: input-device dropdown that hot-swaps the mic via
replaceTrack (no renegotiation, mute preserved); refreshes on
devicechange so a plugged-in interface appears mid-call.
Add an integrity footer to chat.html, zebra-audio.html, and how-it-works.html
showing the build date (2026-05-28) and the page's own MD5 + SHA-256. A file
can't hold its own hash, so web/stamp.js (make stamp) computes the hashes with
the two hash fields zeroed, then writes the real values back — self-consistent
and idempotent. To verify a served page: blank the two fields and re-hash;
confirmed it reproduces the stamped value with plain sha256sum.
Add a "music mode" toggle that re-acquires the mic with echo cancellation,
noise suppression, and auto-gain OFF (so music/audio passes through instead of
being treated as noise and pumped), tags the track contentHint='music' so the
Opus encoder drops speech optimizations (DTX etc.), and raises the send bitrate.
Switchable mid-call via replaceTrack — hot-swaps the track with no
renegotiation, preserving mute state. Verified mid-call switch stays connected
both directions.
Lay out the controls and level rows with CSS grid and give the mute button a
fixed width, so toggling mute/unmute no longer shifts the buttons. Replace the
text "muted" badge with a mic-icon set (green open mic / red slashed mic) shown
next to "you" and "them", so both players can see at a glance whose mic is open
or closed. Verified both directions headless.
Each peer signals its mute state over the rendezvous channel (encrypted with
the room code, so the relay never learns it) on connect and on every toggle.
A "muted" badge now shows on both your own and the partner's level meter, so
both players can see who is muted. Verified both directions headless.
Reconnect: on "partner left", tear down the stale peer connection but stay in
the room, and have whichever peer is already present send the offer when the
other (re)joins — so a partner can leave and rejoin with no refresh, regardless
of who left. Every (re)negotiation runs on a fresh RTCPeerConnection.
Path indicator now reads the transport's selected candidate pair and prints the
actual local/remote IP:port + types, so DIRECT vs RELAYED is verifiable (host =
the two devices' IPs; relay = the TURN server's IP). The mic un-masks the real
host candidate, which is why a voice call reaches direct P2P on a LAN where the
no-mic chat fell back to TURN.
New single-page app: two rendezvoused partners type the same code and get a
live Opus voice call over WebRTC — direct P2P when possible, TURN relay
fallback otherwise, DTLS-SRTP encrypted end to end. Reuses the zebra-signal
rendezvous (code-encrypted SDP, zero-knowledge relay) and the ephemeral
/turn-cred credentials. Mic uses echo-cancellation/noise-suppression; mute,
hang up, live mic/remote level meters, and a direct-vs-relayed path indicator.
Deliberately NOT over the volume modem — ordinary low-latency voice, which
doubles as a plausible cover for the report channel.
Drop the hardcoded shared TURN login; fetch time-limited per-client credentials
from cors-proxy.uncloseai.com/turn-cred before each connection and build
iceServers from them. Falls back to STUN-only (direct path still works) if the
fetch fails.
After connect, read the selected ICE candidate pair from getStats() and show it
in the connect panel: a host/srflx pair => "DIRECT peer-to-peer — nobody between
you"; a relay candidate => "RELAYED through TURN (proxy.uncloseai.com sees
encrypted audio)". Lets users know whether any server sits in the media path.
Standalone blog-style page (matching chunkfive/monospace b&w UX) explaining the
whole solution: amplitude-as-data, the no-copy-paste signaling relay, the
multi-level modem, the Opus->G.711 codec fix and 440 Hz beat, the ACK/outbox
reliability layer, and the Hamming(12,8)+Gray FEC. Linked from chat.html's
header.
Add test/web-protocol.test.js — runs the real protocol code from chat.html in
Node (unit: crc/frame/ACK/HELLO codec, Hamming, Gray; integration: multi-level
modem roundtrip + FEC recovery of off-by-one symbol errors; functional: full
frame -> modem -> FEC -> assembler -> parse + ACK roundtrip). 3348 assertions.
Wired as `make test-web` (also in test-all). Lets us QA the modem without two
devices. Also: retransmit now uses exponential backoff so a lost ACK spaces out
retries instead of hammering the channel.
Each data byte is carried as a 12-bit Hamming codeword (8 data + 4 parity)
inside one modem byte-frame, so any single-bit error self-corrects instead of
failing the frame CRC and stalling the outbox on "no ack". Symbol levels are
Gray-coded so an off-by-one quantization (the dominant error) is a single-bit
flip Hamming can fix. Toggle ?fec=0 (both peers must match). Validated in Node:
all 256 bytes x 12 bit positions corrected; modem recovers 30/30 with a +/-1
symbol error per byte.
Two-panel CSS grid: setup (identity, room, connect, carrier) on the left,
chat on the right; stacks on phones (<=760px). Removes the manual offer/answer
boxes, the benchmark/handshake section, the mDNS note, the about/threat-model
prose, and the now-dead JS (share-link/QR helpers, those handlers, the inlined
QRCode library) — the relay path is the only bootstrap now. Validated headless:
clean load, two-column grid, no broken element refs, connect flow still works.
Add a reliable-delivery layer over the unreliable audio modem. A sent message
is held in an outbox (not echoed to the log) and retransmitted until the far
side ACKs it, then it moves into the log marked delivered. The DATA frame's
existing CRC32 is the message id; a new 15-byte T_ACK frame echoes it back.
Receiver ACKs every valid+decrypted frame (incl. duplicates, so a lost ACK
still stops the sender) and de-dupes on CRC so retransmits never double-show.
The carrier used 440 Hz (L) / 441 Hz (R) for the stereo Battle Toads mode, but
the modem codec is forced to mono G.711, which downmixes the two tones into a
1 Hz beat — a slow sinusoidal amplitude swell that swamps the level readings.
Use 440 Hz on both channels so the mono downmix is a clean single tone.
Generalize the browser physical layer from binary MARK/SPACE to N-level
amplitude symbols, so each baud tick carries log2(N) bits (N=2 = old binary,
N=4 = 2 bits, N=16 = 4 bits). Each byte is framed START(level 0/MIN) ..
STOP(level N-1/MAX) with the carrier idling high, so the receiver locks on the
high->low edge and self-calibrates its low/high amplitude reference every byte
(tolerant of channel gain drift). Symbols are sampled over the middle of each
window to avoid averaging across boundaries. Tunable via ?levels=N (2|4|16),
default 4. Misdecodes fail safe: a bad symbol breaks the frame CRC and the
frame is dropped rather than shown as garbage.
Roundtrip-simulated (clean + noisy channel): N=2 robust at all bauds, N=4
reliable to ~50 baud, N=16 to ~50 baud on a clean channel.
The binary carrier swung only 0.20-0.80; through a lossy codec that narrow gap
shrinks the margin between MARK and SPACE. Widen to 0.95 (near full scale, no
clip) and 0.10 (low but non-zero so the tone stays present for energy
detection), giving the adaptive-threshold decoder a cleaner split.
WebRTC defaulted to Opus, a perceptual codec that re-quantizes audio in 20ms
frames and smears the MARK/SPACE bit-edges the amplitude modem rides on — which
is why the PulseAudio C path does ~800 baud but the WebRTC path couldn't carry
frames at all. Pin the audio transceiver to memoryless G.711 (PCMU/PCMA), then
G.722, ahead of Opus via setCodecPreferences, restoring a clean amplitude
channel. Decoder resolution is now bounded by the AudioWorklet quantum (~375Hz),
not the codec.
The WebRTC Opus codec encodes in 20ms frames and smears the modem's bit
edges; at 50 baud a bit is ~one Opus frame, so frames rarely decode. Lower
baud gives more audio quanta per symbol and survives the smear. Expose the
link baud as ?baud=N (both peers must match) and lower the default to 20 so
the default path is more likely to decode. Also log the actual link baud on
HELLO instead of the advertised max (which was misleading).
The inbound UART decoder is constructed at ZEBRA_BAUD_HANDSHAKE and never
re-tuned, but chat DATA frames were sent at the negotiated/default baud (10).
HELLO frames (sent at 50) decoded fine — peers appeared and the link looked
healthy — but DATA at 10 never decoded against a 50-baud decoder, so messages
never crossed. Send DATA at ZEBRA_BAUD_HANDSHAKE to match the decoder.
Both peers type the same rendezvous code and connect automatically through
proxy.uncloseai.com — no manual offer/answer relay. The code derives an
opaque room ID and an AES key that wraps the SDP before it leaves the page,
so the relay sees only ciphertext. The existing share-link/QR exchange stays
as the offline fallback.
Also caps waitForIceGathering with a timeout: some environments never fire
the ICE 'complete' transition even after all candidates are gathered, which
previously hung offer/answer creation forever (affected the manual path too).
manual copy-paste of raw SDP was v1. this adds:
1. share-link encoding: SDP description → JSON → deflate-raw (native
CompressionStream) → base64url → URL hash. typical 2 KB SDP fits
in ~700 chars after compression. fits in any text channel.
URL format: .../zebra-report/#o=<deflated-base64> (offer)
.../zebra-report/#a=<deflated-base64> (answer)
2. QR rendering: same URL rendered as 180x180 QR using inlined
davidshimjs qrcodejs (MIT, 28 KB). offers a visual scan path
(phone scanners) without an in-browser decoder library yet.
3. auto-fill on link open: page reads location.hash on load.
#o=... → autofills remote-offer textarea, prompts user to
complete identity + audio + click "create answer"
#a=... → autofills remote-answer textarea, prompts "accept"
hash is cleared from address bar after parse so a reload doesn't
double-trigger.
4. parsers tolerate either input form: full URL with #o=/#a= hash,
or raw JSON SDP. peer A can paste back a URL or a textarea dump,
same handler.
5. tucked the raw SDP textareas behind a "show raw SDP" toggle so
the default UI is just the URL + QR. expert users still get the
raw bytes when needed.
UX flow (cross-internet, two peers):
A: enter room → start audio → "create offer" → "copy link"
→ send link to B via Signal/SMS/anywhere
B: open link → page auto-fills offer → enter room → start audio
→ "create answer" → "copy link" → send back to A
A: paste link into the answer textarea → "accept answer"
→ SRTP candidates pair → chat begins.
deferred: in-browser camera scanning of QR (needs jsQR or similar
~60 KB inlined; not on disk currently). v3.
file: 1509 lines, ~85 KB. JS syntax-clean (qrcode lib + main IIFE),
HTML balanced. CompressionStream + DecompressionStream available
in all current browsers (Chrome 80+, Firefox 113+, Safari 16.4+).