docs/VOICE_ROOM.md
Rama main · réplica de lectura del repositorio original.
# Voice Room UI — M14 / Phase 2 community MVP
The voice-room UI is the user-facing deliverable that closes Phase 1 of the roadmap — the "two browsers in the same room" success criterion. M14 ships the real LiveKit-driven UI at `/room/[roomName]`, replacing the developer-facing `/connect` page that the M9 work exposed.
This document covers:
- The `/room/[roomName]` page
- The `livekit-client` choice (no `@livekit/components-react`)
- The connect flow
- The self-mute / server-mute split
- The 5-second presence heartbeat
- The `NEXT_PUBLIC_LIVEKIT_URL` env var
- The relationship to M9 (`/api/livekit/token`)
- The out-of-scope list (server-side mute, screenshare, recording)
> Sections below the "Current behavior" block describe the original M14
> design; where they disagree, the block below wins.
## Current behavior (beta review, 2026-09-19)
The primary voice UI is the lobby (`app/lobby/LobbyVoiceProvider.tsx`);
`/room/[roomName]` is the activities page. Both were exercised end to
end with `apps/web/e2e/voice-ui-audio.spec.ts`, which drives the real UI
and asserts on the browsers' own WebRTC stats (inbound-rtp bytes +
`totalAudioEnergy`), and now runs in CI.
**Endpoint resolution.** `NEXT_PUBLIC_*` is inlined at build time, and
the release image is built without it. The token response carries
`livekitUrl`, resolved at REQUEST time (`LOBBYFORGE_PUBLIC_LIVEKIT_URL`,
then `NEXT_PUBLIC_LIVEKIT_URL`, read through a computed key). Browsers
use, in order: that value, the build-time value, then the same-origin
`/livekit` proxy. The realtime client uses the build-time WS URL, then
`wss://<host>/ws` on HTTPS pages, then `ws://<host>:3001` on plain-HTTP
dev pages (`lib/public-endpoints.ts`).
**Moderator server mute.**
- `POST /api/servers/{id}/channels/{channelId}/members/{userId}/voice/mute`
persists `memberships.voice_muted` (migration 0036).
- The token route leaves the microphone out of `canPublishSources` while
the flag is set, so rejoining does not lift it.
- `syncMemberVoiceAccess` (`lib/voice-moderation.ts`) mutes a live
MICROPHONE track and updates `canPublishSources` through
`updateParticipant`.
- Lifting the mute re-allows the mic. It never switches anyone's mic on
(`enable_remote_unmute: false`).
- Kick, ban, timeout and role/visibility changes call the same sync:
users who may no longer be in the room are removed from it.
- Moderators with MUTE_MEMBERS get a mute control in the connected
channel roster. The target's footer shows "Muted by a moderator".
**Moderator disconnect (Discord's "Disconnect").**
- `POST /api/servers/{id}/channels/{channelId}/members/{userId}/voice/disconnect`
removes the member from that channel's room
(`RemoveParticipant(liveKitRoomName(serverId, channelId), userId)`) and
writes a `voice.disconnect` audit row (moderator as actor, `channelId`
in the metadata). The body is empty or `{}`. A room name sent by the
client is refused, never trusted.
- It needs MUTE_MEMBERS and goes through the moderation hierarchy gate
(`voice_disconnect` in `lib/member-authorization.ts`): the moderator
must outrank the target, the owner cannot be disconnected, and
disconnecting yourself is 400 `self_action` (leave with your own
Disconnect button). Not in the room: 404 `not_in_voice`. LiveKit
unreachable or not configured: 503 `voice_unavailable`. Every refusal
carries a `code` the lobby translates.
- In the voice roster, a "⋮" button (or a right-click) on a member opens
a menu with **Disconnect from voice**. There is no confirmation step,
and Escape closes the menu. The menu shows only for viewers with
MUTE_MEMBERS and only on members they outrank. The lobby page works out
which members those are from the member list
(`lib/voice-moderation-targets.ts`); the route has the final say. It
works for any voice channel, including one the moderator is not in.
- The disconnected member's client gets `PARTICIPANT_REMOVED` and shows
`lobby.voice.error.removed` ("You were removed from the voice channel."
/ "Sesli kanaldan çıkarıldın.", `lib/voice-disconnect-notice.ts`). They
can click the channel and rejoin straight away.
| | Moderator disconnect | Anti-cheat removal (below) |
|---|---|---|
| Who | A moderator with MUTE_MEMBERS who outranks the target | The LiveKit webhook (system) |
| Why | Moderator's choice | A track whose kind does not match its source |
| Rooms | That channel only | Every voice room of the server |
| Voice block | None. The token route keeps issuing tokens | 10 → 30 → 120 min on that server. The token route answers 403 `voice_blocked` |
| Rejoin with an old token | Allowed | Removed again on `participant_joined` (`voice.block_enforced`) |
| What the member sees | "You were removed from the voice channel." | The same, then `voiceBlocked` with the minutes left when they try to rejoin |
| Audit action | `voice.disconnect` | `voice.track_rejected`, then `voice.block_enforced` |
**A track's kind must match its source (security review AUTHZ-006
follow-up).** LiveKit checks the source a new track claims against
`canPublishSources` and nothing else. It never checks that a Camera track
is video. A server-muted member keeps camera and screen share, so a
modified client could publish its microphone as `source: camera` and the
room heard it. A role with STREAM but no SPEAK could talk the same way.
Video could also be sent under the Microphone source. The rule is now
enforced on both sides:
| Kind | Allowed sources |
|---|---|
| audio | `microphone`, `screen_share_audio` |
| video | `camera`, `screen_share` |
Anything else is refused: an unknown source, a data track, or a mime type
that contradicts the declared kind. The rule lives in one module,
`lib/voice-track-policy.ts`.
- **Listeners.** The lobby (`LobbyVoiceProvider`) and `/room/[roomName]`
check every remote track: on publish, on subscribe, for tracks already
in the room at join, and when you join a screen share. A track that
breaks the rule is unsubscribed and never attached. A remote participant
shows as speaking only if they publish audio under an audio source,
because LiveKit computes "speaking" from every audio track, mislabelled
ones included.
- **Server.** LiveKit posts its webhooks to
`POST /api/livekit/webhook`. The route checks LiveKit's signed JWT
(`WebhookReceiver`, `LIVEKIT_API_KEY`/`SECRET`, sha256 of the body) and
answers 401 to anything else. On `track_published`, if the new track
breaks the rule, or any other track the participant already has, it
removes the participant (`RemoveParticipant`). It also logs a warning
with the room, identity, source and type, and writes a
`voice.track_rejected` audit row (with `blockedSeconds`). A raw client
(not the app) can hear a few hundred milliseconds of audio before the
removal lands.
- **Voice block.** LiveKit OSS `RemoveParticipant` does not revoke
tokens, so before removing, the webhook blocks the member from voice
on that SERVER (`lib/voice-block.ts`; Redis key
`lf:{env}:voice-block:{serverId}:{userId}` with a TTL), so hopping to
another voice channel does not help. The block lasts 10 minutes, then
30, then 120 (the cap) when the next offence comes within an hour of
the previous block ending; an hour without one starts over at 10. One
LiveKit connection (participant SID) counts once, so a redelivered
event or several bad tracks on one connection do not escalate.
- `POST /api/livekit/token` answers **403** `{ code: 'voice_blocked',
retryAfter }` (plus `Retry-After`) while the block holds. The lobby
shows `lobby.voice.error.voiceBlocked` with the minutes left. If Redis
cannot be read, production refuses with a retryable 503 (the same
rule as session revocation) and dev/test let the token through.
- On `participant_joined`, a blocked identity is removed at once: it is
connecting with a token minted before the block. This logs a server
line and writes a `voice.block_enforced` audit row (`channelId`,
`room`, `retryAfterSeconds`), at most one per user, per server, per
minute. The limit is a Redis `SET NX EX` key,
`lf:{env}:voice-block-enforced-audit:{serverId}:{userId}`, so a
reconnect loop cannot flood the log. The row is skipped when Redis
cannot take that key. If Redis cannot be read for the block check
itself, the webhook answers 200 and does nothing (fail open): LiveKit
queues a room's webhooks one after another, and retrying every join
would hold back that room's `track_published` events.
- **In the audit log** (Community Settings → Audit Log), the **Voice
security** filter lists `voice.track_rejected`, `voice.block_enforced`
and `voice.disconnect` (below). Each row names the member, gives the
channel when the viewer can see it, and has a one-line summary, for
example "System removed Mallory (0a1b2c3d…) from #Main Lounge: audio
published as camera — voice blocked on this server for 10 minutes".
A rejection caught by its media type (a "video" camera track
carrying `audio/opus`) also records the `mimeType`.
- Every other event gets a 200 and nothing else happens.
- **Token lifetime.** LiveKit tokens live 10 minutes
(`LIVEKIT_TOKEN_TTL_SECONDS`). The token only has to get a participant
connected: while connected, LiveKit (v1.13.7) sends the client a fresh
token every 5 minutes, valid for 10 minutes or whatever the original
had left, whichever is longer. livekit-client keeps it (`refreshToken`
signal) and uses it to resume or restart. The lobby's reconnect policy
gives up after about two minutes (below), well inside that window. The
lobby fetches a new token for every join. A 1-hour token gave a removed
member a valid token for up to an hour. TURN credentials have their
own 12 h lifetime (`lib/turn-credentials.ts`).
- **Wiring.** LiveKit reaches the web service at
`http://web:3000/api/livekit/webhook` over the compose network. nginx
answers 404 for that path at the public edge. `webhook.api_key` must
name one of LiveKit's keys, and that key is random per install.
`scripts/render-configs.sh` fills it in from `LIVEKIT_WEBHOOK_API_KEY`
(`install.sh` passes `LIVEKIT_API_KEY`). Rendered without that variable,
the webhook block stays commented out and nothing changes. The dev and
e2e stacks pass the same block through the `LIVEKIT_CONFIG` config
body. Existing installs pick it up when the configs are re-rendered (see
the changelog).
**Local controls.**
- Mic state follows the real local publication (`TrackMuted`/`Unmuted`
and `ParticipantPermissionsChanged`), not UI state.
- Deafen disables every remote audio publication, including ones that
appear later (new joiners, first unmute, reconnect), and mutes your
own mic. Undeafen restores the mic state you had before.
- Push-to-talk listeners are bound once per connection, and the mic
follows the desired key state through a single-flight loop. `blur` and
hidden tabs count as a key release. The desktop shell's global
Ctrl+Space arrives as `postMessage({type:'lobbyforge:ptt'})`, and
Ctrl+Shift+M/D and Ctrl+, as `lobbyforge:shortcut`.
- A microphone failure (denied, missing, busy, or a saved device that was
unplugged) no longer cancels the join. The app retries the default
device, then stays connected listen-only with a readable message.
- Blocked autoplay (`AudioPlaybackStatusChanged`) shows "click to enable"
in the footer, which calls `room.startAudio()`.
- Disconnect reasons are shown: another tab or device took over, removed
by a moderator, room closed, or server restart.
- Per-user volume is re-applied on every attach.
- The output device preference is applied to the call on Chromium.
- TURN credentials live for 12h, because ICE restarts reuse the servers
passed at connect time.
**Coming back after a reload or a drop**
([ACTIVITY_INTEGRITY_AND_RESILIENCE.md](ACTIVITY_INTEGRITY_AND_RESILIENCE.md)
§3.2). A reload is always a new connection: livekit-client leaves the
room when the page goes away. The rules live in `lib/voice-resume.ts`;
`LobbyVoiceProvider` applies them.
- **The record.** While a tab is in voice it keeps
`sessionStorage['lf.voice.resume.v1'] = { serverId, channelId,
channelName, at }`. It is written when the room reaches Connected, and
`at` is refreshed every 5 s, also while LiveKit is reconnecting.
sessionStorage belongs to the tab: it survives a reload and goes when
the tab closes. Every storage access is wrapped, so a private window or
blocked site data only means there is nothing to come back to.
- **What clears it.** Leaving with Disconnect, joining another channel,
switching community, dismissing the strip, a refused join (403
`voice_blocked`, 401, 403, 404), and these LiveKit endings:
- `DUPLICATE_IDENTITY`: another tab took over. Keeping the record would
make two tabs take turns kicking each other out.
- `PARTICIPANT_REMOVED`: a moderator removed them. An automatic return
must not undo moderation.
- `ROOM_DELETED`.
Any other ending keeps it: LiveKit giving up (no reason), a server
restart, the page being frozen or left (`CLIENT_INITIATED`, which is
also what a reload sends). `voiceResumeClearsOnDisconnect` decides.
- **One click back (the default).** With a record under 2 minutes old,
for this community and a voice channel the viewer can still see, the
lobby shows a strip across the top of the centre column: "You dropped
out of voice. [Back to #channel]" / "Sesten düştün. [#kanal kanalına
dön]" (`role="status"`, dismissable). Inside the activities view of that
channel the strip moves into the panel. On a phone the voice footer sits
in the closed drawer, so this is how a player in a game sees that they
dropped. The click joins through the normal join, and it is also the
gesture browsers want before they play audio. The same strip appears
when LiveKit gives up reconnecting mid-session.
- **Automatic return.** Only when all three hold: the record is under
2 minutes old, the load is a reload
(`performance.getEntriesByType('navigation')[0].type`), and
`navigator.permissions.query({ name: 'microphone' })` says `granted`.
Safari and Firefox may throw or answer `prompt`: then it stays one
click. An automatic return joins with the microphone off, so nothing is
sent until the person unmutes. Blocked playback shows the usual
"click to enable" notice.
- **Duplicated tabs.** Duplicating a tab copies its sessionStorage. The
tab in voice holds the Web Lock `lf-voice`. A tab returns on its own only
if it can take the lock (`ifAvailable`). A tab that loads while another
tab holds the lock drops its copy of the record and offers nothing. A
lock still held at load gets 1.5 s to go first, because the page that
was just reloaded may not have released its lock yet. Without the Locks
API (an old browser, a plain-HTTP page) there is no lock, as before.
- **Network drops.** The Room uses `VoiceReconnectPolicy`, which keeps
livekit-client's own delays (0, 0.3, 1.2, 2.7, 4.8 s, then 7 s plus up
to 1 s of jitter) until about 2 minutes have passed since the drop. The
default gave up after about 45–55 s. Once LiveKit gives up (Disconnected
with no clearing reason), the lobby offers the strip and tries once more
on the window's `online` event and once when the tab becomes visible
again, as long as the record stands. Both retries need the lock and join
with the microphone off.
- **The view comes back too.** While the activities view is open, the
lobby keeps `sessionStorage['lf.view.v1'] = { mode: 'activity',
channelId }`. After a reload it reopens that view instead of the chat.
Any other view clears the record, and a `/dm/<id>` link opens what it
names.
- **Desktop.** The Tauri shell is the same webview, so all of this
applies, and sessionStorage survives a window reload.
## The `/room/[roomName]` page
`apps/web/app/room/[roomName]/page.tsx` is a client component. On mount:
1. **Guest session.** Probes `GET /api/auth/guest` first; if 401, POSTs to mint a new identity. The rebind path lets a returning visitor keep their `gid` (which is also the LiveKit identity).
2. **LiveKit token.** `POST /api/livekit/token` with `{ room: roomName }`. The server signs a JWT with the user's `gid` as the identity and the cookie's display name as the participant name. The token TTL is 10 minutes (LiveKit refreshes it while connected — see "Token lifetime" above); the page's heartbeat keeps the LobbyForge-side presence alive, but the LiveKit token itself is the only thing that authorizes the WebSocket connect.
3. **`Room.connect`.** `new Room({ adaptiveStream: true, dynacast: true }).connect(NEXT_PUBLIC_LIVEKIT_URL, token)`. `adaptiveStream` tells LiveKit to throttle video quality on the server side based on the subscriber's viewport; `dynacast` tells the SFU to dynamically unsubscribe publishers from tracks nobody is watching. Both are server-side cost savers that don't change the wire format.
4. **Render.** The room name + connection state (from `RoomEvent.ConnectionStateChanged`) + a participant list (local + remote, with the "you" decoration on the local participant) + two buttons (mute, deafen).
5. **Heartbeat.** If the URL carries `?serverId=…&channelId=…`, the page posts a presence heartbeat every 5 seconds to `/api/servers/{serverId}/channels/{channelId}/presence`.
The connection state is rendered as a `<code>` tag with one of `connected / connecting… / reconnecting… / disconnected`. The participant list re-renders on `RoomEvent.ParticipantConnected`, `RoomEvent.ParticipantDisconnected`, and `RoomEvent.ActiveSpeakersChanged` (the last so the "speaking" badge updates as people talk).
### Bot participant metadata
Bot runners should join LiveKit with either a `bot:` identity prefix or
participant metadata that marks the participant as a bot:
```json
{
"kind": "bot",
"bot": true,
"botType": "music",
"trustLevel": "official",
"publisher": "LobbyForge"
}
```
The room UI renders a `BOT` badge for these participants and shows the trust
level when provided. The hover title includes publisher and bot type so music,
game-host, narrator, and moderation bots are visually distinct from human
participants even before the full profile popover lands.
## The `livekit-client` choice
We use the vanilla `livekit-client` SDK instead of the React wrapper `@livekit/components-react`. The reasons:
- **Smaller dep surface.** `@livekit/components-react` pulls in `@livekit/components-core` + the React rendering layer + a lot of CSS that we don't need. The vanilla SDK is ~50 KB gzipped.
- **No DOM framework lock-in.** The web app is a thin shell today; we don't have a component library we want to be bound to. When the M15+ UI lands, we can re-evaluate.
- **The rendering is trivial.** The participant list is a `<ul>`. The connection state is a `<code>` tag. The mic / deafen buttons are `<button>`s with `onClick` handlers. None of that needs a custom component.
The M15 follow-up can swap to `@livekit/components-react` if the UI grows past the simple "list of participants + mic / deafen" surface.
## The connect flow
```
[Page Mount]
↓
GET /api/auth/guest (probe)
↓
401? → POST /api/auth/guest (mint identity)
↓
POST /api/livekit/token { serverId, channelId }
↓
new Room({ ... }).connect(NEXT_PUBLIC_LIVEKIT_URL, token)
↓
[LiveKit WebSocket open]
↓
[UI renders participant list + buttons]
↓
setInterval(5_000) → POST /api/servers/{serverId}/channels/{channelId}/presence
```
The page tears down on unmount: `room.disconnect()` + `clearInterval(heartbeat)`. The `useEffect` cleanup function handles both.
### Self-mute vs server-mute
M14 ships **self-mute** only. The mic button calls `localParticipant.setMicrophoneEnabled(!micEnabled)`. The local participant's track is unpublished from the room when mic is off — other participants stop receiving the audio immediately.
**Server-side mute** (M15.7) allows a moderator to force a noisy participant's mic off. This is implemented via a new endpoint `POST /api/servers/{id}/channels/{channelId}/members/{userId}/voice/mute`. It uses the `RoomServiceClient.mutePublishedTrack` method from the LiveKit Server SDK. Requires `MUTE_MEMBERS` permission.
### Stable User Identity (M15)
As of M15, the LiveKit participant identity is unified with the permanent `userId` (uid) from the database. This allows administrative actions (like muting or kicking) to target a specific user reliably across sessions, rather than relying on ephemeral guest IDs (gid).
### `NEXT_PUBLIC_LIVEKIT_URL`
The browser-side WebSocket URL. Read from `process.env.NEXT_PUBLIC_LIVEKIT_URL` at build time, defaults to `ws://localhost:19580` (the dev stack). Documented in `infra/docker/.env.example`.
The dev stack is `ws://` because the LiveKit dev server is plaintext; production is `wss://` behind a TLS-terminating proxy.
## The 90-second presence contract
See `docs/PRESENCE.md` for the full details. The relevant bit for the voice-room UI is the **5-second heartbeat** — fast enough that a participant dropping out (closing the tab, losing network) is reflected in the channel list within ~90 seconds, slow enough that a single dropped POST doesn't drop them out.
## Cross-platform
- `livekit-client` is a browser-only SDK; the page is `'use client'` and the LiveKit code only runs in the browser. No SSR / `node:` imports.
- The `NEXT_PUBLIC_LIVEKIT_URL` env var is browser-visible by convention; the API secret stays server-side in `LIVEKIT_API_SECRET` (consumed by `/api/livekit/token`).
## Tests
The voice-room UI is not unit-tested. The `apps/web` test suite is route-layer coverage; the React component tree is M15's responsibility. The `/connect` (developer surface) page has no tests either; it's a thin shell that exercises the same routes the voice-room UI exercises.
## Out of scope
- **Server-side mute / deafen.** See above. `livekit-server-sdk` + `RoomServiceClient` is M15.
- **Screenshare.** LiveKit supports it (`localParticipant.setScreenShareEnabled`); the UI lands with M15.
- **Recording / egress.** The `recorder: true` grant is already in the M9 token issuer. A "start recording" button + the recording storage pipeline is M15+.
- **Camera (video).** The token issuer already allows `canPublishSources: ['camera', ...]`. The video tile is M15.
- **Active speaker detection.** The vanilla `livekit-client` exposes `RoomEvent.ActiveSpeakersChanged`; we render a "speaking" badge on the participant list. The "this person is speaking, raise their tile" UX is M15.
- **Noise suppression / echo cancellation.** Browser-native, configured per-track. Today's defaults are fine for a test room; the M15 voice polish pass can tune them.
- **Per-room user limits.** A "this room is full" 4xx is M15.
- **Multi-room navigation.** The page is a single room. A "switch rooms" UI is M15.
- **WebRTC stats overlay.** Latency / packet-loss debug overlay is a debug feature; M15+.
- **Mobile layout.** The page uses inline CSS that adapts to a 14" laptop. A mobile layout (sidebar collapses, controls float) is M15.
- **Push-to-talk.** The mic toggle is a click-to-mute; a "hold spacebar to talk" is M15.
- **The `/servers/{id}` page.** The voice-room UI is reachable via a `roomName` URL parameter, but a real server home (with the channel list, member list, and a button to enter a voice channel) is M15 UI. The `/join/{code}` page links to `/servers/{id}` which 404s today.
## Security Hardening Update
Current LiveKit join behavior:
- The client posts `{ serverId, channelId }` to `/api/livekit/token`; it no
longer chooses an arbitrary LiveKit room name.
- The token endpoint verifies server membership, channel ownership, voice/stage
channel type, and `CONNECT_VOICE`.
- The server derives the LiveKit room name from `serverId + channelId`.
- The LiveKit participant identity is the local `uid`, matching roles, bans,
memberships, and moderation actions.
- `/room/[roomName]` now needs `serverId` and `channelId` query params to
connect securely.