# Voice and video Calls in PMs and group PMs, and the voice rooms of a space, run on a self-hosted [LiveKit](https://livekit.io) server. LiveKit only carries media; the API owns everything that decides *who is where and what they may do*. The media itself is end-to-end encrypted, so the SFU forwards ciphertext it cannot read - see [End-to-end encryption](#end-to-end-encryption). This document is the map. ## Pieces | Piece | Where | Job | | --- | --- | --- | | LiveKit SFU | `deploy/docker-compose.yml` → `livekit`; root `docker-compose.yml` for dev | WebRTC media routing, participant tracking, webhooks | | Voice module | `equinox/internal/modules/voice` | Tokens, voice states, permissions, moderation, PM call ringing, webhook + reconciler | | Call keys | `web.strafe.chat/src/lib/e2ee/callKeys.ts`, `lib/voice/e2ee.ts` | Per-sender media keys, Olm-distributed; the LiveKit key provider | | Gateway | `equinox/cmd/stargate` | Puts current voice states and calls in `READY`; relays `VOICE_STATE_UPDATE` / `CALL_*` | | Client store | `web.strafe.chat/src/stores/voice.ts` | The LiveKit connection, audio context, mic pipeline (RNNoise in `lib/voice/rnnoise.ts`), self controls, per-user volume, speaking, ringing | | Client UI | `web.strafe.chat/src/components/voice/` | Stage (tiles + controls), dock, sidebar rows, per-user menu, incoming call | ## Configuration The API needs three variables; without them voice is off and every call control is hidden (`GET /` → `features.voice.enabled`): ``` LIVEKIT_URL=wss://chat.example.com/livekit # what browsers connect to LIVEKIT_INTERNAL_URL=http://livekit:7880 # server API; defaults to LIVEKIT_URL with ws->http LIVEKIT_API_KEY=... LIVEKIT_API_SECRET=... # 32+ characters ``` The compose deployment generates the key pair in `setup.sh`, proxies the signalling WebSocket through Caddy at `/livekit`, and publishes the media ports straight from the LiveKit container: `7881/tcp` (ICE over TCP), `50000-50200/udp` (media), `3478/udp` (built-in TURN). LiveKit discovers its public IP through STUN; `LIVEKIT_NODE_IP` pins it. ## Data model A **voice state** (`voice.State`) is one user in one voice room - Discord's VoiceState: self mute/deaf, moderator mute/deaf, camera and screen flags, `suppress` (no Speak permission), `priority_speaker`, `connected`, and a `user` summary so clients can draw a tile without a member fetch. A user is in at most one voice room across the instance; joining another leaves the first. States live in Redis (`voice:room:` hash, `voice:user:` pointer, `voice:rooms` set) - they describe running connections, nothing that should outlive them. PM / group PM calls add a `voice:call:` object with who started it and who is still being rung. A state is written when a token is issued, confirmed (`connected`) by LiveKit's `participant_joined` webhook, updated by `track_published`/`track_unpublished` (camera and screen flags are LiveKit's word, not the client's), and removed by `participant_left`, `room_finished`, an explicit leave, a moderator, the room or its space being deleted (which ends the call), or the reconciler (every 30 s, drops states LiveKit no longer backs after a 45 s grace). LiveKit identities are `.`; the session part is fresh per join so a late `participant_left` from an earlier connection cannot remove a newer state. LiveKit rooms are named `strafe_`. ## Permissions Eight room-scoped bits in `permissions/bits.go`, mirrored in `web.strafe.chat/src/lib/spacePermissions.ts`: | Bit | Meaning | | --- | --- | | Connect | Join the room at all | | Speak | Publish a microphone; without it the state is `suppress`ed | | Video | Camera and screen share (with its audio) | | Use voice activity | Otherwise the client must use push to talk (client-enforced, as on Discord) | | Priority speaker | Others are ducked while they talk (client-side ducking) | | Mute members / Deafen members | Server mute / deafen people in the room | | Move members | Move people between voice rooms, and disconnect them | `@everyone` gets Connect, Speak, Video and voice activity by default. Existing spaces were backfilled once at startup (`spaces.BackfillVoicePermissions`, tracked in the `data_migrations` table). PMs and group PMs need no permissions. Speak, Video and moderator mute/deafen are enforced by LiveKit, not just the client: the join token's `canPublishSources` and `canSubscribe` follow the permissions and the state, and `UpdateParticipant` changes them live when a moderator acts. A server-muted member cannot re-publish a microphone; a deafened one receives no tracks. Voice rooms also carry a **user limit** (0-99; Move members bypasses it) and an audio **bitrate** (8-384 kbps, default 64) - `PATCH /spaces/:id/rooms/:roomId`. ## HTTP API | Route | Notes | | --- | --- | | `POST /rooms/:id/voice/join` | `{self_mute?, self_deaf?}` → `{url, token, room_name, state, states, bitrate, call?}`. Leaves any other room first. Same room again re-issues the token. | | `POST /voice/leave` | | | `PATCH /voice/state` | `{self_mute?, self_deaf?, self_video?, self_stream?}` | | `GET /rooms/:id/voice/states` | `{states, call}` | | `POST /rooms/:id/call/ring` | `{user_ids?}` - group calls; everyone not yet in it when omitted | | `POST /rooms/:id/call/decline` | Stops ringing the caller on every device | | `PATCH /spaces/:id/members/:userId/voice` | `{mute?, deaf?, room_id?}` - each needs its permission in the target's room; audited | | `DELETE /spaces/:id/members/:userId/voice` | Disconnect (Move members); audited | | `POST /voice/webhook` | LiveKit → API, verified by LiveKit's signature | In a space hosted on another instance, the two moderation routes act at the origin and return its status and message, `502` when it cannot be reached. ## Gateway events - `VOICE_STATE_UPDATE` - a full state; with `left: true` for a leave (and `moved_to` when a moderator moved them, which is the client's cue to rejoin there). Space rooms: on the space channel when nobody is kept out of the room, otherwise on the room's own channel, which the gateway lets only its viewers subscribe to - so who is in a private room reaches no one else, and no member list is read per event. The state's own user also hears it on their channel, because someone who just lost View still needs their left. Where a moved user went is the destination's occupancy: it is in the room's event only when everyone may see the destination, and otherwise goes to the moved user alone, first. PMs: to each participant. - Losing View of a voice room while connected to it ends the connection, as on Discord: the instance hosting the call disconnects them (a mirror hears the leave from it). - `CALL_CREATE` / `CALL_UPDATE` / `CALL_DELETE` - PM calls: started (rings the others), ringing list changed (`rerung: true` restarts the ringtone), ended. To each participant. - `READY` carries `voice_states` and `calls` for every room the user can see. PM rooms also get `call_started` / `call_ended` system messages. ## Client notes - One `AudioContext` per call, at 48 kHz, carries both directions: the microphone pipeline captures into it and LiveKit mixes remote audio out of it (`webAudioMix` with our context; LiveKit keeps a muted `