mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
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:
@@ -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
|
||||
|
||||
@@ -137,7 +137,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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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 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<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", () => {
|
||||
let publisher: Publisher;
|
||||
|
||||
|
||||
@@ -353,8 +353,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(
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
),
|
||||
@@ -361,7 +386,7 @@ export class MediaDevices {
|
||||
false,
|
||||
);
|
||||
public readonly audioInput: MediaDevice<
|
||||
DeviceLabel,
|
||||
AudioInputDeviceLabel,
|
||||
SelectedAudioInputDevice
|
||||
> = new AudioInput(this.usingNames$, this.scope);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user