Compare commits

...
Author SHA1 Message Date
Johannes Marbach a64a911b23 Adapt to changes from main 2026-09-16 17:11:18 +02:00
Johannes Marbach bc772fadd3 Merge branch 'main' into matthew/default-audio-input 2026-09-16 17:10:48 +02:00
Johannes Marbach 11ef319536 Reset tracked request value on failure 2026-09-16 16:23:55 +02:00
Johannes Marbach dda549ffd2 Make sure that selecting a device that was previously resolved from the default selection actually pins it 2026-09-16 16:17:26 +02:00
Matthew Hodgson 4b8cfc4921 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.
2026-09-08 23:27:03 +01:00
7 changed files with 372 additions and 28 deletions
+5 -2
View File
@@ -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<MediaMuteAndSwitchButtonProps> = ({
case "number":
labelText = numberedLabel(label.number);
break;
case "default":
labelText = t("settings.devices.default");
break;
}
return (
<MenuItem
+2 -1
View File
@@ -135,7 +135,8 @@ export const LobbyView: FC<Props> = ({
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,
},
);
+116
View File
@@ -0,0 +1,116 @@
/*
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, {
controlledAudioDevices: false,
});
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, {
controlledAudioDevices: false,
});
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, {
controlledAudioDevices: false,
});
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: "" }),
});
});
});
});
@@ -5,16 +5,26 @@ 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, expect, it, test, vi } from "vitest";
import {
afterEach,
beforeEach,
describe,
expect,
it,
type Mock,
test,
vi,
} from "vitest";
import {
ConnectionState as LivekitConnectionState,
LocalParticipant,
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 +33,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 +194,159 @@ beforeEach(() => {
} as unknown as Connection;
});
describe("Publisher device sync", () => {
type SelectedInput =
| { id: string; hardwareDeviceChange$: typeof NEVER }
| undefined;
function selectedInput(id: string): SelectedInput {
return { id, hardwareDeviceChange$: NEVER };
}
/**
* Creates a Publisher whose LiveKit room starts out capturing from
* capturedDeviceId (undefined meaning no deviceId constraint, i.e. the
* browser default) and reports activeDeviceId as its active audio input.
*/
function createDeviceSyncPublisher({
selected$,
capturedDeviceId,
activeDeviceId,
switchActiveDevice = vi.fn().mockResolvedValue(true),
}: {
selected$: BehaviorSubject<SelectedInput>;
capturedDeviceId?: string;
activeDeviceId: string;
switchActiveDevice?: Mock;
}): { publisher: Publisher; switchActiveDevice: Mock } {
const livekitRoom = mockLivekitRoom({
localParticipant,
state: LivekitConnectionState.Connected,
// ConnectionFactory leaves the deviceId out for the browser default
options: { audioCaptureDefaults: { deviceId: capturedDeviceId } },
switchActiveDevice,
getActiveDevice: () => activeDeviceId,
} 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,
false,
);
return { publisher, switchActiveDevice };
}
it("does not pin a device for the virtual browser default input", async () => {
const selected$ = new BehaviorSubject<SelectedInput>(selectedInput(""));
const { publisher, switchActiveDevice } = createDeviceSyncPublisher({
selected$,
activeDeviceId: "hardware-id-of-whatever-the-browser-chose",
});
// 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(selectedInput("headset"));
expect(switchActiveDevice).toHaveBeenLastCalledWith(
"audioinput",
"headset",
);
selected$.next(selectedInput(""));
expect(switchActiveDevice).toHaveBeenLastCalledWith(
"audioinput",
"default",
false,
);
expect(switchActiveDevice).toHaveBeenCalledTimes(2);
await publisher.destroy();
});
it("pins the device the browser default resolved to when picked explicitly", async () => {
const selected$ = new BehaviorSubject<SelectedInput>(selectedInput(""));
const { publisher, switchActiveDevice } = createDeviceSyncPublisher({
selected$,
// The browser default happens to capture from the built-in microphone
activeDeviceId: "built-in-mic",
});
expect(switchActiveDevice).not.toHaveBeenCalled();
// Picking that same microphone has to replace the loose constraint with a
// pin, or the capture would keep following the OS default
selected$.next(selectedInput("built-in-mic"));
expect(switchActiveDevice).toHaveBeenCalledTimes(1);
expect(switchActiveDevice).toHaveBeenLastCalledWith(
"audioinput",
"built-in-mic",
);
// Once pinned, re-emitting the same selection is a no-op again
selected$.next(selectedInput("built-in-mic"));
expect(switchActiveDevice).toHaveBeenCalledTimes(1);
await publisher.destroy();
});
it("retries the same selection after a failed switch", async () => {
const selected$ = new BehaviorSubject<SelectedInput>(selectedInput(""));
const switchActiveDevice = vi
.fn()
.mockRejectedValueOnce(new Error("could not open the device"))
.mockResolvedValue(true);
const { publisher } = createDeviceSyncPublisher({
selected$,
// Pinned to the headset, so moving to the browser default is a switch
capturedDeviceId: "headset",
activeDeviceId: "headset",
switchActiveDevice,
});
expect(switchActiveDevice).toHaveBeenCalledTimes(1);
await flushPromises();
// The failed switch left us on the headset, so selecting the browser
// default again has to try once more rather than count as already done
selected$.next(selectedInput(""));
expect(switchActiveDevice).toHaveBeenCalledTimes(2);
expect(switchActiveDevice).toHaveBeenLastCalledWith(
"audioinput",
"default",
false,
);
await flushPromises();
// This time it worked, so there is nothing left to do
selected$.next(selectedInput(""));
expect(switchActiveDevice).toHaveBeenCalledTimes(2);
await publisher.destroy();
});
it("does not switch when the room already captures from the selected device", async () => {
const selected$ = new BehaviorSubject<SelectedInput>(
selectedInput("headset"),
);
const { publisher, switchActiveDevice } = createDeviceSyncPublisher({
selected$,
capturedDeviceId: "headset",
activeDeviceId: "headset",
});
expect(switchActiveDevice).not.toHaveBeenCalled();
await publisher.destroy();
});
});
describe("Publisher", () => {
let publisher: Publisher;
@@ -355,8 +355,19 @@ export class Publisher {
const syncDevice = (
kind: MediaDeviceKind,
selected$: Observable<SelectedDevice | undefined>,
): 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(
@@ -365,20 +376,41 @@ export class Publisher {
" !== ",
device?.id,
);
if (
device !== undefined &&
lkRoom.getActiveDevice(kind) !== device.id
if (device === undefined) return;
const browserDefaultInput = device.id === "" && kind !== "audiooutput";
// While we capture from the browser default, LiveKit reports the
// physical device it resolved to, which can be any of the devices the
// user could also pick explicitly. getActiveDevice therefore can't
// tell an explicit pin apart from the default resolving to the same
// device, and only requestedId can: without this, picking the device
// the default happens to use would leave the loose constraint in
// place, and the capture would keep following the OS default.
const capturingBrowserDefault = requestedId === "";
if (browserDefaultInput) {
if (capturingBrowserDefault) return;
} else if (
!capturingBrowserDefault &&
lkRoom.getActiveDevice(kind) === device.id
) {
lkRoom
.switchActiveDevice(kind, device.id)
.catch((e: Error) =>
this.logger.error(
`Failed to sync ${kind} device with LiveKit`,
e,
),
);
return;
}
const previousRequestedId = requestedId;
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) => {
// LiveKit keeps capturing from the previous device, so record that
// again to leave the next selection of this device something to do.
// A selection made while the switch was in flight wins, though.
if (requestedId === device.id) requestedId = previousRequestedId;
this.logger.error(`Failed to sync ${kind} device with LiveKit`, e);
});
});
};
syncDevice("audioinput", devices.audioInput.selected$);
if (!controlledAudioDevices)
@@ -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(),
+34 -9
View File
@@ -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$<Label>(
});
}
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
class AudioInput implements MediaDevice<
AudioInputDeviceLabel,
SelectedAudioInputDevice
> {
private logger = rootLogger.getChild("[MediaDevices AudioInput]");
private readonly availableRaw$: Behavior<MediaDeviceInfo[]> =
@@ -203,7 +209,29 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
);
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(
@@ -271,9 +299,6 @@ export class AudioOutput implements MediaDevice<
// set to empty map if we are on Safari, because it does not support setSinkId
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;
}),
),
@@ -377,7 +402,7 @@ export class MediaDevices {
false,
);
public readonly audioInput: MediaDevice<
DeviceLabel,
AudioInputDeviceLabel,
SelectedAudioInputDevice
> = new AudioInput(this.usingNames$, this.scope);