# 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:///ws` on HTTPS pages, then `ws://: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/` 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 `` 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 `
    `. The connection state is a `` tag. The mic / deafen buttons are `