Compare commits

...
Author SHA1 Message Date
Matthew Hodgson 3d563e8b57 Re-apply a remote participant's playback volume when an audio element is attached
livekit-client keeps the requested volume on the RemoteAudioTrack and
re-applies it when an element is attached, except that a stored volume of
0 is dropped by a truthiness check (RemoteAudioTrack.attach:
`if (this.elementVolume)`). So "mute for me" was lost whenever the muted
participant's microphone track got a new subscription: their first unmute
after joining muted (EC only publishes the track then), a reconnect on
either side, or a leave/rejoin. The element played at full volume while
the tile still showed them as muted for me (rageshake 17381).

Watch the participant's microphone track for ElementAttached and push the
current volume to the participant again at that point, which runs after
the attach-time check and therefore also covers 0. Also log playbackMuted
changes and the re-applications, since nothing about mute-for-me was
visible in rageshakes so far.
2026-09-08 23:30:05 +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 1622 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
@@ -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);
});
}
+49
View File
@@ -9,8 +9,12 @@ import { expect, onTestFinished, test, vi } from "vitest";
import {
type LocalTrackPublication,
LocalVideoTrack,
ParticipantEvent,
type RemoteAudioTrack,
type RemoteTrackPublication,
Track,
TrackEvent,
TrackPublication,
} from "livekit-client";
import { waitFor } from "@testing-library/dom";
@@ -23,6 +27,7 @@ import {
withTestScheduler,
mockRemoteParticipant,
mockRemoteScreenShare,
mockEmitter,
} from "../../utils/test";
import { constant } from "../Behavior";
@@ -160,6 +165,50 @@ test("control a participant's screen share volume", () => {
});
});
test("re-applies the playback volume when an audio element is attached", () => {
// A participant whose microphone track does not exist yet (they joined muted)
const track = mockEmitter<RemoteAudioTrack>() as unknown as RemoteAudioTrack;
let micPublication: Partial<RemoteTrackPublication> = {};
const setVolumeSpy = vi.fn();
const participant = mockRemoteParticipant({
setVolume: setVolumeSpy,
getTrackPublication: (source) =>
(source === Track.Source.Microphone
? micPublication
: {}) as RemoteTrackPublication,
});
const vm = mockRemoteMedia(rtcMembership, {}, participant);
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a-b-c|", {
a() {
vm.togglePlaybackMuted();
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
setVolumeSpy.mockClear();
},
b() {
// The participant unmutes: their track gets published and subscribed,
// then the renderer attaches an audio element to it
micPublication = { track };
participant.emit(
ParticipantEvent.TrackSubscriptionStatusChanged,
micPublication as RemoteTrackPublication,
TrackPublication.SubscriptionStatus.Subscribed,
);
expect(setVolumeSpy).not.toHaveBeenCalled();
track.emit(TrackEvent.ElementAttached, {} as HTMLMediaElement);
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
setVolumeSpy.mockClear();
},
c() {
// ...and again for every further attach (e.g. a re-render)
track.emit(TrackEvent.ElementAttached, {} as HTMLMediaElement);
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
},
});
expectObservable(vm.playbackMuted$).toBe("ab", { a: false, b: true });
});
});
test("local media remembers whether it should always be shown", () => {
const vm1 = mockLocalMedia(
rtcMembership,
+58 -8
View File
@@ -6,8 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type RemoteParticipant } from "livekit-client";
import { combineLatest, map, of, switchMap } from "rxjs";
import { type RemoteParticipant, Track, TrackEvent } from "livekit-client";
import { observeParticipantMedia } from "@livekit/components-core";
import {
combineLatest,
distinctUntilChanged,
fromEvent,
map,
NEVER,
of,
startWith,
switchMap,
} from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../Behavior";
@@ -60,14 +70,54 @@ export function createRemoteUserMedia(
logger.info(`[RemoteUserMedia ${inputs.id}] waitingForMedia=${waiting}`);
});
// Emits whenever an audio element is attached to this participant's
// microphone track: a new subscription after they rejoin or reconnect, or
// their first unmute if they joined muted (EC only publishes the track
// then). The requested volume has to be applied again at that point.
// livekit-client stores the volume on the RemoteAudioTrack and re-applies
// it on attach, except that a stored volume of 0 is dropped by a truthiness
// check (RemoteAudioTrack.attach: `if (this.elementVolume)`), so "mute for
// me" would otherwise be lost as soon as a new track arrives.
const audioElementAttached$ = inputs.participant$.pipe(
switchMap((p) =>
p === null
? NEVER
: observeParticipantMedia(p).pipe(
map(() => p.getTrackPublication(Track.Source.Microphone)?.track),
distinctUntilChanged(),
switchMap((track) =>
track === undefined
? NEVER
: fromEvent(track, TrackEvent.ElementAttached),
),
),
),
);
const volumeControls = createVolumeControls(scope, {
pretendToBeDisconnected$,
sink$: scope.behavior(
combineLatest([
inputs.participant$,
audioElementAttached$.pipe(startWith(null)),
]).pipe(
map(([p, attached]) => (volume) => {
if (attached !== null)
logger.info(
`[RemoteUserMedia ${inputs.id}] re-applying playback volume ${volume} after audio element attach`,
);
p?.setVolume(volume);
}),
),
),
});
volumeControls.playbackMuted$.pipe(scope.bind()).subscribe((muted) => {
logger.info(`[RemoteUserMedia ${inputs.id}] playbackMuted=${muted}`);
});
return {
...baseUserMedia,
...createVolumeControls(scope, {
pretendToBeDisconnected$,
sink$: scope.behavior(
inputs.participant$.pipe(map((p) => (volume) => p?.setVolume(volume))),
),
}),
...volumeControls,
local: false,
speaking$: scope.behavior(
pretendToBeDisconnected$.pipe(