Voxal · WebRTC architecture reference

How a Voxal room is wired

Three planes, drawn from src/main.js. Signalling is a star through the host. Audio is a full mesh, peer to peer, always — no media server touches it under any setting. Camera and screen are their own meshes, and are the only thing that may move to a relay when a room grows.

Signalling — starDataConnection to the host. Room code = the host's PeerJS id.
Audio — mesh, alwaysOpus 32 kbps. Direct, or TURN-relayed. Never decoded by a server.
Video — mesh, then SFUPeer to peer up to 2 people; above that the default routes it through Cloudflare.

Reading the diagrams

SignallingPeerJS DataConnection. Control messages only — no media.
Audio mediaA MediaConnection carrying voice. Always peer to peer.
Camera / screen mediaSeparate MediaConnection sets, independent of audio.
HTTPSA plain request to a serverless endpoint. Setup only.

The main case

2–N participants · voice only

A simple voice room

One person creates a room and becomes the host. The room code is their PeerJS id, so there is nothing to look up. Everyone else connects to the host for control, and to each other for sound.

Figure 1 · Joining

Joiner voxal.app API PeerJS broker Host Peer C 1 · GET READY GET /api/ice-servers short-lived TURN credentials new Peer() — websocket to broker peer.id (a fresh UUID) 2 · SIGNALLING LINK connect(roomCode) — SDP offer + ICE candidates relayed to the host answer + candidates (back through the broker) DataConnection open — DTLS / SCTP, direct the broker is out of the path from here on 3 · ROSTER hello {pseudo, protocolVersion: 3, appVersion} pseudo-assigned — the host owns the name, and de-duplicates it peer-list {peers, hostId, deputyId, successorIds} + chat-history peer-joined, then peer-list to all 4 · SOUND peer.call(host, micStream) — Opus, in-band FEC peer.call(peerC, micStream) — one call per roster entry answered with C's own mic — one full-duplex connection per pair 5 · WHILE TALKING heartbeat every 2 s · a 7 s gap starts a migration talking {active: true} — push-to-talk pressed fanned out to every peer
The broker only introduces people. It carries the SDP offer, answer and ICE candidates, then drops out — every later message and every audio packet goes peer to peer. A joiner opens a call to every name in peer-list, not just to the host.

Figure 2 · Steady state, 4 people

Signalling — star everything routes through the host Audio — full mesh every pair, directly HOST A B C 3 DataConnections · N−1 HOST A B C 6 links · N(N−1)/2
Same four people, two different shapes. The host is special for control only — it stamps names, owns the clock for chat, and publishes the successor chain. For sound it is just another peer, and losing it costs no audio link between the survivors.

Figure 3 · Network flow — what each server sees

Your browser main.js + PeerJS HTTPS · SETUP ONLY PeerJS broker sees peer ids, SDP and ICE — never the media /api/ice-servers sees your IP · returns short-lived TURN credentials Presence API (optional) room directory when signed in · never media media — audio (and P2P video) TURN relay only if direct fails The other peer DTLS-SRTP, end to end A TURN relay forwards encrypted DTLS-SRTP. It cannot decode what it carries. No voice packet ever enters a media server — that boundary is permanent.
Four parties, and only one of them is in the media path. fetchIceServers() resolves the first source that answers: an embedding page, your organisation, metered.ca, this deployment's /api/ice-servers, then public STUN. The relay is a fallback for blocked networks, not a hop the app prefers.

Figure 4 · Losing the host

1 · running 2 · host lost 3 · migrated H A B C successorIds: [A, B, C] A B C no heartbeat for 7 s A B C kept A is host · first peer-list confirms
The chain is published, not elected on the spot. Every peer-list and every heartbeat carries deputyId and successorIds, so when the host goes quiet each peer already knows who is next. Audio links to non-host peers are never torn down during a migration — only the star is rebuilt.
Microphone
16 kHzmono · EC + AGC on · 48 kHz on the RNNoise path
Codec
Opus≤ 32 kbps · in-band FEC · DTX off
Heartbeat
2 s7 s silence starts a migration
Protocol
v3additive and tolerant · skew is expected

A heavier case

2 participants · camera + screen

Video between two peers

Nothing about the room changes. Turning a camera on adds a second, entirely separate MediaConnection set — it does not ride the audio one, and it does not renegotiate it.

Figure 5 · One pair, four channels

Peer A host Peer B DataConnection — hello · peer-list · talking · chat · video-offer MediaConnection · audio — Opus ≤ 32 kbps MediaConnection · camera — ≤ 600 kbps each way MediaConnection · screen — ≤ 1.5 Mbps, never cropped participants = 2 · not over the threshold (> 2) · stays peer to peer
Three media kinds, three independent sets. That separation is why the blur slider, a camera flip, or a mid-call microphone change never disturbs the others: each is a track swap or a shader uniform on its own connection. The video-offer that announces a camera travels the signalling star, like every other control message.

The routing decision · selectVideoTopology()

Your setting2 participants3 or moreReason code
Allow relay
default
P2P SFU preference-allow-sfu
Prefer direct P2P P2P + asks you p2p-unsuitable
Direct only P2P P2P
video may fail to establish
preference-p2p-only

The function is pure — no network, no DOM — and can only return sfu when the preference is Allow relay. If no SFU is configured on the deployment, every row falls back to P2P with sfu-unavailable. Audio is never passed through this function at all.

Where the mesh breaks

3+ participants · camera + screen

Video with more than two

Above two people, a video mesh costs each publisher one full upload per viewer. This is the one place Voxal will move media onto a server — and it moves the picture only.

Figure 6 · The same four people, two ways

Full mesh · each publisher uploads N−1 copies A B C D 6 video links · 3 × 600 kbps = 1.8 Mbps up from every camera Through the SFU · one up, N−1 down Cloudflare Realtime decrypts in order to forward A B C D up: 1 × 600 kbps down: 3 × 600 kbps Audio is identical in both: 6 peer-to-peer links, never through the SFU.
Only the blue and amber lines move. The decision is re-run whenever the roster changes — debounced 1.5 s and memoised on the participant count — so someone who started sharing in a two-person room is migrated when the third person arrives, rather than staying on a mesh for the life of the call. Migrating costs viewers a brief interruption in the picture; audio is untouched.

Figure 7 · Uplink per publisher, camera at 600 kbps

full mesh SFU (audio still meshed) 1 Mbps 1264 664 1896 696 2528 728 3160 760 4424 824 3 4 5 6 8 people in the room kbps uploaded by one person with a camera on, audio included
The SFU line is nearly flat because only audio still grows. At eight people the mesh asks for 4.4 Mbps up from every camera — past a normal home uplink — while the relayed path asks for 824 kbps, of which 224 kbps is the audio mesh that never leaves peer to peer. At two people both paths are identical (632 kbps), which is why the threshold sits where it does.

Figure 8 · The SFU control plane

Publisher /api/sfu-session /api/sfu-track Cloudflare Viewer MAY I? {roomCode, participantId, kind, action: 'publish'} capability · HMAC · 300 s · 30 per IP per minute NEGOTIATE capability + offer + tracks[{location:'local', mid, trackName}] sessions/new → tracks/new sessionId + answer sessionDescription — the app secret never leaves the server media up — DTLS-SRTP TELL THE ROOM video-offer {topology:'sfu', providerRef:{sessionId, trackName}} — relayed by the host VIEWER PULLS action: 'subscribe' → capability tracks[{location:'remote', sessionId, trackName}] Cloudflare replies with an OFFER · requiresImmediateRenegotiation POST /api/sfu-renegotiate {answer} media down
Two endpoints, and only one of them holds a secret. /api/sfu-session is the cheap, rate-limited "may I?" — it never talks to Cloudflare. /api/sfu-track verifies that token and is the only place the Cloudflare app secret is used. The providerRef that names the track travels over Voxal's own signalling star; no Cloudflare data channel is used for application state.
The relay is not the TURN relay. A TURN server forwards opaque encrypted DTLS-SRTP and cannot decode it. An SFU decrypts media in order to forward it selectively — so with the default Allow relay setting, camera and screen in a room of three or more can technically be seen by Cloudflare. A ☁ Relayed badge appears on those tiles. Voice is never affected under any setting.

Reference

protocol v3

What travels the star

Every message below rides a DataConnection. Peers send to the host; the host is the only party that fans out.

MessageDirectionWhat it carries
hellojoiner → hostpseudo, protocolVersion, appVersion
pseudo-assignedhost → peerthe name the host actually granted, de-duplicated
peer-listhost → peerthe whole roster, plus hostId, deputyId, successorIds — the authoritative view
peer-joined / peer-left / peer-renamedhost → allroster deltas between full lists
talkingpeer → host → allpush-to-talk state
heartbeathost ↔ peersevery 2 s, carrying the successor chain again
video-offer / screen-offerpeer → host → alltopology (p2p or sfu) and, when relayed, providerRef
video-call-peerhost → P2P sharers"a newcomer arrived — call them with your camera". Not sent for an SFU sharer: the newcomer subscribes on its own.
chat / chat-editpeer → host → all, sender includedthe host stamps sender and time, so the echo back is the ack
chat-historyhost → joinerthe transcript so far, from the host's own replica
redirectpeer → joiner"the host moved" — sent by a peer someone tried to join after a migration

Drawn from src/main.js, api/_sfu.js, docs/video-routing.md and docs/turn-and-ice.md. Bitrates are the configured ceilings (OPUS_MAX_BITRATE 32 kbps, CAMERA_MAX_BITRATE 600 kbps, SCREEN_MAX_BITRATE 1.5 Mbps), not measured averages.