Compare commits

...
Author SHA1 Message Date
Matthew Hodgson 426acd222e Prefer the browser default audio output when no output has been chosen
On browsers without a "default" pseudo-device (Firefox, Safari), the
virtual default output entry was appended after the physical devices, so
with no saved preference EC selected the first physical device and pinned
every remote audio element to it with setSinkId. Pinned sinks are not
re-routed by the browser: on Firefox/Linux a Bluetooth headset switching
from A2DP to HFP when its microphone is opened (i.e. on unmute) destroys
the pinned sink and all remote audio goes silent, with no error and no
fallback (rageshake 17320).

List the virtual default first so it is the fallback both when nothing was
chosen and when the chosen output disappears, and stop labelling it with
the first device's name since the browser default is not necessarily that
device.
2026-09-08 23:22:44 +01:00
Johannes Marbach 00d0adc3c4 Merge pull request #4222 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2026-09-08 11:40:20 +02:00
Johannes Marbach ddc5a50529 Disable new react compiler rules 2026-09-08 11:29:36 +02:00
renovate[bot] 27734a1a02 Update all non-major dependencies 2026-09-08 06:13:17 +00:00
Johannes Marbach 14c61db43d Merge pull request #4241 from element-hq/matthew/encryption-mismatch-logging
Log track encryption flag and cryptor events for remote tracks
2026-09-07 13:30:50 +02:00
Johannes Marbach 65e1b845a7 Apply suggestion from @Johennes 2026-09-07 13:18:00 +02:00
Matthew Hodgson eddd95ba41 Log track encryption flag and cryptor events for remote tracks
Extends the [RemoteTracks] logging from #4235 with the per-publication
encryption flag and the ParticipantEncryptionStatusChanged /
EncryptionError room events.

If a publisher encrypts frames while the subscriber believes the
publication is unencrypted, livekit-client bypasses the cryptor and hands
raw ciphertext to the decoder, which is audible as loud noise bursts.
The reverse mismatch (or a missing/invalid key) drops frames instead.
Neither case is visible in a rageshake today.
2026-09-07 11:53:01 +01:00
7 changed files with 1590 additions and 1475 deletions
+11
View File
@@ -39,6 +39,17 @@
"jsdoc/check-property-names": "error",
"jsdoc/require-param-description": "warn",
"react/display-name": "error",
// TODO: These React Compiler-based rules joined oxlint's correctness
// category in v1.81 and flag many pre-existing patterns, including hooks
// like useLatest/useInitial/useReactiveState that intentionally access
// refs during render. Re-evaluate them when adopting the React Compiler.
"react/immutability": "off",
"react/no-deriving-state-in-effects": "off",
"react/preserve-manual-memoization": "off",
"react/refs": "off",
"react/set-state-in-effect": "off",
"react/static-components": "off",
"react/use-memo": "off",
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
// "rxjs/no-exposed-subjects": "error",
// "rxjs/finnish": [
+2 -2
View File
@@ -81,7 +81,7 @@
"@vitejs/plugin-react": "^6.0.2",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "4.1.10",
"@vitest/ui": "4.1.11",
"babel-plugin-react-compiler": "^1.0.0",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3",
@@ -101,7 +101,7 @@
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"oxfmt": "^0.61.0",
"oxfmt": "^0.66.0",
"oxlint": "^1.70.0",
"oxlint-tsgolint": "^0.25.0",
"pako": "^2.0.4",
+1450 -1458
View File
File diff suppressed because it is too large Load Diff
+61 -1
View File
@@ -10,6 +10,7 @@ import * as ComponentsCore from "@livekit/components-core";
import { ObservableScope } from "./ObservableScope";
import { AudioOutput } from "./MediaDevices";
import { audioOutput as audioOutputSetting } from "../settings/settings";
import { withTestScheduler } from "../utils/test";
const BT_SPEAKER = {
@@ -93,6 +94,8 @@ describe("AudioOutput Tests", () => {
beforeEach(() => {
testScope = new ObservableScope();
// Device preferences persist in localStorage across tests
audioOutputSetting.setValue(undefined);
});
afterEach(() => {
@@ -150,7 +153,9 @@ describe("AudioOutput Tests", () => {
expectObservable(audioOutput.selected$).toBe("abcde", {
a: undefined,
b: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
// No "default" pseudo-device (Firefox): the virtual browser default is
// selected until the user picks something.
b: { id: "", virtualEarpiece: false },
c: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
d: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
e: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
@@ -158,6 +163,61 @@ describe("AudioOutput Tests", () => {
});
});
it("falls back to the browser default when the selected device disappears", () => {
withTestScheduler(({ behavior, cold, schedule, expectObservable }) => {
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
cold("a---b", {
a: DEVICE_LIST_B,
// The monitor is unplugged (or a Bluetooth sink changes profile)
b: [LAPTOP_SPEAKER],
}),
);
const audioOutput = new AudioOutput(
behavior("a", { a: true }),
testScope,
);
schedule("--a", {
a: () => audioOutput.select(MONITOR_SPEAKER.deviceId),
});
expectObservable(audioOutput.selected$).toBe("a-b-c", {
a: { id: "", virtualEarpiece: false },
b: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
// Rather than pinning some other physical device, let the browser route
c: { id: "", virtualEarpiece: false },
});
});
});
it("lists the virtual browser default first when there is no default pseudo-device", () => {
withTestScheduler(({ behavior, cold, expectObservable }) => {
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
cold("a", { a: DEVICE_LIST_B }),
);
const audioOutput = new AudioOutput(
behavior("a", { a: true }),
testScope,
);
expectObservable(audioOutput.available$).toBe("a", {
a: new Map([
["", { type: "default", name: null }],
[
LAPTOP_SPEAKER.deviceId,
{ type: "name", name: LAPTOP_SPEAKER.label },
],
[
MONITOR_SPEAKER.deviceId,
{ type: "name", name: MONITOR_SPEAKER.label },
],
]),
});
});
});
it("Test mappings", () => {
// In a real life setup there would be first a blanked list
// then the real one.
@@ -516,11 +516,12 @@ describe("remote track logging", () => {
it("logs remote participant and track events on the connection logger", () => {
setupTest();
const info = vi.fn();
const warn = vi.fn();
const testLogger = {
getChild: (): unknown => testLogger,
info,
debug: vi.fn(),
warn: vi.fn(),
warn,
error: vi.fn(),
} as unknown as Logger;
new Connection(
@@ -545,6 +546,7 @@ describe("remote track logging", () => {
source: "microphone",
trackSid: "TR_mic",
isMuted: false,
isEncrypted: true,
} as unknown as RemoteTrackPublication;
const messages = (): string[] => info.mock.calls.map((c) => c[0] as string);
@@ -564,11 +566,27 @@ describe("remote track logging", () => {
);
expect(messages()).toEqual([
"Participant connected: @bob:example.org:DEV111 (PA_bob)",
"Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 muted=false",
"Muted: audio microphone TR_mic of @bob:example.org:DEV111",
"Stream paused: audio microphone TR_mic of @bob:example.org:DEV111",
"Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true muted=false",
"Muted: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
"Stream paused: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
]);
// Encryption status changes are logged; cryptor errors are warnings
fakeLivekitRoom.emit(
RoomEvent.ParticipantEncryptionStatusChanged,
false,
bob,
);
expect(messages().at(-1)).toBe(
"Encryption status of @bob:example.org:DEV111: encrypted=false",
);
const cryptorError = new Error("missing key at index 3");
fakeLivekitRoom.emit(RoomEvent.EncryptionError, cryptorError, bob);
expect(warn).toHaveBeenCalledWith(
"Encryption error for @bob:example.org:DEV111:",
cryptorError,
);
// Local mute events are already logged by the Publisher
fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, {
...fakeLocalParticipant,
@@ -186,8 +186,11 @@ export class Connection {
private logRemoteTrackEvents(): void {
const room = this.livekitRoom;
const log = this.logger.getChild("[RemoteTracks]");
// The encryption flag matters: if the publisher encrypts but this client
// believes the track is unencrypted, the cryptor is bypassed and raw
// ciphertext reaches the decoder (audible as loud noise bursts).
const track = (pub: TrackPublication, p: Participant): string =>
`${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity}`;
`${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity} encrypted=${pub.isEncrypted}`;
const onParticipantConnected = (p: RemoteParticipant): void =>
log.info(`Participant connected: ${p.identity} (${p.sid})`);
@@ -232,6 +235,20 @@ export class Connection {
state: Track.StreamState,
p: RemoteParticipant,
): void => log.info(`Stream ${state}: ${track(pub, p)}`);
const onEncryptionStatusChanged = (
encrypted: boolean,
p?: Participant,
): void =>
log.info(
`Encryption status of ${p?.identity ?? "unknown participant"}: encrypted=${encrypted}`,
);
// livekit-client throttles these per cryptor; they indicate frames being
// dropped (missing/invalid key).
const onEncryptionError = (error: Error, p?: Participant): void =>
log.warn(
`Encryption error for ${p?.identity ?? "unknown participant"}:`,
error,
);
room
.on(RoomEvent.ParticipantConnected, onParticipantConnected)
@@ -243,7 +260,12 @@ export class Connection {
.on(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
.on(RoomEvent.TrackMuted, onTrackMuted)
.on(RoomEvent.TrackUnmuted, onTrackUnmuted)
.on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged);
.on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
.on(
RoomEvent.ParticipantEncryptionStatusChanged,
onEncryptionStatusChanged,
)
.on(RoomEvent.EncryptionError, onEncryptionError);
this.scope.onEnd(() => {
room
.off(RoomEvent.ParticipantConnected, onParticipantConnected)
@@ -255,7 +277,12 @@ export class Connection {
.off(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
.off(RoomEvent.TrackMuted, onTrackMuted)
.off(RoomEvent.TrackUnmuted, onTrackUnmuted)
.off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged);
.off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
.off(
RoomEvent.ParticipantEncryptionStatusChanged,
onEncryptionStatusChanged,
)
.off(RoomEvent.EncryptionError, onEncryptionError);
});
}
+14 -7
View File
@@ -257,14 +257,21 @@ export class AudioOutput implements MediaDevice<
map((availableRaw) => {
let available: Map<string, AudioOutputDeviceLabel> =
buildDeviceMap(availableRaw);
// Create a virtual default audio output for browsers that don't have one.
// Its device ID must be the empty string because that's what setSinkId
// recognizes.
// Create a virtual default audio output for browsers that don't have one
// (Firefox, Safari). Its device ID must be the empty string because
// that's what setSinkId recognizes. It goes first so that it is the
// fallback when no output has been explicitly chosen (or the chosen
// one disappears), rather than pinning the first physical device
// with setSinkId: pinned sinks are not re-routed by the browser, and
// Firefox leaves the audio elements silent when a pinned sink goes
// away (e.g. a Bluetooth headset switching profile when its
// microphone is opened). We can't know which physical device the
// browser default resolves to, so the entry carries no name.
if (available.size && !available.has("") && !available.has("default"))
available.set("", {
type: "default",
name: availableRaw[0]?.label || null,
});
available = new Map<string, AudioOutputDeviceLabel>([
["", { type: "default", name: null }],
...available,
]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isSafari = !!(window as any).GestureEvent; // non standard api only found on Safari. https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent#browser_compatibility
if (isSafari) {