Make sure that selecting a device that was previously resolved from the default selection actually pins it

This commit is contained in:
Johannes Marbach
2026-09-16 16:17:26 +02:00
parent 4b8cfc4921
commit dda549ffd2
2 changed files with 100 additions and 11 deletions
@@ -5,7 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. 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 { import {
ConnectionState as LivekitConnectionState, ConnectionState as LivekitConnectionState,
LocalParticipant, LocalParticipant,
@@ -186,18 +195,36 @@ beforeEach(() => {
}); });
describe("Publisher device sync", () => { describe("Publisher device sync", () => {
it("does not pin a device for the virtual browser default input", async () => { type SelectedInput =
const selected$ = new BehaviorSubject< | { id: string; hardwareDeviceChange$: typeof NEVER }
{ id: string; hardwareDeviceChange$: typeof NEVER } | undefined | undefined;
>({ id: "", hardwareDeviceChange$: NEVER });
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,
}: {
selected$: BehaviorSubject<SelectedInput>;
capturedDeviceId?: string;
activeDeviceId: string;
}): { publisher: Publisher; switchActiveDevice: Mock } {
const switchActiveDevice = vi.fn().mockResolvedValue(true); const switchActiveDevice = vi.fn().mockResolvedValue(true);
const livekitRoom = mockLivekitRoom({ const livekitRoom = mockLivekitRoom({
localParticipant, localParticipant,
state: LivekitConnectionState.Connected, state: LivekitConnectionState.Connected,
// ConnectionFactory leaves the deviceId out for the browser default // ConnectionFactory leaves the deviceId out for the browser default
options: { audioCaptureDefaults: {} }, options: { audioCaptureDefaults: { deviceId: capturedDeviceId } },
switchActiveDevice, switchActiveDevice,
getActiveDevice: () => "hardware-id-of-whatever-the-browser-chose", getActiveDevice: () => activeDeviceId,
} as unknown as Partial<LivekitRoom>); } as unknown as Partial<LivekitRoom>);
const publisher = new Publisher( const publisher = new Publisher(
@@ -210,17 +237,27 @@ describe("Publisher device sync", () => {
logger, logger,
); );
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 // Already capturing from the browser default: nothing to switch, even
// though LiveKit reports the physical device it ended up with. // though LiveKit reports the physical device it ended up with.
expect(switchActiveDevice).not.toHaveBeenCalled(); expect(switchActiveDevice).not.toHaveBeenCalled();
selected$.next({ id: "headset", hardwareDeviceChange$: NEVER }); selected$.next(selectedInput("headset"));
expect(switchActiveDevice).toHaveBeenLastCalledWith( expect(switchActiveDevice).toHaveBeenLastCalledWith(
"audioinput", "audioinput",
"headset", "headset",
); );
selected$.next({ id: "", hardwareDeviceChange$: NEVER }); selected$.next(selectedInput(""));
expect(switchActiveDevice).toHaveBeenLastCalledWith( expect(switchActiveDevice).toHaveBeenLastCalledWith(
"audioinput", "audioinput",
"default", "default",
@@ -230,6 +267,47 @@ describe("Publisher device sync", () => {
await publisher.destroy(); 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("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", () => { describe("Publisher", () => {
@@ -376,9 +376,20 @@ export class Publisher {
); );
if (device === undefined) return; if (device === undefined) return;
const browserDefaultInput = device.id === "" && kind !== "audiooutput"; 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 (browserDefaultInput) {
if (requestedId === "") return; if (capturingBrowserDefault) return;
} else if (lkRoom.getActiveDevice(kind) === device.id) { } else if (
!capturingBrowserDefault &&
lkRoom.getActiveDevice(kind) === device.id
) {
return; return;
} }
requestedId = device.id; requestedId = device.id;