Voice Channels¶
Partly out of date
Rendered from ProjectInfo/client/voice.md on docs/encryption-modes-spec. Its protocol content is still useful, but parts of it describe an older client or an earlier API. Where it disagrees with the code, the code wins — see the spec errata.
Voice is powered by LiveKit (SFU). The client never connects directly to the API for audio — it connects to the LiveKit server using a short-lived token issued by the API.
Supported Client SDKs¶
| Platform | Package |
|---|---|
| Web / Tauri | @livekit/client |
| iOS | LiveKitClient (Swift) |
| Android | io.livekit.android |
| React Native | @livekit/react-native |
Flow¶
1. User clicks "Join Voice"
2. GET /api/voice/channels/{channelID}/states
→ VoiceState[] (show who is currently in the channel)
3. POST /api/voice/channels/{channelID}/token
→ { token, livekit_url, room_name }
4. Connect to LiveKit:
room.connect(livekit_url, token)
5. Publish audio:
room.localParticipant.setMicrophoneEnabled(true)
6. Subscribe to remote audio tracks automatically (LiveKit default)
7. On leave: room.disconnect()
Token Endpoint¶
POST /api/voice/channels/{channelID}/token
Authorization: Bearer <access_token>
(no body required)
→ {
"token": "livekit-jwt-string",
"livekit_url": "wss://voice.yourdomain.com",
"room_name": "channel-uuid"
}
Requires PermConnect.
Token TTL is short (minutes). If the connection drops, request a fresh token before reconnecting — do not reuse stale tokens.
Fetching Voice State¶
Who is currently in the voice channel:
interface VoiceState {
user_id: string;
session_id: string;
self_mute: boolean;
self_deaf: boolean;
self_video: boolean;
self_stream: boolean;
server_mute: boolean;
server_deaf: boolean;
suppress: boolean;
joined_at: string;
}
Note: VOICE_STATE_UPDATE events are not yet broadcast to clients via the WebSocket gateway. Poll /states for live participant updates.
LiveKit Integration (TypeScript example)¶
import { Room, RoomEvent, Track } from "@livekit/client";
let room: Room | null = null;
async function joinVoice(channelId: string, token: string) {
const { token: lkToken, livekit_url } = await postJSON(
`/api/voice/channels/${channelId}/token`, {}
);
room = new Room({
adaptiveStream: true,
dynacast: true,
});
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind === Track.Kind.Audio) {
track.attach(); // attach to a hidden <audio> element
}
});
room.on(RoomEvent.ParticipantConnected, (participant) => {
// add participant to UI
});
room.on(RoomEvent.ParticipantDisconnected, (participant) => {
// remove participant from UI
});
room.on(RoomEvent.Disconnected, () => {
room = null;
// update UI to reflect disconnection
});
await room.connect(livekit_url, lkToken, {
iceTransportPolicy: "all",
});
await room.localParticipant.setMicrophoneEnabled(true);
}
async function leaveVoice() {
await room?.disconnect();
room = null;
}
function setMuted(muted: boolean) {
room?.localParticipant.setMicrophoneEnabled(!muted);
}
function setDeafened(deafened: boolean) {
room?.remoteParticipants.forEach(p => {
p.audioTrackPublications.forEach(pub => {
if (pub.track) pub.track.isMuted = deafened;
});
});
}
function setScreenShare(enabled: boolean) {
room?.localParticipant.setScreenShareEnabled(enabled);
}
Required Permissions¶
| Action | Permission Required |
|---|---|
| Join voice channel | PermConnect |
| Speak | PermSpeak |
| Stream / screen share | PermStream |
| Server-mute others | PermMuteMembers |
| Server-deafen others | PermDeafenMembers |
| Move members | PermMoveMembers |
UI Elements¶
Minimum voice UI:
- Participant list with usernames and mute/deafen indicators
- Mute self toggle (mic icon)
- Deafen self toggle (headphones icon)
- Screen share button (if
PermStream) - Leave button
- Connected bar visible on all screens while in voice (so user knows they're connected)
Voice and Screen Share (permissions check)¶
Before showing the screen share button, check PermStream. Before enabling the mic, check PermSpeak. These are UI hints — LiveKit will also refuse publishing if the server-side LiveKit room permissions don't allow it.