From 4b8cfc4921fce7eed9fc748362726d11b27aadb8 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 8 Sep 2026 23:27:03 +0100 Subject: [PATCH] Use the browser default microphone when none has been chosen On browsers without a "default" pseudo-device (Firefox, Safari and in particular iOS) EC picked the first enumerated audio input when the user had no saved preference and pinned it with an exact deviceId constraint. On iOS that is the built-in microphone even when a wired or Bluetooth headset is connected, and the pin also stops iOS from re-routing the capture when a headset is plugged in mid-call. Give the audio input list the same virtual "" default entry the output list has, listed first so it is the fallback selection, and translate it into "no deviceId constraint" in ConnectionFactory, LobbyView and the LiveKit device sync (where it becomes a non-exact "default" constraint, which browsers without such a device ignore). The user can still pick a specific microphone, and the selection falls back to the browser default if that device disappears. --- src/components/MediaMuteAndSwitchButton.tsx | 7 +- src/room/LobbyView.tsx | 3 +- src/state/AudioInput.test.ts | 110 ++++++++++++++++++ .../localMember/Publisher.test.ts | 52 ++++++++- .../CallViewModel/localMember/Publisher.ts | 44 ++++--- .../remoteMembers/ConnectionFactory.ts | 4 +- src/state/MediaDevices.ts | 43 +++++-- 7 files changed, 235 insertions(+), 28 deletions(-) create mode 100644 src/state/AudioInput.test.ts diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 3e344dd3a..275417a4c 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -25,11 +25,11 @@ import { useTranslation } from "react-i18next"; import styles from "./MediaMuteAndSwitchButton.module.css"; import { MicButton, VideoButton } from "../button"; -import { type DeviceLabel } from "../state/MediaDevices"; +import { type AudioInputDeviceLabel } from "../state/MediaDevices"; import { useMediaDevices } from "../MediaDevicesContext"; export interface MenuOptions { - label: DeviceLabel; + label: AudioInputDeviceLabel; id: string; } @@ -180,6 +180,9 @@ export const MediaMuteAndSwitchButton: FC = ({ case "number": labelText = numberedLabel(label.number); break; + case "default": + labelText = t("settings.devices.default"); + break; } return ( = ({ const initialAudioOptions = useInitial( () => audioEnabled && { - deviceId: getValue(devices.audioInput.selected$)?.id, + // "" is the virtual browser default: no deviceId constraint + deviceId: getValue(devices.audioInput.selected$)?.id || undefined, }, ); diff --git a/src/state/AudioInput.test.ts b/src/state/AudioInput.test.ts new file mode 100644 index 000000000..c93cb72fc --- /dev/null +++ b/src/state/AudioInput.test.ts @@ -0,0 +1,110 @@ +/* +Copyright 2026 Element Corp. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { afterEach, beforeEach, describe, vi, it, expect } from "vitest"; +import * as ComponentsCore from "@livekit/components-core"; + +import { ObservableScope } from "./ObservableScope"; +import { MediaDevices } from "./MediaDevices"; +import { audioInput as audioInputSetting } from "../settings/settings"; +import { withTestScheduler } from "../utils/test"; + +const BUILT_IN_MIC = { + deviceId: "b3d4cb5e0c8e1d7a2f9b6c4e5d8a7f1c3e2b9d6a5c4f8e7d1b2a3c4d5e6f7a8b", + kind: "audioinput", + label: "iPhone Microphone", + groupId: "1", +} as unknown as MediaDeviceInfo; + +const HEADSET_MIC = { + deviceId: "c4e5d6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5", + kind: "audioinput", + label: "Headset Microphone", + groupId: "2", +} as unknown as MediaDeviceInfo; + +// Chrome exposes a synthetic "default" device that follows the OS default. +const CHROME_DEFAULT = { + ...HEADSET_MIC, + deviceId: "default", + label: `Default - ${HEADSET_MIC.label}`, +} as unknown as MediaDeviceInfo; + +vi.mock("@livekit/components-core", () => ({ + createMediaDeviceObserver: vi.fn(), +})); + +describe("AudioInput", () => { + let testScope: ObservableScope; + + beforeEach(() => { + testScope = new ObservableScope(); + // Device preferences persist in localStorage across tests + audioInputSetting.setValue(undefined); + }); + + afterEach(() => { + testScope.end(); + }); + + it("uses the browser default input when there is no default pseudo-device", () => { + withTestScheduler(({ behavior, cold, expectObservable }) => { + vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue( + // iOS / Firefox: physical devices only, built-in first + cold("a", { a: [BUILT_IN_MIC, HEADSET_MIC] }), + ); + + const { audioInput } = new MediaDevices(testScope); + + expectObservable(audioInput.available$).toBe("a", { + a: new Map([ + ["", { type: "default", name: null }], + [BUILT_IN_MIC.deviceId, { type: "name", name: BUILT_IN_MIC.label }], + [HEADSET_MIC.deviceId, { type: "name", name: HEADSET_MIC.label }], + ]), + }); + expectObservable(audioInput.selected$.pipe()).toBe("a", { + a: expect.objectContaining({ id: "" }), + }); + }); + }); + + it("keeps using Chrome's default device", () => { + withTestScheduler(({ behavior, cold, expectObservable }) => { + vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue( + cold("a", { a: [CHROME_DEFAULT, BUILT_IN_MIC, HEADSET_MIC] }), + ); + + const { audioInput } = new MediaDevices(testScope); + + expectObservable(audioInput.selected$).toBe("a", { + a: expect.objectContaining({ id: "default" }), + }); + }); + }); + + it("honours an explicit choice and falls back to the browser default when it disappears", () => { + withTestScheduler(({ behavior, cold, schedule, expectObservable }) => { + vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue( + cold("a---b", { + a: [BUILT_IN_MIC, HEADSET_MIC], + b: [BUILT_IN_MIC], + }), + ); + + const { audioInput } = new MediaDevices(testScope); + + schedule("--a", { a: () => audioInput.select(HEADSET_MIC.deviceId) }); + + expectObservable(audioInput.selected$).toBe("a-b-c", { + a: expect.objectContaining({ id: "" }), + b: expect.objectContaining({ id: HEADSET_MIC.deviceId }), + c: expect.objectContaining({ id: "" }), + }); + }); + }); +}); diff --git a/src/state/CallViewModel/localMember/Publisher.test.ts b/src/state/CallViewModel/localMember/Publisher.test.ts index 21775c58d..8e03766ed 100644 --- a/src/state/CallViewModel/localMember/Publisher.test.ts +++ b/src/state/CallViewModel/localMember/Publisher.test.ts @@ -12,9 +12,10 @@ import { type LocalTrack, type LocalTrackPublication, ParticipantEvent, + type Room as LivekitRoom, Track, } from "livekit-client"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, NEVER } from "rxjs"; import { logger } from "matrix-js-sdk/lib/logger"; import { ObservableScope } from "../../ObservableScope"; @@ -23,10 +24,12 @@ import { flushPromises, mockLivekitRoom, mockMediaDevices, + deviceStub, } from "../../../utils/test"; import { Publisher } from "./Publisher"; import { type Connection } from "../remoteMembers/Connection"; import { type MuteStates } from "../../MuteStates"; +import { type MediaDevices } from "../../MediaDevices"; let scope: ObservableScope; @@ -182,6 +185,53 @@ beforeEach(() => { } as unknown as Connection; }); +describe("Publisher device sync", () => { + it("does not pin a device for the virtual browser default input", async () => { + const selected$ = new BehaviorSubject< + { id: string; hardwareDeviceChange$: typeof NEVER } | undefined + >({ id: "", hardwareDeviceChange$: NEVER }); + const switchActiveDevice = vi.fn().mockResolvedValue(true); + const livekitRoom = mockLivekitRoom({ + localParticipant, + state: LivekitConnectionState.Connected, + // ConnectionFactory leaves the deviceId out for the browser default + options: { audioCaptureDefaults: {} }, + switchActiveDevice, + getActiveDevice: () => "hardware-id-of-whatever-the-browser-chose", + } as unknown as Partial); + + const publisher = new Publisher( + { ...connection, livekitRoom }, + mockMediaDevices({ + audioInput: { ...deviceStub, selected$ }, + } as unknown as Partial), + muteStates, + constant({ supported: false, processor: undefined }), + logger, + ); + + // Already capturing from the browser default: nothing to switch, even + // though LiveKit reports the physical device it ended up with. + expect(switchActiveDevice).not.toHaveBeenCalled(); + + selected$.next({ id: "headset", hardwareDeviceChange$: NEVER }); + expect(switchActiveDevice).toHaveBeenLastCalledWith( + "audioinput", + "headset", + ); + + selected$.next({ id: "", hardwareDeviceChange$: NEVER }); + expect(switchActiveDevice).toHaveBeenLastCalledWith( + "audioinput", + "default", + false, + ); + expect(switchActiveDevice).toHaveBeenCalledTimes(2); + + await publisher.destroy(); + }); +}); + describe("Publisher", () => { let publisher: Publisher; diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index 0d5f263a6..092fa094c 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -353,8 +353,19 @@ export class Publisher { const syncDevice = ( kind: MediaDeviceKind, selected$: Observable, - ): Subscription => - selected$.pipe(scope.bind()).subscribe((device) => { + ): Subscription => { + // The ID we last asked LiveKit for. Needed for the virtual browser + // default input ("") because LiveKit reports the physical device it + // ended up capturing from, which never equals "". + // ConnectionFactory already applied the selection when the room was + // created; a browser-default input is represented there by an absent + // deviceId. + let requestedId: string | undefined = + kind === "audioinput" && + lkRoom.options.audioCaptureDefaults?.deviceId === undefined + ? "" + : undefined; + return selected$.pipe(scope.bind()).subscribe((device) => { if (lkRoom.state != LivekitConnectionState.Connected) return; // if (this.connectionState$.value !== ConnectionState.Connected) return; this.logger.info( @@ -363,20 +374,25 @@ export class Publisher { " !== ", device?.id, ); - if ( - device !== undefined && - lkRoom.getActiveDevice(kind) !== device.id - ) { - lkRoom - .switchActiveDevice(kind, device.id) - .catch((e: Error) => - this.logger.error( - `Failed to sync ${kind} device with LiveKit`, - e, - ), - ); + if (device === undefined) return; + const browserDefaultInput = device.id === "" && kind !== "audiooutput"; + if (browserDefaultInput) { + if (requestedId === "") return; + } else if (lkRoom.getActiveDevice(kind) === device.id) { + return; } + requestedId = device.id; + // For the browser default input, ask for "default" as a non-exact + // constraint: browsers that have no such device ignore it and capture + // from the OS default input. + (browserDefaultInput + ? lkRoom.switchActiveDevice(kind, "default", false) + : lkRoom.switchActiveDevice(kind, device.id) + ).catch((e: Error) => + this.logger.error(`Failed to sync ${kind} device with LiveKit`, e), + ); }); + }; syncDevice("audioinput", devices.audioInput.selected$); if (!controlledAudioDevices) diff --git a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts index 30ff37f3e..cbdf83cc1 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts @@ -174,7 +174,9 @@ function generateRoomOption({ publishDefaults, audioCaptureDefaults: { ...liveKitOptions.audioCaptureDefaults, - deviceId: devices.audioInput.selected$.value?.id, + // "" is the virtual browser default: leave the constraint out so the + // browser captures from the OS default input. + deviceId: devices.audioInput.selected$.value?.id || undefined, echoCancellation: echoCancellationSetting.getValue(), noiseSuppression: noiseSuppressionSetting.getValue(), autoGainControl: autoGainControlSetting.getValue(), diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index 70a676cf5..737bd5438 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -36,12 +36,15 @@ export type DeviceLabel = | { type: "name"; name: string } | { type: "number"; number: number }; -export type AudioOutputDeviceLabel = +export type AudioInputDeviceLabel = | DeviceLabel - | { type: "speaker" } - | { type: "earpiece" } | { type: "default"; name: string | null }; +export type AudioOutputDeviceLabel = + | AudioInputDeviceLabel + | { type: "speaker" } + | { type: "earpiece" }; + /** * Base selected-device value shared by all media kinds. * @@ -191,7 +194,10 @@ function selectDevice$