diff --git a/docs/systems/voice.md b/docs/systems/voice.md index 9ce73a87..2fb77962 100644 --- a/docs/systems/voice.md +++ b/docs/systems/voice.md @@ -292,6 +292,60 @@ The fullscreen toggle in `VoiceControlBar` flips the `voiceFullscreen` flag in ` --- +## Audio Device Selection (Microphone & Speakers) + +Users pick mic and speaker devices in two surfaces: +1. **User Settings → Voice & Video** (`AudioInputSection.tsx`, `AudioOutputSection.tsx`) — full picker with input volume, live level meter, output volume, and a "Play test sound" button. +2. **Bottom-left UserArea quick popups** (`ChannelSidebar.tsx UserAreaPanel`) — opened by the caret buttons next to mute (input picker) and deafen (output picker). Same picker UX, more compact. + +Both surfaces are backed by the shared `useAudioDevices()` hook. The store fields `inputDeviceId` and `outputDeviceId` (both `string`, default `'default'`) are persisted in `voiceStore`. + +### `useAudioDevices()` hook (canonical enumeration) +- Mirrors `VideoSection.tsx`'s permission/enumeration/devicechange pattern. +- Mount-time probe: `navigator.permissions.query({ name: 'microphone' })`. **Never auto-fires `getUserMedia`** — that requires an explicit user gesture via the returned `requestPermission()`. +- States: `unknown` → `granted` | `prompt` | `denied`. Lists are populated only in `granted`. +- Refreshes both `inputs` and `outputs` on every `devicechange` event. +- Output devices are gated behind microphone permission (no separate output permission exists in browsers). +- Returns `inputLabels` / `outputLabels` maps with disambiguation suffixes for duplicate names (e.g. `"USB Audio (1)"`, `"USB Audio (2)"`). + +### Output routing — `AudioContext.setSinkId` +All audio (remote voice, screen-share audio, sound effects) flows through `AudioManager`'s master bus → `AudioContext.destination`. Output device switching is therefore done via `AudioContext.setSinkId(deviceId)`, NOT via LiveKit's `switchActiveDevice('audiooutput')` (which targets `