// SPDX-License-Identifier: AGPL-3.0-or-later import {isPendingMigratedDeviceId} from '@app/features/app/domain_migration/DomainMigrationDeviceRemap'; import {Logger} from '@app/features/platform/utils/AppLogger'; import { canRouteVoiceAudioContextOutput, setVoiceAudioContextOutput, } from '@app/features/voice/engine/VoiceSharedAudioContext'; import {VoiceTrackKind} from '@app/features/voice/engine/VoiceTrackSource'; import VoiceSettings from '@app/features/voice/state/VoiceSettings'; import {voiceDeviceManager} from '@app/features/voice/utils/VoiceDeviceManager'; import type {RemoteAudioTrack, Room} from 'livekit-client'; const logger = new Logger('VoiceOutputDeviceSync'); interface SinkableAudioContext extends AudioContext { setSinkId?: ( sinkId: | string | { type: 'none'; }, ) => Promise; } type SinkableMediaElement = HTMLMediaElement & { setSinkId?: (sinkId: string) => Promise; }; function normalizeSinkIdForBrowserApi(deviceId: string): string { return deviceId === 'default' ? '' : deviceId; } function isDeviceMissingError(error: unknown): boolean { if (!(error instanceof Error)) return false; if (error.name === 'NotFoundError') return true; return /not\s+found/i.test(error.message); } function isSinkableAudioContext(value: unknown): value is SinkableAudioContext { if (typeof value !== 'object' || value === null) return false; const candidate = value as {setSinkId?: unknown}; return candidate.setSinkId === undefined || typeof candidate.setSinkId === 'function'; } function getRoomAudioContext(room: Room): SinkableAudioContext | null { const audioContext: unknown = Reflect.get(room, 'audioContext'); return isSinkableAudioContext(audioContext) ? audioContext : null; } function isRoomWebAudioMixEnabled(room: Room): boolean { const options: unknown = Reflect.get(room, 'options'); if (typeof options !== 'object' || options === null) return false; return Boolean((options as {webAudioMix?: unknown}).webAudioMix); } function supportsMediaElementSetSinkId(): boolean { if (typeof HTMLMediaElement === 'undefined') return false; return 'setSinkId' in HTMLMediaElement.prototype; } function canRoomSwitchAudioOutput(room: Room): boolean { if (!isRoomWebAudioMixEnabled(room)) return supportsMediaElementSetSinkId(); const audioContext = getRoomAudioContext(room); if (!audioContext) return true; return typeof audioContext.setSinkId === 'function' || canRouteVoiceAudioContextOutput(audioContext); } function resolveLiveKitSwitchDeviceId(room: Room, deviceId: string): string { if (isRoomWebAudioMixEnabled(room)) return deviceId; return normalizeSinkIdForBrowserApi(deviceId); } function isSinkableMediaElement(value: unknown): value is SinkableMediaElement { if (typeof HTMLMediaElement === 'undefined') return false; return value instanceof HTMLMediaElement; } function getAttachedElements(track: RemoteAudioTrack | undefined): ReadonlyArray { if (!track) return []; const attachedElements: unknown = Reflect.get(track, 'attachedElements'); if (!Array.isArray(attachedElements)) return []; return attachedElements.filter(isSinkableMediaElement); } async function resolveAvailableOutputDeviceId(deviceId: string): Promise { if (deviceId === 'default') { return deviceId; } const state = await voiceDeviceManager.ensureDevices({requestPermissions: false}); const currentDeviceId = VoiceSettings.getOutputDeviceId(); if (currentDeviceId !== deviceId) { return currentDeviceId; } if (state.outputDevices.length === 0) { return deviceId; } if (state.outputDevices.some((device) => device.deviceId === deviceId)) { return deviceId; } if (isPendingMigratedDeviceId(deviceId)) { return 'default'; } logger.warn('Selected audio output device no longer available; falling back to default', {deviceId}); VoiceSettings.updateSettings({outputDeviceId: 'default'}); return 'default'; } async function applyOutputDeviceToWebAudioMixer(room: Room, deviceId: string): Promise { const audioContext = getRoomAudioContext(room); if (!audioContext) { return; } const applySink = (sinkId: string) => audioContext.setSinkId ? audioContext.setSinkId(sinkId) : setVoiceAudioContextOutput(audioContext, sinkId); const sinkId = normalizeSinkIdForBrowserApi(deviceId); try { await applySink(sinkId); } catch (error) { if (isDeviceMissingError(error)) { logger.warn('Web Audio mixer sink no longer available', {deviceId}); if (deviceId !== 'default') { if (VoiceSettings.getOutputDeviceId() === deviceId && !isPendingMigratedDeviceId(deviceId)) { VoiceSettings.updateSettings({outputDeviceId: 'default'}); } try { await applySink(''); } catch (fallbackError) { logger.warn('Failed to fall back Web Audio mixer sink to default', {deviceId, error: fallbackError}); } } return; } throw error; } } async function applyOutputDeviceToAttachedElements(room: Room, deviceId: string): Promise { const sinkId = normalizeSinkIdForBrowserApi(deviceId); const operations: Array> = []; room.remoteParticipants.forEach((participant) => { participant.audioTrackPublications.forEach((publication) => { if (publication.kind !== VoiceTrackKind.Audio) return; const track = publication.track as RemoteAudioTrack | undefined; if (track?.kind === VoiceTrackKind.Audio && typeof track.setSinkId === 'function') { operations.push(track.setSinkId(sinkId)); return; } for (const element of getAttachedElements(track)) { if (typeof element.setSinkId === 'function') { operations.push(element.setSinkId(sinkId)); } } }); }); const results = await Promise.allSettled(operations); for (const result of results) { if (result.status !== 'rejected') continue; if (isDeviceMissingError(result.reason)) { logger.warn('Audio element sink no longer available', {deviceId}); continue; } logger.warn('Failed to apply audio output device to attached audio element', {deviceId, error: result.reason}); } } export async function applyOutputDeviceToRoom(room: Room, deviceId: string): Promise { const resolvedDeviceId = await resolveAvailableOutputDeviceId(deviceId); if (canRoomSwitchAudioOutput(room)) { try { if (!isRoomWebAudioMixEnabled(room) || getRoomAudioContext(room)?.setSinkId) { await room.switchActiveDevice('audiooutput', resolveLiveKitSwitchDeviceId(room, resolvedDeviceId)); } } catch (error) { logger.warn('LiveKit failed to apply audio output device', {deviceId: resolvedDeviceId, error}); } } else { logger.warn('Audio output selection is unavailable: this browser cannot move the voice playback sink', { deviceId: resolvedDeviceId, webAudioMix: isRoomWebAudioMixEnabled(room), }); } try { await applyOutputDeviceToWebAudioMixer(room, resolvedDeviceId); } catch (error) { logger.warn('Failed to apply audio output device to Web Audio mixer', {deviceId: resolvedDeviceId, error}); } try { await applyOutputDeviceToAttachedElements(room, resolvedDeviceId); } catch (error) { logger.warn('Failed to apply audio output device to attached audio elements', {deviceId: resolvedDeviceId, error}); } } export function bindOutputDeviceSync(room: Room): () => void { const initial = VoiceSettings.getOutputDeviceId(); if (initial) { void applyOutputDeviceToRoom(room, initial).catch((error) => { logger.warn('Initial audio output sync failed', {deviceId: initial, error}); }); } let previousDeviceId = initial; return VoiceSettings.subscribe(() => { const deviceId = VoiceSettings.getOutputDeviceId(); if (!deviceId || deviceId === previousDeviceId) return; previousDeviceId = deviceId; void applyOutputDeviceToRoom(room, deviceId).catch((error) => { logger.warn('Audio output sync failed', {deviceId, error}); }); }); }