From a1ebb232648e390559d6e9fa2c28818d7766643e Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 00:51:20 +0200 Subject: [PATCH 01/14] feat(web): add useAudioDevices hook for permission-aware audio enumeration --- .../web/src/hooks/useAudioDevices.test.ts | 129 +++++++++++++ packages/web/src/hooks/useAudioDevices.ts | 171 ++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 packages/web/src/hooks/useAudioDevices.test.ts create mode 100644 packages/web/src/hooks/useAudioDevices.ts diff --git a/packages/web/src/hooks/useAudioDevices.test.ts b/packages/web/src/hooks/useAudioDevices.test.ts new file mode 100644 index 00000000..d2ebad58 --- /dev/null +++ b/packages/web/src/hooks/useAudioDevices.test.ts @@ -0,0 +1,129 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useAudioDevices } from './useAudioDevices'; + +type Listener = () => void; + +function setupMediaDevicesMock(opts: { + permissionState?: 'granted' | 'prompt' | 'denied'; + devices?: MediaDeviceInfo[]; + permissionThrows?: boolean; +} = {}) { + const listeners = new Set(); + const permState = opts.permissionState ?? 'granted'; + const devices = opts.devices ?? [ + { deviceId: 'mic-1', kind: 'audioinput', label: 'Built-in Mic', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'mic-2', kind: 'audioinput', label: 'USB Headset', groupId: 'g2', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'spk-1', kind: 'audiooutput', label: 'Built-in Speakers', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'spk-2', kind: 'audiooutput', label: 'USB Headset', groupId: 'g2', toJSON: () => ({}) } as MediaDeviceInfo, + ]; + + const mediaDevices = { + enumerateDevices: vi.fn().mockResolvedValue(devices), + addEventListener: (_evt: string, l: Listener) => { listeners.add(l); }, + removeEventListener: (_evt: string, l: Listener) => { listeners.delete(l); }, + getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [{ stop: vi.fn() }] }), + }; + Object.defineProperty(navigator, 'mediaDevices', { value: mediaDevices, configurable: true }); + + const permStatus = { + state: permState, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + const permissions = { + query: opts.permissionThrows + ? vi.fn().mockRejectedValue(new Error('not supported')) + : vi.fn().mockResolvedValue(permStatus), + }; + Object.defineProperty(navigator, 'permissions', { value: permissions, configurable: true }); + + return { listeners, mediaDevices, permissions, permStatus, fireDeviceChange: () => listeners.forEach(l => l()) }; +} + +describe('useAudioDevices', () => { + beforeEach(() => { vi.restoreAllMocks(); }); + + it('starts in unknown state, transitions to granted, enumerates inputs and outputs', async () => { + const m = setupMediaDevicesMock({ permissionState: 'granted' }); + const { result } = renderHook(() => useAudioDevices()); + + expect(result.current.permState).toBe('unknown'); + await waitFor(() => expect(result.current.permState).toBe('granted')); + await waitFor(() => expect(result.current.inputs.length).toBe(2)); + expect(result.current.outputs.length).toBe(2); + expect(result.current.inputs[0].deviceId).toBe('mic-1'); + }); + + it('returns prompt state when permission is prompt', async () => { + setupMediaDevicesMock({ permissionState: 'prompt' }); + const { result } = renderHook(() => useAudioDevices()); + await waitFor(() => expect(result.current.permState).toBe('prompt')); + expect(result.current.inputs).toEqual([]); + expect(result.current.outputs).toEqual([]); + }); + + it('returns denied state when permission is denied', async () => { + setupMediaDevicesMock({ permissionState: 'denied' }); + const { result } = renderHook(() => useAudioDevices()); + await waitFor(() => expect(result.current.permState).toBe('denied')); + }); + + it('falls back to prompt when permissions.query throws', async () => { + setupMediaDevicesMock({ permissionThrows: true }); + const { result } = renderHook(() => useAudioDevices()); + await waitFor(() => expect(result.current.permState).toBe('prompt')); + }); + + it('refreshes lists on devicechange', async () => { + const m = setupMediaDevicesMock({ permissionState: 'granted' }); + const { result } = renderHook(() => useAudioDevices()); + await waitFor(() => expect(result.current.inputs.length).toBe(2)); + + m.mediaDevices.enumerateDevices.mockResolvedValueOnce([ + { deviceId: 'mic-1', kind: 'audioinput', label: 'Built-in Mic', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo, + ]); + act(() => { m.fireDeviceChange(); }); + await waitFor(() => expect(result.current.inputs.length).toBe(1)); + }); + + it('deduplicates devices by deviceId', async () => { + setupMediaDevicesMock({ + permissionState: 'granted', + devices: [ + { deviceId: 'mic-1', kind: 'audioinput', label: 'A', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'mic-1', kind: 'audioinput', label: 'A', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'spk-1', kind: 'audiooutput', label: 'B', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo, + ], + }); + const { result } = renderHook(() => useAudioDevices()); + await waitFor(() => expect(result.current.permState).toBe('granted')); + expect(result.current.inputs.length).toBe(1); + expect(result.current.outputs.length).toBe(1); + }); + + it('requestPermission fires getUserMedia({audio:true}) and stops the stream', async () => { + const m = setupMediaDevicesMock({ permissionState: 'prompt' }); + const { result } = renderHook(() => useAudioDevices()); + await waitFor(() => expect(result.current.permState).toBe('prompt')); + + await act(async () => { await result.current.requestPermission(); }); + expect(m.mediaDevices.getUserMedia).toHaveBeenCalledWith({ audio: true }); + }); + + it('builds display labels with disambiguation suffix for duplicate names', async () => { + setupMediaDevicesMock({ + permissionState: 'granted', + devices: [ + { deviceId: 'mic-1', kind: 'audioinput', label: 'USB Audio', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'mic-2', kind: 'audioinput', label: 'USB Audio', groupId: 'g2', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'mic-3', kind: 'audioinput', label: '', groupId: 'g3', toJSON: () => ({}) } as MediaDeviceInfo, + ], + }); + const { result } = renderHook(() => useAudioDevices()); + await waitFor(() => expect(result.current.inputs.length).toBe(3)); + expect(result.current.inputLabels.get('mic-1')).toBe('USB Audio (1)'); + expect(result.current.inputLabels.get('mic-2')).toBe('USB Audio (2)'); + expect(result.current.inputLabels.get('mic-3')).toBe('Microphone 3'); + }); +}); diff --git a/packages/web/src/hooks/useAudioDevices.ts b/packages/web/src/hooks/useAudioDevices.ts new file mode 100644 index 00000000..b30210f7 --- /dev/null +++ b/packages/web/src/hooks/useAudioDevices.ts @@ -0,0 +1,171 @@ +import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; + +/** + * Permission state machine for audio device enumeration. + * + * - `unknown`: initial mount, before `permissions.query` resolves. + * - `granted`: permission granted; both input + output lists populated. + * - `prompt`: permission not yet decided; lists empty until requestPermission(). + * - `denied`: permission denied; lists empty. + * + * `permissions.query({ name: 'microphone' })` is the ONLY mount-time API call. + * It is passive — does NOT light the mic indicator on any platform. We never + * auto-fire `getUserMedia` to "unlock labels"; that requires an explicit user + * gesture via `requestPermission()`. + * + * Note on output devices: there is no separate "speaker" permission. Browsers + * gate output-device labels behind the same microphone permission. So a single + * permission state covers both lists. + */ +export type AudioDevicesPermState = 'unknown' | 'granted' | 'prompt' | 'denied'; + +export interface UseAudioDevicesResult { + permState: AudioDevicesPermState; + inputs: MediaDeviceInfo[]; + outputs: MediaDeviceInfo[]; + inputLabels: Map; + outputLabels: Map; + /** Re-enumerate immediately. Safe to call any time after permission is granted. */ + refresh: () => void; + /** + * Explicit user gesture: fires `getUserMedia({audio:true})` to grant permission + * and unlock device labels. Stops the stream immediately. Only call from a + * click/keydown handler — calling this from an effect would defeat the privacy + * model and flash the mic indicator. + */ + requestPermission: () => Promise; +} + +function buildLabels(devices: MediaDeviceInfo[], kindLabel: string): Map { + const counts = new Map(); + for (const d of devices) { + if (d.label) counts.set(d.label, (counts.get(d.label) ?? 0) + 1); + } + const seen = new Map(); + const labels = new Map(); + devices.forEach((d, i) => { + if (!d.label) { + labels.set(d.deviceId, `${kindLabel} ${i + 1}`); + return; + } + const total = counts.get(d.label) ?? 1; + if (total <= 1) { + labels.set(d.deviceId, d.label); + return; + } + const used = (seen.get(d.label) ?? 0) + 1; + seen.set(d.label, used); + labels.set(d.deviceId, `${d.label} (${used})`); + }); + return labels; +} + +export function useAudioDevices(): UseAudioDevicesResult { + const [permState, setPermState] = useState('unknown'); + const [inputs, setInputs] = useState([]); + const [outputs, setOutputs] = useState([]); + const mountedRef = useRef(true); + const enumerateGenRef = useRef(0); + + const enumerate = useCallback(async () => { + const gen = ++enumerateGenRef.current; + try { + const all = await navigator.mediaDevices.enumerateDevices(); + if (gen !== enumerateGenRef.current || !mountedRef.current) return; + const seenIn = new Set(); + const seenOut = new Set(); + const ins: MediaDeviceInfo[] = []; + const outs: MediaDeviceInfo[] = []; + for (const d of all) { + if (d.kind === 'audioinput' && !seenIn.has(d.deviceId)) { + seenIn.add(d.deviceId); + ins.push(d); + } else if (d.kind === 'audiooutput' && !seenOut.has(d.deviceId)) { + seenOut.add(d.deviceId); + outs.push(d); + } + } + setInputs(ins); + setOutputs(outs); + } catch { + if (gen === enumerateGenRef.current && mountedRef.current) { + setInputs([]); + setOutputs([]); + } + } + }, []); + + // Mount-time permission probe. Mirrors VideoSection.tsx:158-210. + useEffect(() => { + mountedRef.current = true; + let cancelled = false; + let status: PermissionStatus | null = null; + let onChange: (() => void) | null = null; + + const apply = (state: PermissionState) => { + if (cancelled || !mountedRef.current) return; + if (state === 'granted') setPermState('granted'); + else if (state === 'prompt') setPermState('prompt'); + else setPermState('denied'); + }; + + const run = async () => { + if (!navigator.permissions || typeof navigator.permissions.query !== 'function') { + if (!cancelled && mountedRef.current) setPermState('prompt'); + return; + } + try { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const s = await navigator.permissions.query({ name: 'microphone' as PermissionName }); + status = s; + apply(s.state); + onChange = () => apply(s.state); + s.addEventListener('change', onChange); + } catch { + if (!cancelled && mountedRef.current) setPermState('prompt'); + } + }; + + run(); + + return () => { + cancelled = true; + mountedRef.current = false; + if (status && onChange) { + try { status.removeEventListener('change', onChange); } catch { /* best-effort */ } + } + }; + }, []); + + // Enumerate when permission grants; refresh on devicechange. + useEffect(() => { + if (permState !== 'granted') return; + enumerate(); + const onChange = () => { enumerate(); }; + navigator.mediaDevices.addEventListener('devicechange', onChange); + return () => { + navigator.mediaDevices.removeEventListener('devicechange', onChange); + }; + }, [permState, enumerate]); + + const inputLabels = useMemo(() => buildLabels(inputs, 'Microphone'), [inputs]); + const outputLabels = useMemo(() => buildLabels(outputs, 'Speakers'), [outputs]); + + const requestPermission = useCallback(async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + stream.getTracks().forEach(t => t.stop()); + if (mountedRef.current) { + setPermState('granted'); + await enumerate(); + } + } catch (err: unknown) { + if (err instanceof Error && err.name === 'NotAllowedError' && mountedRef.current) { + setPermState('denied'); + } + throw err; + } + }, [enumerate]); + + return { permState, inputs, outputs, inputLabels, outputLabels, refresh: enumerate, requestPermission }; +} From 2726cc5c5a3822010a13b842af2ed09b1adb111b Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 00:57:26 +0200 Subject: [PATCH 02/14] feat(web): expose AudioManager helpers for device introspection + test tone --- packages/web/src/audio/AudioManager.ts | 40 ++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/web/src/audio/AudioManager.ts b/packages/web/src/audio/AudioManager.ts index dc610068..6ba63b29 100644 --- a/packages/web/src/audio/AudioManager.ts +++ b/packages/web/src/audio/AudioManager.ts @@ -389,6 +389,46 @@ export class AudioManager { return this.masterBoost!; } + /** + * Returns the deviceId of the currently active mic stream (the one being + * captured by getUserMedia). May differ from the persisted store value when + * the store says 'default' but Chromium has resolved that to a concrete ID. + */ + getCurrentInputDeviceId(): string { + return this.currentInputDeviceId; + } + + /** + * True if a live mic stream is currently captured. Used by the global + * devicechange handler to decide whether to force-reacquire on OS-default + * change. + */ + hasActiveStream(): boolean { + return !!this.currentStream?.active; + } + + /** + * Plays a short test tone through the master output bus, exercising the + * current setSinkId binding. Used by the "Test Sound" button in audio + * settings to confirm that audio is reaching the chosen output device. + */ + async playTestTone(): Promise { + await this.resumeContext(); + if (!this.ctx) return; + const osc = this.ctx.createOscillator(); + osc.frequency.value = 440; + const gain = this.ctx.createGain(); + // Soft envelope to avoid pop on start/stop. + const now = this.ctx.currentTime; + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(0.15, now + 0.02); + gain.gain.linearRampToValueAtTime(0, now + 0.4); + osc.connect(gain); + gain.connect(this.masterBoost!); + osc.start(now); + osc.stop(now + 0.45); + } + getContext(): AudioContext | null { return this.ctx; } From d992b2bb411b3c1199aab865c43012d784d5a81a Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 01:01:03 +0200 Subject: [PATCH 03/14] =?UTF-8?q?feat(web):=20seamless=20audio=20hot-plug?= =?UTF-8?q?=20=E2=80=94=20re-acquire=20live=20stream=20+=20toast=20new=20d?= =?UTF-8?q?evices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/src/components/layout/AppLayout.tsx | 99 ++++++++++++++++++- 1 file changed, 94 insertions(+), 5 deletions(-) diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index eaefe4b2..eb995b47 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -92,14 +92,103 @@ export function AppLayout() { AudioManager.getInstance().setOutputDevice(outputDeviceId); }, [outputDeviceId]); - // Sweep stale persisted device IDs (mic/speaker/camera) on mount and whenever - // the device list changes (USB plug/unplug, permission unlock, etc.). + // Audio device hot-plug handler. + // + // Three jobs on every devicechange event: + // (1) Prune persisted IDs that no longer exist (delegated to voiceStore). + // (2) Force re-acquire the live mic stream when the user's chosen input is + // 'default' AND a stream is already live. Chromium does NOT migrate an + // existing getUserMedia track to the new OS-default — it stays bound to + // the device that was default at acquisition time. Setting the input + // again with 'default' triggers a fresh getUserMedia, which picks up + // the new OS default. The downstream syncMic effect republishes. + // (3) Force re-apply setSinkId('') for output when 'default' is selected, + // for the same reason on the output side. + // (4) Toast on a *new* audioinput appearance (debounced + dedupe by groupId). + // Removals do not toast — the user already knows they unplugged it. useEffect(() => { const prune = useVoiceStore.getState().pruneStaleDevices; - prune(); // initial sweep - const handler = () => prune(); + let lastInputGroupIds = new Set(); + const recentToastByGroup = new Map(); // groupId -> timestamp ms + const TOAST_DEBOUNCE_MS = 1000; + const TOAST_DEDUPE_WINDOW_MS = 30_000; + let pendingDebounceTimer: ReturnType | null = null; + + const seedBaseline = async () => { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + lastInputGroupIds = new Set( + devices.filter(d => d.kind === 'audioinput' && d.groupId).map(d => d.groupId) + ); + } catch { /* enumeration may be blocked pre-permission; baseline is empty */ } + }; + + const handleNewDeviceToasts = async () => { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const currentInputGroupIds = new Set( + devices.filter(d => d.kind === 'audioinput' && d.groupId).map(d => d.groupId) + ); + const now = Date.now(); + const newGroups: string[] = []; + for (const gid of currentInputGroupIds) { + if (lastInputGroupIds.has(gid)) continue; + const lastToast = recentToastByGroup.get(gid) ?? 0; + if (now - lastToast < TOAST_DEDUPE_WINDOW_MS) continue; + newGroups.push(gid); + recentToastByGroup.set(gid, now); + } + // GC dedupe map entries older than the window so it doesn't leak. + for (const [gid, ts] of recentToastByGroup) { + if (now - ts > TOAST_DEDUPE_WINDOW_MS) recentToastByGroup.delete(gid); + } + lastInputGroupIds = currentInputGroupIds; + + if (newGroups.length > 0) { + const newest = devices.find(d => + d.kind === 'audioinput' && d.groupId && newGroups.includes(d.groupId) && d.label, + ); + const label = newest?.label || 'New audio device'; + useUIStore.getState().addToast( + `${label} detected — choose it in Voice settings to switch`, + 'info', + 6000, + ); + } + } catch { /* enumeration failure is non-fatal */ } + }; + + const reacquireLiveStream = async () => { + const am = AudioManager.getInstance(); + const inputId = useVoiceStore.getState().inputDeviceId; + const outputId = useVoiceStore.getState().outputDeviceId; + // Re-acquire input only if user is on 'default' AND a stream is live. + // Anything else: persisted ID is concrete, stream stays bound correctly. + if (inputId === 'default' && am.hasActiveStream()) { + try { await am.setInputDevice('default'); } catch { /* serialized chain handles errors */ } + } + if (outputId === 'default') { + try { await am.setOutputDevice('default'); } catch { /* setSinkId may not exist on Safari */ } + } + }; + + const handler = async () => { + await prune(); + await reacquireLiveStream(); + // Debounce toast logic so a single plug event that emits multiple + // devicechange fires (Bluetooth re-pair storms) collapses to one toast. + if (pendingDebounceTimer) clearTimeout(pendingDebounceTimer); + pendingDebounceTimer = setTimeout(() => { handleNewDeviceToasts(); }, TOAST_DEBOUNCE_MS); + }; + + // Seed baseline so the first event after mount doesn't false-positive every + // existing device as "new". + seedBaseline().then(() => prune()); navigator.mediaDevices.addEventListener('devicechange', handler); - return () => navigator.mediaDevices.removeEventListener('devicechange', handler); + return () => { + if (pendingDebounceTimer) clearTimeout(pendingDebounceTimer); + navigator.mediaDevices.removeEventListener('devicechange', handler); + }; }, []); const { user, isLoading } = useAuth(); From 674d011d3cc1120d74038792af41956dd949fb1e Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 01:05:37 +0200 Subject: [PATCH 04/14] fix(web): debounce devicechange handler and gate listener on baseline seeding --- .../web/src/components/layout/AppLayout.tsx | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index eb995b47..4c498af6 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -113,6 +113,12 @@ export function AppLayout() { const TOAST_DEBOUNCE_MS = 1000; const TOAST_DEDUPE_WINDOW_MS = 30_000; let pendingDebounceTimer: ReturnType | null = null; + // Handler-level debounce: collapses devicechange storms (BT re-pair, USB + // hub enumeration can fire 5-10x/sec) into a single prune + reacquire pass. + // The toast logic has its own longer debounce (TOAST_DEBOUNCE_MS) layered + // on top so user-visible toasts collapse storms even more aggressively. + let pendingHandlerTimer: ReturnType | null = null; + const HANDLER_DEBOUNCE_MS = 250; const seedBaseline = async () => { try { @@ -172,22 +178,35 @@ export function AppLayout() { } }; - const handler = async () => { - await prune(); - await reacquireLiveStream(); - // Debounce toast logic so a single plug event that emits multiple - // devicechange fires (Bluetooth re-pair storms) collapses to one toast. - if (pendingDebounceTimer) clearTimeout(pendingDebounceTimer); - pendingDebounceTimer = setTimeout(() => { handleNewDeviceToasts(); }, TOAST_DEBOUNCE_MS); + const handler = () => { + if (pendingHandlerTimer) clearTimeout(pendingHandlerTimer); + pendingHandlerTimer = setTimeout(async () => { + await prune(); + await reacquireLiveStream(); + if (pendingDebounceTimer) clearTimeout(pendingDebounceTimer); + pendingDebounceTimer = setTimeout(() => { handleNewDeviceToasts(); }, TOAST_DEBOUNCE_MS); + }, HANDLER_DEBOUNCE_MS); }; // Seed baseline so the first event after mount doesn't false-positive every - // existing device as "new". - seedBaseline().then(() => prune()); - navigator.mediaDevices.addEventListener('devicechange', handler); + // existing device as "new". Register the listener only AFTER baseline is + // seeded so the first real event compares against a populated set. + let listenerRegistered = false; + let cancelled = false; + seedBaseline() + .then(() => prune()) + .then(() => { + if (cancelled) return; + navigator.mediaDevices.addEventListener('devicechange', handler); + listenerRegistered = true; + }); return () => { + cancelled = true; + if (pendingHandlerTimer) clearTimeout(pendingHandlerTimer); if (pendingDebounceTimer) clearTimeout(pendingDebounceTimer); - navigator.mediaDevices.removeEventListener('devicechange', handler); + if (listenerRegistered) { + navigator.mediaDevices.removeEventListener('devicechange', handler); + } }; }, []); From 9a26513009e4c0499247ca7672c30a9b3016029e Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 01:09:04 +0200 Subject: [PATCH 05/14] =?UTF-8?q?feat(web):=20mic-track-loss=20recovery=20?= =?UTF-8?q?=E2=80=94=20probe,=20attempt=20re-acquire,=20toast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/web/src/hooks/useLiveKit.ts | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index d18f6734..8ecdfe40 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -613,6 +613,62 @@ export function useLiveKit() { }; } } + if (publication.source === Track.Source.Microphone) { + const mst = publication.track?.mediaStreamTrack; + if (mst) { + mst.onended = async () => { + // The mic track we publish is the *cloned* output of the AudioManager + // pipeline (see AudioManager.getFreshTrack). It can end for two + // distinct reasons: + // (a) The underlying upstream getUserMedia track ended (unplug, + // OS revoke). The clone goes too. + // (b) syncMic called unpublishTrack() during a deliberate + // republish (device change, RNNoise toggle). In that case + // the user did NOT lose audio — a fresh track is incoming. + // + // Distinguishing (a) from (b): inspect the AudioManager's current + // upstream stream. If it's null or non-active AND the room is + // still ours, we're in case (a). + if (roomRef.current !== newRoom) return; + const am = AudioManager.getInstance(); + if (am.hasActiveStream()) return; // case (b) — pipeline is alive + + // Probe getUserMedia to distinguish unplug vs revoke vs unavailable. + const deviceId = useVoiceStore.getState().inputDeviceId; + let copy = 'Microphone unavailable'; + try { + const probe = await navigator.mediaDevices.getUserMedia({ + audio: deviceId === 'default' ? true : { deviceId: { exact: deviceId } }, + }); + probe.getTracks().forEach(t => t.stop()); + // Probe succeeded — device is back. Try to re-acquire silently. + if (roomRef.current !== newRoom) return; + try { + await am.setInputDevice(deviceId); + // syncMic effect re-publishes when stream generation bumps. + return; + } catch { + copy = 'Microphone could not be restored'; + } + } catch (err: any) { + if (err?.name === 'NotAllowedError') copy = 'Microphone permission was revoked'; + else if (err?.name === 'NotFoundError') { + // The configured device disappeared. Fall back to default if + // the user wasn't already on it. + if (deviceId !== 'default') { + useVoiceStore.getState().setInputDevice('default'); + copy = 'Microphone disconnected — switched to system default'; + } else { + copy = 'Microphone disconnected'; + } + } + } + + if (roomRef.current !== newRoom) return; + useUIStore.getState().addToast(copy, 'warning'); + }; + } + } guardedUpdate(); }); newRoom.on(RoomEvent.LocalTrackUnpublished, (publication: LocalTrackPublication) => { From 63bf9e684ca5d115d381be04b4988e6b9a216805 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 01:20:13 +0200 Subject: [PATCH 06/14] fix(web): mic-loss detection observes upstream stream, not published clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous Task 4 handler installed onended on the *published* mic track, but that track is a clone of AudioManager's MediaStreamAudioDestinationNode output — destination-node tracks never end on upstream loss, they just go silent. The handler also called setInputDevice for silent-recovery and assumed syncMic would re-publish, but syncMic's dep array does not depend on streamGeneration, so the recovery never republished. This commit moves loss detection into AudioManager (where the upstream getUserMedia track lives) via a new onInputTrackEnded subscription, extracts republishMicrophone from syncMic into a module-level helper that both the normal device-change path and the recovery path call directly, and removes the published-track Microphone branch from RoomEvent.LocalTrackPublished. The plan and Task 9 spec text are updated to match. --- packages/web/src/audio/AudioManager.ts | 83 ++++++++++- packages/web/src/hooks/useLiveKit.ts | 195 ++++++++++++++----------- 2 files changed, 188 insertions(+), 90 deletions(-) diff --git a/packages/web/src/audio/AudioManager.ts b/packages/web/src/audio/AudioManager.ts index 6ba63b29..96f2f479 100644 --- a/packages/web/src/audio/AudioManager.ts +++ b/packages/web/src/audio/AudioManager.ts @@ -32,6 +32,12 @@ export class AudioManager { private rnnoiseReady = false; private keepAliveOscillator: OscillatorNode | null = null; + // Subscribers notified when the *upstream* getUserMedia track ends unexpectedly + // (hardware unplug, OS-level revoke, system audio service crash). Distinct from + // the published mic track's `onended` — the published track is a clone of the + // WebAudio destination node, which never ends on upstream loss. + private inputTrackEndedListeners: Set<(reason: 'unplug' | 'revoke' | 'unknown') => void> = new Set(); + private constructor() {} static getInstance(): AudioManager { @@ -205,7 +211,17 @@ export class AudioManager { try { if (this.currentStream) { - this.currentStream.getTracks().forEach(t => t.stop()); + // Detach our `onended` handlers BEFORE stopping. `.stop()` synchronously + // queues an `ended` event on each track; by clearing the listener first + // we guarantee the deliberate-replace path never notifies subscribers, + // regardless of microtask/task ordering. + const oldTracks = this.currentStream.getTracks(); + oldTracks.forEach(t => { t.onended = null; }); + oldTracks.forEach(t => t.stop()); + // Drop the reference immediately so any stray handler that survived + // (e.g. attached by external code) sees `currentStream` no longer + // pointing to the old stream and bails out via the identity check. + this.currentStream = null; } // Chrome AEC stays on during screen share — headphone users unaffected, @@ -231,10 +247,17 @@ export class AudioManager { } as any }; - this.currentStream = await navigator.mediaDevices.getUserMedia(constraints); + const newStream = await navigator.mediaDevices.getUserMedia(constraints); + this.currentStream = newStream; this.currentInputDeviceId = deviceId; this.streamGeneration++; + // Attach upstream-loss detection to every track in the new stream. If the + // OS / hardware ends a track (unplug, revoke, audio-service crash), the + // `ended` event fires and we notify subscribers — provided the stream is + // still the active one (identity check guards against later replacements). + this.attachInputEndedListeners(newStream); + if (this.ctx && this.inputGain) { if (this.inputSource) { this.inputSource.disconnect(); @@ -250,6 +273,52 @@ export class AudioManager { } } + /** + * Attaches an `onended` listener to every audio track in the supplied stream. + * The listener identity-checks against `this.currentStream` so it only fires + * for *unexpected* track loss — deliberate replacement clears the listener and + * nulls `currentStream` BEFORE stopping, so neither path can leak a false + * positive into subscribers. + */ + private attachInputEndedListeners(stream: MediaStream): void { + for (const track of stream.getTracks()) { + track.onended = () => { + // Stream has been replaced (deliberate device change, RNNoise toggle, + // setVoiceProcessing reset) — this is not a hardware-loss event. + if (this.currentStream !== stream) return; + // Drop our reference so any subsequent `hasActiveStream()` check + // reflects reality, and so a downstream re-acquire attempt via + // `setInputDevice` does not short-circuit on the stale-but-non-null + // currentStream. + this.currentStream = null; + // Reason classification is the consumer's responsibility — they probe + // `getUserMedia` to distinguish unplug vs revoke vs unavailable. We + // emit `'unknown'` so the type is still informative if a future caller + // wires reason inference at this layer. + const reason: 'unplug' | 'revoke' | 'unknown' = 'unknown'; + // Snapshot listeners before iteration: a subscriber that synchronously + // unsubscribes during notification (e.g. cleanup-on-disconnect) would + // otherwise mutate the set mid-iteration. + const snapshot = Array.from(this.inputTrackEndedListeners); + for (const cb of snapshot) { + try { cb(reason); } catch (err) { + console.error('[AudioManager] inputTrackEnded listener threw:', err); + } + } + }; + } + } + + /** + * Subscribe to upstream input-track-end events. Returns an unsubscribe. + * Callers should treat `reason` as a hint and probe `getUserMedia` themselves + * to distinguish unplug from revoke. + */ + onInputTrackEnded(cb: (reason: 'unplug' | 'revoke' | 'unknown') => void): () => void { + this.inputTrackEndedListeners.add(cb); + return () => { this.inputTrackEndedListeners.delete(cb); }; + } + private getInputTarget(): AudioNode { return (this.rnnoiseEnabled && this.rnnoiseNode) ? this.rnnoiseNode : this.inputGain!; } @@ -307,7 +376,10 @@ export class AudioManager { // Force track re-publish so LiveKit picks up the new pipeline this.streamGeneration++; if (this.currentStream) { - this.currentStream.getTracks().forEach(t => t.stop()); + // Detach listeners before stopping (see `_setInputDeviceImpl`). + const tracks = this.currentStream.getTracks(); + tracks.forEach(t => { t.onended = null; }); + tracks.forEach(t => t.stop()); this.currentStream = null; } } @@ -339,7 +411,10 @@ export class AudioManager { changed = true; } if (changed && this.currentStream) { - this.currentStream.getTracks().forEach(t => t.stop()); + // Detach listeners before stopping (see `_setInputDeviceImpl`). + const tracks = this.currentStream.getTracks(); + tracks.forEach(t => { t.onended = null; }); + tracks.forEach(t => t.stop()); this.currentStream = null; } } diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index 8ecdfe40..dc82937a 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -143,6 +143,49 @@ function destroyRoom(room: Room | null): Promise | void { return room.disconnect(); } +/** + * Ensures a fresh microphone track from the AudioManager pipeline is published + * to the supplied room. If the existing publication is already current (live + * MediaStreamTrack matching the latest AudioManager streamGeneration), this is + * a no-op apart from un-muting. Otherwise the stale track is unpublished and a + * cloned destination-node track is published in its place. + * + * Extracted from the syncMic effect so the input-track-loss recovery path can + * call it directly — without relying on syncMic's React dep array catching a + * change that never re-renders the hook. + */ +async function republishMicrophone(r: Room, lastMicGenRef: { current: number }): Promise { + const audioManager = AudioManager.getInstance(); + const currentGen = audioManager.getStreamGeneration(); + + const micPub = r.localParticipant.getTrackPublications() + .find(p => p.source === Track.Source.Microphone); + + if (micPub?.track) { + // Track already published — check if it's still current + if (micPub.track.mediaStreamTrack?.readyState === 'live' && lastMicGenRef.current === currentGen) { + // Current and live — just unmute if needed + if (micPub.isMuted) { + await r.localParticipant.setMicrophoneEnabled(true); + } + return; + } + // Track is stale (device or constraint change) — replace it + await r.localParticipant.unpublishTrack(micPub.track as LocalAudioTrack); + } + + // Publish fresh track from AudioManager pipeline + const audioTrack = audioManager.getFreshTrack(); + if (!audioTrack) return; + + console.log('[LiveKit] Publishing fresh microphone track (gen:', currentGen, ')'); + await r.localParticipant.publishTrack(audioTrack, { + name: 'microphone', + source: Track.Source.Microphone, + }); + lastMicGenRef.current = currentGen; +} + export function useLiveKit() { const [room, setRoom] = useState(null); const [isConnected, setIsConnected] = useState(false); @@ -353,36 +396,12 @@ export function useLiveKit() { return; } - // Not muted — ensure mic is published and live + // Not muted — ensure the AudioManager pipeline is on the right device + // and at the right volume, then republish if the published track is + // stale or missing. await audioManager.setInputDevice(inputDeviceId); audioManager.setInputVolume(inputVolume); - - const currentGen = audioManager.getStreamGeneration(); - - if (micPub?.track) { - // Track already published — check if it's still current - if (micPub.track.mediaStreamTrack?.readyState === 'live' && lastMicGenRef.current === currentGen) { - // Current and live — just unmute if needed - if (micPub.isMuted) { - await r.localParticipant.setMicrophoneEnabled(true); - } - return; - } - // Track is stale (device or constraint change) — replace it - await r.localParticipant.unpublishTrack(micPub.track as LocalAudioTrack); - } - - // Publish fresh track from AudioManager pipeline - const audioTrack = audioManager.getFreshTrack(); - if (!audioTrack) return; - - console.log('[LiveKit] Publishing fresh microphone track (gen:', currentGen, ')'); - await r.localParticipant.publishTrack(audioTrack, { - name: 'microphone', - source: Track.Source.Microphone, - }); - lastMicGenRef.current = currentGen; - + await republishMicrophone(r, lastMicGenRef); } catch (err) { console.error('[LiveKit] Failed to sync mic state:', err); } @@ -391,15 +410,70 @@ export function useLiveKit() { syncMic(); // Re-sync when AudioManager resumes - const unsubscribe = AudioManager.getInstance().onResumed(() => { + const unsubscribeResume = AudioManager.getInstance().onResumed(() => { syncMic(); }); return () => { - unsubscribe(); + unsubscribeResume(); }; }, [isMuted, isDeafened, spaceMutedUserIds, spaceDeafenedUserIds, permissionMutedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]); + // Subscribe to upstream-input-track-end events from AudioManager whenever a + // room is connected. The published mic track is a clone of a WebAudio + // destination node and never ends on hardware loss; only the upstream + // getUserMedia track does. AudioManager owns that signal — we react to it. + useEffect(() => { + if (!isConnected) return; + const am = AudioManager.getInstance(); + const subscriberRoom = roomRef.current; + + const unsubscribe = am.onInputTrackEnded(async () => { + // Room was replaced or torn down between event emission and handler run. + if (roomRef.current !== subscriberRoom || !subscriberRoom) return; + + const deviceId = useVoiceStore.getState().inputDeviceId; + let copy = 'Microphone unavailable'; + + try { + const probe = await navigator.mediaDevices.getUserMedia({ + audio: deviceId === 'default' ? true : { deviceId: { exact: deviceId } }, + }); + probe.getTracks().forEach(t => t.stop()); + + // Probe succeeded — device is back. Re-acquire and force a republish. + if (roomRef.current !== subscriberRoom) return; + try { + await am.setInputDevice(deviceId); + if (roomRef.current !== subscriberRoom) return; + await republishMicrophone(subscriberRoom, lastMicGenRef); + return; + } catch { + copy = 'Microphone could not be restored'; + } + } catch (err: any) { + if (err?.name === 'NotAllowedError') { + copy = 'Microphone permission was revoked'; + } else if (err?.name === 'NotFoundError') { + if (deviceId !== 'default') { + // The configured device disappeared. Fall back to default — the + // store update triggers syncMic via its dep array, which calls + // republishMicrophone with the freshly acquired default stream. + useVoiceStore.getState().setInputDevice('default'); + copy = 'Microphone disconnected — switched to system default'; + } else { + copy = 'Microphone disconnected'; + } + } + } + + if (roomRef.current !== subscriberRoom) return; + useUIStore.getState().addToast(copy, 'warning'); + }); + + return () => { unsubscribe(); }; + }, [isConnected]); + // Hot-swap the camera source when cameraDeviceId changes mid-call. // Compares against the published track's actual deviceId (getSettings().deviceId) // rather than a memoised previous store value, so the null → explicit-same-device @@ -613,62 +687,11 @@ export function useLiveKit() { }; } } - if (publication.source === Track.Source.Microphone) { - const mst = publication.track?.mediaStreamTrack; - if (mst) { - mst.onended = async () => { - // The mic track we publish is the *cloned* output of the AudioManager - // pipeline (see AudioManager.getFreshTrack). It can end for two - // distinct reasons: - // (a) The underlying upstream getUserMedia track ended (unplug, - // OS revoke). The clone goes too. - // (b) syncMic called unpublishTrack() during a deliberate - // republish (device change, RNNoise toggle). In that case - // the user did NOT lose audio — a fresh track is incoming. - // - // Distinguishing (a) from (b): inspect the AudioManager's current - // upstream stream. If it's null or non-active AND the room is - // still ours, we're in case (a). - if (roomRef.current !== newRoom) return; - const am = AudioManager.getInstance(); - if (am.hasActiveStream()) return; // case (b) — pipeline is alive - - // Probe getUserMedia to distinguish unplug vs revoke vs unavailable. - const deviceId = useVoiceStore.getState().inputDeviceId; - let copy = 'Microphone unavailable'; - try { - const probe = await navigator.mediaDevices.getUserMedia({ - audio: deviceId === 'default' ? true : { deviceId: { exact: deviceId } }, - }); - probe.getTracks().forEach(t => t.stop()); - // Probe succeeded — device is back. Try to re-acquire silently. - if (roomRef.current !== newRoom) return; - try { - await am.setInputDevice(deviceId); - // syncMic effect re-publishes when stream generation bumps. - return; - } catch { - copy = 'Microphone could not be restored'; - } - } catch (err: any) { - if (err?.name === 'NotAllowedError') copy = 'Microphone permission was revoked'; - else if (err?.name === 'NotFoundError') { - // The configured device disappeared. Fall back to default if - // the user wasn't already on it. - if (deviceId !== 'default') { - useVoiceStore.getState().setInputDevice('default'); - copy = 'Microphone disconnected — switched to system default'; - } else { - copy = 'Microphone disconnected'; - } - } - } - - if (roomRef.current !== newRoom) return; - useUIStore.getState().addToast(copy, 'warning'); - }; - } - } + // NOTE: Microphone track-loss is handled at the AudioManager layer via + // `onInputTrackEnded`, NOT here. The published mic track is a clone of + // a WebAudio destination node and does not end on hardware loss — only + // the upstream getUserMedia track does. See the `useEffect` that + // subscribes to `AudioManager.onInputTrackEnded` above. guardedUpdate(); }); newRoom.on(RoomEvent.LocalTrackUnpublished, (publication: LocalTrackPublication) => { From 8907bb305da7a6f5d7f5f6393e7fbd23a426d6db Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 01:35:07 +0200 Subject: [PATCH 07/14] fix(web): mic-loss recovery uses 'could not be restored' for unclassified errors --- packages/web/src/hooks/useLiveKit.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index dc82937a..bc31830b 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -433,7 +433,7 @@ export function useLiveKit() { if (roomRef.current !== subscriberRoom || !subscriberRoom) return; const deviceId = useVoiceStore.getState().inputDeviceId; - let copy = 'Microphone unavailable'; + let copy = 'Microphone could not be restored'; try { const probe = await navigator.mediaDevices.getUserMedia({ @@ -449,7 +449,7 @@ export function useLiveKit() { await republishMicrophone(subscriberRoom, lastMicGenRef); return; } catch { - copy = 'Microphone could not be restored'; + // copy already holds 'Microphone could not be restored' } } catch (err: any) { if (err?.name === 'NotAllowedError') { @@ -464,6 +464,8 @@ export function useLiveKit() { } else { copy = 'Microphone disconnected'; } + } else { + copy = 'Microphone could not be restored'; } } From 3aea8210bd4845daea3fde1c083f259e21928592 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 01:37:37 +0200 Subject: [PATCH 08/14] =?UTF-8?q?feat(web):=20AudioInputSection=20?= =?UTF-8?q?=E2=80=94=20full=20input=20picker=20with=20level=20meter=20and?= =?UTF-8?q?=20resolved-default=20hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settingsPanels/AudioInputSection.tsx | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx diff --git a/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx b/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx new file mode 100644 index 00000000..3bedaeb3 --- /dev/null +++ b/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx @@ -0,0 +1,230 @@ +import { useEffect, useRef, useState } from 'react'; +import { useVoiceStore } from '../../../stores/voiceStore'; +import { AudioManager } from '../../../audio/AudioManager'; +import { useAudioDevices } from '../../../hooks/useAudioDevices'; + +export function AudioInputSection() { + const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); + const setInputDevice = useVoiceStore((s) => s.setInputDevice); + const inputVolume = useVoiceStore((s) => s.inputVolume); + const setInputVolume = useVoiceStore((s) => s.setInputVolume); + const { permState, inputs, inputLabels, requestPermission } = useAudioDevices(); + + const [listOpen, setListOpen] = useState(false); + const [micLevel, setMicLevel] = useState(0); + const [activeUpstreamId, setActiveUpstreamId] = useState(null); + const dropdownRef = useRef(null); + const animFrameRef = useRef(0); + + // Click-outside-to-close. + useEffect(() => { + if (!listOpen) return; + const onMouseDown = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setListOpen(false); + } + }; + document.addEventListener('mousedown', onMouseDown); + return () => document.removeEventListener('mousedown', onMouseDown); + }, [listOpen]); + + // Live mic-level meter. Reuses AudioManager's analyser node, which is part + // of the canonical pipeline — no extra getUserMedia required if the user is + // already in voice OR the AudioContext is active. + useEffect(() => { + if (permState !== 'granted') return; + let stopped = false; + const am = AudioManager.getInstance(); + const ctx = am.getContext(); + if (!ctx) return; // nothing to measure until the user joins voice or hits Test + const analyser = am.getAnalyserNode(); + analyser.fftSize = 256; + const data = new Uint8Array(analyser.frequencyBinCount); + const tick = () => { + if (stopped) return; + analyser.getByteFrequencyData(data); + const avg = data.reduce((a, b) => a + b, 0) / data.length; + setMicLevel(Math.min(avg / 128, 1)); + animFrameRef.current = requestAnimationFrame(tick); + }; + tick(); + return () => { + stopped = true; + if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); + }; + }, [permState]); + + // Track the resolved upstream deviceId for the "System Default · X" hint. + useEffect(() => { + if (permState !== 'granted') return; + const am = AudioManager.getInstance(); + const id = am.getCurrentInputDeviceId(); + setActiveUpstreamId(id === 'default' ? null : id); + }, [permState, inputDeviceId]); + + if (permState === 'unknown') { + return ( + +
Checking microphone access…
+
+ ); + } + + if (permState === 'denied') { + return ( + +
+
⚠ Microphone access denied
+
+ Grant microphone permission to choose an input device. +
+ +
+
+ ); + } + + if (permState === 'prompt') { + return ( + +
+
+ Microphone permission needed to list and choose an input device. +
+ +
+
+ ); + } + + // permState === 'granted' + const selectedLabel = inputDeviceId === 'default' + ? 'System Default' + : inputLabels.get(inputDeviceId) ?? 'System Default'; + const resolvedHint = inputDeviceId === 'default' && activeUpstreamId + ? inputLabels.get(activeUpstreamId) + : null; + + const handleSelect = (id: string) => { + setInputDevice(id); + AudioManager.getInstance().setInputDevice(id).catch(() => {}); + setListOpen(false); + }; + + const micBars = 20; + const activeBars = Math.round(micLevel * micBars * (inputVolume / 100)); + + return ( + +
+
+ + {listOpen && ( +
+ handleSelect('default')} /> + {inputs.filter(d => d.deviceId !== 'default').map((d) => ( + handleSelect(d.deviceId)} + /> + ))} +
+ )} +
+ {resolvedHint && ( +
Currently using: {resolvedHint}
+ )} + {inputs.length === 0 && ( +
No microphones detected.
+ )} + +
+
+
Input Volume
+
{inputVolume}%
+
+ setInputVolume(Number(e.target.value))} + className="w-full h-1.5 rounded-full appearance-none cursor-pointer bg-surface-base [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md" + style={{ + background: `linear-gradient(to right, rgb(var(--accent-primary)) 0%, rgb(var(--accent-primary)) ${inputVolume / 2}%, rgb(var(--interactive-muted)) ${inputVolume / 2}%, rgb(var(--interactive-muted)) 100%)`, + }} + /> +
+ {Array.from({ length: micBars }).map((_, i) => ( +
+ ))} +
+
+ The level meter is live whenever an audio session is active. Join a voice channel to test mic input. +
+
+
+ + ); +} + +function SectionShell({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +interface DropdownItemProps { + label: string; + active: boolean; + onClick: () => void; +} + +function DropdownItem({ label, active, onClick }: DropdownItemProps) { + return ( + + ); +} From 8e67f1d553d20d629c9c5c5770073a13fe2087f4 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 01:43:01 +0200 Subject: [PATCH 09/14] fix(web): mic meter reacts to AudioContext lifecycle; extract picker primitives - AudioInputSection now subscribes to AudioManager.onResumed and bumps an audioCtxGen state on each 'running' transition. Mic-level meter and resolved-default hint effects depend on it, so opening Settings before joining voice and then joining voice activates the meter without needing to remount the panel. Footer copy updated to match the new behavior. - SectionShell and DropdownItem extracted to settingsPanels/_shared/SettingsPickerPrimitives.tsx so Task 6 (AudioOutputSection) can import them instead of triplicating the markup. The _shared/ folder keeps these settings-internal primitives out of the broader ui/ namespace. - Deliberate scope choice: VideoSection.tsx still has its own DropdownItem copy. Unifying all three is left to a follow-up; touching VideoSection here would expand scope beyond the audio-device-ux branch. --- .../settingsPanels/AudioInputSection.tsx | 67 ++++++++----------- .../_shared/SettingsPickerPrimitives.tsx | 46 +++++++++++++ 2 files changed, 73 insertions(+), 40 deletions(-) create mode 100644 packages/web/src/components/modals/settingsPanels/_shared/SettingsPickerPrimitives.tsx diff --git a/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx b/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx index 3bedaeb3..2bc2931c 100644 --- a/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx +++ b/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { useVoiceStore } from '../../../stores/voiceStore'; import { AudioManager } from '../../../audio/AudioManager'; import { useAudioDevices } from '../../../hooks/useAudioDevices'; +import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives'; export function AudioInputSection() { const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); @@ -13,6 +14,12 @@ export function AudioInputSection() { const [listOpen, setListOpen] = useState(false); const [micLevel, setMicLevel] = useState(0); const [activeUpstreamId, setActiveUpstreamId] = useState(null); + // Bumped whenever AudioManager's AudioContext transitions to 'running'. + // Used as a dep on effects that need to re-run once the context exists — + // the user may open Settings before joining voice (no AudioContext yet), + // then join voice and expect the meter / resolved-default hint to come + // alive without reopening the panel. + const [audioCtxGen, setAudioCtxGen] = useState(0); const dropdownRef = useRef(null); const animFrameRef = useRef(0); @@ -28,9 +35,21 @@ export function AudioInputSection() { return () => document.removeEventListener('mousedown', onMouseDown); }, [listOpen]); + // Listen for AudioContext resume events so dependent effects re-trigger + // when the context first becomes available (e.g. user joins voice after + // opening Settings). onResumed fires on every 'running' state transition; + // we only need an opaque generation bump to re-run downstream effects. + useEffect(() => { + if (permState !== 'granted') return; + const am = AudioManager.getInstance(); + const unsubscribe = am.onResumed(() => setAudioCtxGen((g) => g + 1)); + return () => { unsubscribe(); }; + }, [permState]); + // Live mic-level meter. Reuses AudioManager's analyser node, which is part - // of the canonical pipeline — no extra getUserMedia required if the user is - // already in voice OR the AudioContext is active. + // of the canonical pipeline — no extra getUserMedia required once the user + // is in voice. Re-runs on `audioCtxGen` bumps so the meter activates after + // the AudioContext appears mid-session. useEffect(() => { if (permState !== 'granted') return; let stopped = false; @@ -52,15 +71,17 @@ export function AudioInputSection() { stopped = true; if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); }; - }, [permState]); + }, [permState, audioCtxGen]); - // Track the resolved upstream deviceId for the "System Default · X" hint. + // Track the resolved upstream deviceId for the "Currently using: X" hint. + // Re-runs on `audioCtxGen` because the resolved-default ID is only known + // after AudioManager has actually opened a stream. useEffect(() => { if (permState !== 'granted') return; const am = AudioManager.getInstance(); const id = am.getCurrentInputDeviceId(); setActiveUpstreamId(id === 'default' ? null : id); - }, [permState, inputDeviceId]); + }, [permState, inputDeviceId, audioCtxGen]); if (permState === 'unknown') { return ( @@ -187,44 +208,10 @@ export function AudioInputSection() { ))}
- The level meter is live whenever an audio session is active. Join a voice channel to test mic input. + The level meter activates once you join a voice channel.
); } - -function SectionShell({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-
{title}
-
{children}
-
- ); -} - -interface DropdownItemProps { - label: string; - active: boolean; - onClick: () => void; -} - -function DropdownItem({ label, active, onClick }: DropdownItemProps) { - return ( - - ); -} diff --git a/packages/web/src/components/modals/settingsPanels/_shared/SettingsPickerPrimitives.tsx b/packages/web/src/components/modals/settingsPanels/_shared/SettingsPickerPrimitives.tsx new file mode 100644 index 00000000..3dddae44 --- /dev/null +++ b/packages/web/src/components/modals/settingsPanels/_shared/SettingsPickerPrimitives.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from 'react'; + +/** + * Section wrapper used by audio/video picker subsections (AudioInputSection, + * AudioOutputSection, …). Provides the small uppercase title above a soft + * inset card. Settings-panel-internal — kept under `_shared/` rather than + * promoted to `ui/` because nothing outside settings panels needs this look. + */ +export function SectionShell({ title, children }: { title: string; children: ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +export interface DropdownItemProps { + label: string; + active: boolean; + onClick: () => void; +} + +/** + * Single row of a settings picker dropdown (input device, output device, etc.). + * Renders a checkmark on the active row and indents inactive labels by the + * checkmark's width so labels align across rows. Settings-panel-internal. + */ +export function DropdownItem({ label, active, onClick }: DropdownItemProps) { + return ( + + ); +} From 5b8af14e12f47c415294d3bc67bcf96e9b956b1f Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 01:47:49 +0200 Subject: [PATCH 10/14] =?UTF-8?q?feat(web):=20AudioOutputSection=20?= =?UTF-8?q?=E2=80=94=20output=20picker,=20volume,=20test=20tone,=20lifecyc?= =?UTF-8?q?le-aware=20sinkId=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Uses shared SectionShell/DropdownItem from ./_shared/SettingsPickerPrimitives rather than redefining local copies (parity with AudioInputSection). - supportsSinkId is reactive to AudioContext lifecycle via the audioCtxGen pattern (bumped by AudioManager.onResumed). Defaults to true and only flips to false when a real context exists AND lacks setSinkId (Safari < 17), so the picker is never preemptively hidden when the user opens Settings before joining voice. AudioManager.setOutputDevice + initContext re-apply path handles the deferred sinkId binding once the context appears. --- .../settingsPanels/AudioOutputSection.tsx | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 packages/web/src/components/modals/settingsPanels/AudioOutputSection.tsx diff --git a/packages/web/src/components/modals/settingsPanels/AudioOutputSection.tsx b/packages/web/src/components/modals/settingsPanels/AudioOutputSection.tsx new file mode 100644 index 00000000..2c2108a7 --- /dev/null +++ b/packages/web/src/components/modals/settingsPanels/AudioOutputSection.tsx @@ -0,0 +1,167 @@ +import { useEffect, useRef, useState } from 'react'; +import { useVoiceStore } from '../../../stores/voiceStore'; +import { AudioManager } from '../../../audio/AudioManager'; +import { useAudioDevices } from '../../../hooks/useAudioDevices'; +import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives'; + +export function AudioOutputSection() { + const outputDeviceId = useVoiceStore((s) => s.outputDeviceId); + const setOutputDevice = useVoiceStore((s) => s.setOutputDevice); + const outputVolume = useVoiceStore((s) => s.outputVolume); + const setOutputVolume = useVoiceStore((s) => s.setOutputVolume); + const { permState, outputs, outputLabels, requestPermission } = useAudioDevices(); + + const [listOpen, setListOpen] = useState(false); + // Default to "supported" — only flip false if a real context exists and + // lacks setSinkId (Safari < 17). Pre-context users can still pick a device; + // AudioManager.setOutputDevice defers the actual setSinkId until the + // context exists (applyOutputDevice early-returns when ctx is null, and + // initContext re-applies the persisted ID on creation). + const [supportsSinkId, setSupportsSinkId] = useState(true); + // Bumped whenever AudioManager's AudioContext transitions to 'running'. + // Used as a dep on the supportsSinkId effect so the check re-evaluates + // when the user joins voice after opening Settings — without this, a + // user who opens Settings before joining voice would see an incorrect + // "browser doesn't support setSinkId" fallback that never recovers. + const [audioCtxGen, setAudioCtxGen] = useState(0); + const dropdownRef = useRef(null); + + // Click-outside-to-close. + useEffect(() => { + if (!listOpen) return; + const onMouseDown = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setListOpen(false); + } + }; + document.addEventListener('mousedown', onMouseDown); + return () => document.removeEventListener('mousedown', onMouseDown); + }, [listOpen]); + + // Listen for AudioContext resume events so the supportsSinkId check + // re-evaluates once the context first becomes available. + useEffect(() => { + if (permState !== 'granted') return; + const am = AudioManager.getInstance(); + const unsubscribe = am.onResumed(() => setAudioCtxGen((g) => g + 1)); + return () => { unsubscribe(); }; + }, [permState]); + + // Detect setSinkId support — Safari < 17 does not support it on AudioContext. + // Default state is "supported"; we only flip to false once we have a real + // context to inspect AND it lacks the API. This avoids hiding the picker + // from users who open Settings before joining voice (no context yet). + useEffect(() => { + const ctx = AudioManager.getInstance().getContext(); + if (ctx && !('setSinkId' in ctx)) { + setSupportsSinkId(false); + } else { + setSupportsSinkId(true); + } + }, [permState, audioCtxGen]); + + if (permState === 'unknown') { + return ( + +
Checking audio access…
+
+ ); + } + + // Output-device labels are gated behind microphone permission. If permission + // is not granted we can still let the user adjust output volume + test the + // current default, but the picker is hidden. + const showPicker = permState === 'granted' && supportsSinkId; + const selectedLabel = outputDeviceId === 'default' + ? 'System Default' + : outputLabels.get(outputDeviceId) ?? 'System Default'; + + const handleSelect = (id: string) => { + setOutputDevice(id); + AudioManager.getInstance().setOutputDevice(id).catch(() => {}); + setListOpen(false); + }; + + const handleTestTone = async () => { + try { await AudioManager.getInstance().playTestTone(); } catch { /* best-effort */ } + }; + + return ( + +
+ {showPicker ? ( +
+ + {listOpen && ( +
+ handleSelect('default')} /> + {outputs.filter(d => d.deviceId !== 'default').map((d) => ( + handleSelect(d.deviceId)} + /> + ))} +
+ )} +
+ ) : permState === 'granted' && !supportsSinkId ? ( +
+ This browser doesn't support choosing an output device. Audio plays to the system default. +
+ ) : ( +
+
+ Grant microphone permission to list output devices (browsers gate output names behind microphone access). +
+ +
+ )} + +
+
+
Output Volume
+
{outputVolume}%
+
+ setOutputVolume(Number(e.target.value))} + className="w-full h-1.5 rounded-full appearance-none cursor-pointer bg-surface-base [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md" + style={{ + background: `linear-gradient(to right, rgb(var(--accent-primary)) 0%, rgb(var(--accent-primary)) ${outputVolume / 2}%, rgb(var(--interactive-muted)) ${outputVolume / 2}%, rgb(var(--interactive-muted)) 100%)`, + }} + /> +
+ + +
+
+ ); +} From ff8dcc28ea122b9ddec0cac0e2e4a01317156dea Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 01:52:20 +0200 Subject: [PATCH 11/14] feat(web): wire AudioInput/Output sections into Voice & Video settings; fix EC tooltip --- .../src/components/modals/settingsPanels/VoicePanel.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/modals/settingsPanels/VoicePanel.tsx b/packages/web/src/components/modals/settingsPanels/VoicePanel.tsx index 3c42533f..b848e7e5 100644 --- a/packages/web/src/components/modals/settingsPanels/VoicePanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/VoicePanel.tsx @@ -1,6 +1,8 @@ import { useVoiceStore } from '../../../stores/voiceStore'; import { Toggle } from '../../ui/Toggle'; import { VideoSection } from './VideoSection'; +import { AudioInputSection } from './AudioInputSection'; +import { AudioOutputSection } from './AudioOutputSection'; export function VoicePanel() { const echoCancellation = useVoiceStore((s) => s.echoCancellation); @@ -17,6 +19,10 @@ export function VoicePanel() { return (

Voice & Video

+ + + +
Volume @@ -69,7 +75,7 @@ export function VoicePanel() {
Echo Cancellation
-
Removes echo when using speakers
+
Cancels echo from your speakers feeding back into the mic. Always on for voice channels and calls.
From 69050714599afe8393d44951cac828a5273c5d77 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 01:56:24 +0200 Subject: [PATCH 12/14] =?UTF-8?q?refactor(web):=20UserAreaPanel=20uses=20u?= =?UTF-8?q?seAudioDevices=20=E2=80=94=20kills=20unconditional=20getUserMed?= =?UTF-8?q?ia=20probe;=20reuses=20shared=20DropdownItem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/layout/ChannelSidebar.tsx | 158 +++++++++--------- 1 file changed, 77 insertions(+), 81 deletions(-) diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 2b7f5732..0770df11 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -21,6 +21,8 @@ import { DmSearchBar } from './DmSearchBar'; import { DmListItem } from './DmListItem'; import { useDragManager, type DropTarget, type LayoutItem } from '../../hooks/useDragManager'; import { useDelayedLoading } from '../../hooks/useDelayedLoading'; +import { useAudioDevices } from '../../hooks/useAudioDevices'; +import { DropdownItem } from '../modals/settingsPanels/_shared/SettingsPickerPrimitives'; export function ChannelSidebar() { const spaces = useSpaceStore((s) => s.spaces); @@ -843,15 +845,20 @@ function UserAreaPanel({ onSettingsClick: (tab?: string) => void; }) { const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null); - const [inputDevices, setInputDevices] = useState([]); - const [outputDevices, setOutputDevices] = useState([]); const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); const outputDeviceId = useVoiceStore((s) => s.outputDeviceId); const setInputDevice = useVoiceStore((s) => s.setInputDevice); const setOutputDevice = useVoiceStore((s) => s.setOutputDevice); - - const [selectedInputLabel, setSelectedInputLabel] = useState('Default'); - const [selectedOutputLabel, setSelectedOutputLabel] = useState('Default'); + + // Shared hook drives lists, permission state, and live devicechange refresh. + const { permState, inputs: inputDevices, outputs: outputDevices, inputLabels, outputLabels, requestPermission } = useAudioDevices(); + + const selectedInputLabel = inputDeviceId === 'default' + ? 'System Default' + : inputLabels.get(inputDeviceId) ?? 'System Default'; + const selectedOutputLabel = outputDeviceId === 'default' + ? 'System Default' + : outputLabels.get(outputDeviceId) ?? 'System Default'; const inputVolume = useVoiceStore((s) => s.inputVolume); const storeSetInputVolume = useVoiceStore((s) => s.setInputVolume); @@ -864,38 +871,6 @@ function UserAreaPanel({ const analyserRef = useRef(null); const animFrameRef = useRef(0); - const loadDevices = useCallback(async () => { - try { - // Need to request permission first to get labels - if (!AudioManager.getInstance().getContext()) { - await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())); - } - const devices = await navigator.mediaDevices.enumerateDevices(); - // Deduplicate by deviceId — USB devices sharing the same audio chipset - // (e.g. C-Media 0d8c:0134) appear as multiple entries with identical IDs. - const dedup = (list: MediaDeviceInfo[]): MediaDeviceInfo[] => { - const seen = new Set(); - return list.filter(d => { - if (seen.has(d.deviceId)) return false; - seen.add(d.deviceId); - return true; - }); - }; - const inputs = dedup(devices.filter(d => d.kind === 'audioinput')); - const outputs = dedup(devices.filter(d => d.kind === 'audiooutput')); - setInputDevices(inputs); - setOutputDevices(outputs); - - const currentInput = inputs.find(d => d.deviceId === inputDeviceId); - if (currentInput) setSelectedInputLabel(currentInput.label || 'Default'); - - const currentOutput = outputs.find(d => d.deviceId === outputDeviceId); - if (currentOutput) setSelectedOutputLabel(currentOutput.label || 'Default'); - } catch { - // permission denied - } - }, [inputDeviceId, outputDeviceId]); - // Start mic level monitoring when input panel opens useEffect(() => { if (openPanel !== 'input') { @@ -946,27 +921,24 @@ function UserAreaPanel({ if (openPanel === panel) { setOpenPanel(null); } else { - loadDevices(); setOpenPanel(panel); setShowInputDeviceList(false); setShowOutputDeviceList(false); - // Explicitly resume on interaction + // Resume the AudioContext so the mic-level meter starts measuring on open. AudioManager.getInstance().resumeContext(); } }; - const selectInput = (device: MediaDeviceInfo) => { - setInputDevice(device.deviceId); // Pure state update → triggers syncMic if in voice call - AudioManager.getInstance().setInputDevice(device.deviceId); // Immediate preview for mic level meter - setSelectedInputLabel(device.label || 'Default'); + const selectInput = (deviceId: string) => { + setInputDevice(deviceId); // Pure state update → triggers syncMic if in voice call + AudioManager.getInstance().setInputDevice(deviceId).catch(() => {}); setShowInputDeviceList(false); }; - const selectOutput = (device: MediaDeviceInfo) => { - setOutputDevice(device.deviceId); - setSelectedOutputLabel(device.label || 'Default'); + const selectOutput = (deviceId: string) => { + setOutputDevice(deviceId); + AudioManager.getInstance().setOutputDevice(deviceId).catch(() => {}); setShowOutputDeviceList(false); - AudioManager.getInstance().setOutputDevice(device.deviceId); }; // Generate mic level bars (20 bars like Discord) @@ -993,23 +965,35 @@ function UserAreaPanel({ {showInputDeviceList && ( -
- {inputDevices.map(d => ( - - ))} +
+ {permState !== 'granted' && ( +
+ Microphone permission needed.{' '} + +
+ )} + {permState === 'granted' && ( + <> + selectInput('default')} + /> + {inputDevices.filter(d => d.deviceId !== 'default').map(d => ( + selectInput(d.deviceId)} + /> + ))} + + )}
)}
@@ -1079,23 +1063,35 @@ function UserAreaPanel({ {showOutputDeviceList && ( -
- {outputDevices.map(d => ( - - ))} +
+ {permState !== 'granted' && ( +
+ Audio permission needed.{' '} + +
+ )} + {permState === 'granted' && ( + <> + selectOutput('default')} + /> + {outputDevices.filter(d => d.deviceId !== 'default').map(d => ( + selectOutput(d.deviceId)} + /> + ))} + + )}
)}
From 4044e910c3b67e503a3ce82b1e6fea9f17838bb9 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 02:01:09 +0200 Subject: [PATCH 13/14] docs(voice): add Audio Device Selection section parallel to Camera Device Selection --- docs/systems/voice.md | 54 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) 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 `