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.
This commit is contained in:
Matthew Hodgson
2026-09-08 23:27:03 +01:00
parent 00d0adc3c4
commit 4b8cfc4921
7 changed files with 235 additions and 28 deletions
+5 -2
View File
@@ -25,11 +25,11 @@ import { useTranslation } from "react-i18next";
import styles from "./MediaMuteAndSwitchButton.module.css"; import styles from "./MediaMuteAndSwitchButton.module.css";
import { MicButton, VideoButton } from "../button"; import { MicButton, VideoButton } from "../button";
import { type DeviceLabel } from "../state/MediaDevices"; import { type AudioInputDeviceLabel } from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext"; import { useMediaDevices } from "../MediaDevicesContext";
export interface MenuOptions { export interface MenuOptions {
label: DeviceLabel; label: AudioInputDeviceLabel;
id: string; id: string;
} }
@@ -180,6 +180,9 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
case "number": case "number":
labelText = numberedLabel(label.number); labelText = numberedLabel(label.number);
break; break;
case "default":
labelText = t("settings.devices.default");
break;
} }
return ( return (
<MenuItem <MenuItem
+2 -1
View File
@@ -137,7 +137,8 @@ export const LobbyView: FC<Props> = ({
const initialAudioOptions = useInitial( const initialAudioOptions = useInitial(
() => () =>
audioEnabled && { audioEnabled && {
deviceId: getValue(devices.audioInput.selected$)?.id, // "" is the virtual browser default: no deviceId constraint
deviceId: getValue(devices.audioInput.selected$)?.id || undefined,
}, },
); );
+110
View File
@@ -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: "" }),
});
});
});
});
@@ -12,9 +12,10 @@ import {
type LocalTrack, type LocalTrack,
type LocalTrackPublication, type LocalTrackPublication,
ParticipantEvent, ParticipantEvent,
type Room as LivekitRoom,
Track, Track,
} from "livekit-client"; } from "livekit-client";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject, NEVER } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { ObservableScope } from "../../ObservableScope"; import { ObservableScope } from "../../ObservableScope";
@@ -23,10 +24,12 @@ import {
flushPromises, flushPromises,
mockLivekitRoom, mockLivekitRoom,
mockMediaDevices, mockMediaDevices,
deviceStub,
} from "../../../utils/test"; } from "../../../utils/test";
import { Publisher } from "./Publisher"; import { Publisher } from "./Publisher";
import { type Connection } from "../remoteMembers/Connection"; import { type Connection } from "../remoteMembers/Connection";
import { type MuteStates } from "../../MuteStates"; import { type MuteStates } from "../../MuteStates";
import { type MediaDevices } from "../../MediaDevices";
let scope: ObservableScope; let scope: ObservableScope;
@@ -182,6 +185,53 @@ beforeEach(() => {
} as unknown as Connection; } 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<LivekitRoom>);
const publisher = new Publisher(
{ ...connection, livekitRoom },
mockMediaDevices({
audioInput: { ...deviceStub, selected$ },
} as unknown as Partial<MediaDevices>),
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", () => { describe("Publisher", () => {
let publisher: Publisher; let publisher: Publisher;
@@ -353,8 +353,19 @@ export class Publisher {
const syncDevice = ( const syncDevice = (
kind: MediaDeviceKind, kind: MediaDeviceKind,
selected$: Observable<SelectedDevice | undefined>, selected$: Observable<SelectedDevice | undefined>,
): Subscription => ): Subscription => {
selected$.pipe(scope.bind()).subscribe((device) => { // 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 (lkRoom.state != LivekitConnectionState.Connected) return;
// if (this.connectionState$.value !== ConnectionState.Connected) return; // if (this.connectionState$.value !== ConnectionState.Connected) return;
this.logger.info( this.logger.info(
@@ -363,20 +374,25 @@ export class Publisher {
" !== ", " !== ",
device?.id, device?.id,
); );
if ( if (device === undefined) return;
device !== undefined && const browserDefaultInput = device.id === "" && kind !== "audiooutput";
lkRoom.getActiveDevice(kind) !== device.id if (browserDefaultInput) {
) { if (requestedId === "") return;
lkRoom } else if (lkRoom.getActiveDevice(kind) === device.id) {
.switchActiveDevice(kind, device.id) return;
.catch((e: Error) =>
this.logger.error(
`Failed to sync ${kind} device with LiveKit`,
e,
),
);
} }
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$); syncDevice("audioinput", devices.audioInput.selected$);
if (!controlledAudioDevices) if (!controlledAudioDevices)
@@ -174,7 +174,9 @@ function generateRoomOption({
publishDefaults, publishDefaults,
audioCaptureDefaults: { audioCaptureDefaults: {
...liveKitOptions.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(), echoCancellation: echoCancellationSetting.getValue(),
noiseSuppression: noiseSuppressionSetting.getValue(), noiseSuppression: noiseSuppressionSetting.getValue(),
autoGainControl: autoGainControlSetting.getValue(), autoGainControl: autoGainControlSetting.getValue(),
+34 -9
View File
@@ -36,12 +36,15 @@ export type DeviceLabel =
| { type: "name"; name: string } | { type: "name"; name: string }
| { type: "number"; number: number }; | { type: "number"; number: number };
export type AudioOutputDeviceLabel = export type AudioInputDeviceLabel =
| DeviceLabel | DeviceLabel
| { type: "speaker" }
| { type: "earpiece" }
| { type: "default"; name: string | null }; | { type: "default"; name: string | null };
export type AudioOutputDeviceLabel =
| AudioInputDeviceLabel
| { type: "speaker" }
| { type: "earpiece" };
/** /**
* Base selected-device value shared by all media kinds. * Base selected-device value shared by all media kinds.
* *
@@ -191,7 +194,10 @@ function selectDevice$<Label>(
}); });
} }
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> { class AudioInput implements MediaDevice<
AudioInputDeviceLabel,
SelectedAudioInputDevice
> {
private logger = rootLogger.getChild("[MediaDevices AudioInput]"); private logger = rootLogger.getChild("[MediaDevices AudioInput]");
private readonly availableRaw$: Behavior<MediaDeviceInfo[]> = private readonly availableRaw$: Behavior<MediaDeviceInfo[]> =
@@ -203,7 +209,29 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
); );
public readonly available$ = this.scope.behavior( public readonly available$ = this.scope.behavior(
this.availableRaw$.pipe(map(buildDeviceMap)), this.availableRaw$.pipe(
map((availableRaw) => {
const available: Map<string, AudioInputDeviceLabel> =
buildDeviceMap(availableRaw);
// Browsers without a "default" pseudo-device (Firefox, Safari, and
// in particular iOS) get a virtual default entry, listed first so
// that it is what we use when the user has not chosen a microphone.
// Its ID is the empty string, which consumers translate into "no
// deviceId constraint": the browser then captures from whatever the
// OS routes as the default input (a wired or Bluetooth headset when
// one is connected) instead of EC pinning whichever device happens
// to be enumerated first, which on iOS is the built-in microphone.
// Unlike Chrome's "default" device, a stream opened this way does
// not follow later changes of the OS default on desktop browsers;
// iOS re-routes the audio session itself.
if (available.size && !available.has("") && !available.has("default"))
return new Map<string, AudioInputDeviceLabel>([
["", { type: "default", name: null }],
...available,
]);
return available;
}),
),
); );
public readonly selected$ = this.scope.behavior( public readonly selected$ = this.scope.behavior(
@@ -271,9 +299,6 @@ export class AudioOutput implements MediaDevice<
// set to empty map if we are on Safari, because it does not support setSinkId // set to empty map if we are on Safari, because it does not support setSinkId
available = new Map(); available = new Map();
} }
// Note: creating virtual default input devices would be another problem
// entirely, because requesting a media stream from deviceId "" won't
// automatically track the default device.
return available; return available;
}), }),
), ),
@@ -361,7 +386,7 @@ export class MediaDevices {
false, false,
); );
public readonly audioInput: MediaDevice< public readonly audioInput: MediaDevice<
DeviceLabel, AudioInputDeviceLabel,
SelectedAudioInputDevice SelectedAudioInputDevice
> = new AudioInput(this.usingNames$, this.scope); > = new AudioInput(this.usingNames$, this.scope);