Files
fluxer/fluxer_app/src/features/voice/engine/VoiceOutputDeviceSync.ts
T

212 lines
7.7 KiB
TypeScript

// 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<void>;
}
type SinkableMediaElement = HTMLMediaElement & {
setSinkId?: (sinkId: string) => Promise<void>;
};
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<SinkableMediaElement> {
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<string> {
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<void> {
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<void> {
const sinkId = normalizeSinkIdForBrowserApi(deviceId);
const operations: Array<Promise<void>> = [];
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<void> {
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});
});
});
}