/ concept-collection / commonroom
Sign in
concept-collection / commonroom
commonroom
Go to fileHistoryFork
Jeremy Magland update App.tsx 7d08e76 5 Commits
.githubOptional TURN relay, gated by a token and shared across the room
srcupdate App.tsx
workerOptional TURN relay, gated by a token and shared across the room
.env.exampleOptional TURN relay, gated by a token and shared across the room
.gitignoreOptional TURN relay, gated by a token and shared across the room
CLAUDE.mdOptional meeting transcription, gated on speech and paid for by one participant
index.htmlServerless group video calls: rooms, WebRTC mesh, shared settings
package-lock.jsonServerless group video calls: rooms, WebRTC mesh, shared settings
package.jsonServerless group video calls: rooms, WebRTC mesh, shared settings
README.mdOptional meeting transcription, gated on speech and paid for by one participant
tsconfig.jsonServerless group video calls: rooms, WebRTC mesh, shared settings
tsconfig.tsbuildinfoOptional meeting transcription, gated on speech and paid for by one participant
vite.config.tsServerless group video calls: rooms, WebRTC mesh, shared settings

commonroom#

Serverless group video calls in the browser.

Live page: https://concept-collection.github.io/commonroom/

Enter your name and a room name — any string you like (no spaces) — and you're in. Share the room URL with anyone; everyone who joins the same room is connected to everyone else over a full WebRTC mesh, up to 8 people. You enter with your microphone and camera off and turn them on when you're ready. Click any tile to enlarge it (the rest shrink to a filmstrip); click it again or press Esc to return to the gallery.

The room has shared settings that anyone can change and that apply to everyone — currently the video quality (low / medium / high / auto, medium by default). You can also share your screen in place of your camera, and there's a room chat (with join/left notices and clickable links) that is as ephemeral as the call itself: you only see what's said while you're in the room, and nothing is stored anywhere. Anyone with a Deepgram API key can also transcribe the meeting from their own browser (see below).

How it works#

There is no backend and no room registry. The techniques come from the sibling projects commonview (the auto-connecting mesh) and commoncall (WebRTC media, quality presets, screen share):

  • Identity — each browser generates a secp256k1 (BIP340 schnorr) keypair, persisted in localStorage. The x-only public key is the peer ID, and every nostr event is signed with it, so peers can't be impersonated.
  • Rooms — the room name is hashed into a nostr topic; knowing the name IS the key. Everyone in the room announces {peerId, name} on that topic every few seconds via ephemeral events on public relays; entries expire when announcements stop.
  • Mesh — being in the room is the consent: every participant automatically brings up a WebRTC connection with every other participant (deterministic initiator = smaller peer ID; offer/answer/ICE ride per-peer nostr topics). Audio/video flows directly between browsers, with public STUN servers and an optional TURN relay as fallback (see below). Rooms are softly capped at 8 — peers already at capacity turn newcomers away.
  • Muted by default — camera/mic are requested on entry so unmuting is instant, but tracks start disabled. If you deny access you still join, sending silent/black placeholder tracks; unmuting retries the device and upgrades the track in place (replaceTrack, no renegotiation).
  • Shared settings — one settings object for the whole room, synced over the per-peer control data channels with per-key last-writer-wins (revision counters; ties resolved by the setter's peer ID). The video-quality presets map to RTCRtpSender.setParameters caps that each participant applies to its own outgoing senders.

Transcribing a meeting#

Anyone in the room who has a Deepgram API key can turn on a live transcript from the transcript panel in the control bar. Because a mesh call already delivers everyone's audio to every participant, that one browser can transcribe the whole room: it opens a separate streaming connection per speaker, so the transcript says who said what without relying on speaker diarization to guess.

Only speech is sent. A voice-activity gate on each participant's audio holds the connection open through silence with unbilled keep-alive messages and streams audio only while someone is actually talking, which is what keeps an hour-long call from being billed as eight hours of room tone. The panel shows how many seconds have actually been sent, so the cost is visible while it accrues. A short pre-roll buffer means the gate opening does not clip the first word.

The key is stored in this browser and used only to connect to Deepgram; it is never shared with the other participants, and the person who enters it is the one billed. The transcript is likewise local: it is not sent to the other participants, for the same reason the chat has no history replay — text attributed to someone but relayed by someone else is text they cannot vouch for. Everyone does see that transcription is running, as a badge on the transcriber's tile and a line in the chat.

Unlike the chat, the transcript is kept: it is stored in this browser under the room's name and comes back the next time you enter that room, with buttons to copy it, save it as a text file, or clear it (which asks first). Bear in mind that this leaves meeting transcripts in the browser's local storage.

The relay, and how one token covers a room#

Most pairs of browsers can reach each other directly once STUN has told them their public addresses. Some cannot: symmetric NAT at both ends, or a firewall that only permits outbound 443. Those pairs need a TURN relay, which costs bandwidth and therefore cannot simply be handed to everyone who loads the page.

The arrangement here is that relay credentials are bought with a token, but only one person in the room needs to have one. Whoever has it types it into the landing form; their browser exchanges it at the credential Worker in worker/ for a short-lived ICE configuration, and that configuration is then shared with the rest of the room over the same control data channels that carry chat and settings. Everyone else adopts it and gains relay candidates of their own, so the relay is available to every pair in the room rather than only to the token holder.

What travels between peers is the credential, never the token. The token stays in the browser it was typed into; the credential expires on its own and can be revoked at the Worker. Credentials arriving from a peer are treated as untrusted input and validated down to a bounded list of well-formed ICE URLs before they go anywhere near an RTCPeerConnection.

Note that a credential cannot help the connection that carried it: a peer learns it from the first peer it manages to reach, and uses it for every connection after that. In practice the token holder's own relay candidates are what make that first connection work for a peer that has none. A pair that stalls in the meantime is rebuilt by the ordinary connection retry, which picks up whatever credentials have arrived since, so recovery takes up to about twenty seconds.

The control bar shows where the room's relay came from — your token, another participant's, or none at all. All of this is optional: with no VITE_TURN_ENDPOINT configured at build time the token field is hidden entirely, and calls fall back to STUN plus a free public relay exactly as before. See worker/README.md for deployment and costs.

Development#

npm install
npm run dev

Identity is per-browser-profile (localStorage), so two tabs in the same profile are the same peer — to try a room with multiple participants, use a second browser or a private window.

npm run build type-checks and bundles to dist/. Pushes to main deploy to GitHub Pages via .github/workflows/deploy.yml.

To work on the relay as well, copy .env.example to .env.local and point VITE_TURN_ENDPOINT at a Worker (cd worker && npm install && npm run dev serves one locally on port 8787). The Worker is deployed separately from the page, with npx wrangler deploy.

moveopenescclose