Commit graph

275 commits

Author SHA1 Message Date
353037bc5d
zebra-spaces: kill audio chops + clear tile when publisher unshares
Audio robustness:
- useinbandfec=1 + usedtx=1 in publisher's Opus fmtp (was only fec; dtx
  drops silence so the budget goes to audible content + cuts congestion)
- ev.receiver.playoutDelayHint = 0.1 (100ms jitter buffer) on every
  incoming audio receiver — SFU subscribe + mesh peer. Absorbs Wi-Fi
  micro-bursts without perceptible conversation lag.

Tile cleanup on unshare:
- when SFU stops a screen/camera transceiver after the publisher
  unpublishes, the remote track fires 'ended'. Listeners now wire
  track.onended → removeScreenTile/removeCameraTile so the tile
  disappears instead of freezing on the last frame.

Paired with SFU 60a620f which advertises nack + transport-cc feedback
so browsers send the RTCP we depend on for both retransmit and
congestion control.
2026-06-01 21:05:30 -04:00
7ddedc9a09
zebra-spaces: speakers can toggle camera (1280x720 @ 1.5Mbps), separate publish
Camera is a third SFU publisher alongside mic and screen — same multi-
track pipeline, kind=camera suffix on streamID, own #cameras grid in
the timeline column (multi-column auto-fill so multiple face cams fit
without dwarfing a screen share above them).

- new UI: share-camera / stop-camera buttons + device selector in the
  same share section. Camera-select restart triggers a clean unpub +
  re-pub because deviceId change needs renegotiation anyway.
- new state: sfuCameraPC/Stream/PeerID + cameraStreams/cameraVideos maps
- subscribe ontrack: generic colon-split routes pubkey:screen/camera
  to the right tile renderer; back-compat for the existing :screen path
- renderScreenTile + removeScreenTile refactored to a kind-parametric
  renderVideoTile via a TILE_KINDS map; the old names stay as thin
  shims so callers don't change
- role demotion + leave + peer-left + boot all clean up cameras too
- contentHint='motion' for face-cam (vs 'detail' on screen)
- camera bitrate capped at 1.5Mbps so screen-share headroom isn't
  cannibalised when both are publishing simultaneously

Paired with SFU change zebra-spaces-sfu#017c94b which allowlists
kind=camera alongside screen.
2026-06-01 20:43:49 -04:00
3f43379e00
zebra-spaces: applyConstraints + log effective mic settings — surfaces hidden filtering
When a PulseAudio monitor source is selected as the mic and music mode
is toggled on, Firefox can silently apply its default audio-processing
pipeline (EC/NS/AGC) regardless of the getUserMedia constraints. The
broadcast then sounds 'cleaned up' instead of letting the source pass
through transparently.

Two fixes:
- call track.applyConstraints(micConstraints()) after getUserMedia/replace.
  Some UAs honour applyConstraints when they silently ignored the initial
  request. Belt-and-suspenders.
- log track.getSettings() so we can see what the UA actually applied —
  ec/ns/agc/channels/sampleRate. If applyConstraints didn't stick, the
  log shows it instead of failing silently.
2026-06-01 20:33:41 -04:00
1294e96f01
zebra-spaces: surface getDisplayMedia audio-track count + browser-specific hint
Firefox getDisplayMedia silently drops audio for window/tab sources (only
'entire screen' carries system audio). Users were broadcasting video-only
without knowing the audio never made it into the captured stream.

Log the captured track counts unconditionally; when audio is 0 explain
the limitation per browser and point to the workaround (route the tab
through mic music mode for high-quality stereo broadcast).
2026-06-01 20:27:38 -04:00
6fbeade12d
zebra-spaces: tap-to-play overlay + HTML muted attribute — fixes Firefox Android autoplay
Firefox Android (and other strict mobile browsers) check the HTML 'muted'
attribute, not the IDL .muted property, when deciding whether MediaStream
<video> is autoplay-eligible. Setting only the property left the video
ineligible, so play() rejected silently and the user saw a black tile.

Three changes:
- set the autoplay/playsinline/muted attributes alongside the properties
  so every UA's autoplay heuristic agrees the element is eligible
- log play() rejections instead of swallowing them — silent failures hid
  this from us until now
- when play() does reject, show a 'tap to play' overlay that covers the
  video area (but not the meta bar); tapping counts as the gesture and
  the retry succeeds
2026-06-01 20:20:10 -04:00
5affebead5
zebra-spaces: broadcast-quality mic + screen capture (stereo Opus 256k, 1080p30 @ 6Mbps)
Music mode mic upgrades:
- request stereo @ 48kHz from getUserMedia (was mono default)
- RTP-level bitrate cap 256kbps (was 160kbps mono — too low for music)
- SDP fmtp munged to advertise stereo=1+sprop-stereo=1+maxaveragebitrate=256000
  so both ends agree on stereo + room to use the bitrate
- music-mode toggle now re-publishes the SFU PC instead of just replaceTrack
  (replaceTrack alone leaves the negotiated SDP mono — stereo never reaches
  the SFU even with a stereo track)

Screen-share upgrades:
- getDisplayMedia asks for 1920×1080 @ 30fps + stereo 48kHz audio
- video sender capped at 6Mbps, audio sender at 256kbps
- track contentHint 'detail' for video (favour pixels over framerate)
  and 'music' for audio
- offer SDP munged for stereo Opus same as mic

Helper added: preferStereoOpus(sdp, bps), setSenderMaxBitrate(sender, bps).
2026-06-01 20:18:24 -04:00
c454164d06
zebra-spaces: wider layout, narrower right column, 92vh video, trim prose
Maximize screen-share resolution on desktop/laptop:
- drop body max-width cap (was 1440px) so layout breathes to full viewport
- shrink right column from 520px to 360px; left column gets the surplus
- bump screen-tile video max-height from 80vh to 92vh
- tighter padding + gap

Cut four prose blocks to roughly a third:
- identity note: full key-management paragraph → 'key lives in this browser only'
- input note: full role/encryption paragraph → 'join as listener; host promotes to mic. end-to-end encrypted.'
- share note: full URL-embedding paragraph → 'share only with people you trust to hear the room.'
- screen-share note: full picker explanation → 'window or tab; tick share audio if offered.'
2026-06-01 20:06:05 -04:00
2fd2ac3546
zebra-spaces: start remote screen tiles muted + add unmute button — fixes mobile autoplay
Mobile browsers refuse to autoplay a <video> carrying an unmuted audio
track without a user gesture; the whole element stays paused, so the
video pixels never render either. The phone listener saw a black tile
even with a healthy track flowing.

Start the remote tile muted so it autoplays everywhere; expose an
'unmute audio' button in the meta bar that toggles. The button click
counts as the gesture, so audio kicks in on demand. Local preview never
offers unmute (would feed back into the publisher's own mic).
2026-06-01 20:02:03 -04:00
d9852e661e
zebra-spaces: local preview tile so publisher sees their own screen share
The SFU does not echo a publisher's stream back, so anyone sharing alone
in a room had no visual confirmation — game iframe stayed up, no tile
appeared. Render a muted local preview keyed by myKeys.pubHex on publish,
remove on unpublish. Subscribe path skips own pubHex to defend against
SFUs that do echo publishers.

Also refreshes integrity stamps on the other web pages (date drift).
2026-06-01 19:45:15 -04:00
e8ba836b9c
zebra-spaces: shared screens render in the left column (full width), game iframe hides while sharing
Previously sec-screens lived inside the controls aside, capped at 520px
wide — useless for any real screen view. Move it into the timeline
column at the top, where there's actual horizontal space. CSS :has()
on .timeline hides the game tabs + iframe whenever a screen tile is
visible, so the screen owns the full left column. As soon as the last
sharer stops, the game switcher returns.
2026-06-01 15:21:38 -04:00
70de089754
zebra-spaces: log out button next to backup/restore — wipe identity + generate fresh 2026-06-01 15:10:13 -04:00
5e23ad53ff
zebra-spaces: screen share — share button, separate sfuScreenPC, render incoming :screen streams as <video> tiles
UI:
- sec-screen-share: 'share screen' button visible only when canSpeak
  (host/cohost/speaker). On click: getDisplayMedia({video, audio}).
- sec-screens: video tile per active screen share; auto-shows when
  the first tile arrives, auto-hides when the last leaves.
- Each tile has a 'fullscreen' button.

Publish:
- sfuPublishScreen opens a SEPARATE sfuScreenPC, adds the
  display-media tracks (video + optional audio), POSTs
  /publish?kind=screen so the SFU's TrackLocal streamID gets a
  ':screen' suffix.
- Browser-native 'stop sharing' bar fires the video track's 'ended'
  event — we trap it to do a clean sfuUnpublishScreen.

Subscribe:
- sfuSubscribe ontrack checks streams[0].id for the ':screen' suffix
  — screen streams route to renderScreenTile (creates / updates a
  <video> element). Mic streams continue down the existing audio
  path. The label uses the publisher's handle from the room state.
- Cleanup paths: peer-left (drops the matching pubHex's tile),
  self-boot, self-blocked, leave button — all call
  sfuUnpublishScreen + removeScreenTile.

CSS: screen-tile has its own 1fr grid, max-height 70vh so a screen
share never bullies the controls column off the page on a small
display.

Pairs with the multi-track + kind=screen support that just landed in
zebra-spaces-sfu (b7c5a8a).
2026-06-01 14:41:27 -04:00
95d68fafac
zebra-spaces: timeline column gets a game switcher (unmario / cake murder adventure) 2026-05-31 18:35:04 -04:00
c7a8a938b3
zebra-spaces: load unmario.com in the timeline column until v0.3 lands 2026-05-31 16:57:19 -04:00
f3b154da2b
zebra-spaces: terminal block + share URL + autofill; host-your-own: SFU section
- 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.
2026-05-31 15:53:14 -04:00
e72f2e8eed
zebra-spaces: pass identity pubkey on SFU /subscribe so boots evict the listener path; tear SFU + mesh on self-boot 2026-05-31 15:20:03 -04:00
f56bd39980
zebra-spaces: invite banner stacks message above accept/decline buttons 2026-05-31 15:10:12 -04:00
a1ce6c0598
zebra-spaces: fix mobile horizontal scroll + host rejoin audio + timeline below
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.
2026-05-31 14:19:43 -04:00
c525c7df11
web: bump mobile body padding from 1rem to 1.25rem so the right edge has visible breathing room 2026-05-31 14:13:25 -04:00
cc46d96d30
zebra-spaces: queue SFU ontrack until peer-joined catches up
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.
2026-05-31 14:08:53 -04:00
2051a712af
zebra-spaces: clear the 'space closing in 30s' status when host returns
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.
2026-05-31 14:02:58 -04:00
3a07426994
zebra-spaces: own mute flips own mic icon
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.
2026-05-31 13:54:23 -04:00
c8a9adc423
web: every page works on mobile without horizontal overflow
- 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`.
2026-05-31 13:52:32 -04:00
1a6e955424
zebra-spaces: client wires grant-mic for hand-raised listeners
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.
2026-05-31 13:25:51 -04:00
9ed1cefcd5
zebra-spaces: SFU bridge + role-change notice banner
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.
2026-05-31 12:48:20 -04:00
7e54e1b7ef
zebra-spaces: persistent per-peer meter runner (survives re-renders + wires local mic)
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.
2026-05-31 12:10:57 -04:00
cd4a0ff6f5
zebra-spaces: mod-action buttons drop to a new row under each member
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.
2026-05-31 12:00:10 -04:00
a038adde7b
zebra-spaces: two-column layout — right=controls (50%), left=timeline (placeholder)
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.
2026-05-31 11:59:11 -04:00
a2d43e40c3
zebra-spaces: show mic input + music-mode controls before getting the mic
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.
2026-05-31 11:52:33 -04:00
dedb44179d
zebra-spaces: JS↔Go protocol parity + crypto tests
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.
2026-05-31 11:06:59 -04:00
1e76f62f71
zebra-spaces v0.1: multi-party voice rooms with roles, identity, vault
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).
2026-05-31 10:54:36 -04:00
80e062c084
zebra-audio: puppet reveal counts our own clicks — mobile taps work too
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.
2026-05-29 19:14:15 -04:00
17ba25f9cb
zebra-audio: stop displaying peer IP/port in path status — no dox
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.
2026-05-29 17:43:33 -04:00
5e1c4402b2
web: host-your-own page + nav links; deploy-to-both-repos reminder
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.
2026-05-29 15:17:22 -04:00
b269fce2f6
zebra-audio: secret TTS puppet console
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.
2026-05-29 15:17:16 -04:00
162b4b2917
web: auto-reconnect on cross-LAN drops + live mic-input switching
- 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.
2026-05-28 21:29:50 -04:00
21e3e84b14
CLAUDE.md: document make stamp page-integrity workflow 2026-05-28 19:20:57 -04:00
0ca6b2b31a
how-it-works: explain what 'hi' becomes (AES-GCM scramble before volume) 2026-05-28 17:58:23 -04:00
ae3e4316fb
web: stamp each zebra-report page with build date + md5/sha256
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.
2026-05-28 17:50:12 -04:00
60615a2e73
zebra-audio: music mode — disable voice isolation for playing audio
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.
2026-05-28 17:38:41 -04:00
d106bd224e
zebra-audio: mic on/off icons via CSS grid; stop button reflow
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.
2026-05-28 15:09:24 -04:00
da78dc8612
zebra-audio: advertise mic (mute) state to the partner
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.
2026-05-28 14:58:16 -04:00
8dfdd9561c
zebra-audio: auto-reconnect on partner return + verifiable path
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.
2026-05-28 14:13:38 -04:00
860afbd9e4
zebra-audio: real-time WebRTC voice-call SPA (cover front)
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.
2026-05-28 13:57:05 -04:00
cc4cc8065c
chat.html: fetch ephemeral TURN credentials from /turn-cred
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.
2026-05-28 13:27:31 -04:00
5077e982fe
chat.html: when relayed, log how to force a direct P2P path (disable mDNS masking) 2026-05-28 13:09:08 -04:00
6002732581
chat.html: surface the media path (direct P2P vs relayed via TURN)
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.
2026-05-28 12:56:51 -04:00
c287328bb9
web: add "how it works" write-up + link it from the chat header
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.
2026-05-28 12:50:34 -04:00
7c39f3537a
test: node web protocol suite (no devices) + exponential retransmit backoff
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.
2026-05-28 12:44:20 -04:00
a01f1b4aac
chat.html: Hamming(12,8) FEC + Gray-coded levels on the modem
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.
2026-05-28 12:41:13 -04:00