/** * Pure decisions about which audio device to capture from or play to. * * The browser device list is unreliable evidence: it is empty before the * microphone permission is granted and can hold unlabelled placeholder * entries, so "my device is not in the list" only means the device is gone * when the list itself is trustworthy. */ /** The stored value that means "follow whatever the operating system picked". */ export const SYSTEM_DEFAULT_AUDIO_DEVICE_ID = ''; export interface AudioDeviceSelectionInput { /** The user's saved choice, or {@link SYSTEM_DEFAULT_AUDIO_DEVICE_ID}. */ readonly selectedDeviceId: string; /** Device ids the browser currently reports for this device kind. */ readonly availableDeviceIds: readonly string[]; } export interface AudioDeviceSelectionOutcome { /** The device id to use now. */ readonly deviceId: string; /** True only when a real device list proved the saved choice is gone. */ readonly didFallBack: boolean; } export interface MicrophoneConstraintsInput { readonly deviceId: string; /** Whether the browser's own noise suppression should run (off when RNNoise handles it). */ readonly browserNoiseSuppression: boolean; } /** * Decide which device to use, falling back to the system default when the * saved choice is provably gone. */ export function resolveAudioDeviceSelection( input: AudioDeviceSelectionInput ): AudioDeviceSelectionOutcome { const { selectedDeviceId } = input; if (!selectedDeviceId) { return { deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID, didFallBack: false }; } const knownDeviceIds = input.availableDeviceIds.filter((deviceId) => !!deviceId); if (knownDeviceIds.length === 0) { return { deviceId: selectedDeviceId, didFallBack: false }; } if (knownDeviceIds.includes(selectedDeviceId)) { return { deviceId: selectedDeviceId, didFallBack: false }; } return { deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID, didFallBack: true }; } /** * Build the `getUserMedia` constraints for the microphone. * * `deviceId` is `exact`, because a bare id is only a preference the browser may * ignore - against real hardware it can hand back the current default, so the * picker appears to do nothing. Callers must handle `OverconstrainedError` by * retrying with the system default (see `isDeviceUnavailableError`). */ export function buildMicrophoneConstraints( input: MicrophoneConstraintsInput ): MediaStreamConstraints { const audio: MediaTrackConstraints = { echoCancellation: true, noiseSuppression: input.browserNoiseSuppression, autoGainControl: true }; if (input.deviceId) { audio.deviceId = { exact: input.deviceId }; } return { audio, video: false }; } /** * Whether a `getUserMedia` rejection means "that exact device is not usable", * as opposed to a denied permission or a missing API, which must keep failing. */ export function isDeviceUnavailableError(error: unknown): boolean { const name = (error as { name?: string } | null)?.name; return name === 'OverconstrainedError' || name === 'NotFoundError'; }