Don't render audio from participants that aren't meant to be publishing

This commit is contained in:
Robin
2025-09-25 21:29:02 -04:00
parent 4980d8a622
commit 0759f9b27d
4 changed files with 122 additions and 98 deletions

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { getTrackReferenceId } from "@livekit/components-core"; import { getTrackReferenceId } from "@livekit/components-core";
import { type Room as LivekitRoom } from "livekit-client"; import { type Room as LivekitRoom, type Participant } from "livekit-client";
import { type RemoteAudioTrack, Track } from "livekit-client"; import { type RemoteAudioTrack, Track } from "livekit-client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { import {
@@ -14,7 +14,7 @@ import {
AudioTrack, AudioTrack,
type AudioTrackProps, type AudioTrackProps,
} from "@livekit/components-react"; } from "@livekit/components-react";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; import { type RoomMember } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { useEarpieceAudioConfig } from "../MediaDevicesContext"; import { useEarpieceAudioConfig } from "../MediaDevicesContext";
@@ -22,13 +22,20 @@ import { useReactiveState } from "../useReactiveState";
import * as controls from "../controls"; import * as controls from "../controls";
import {} from "@livekit/components-core"; import {} from "@livekit/components-core";
export interface MatrixAudioRendererProps { export interface MatrixAudioRendererProps {
/**
* The service URL of the LiveKit room.
*/
url: string;
livekitRoom: LivekitRoom;
/** /**
* The list of participants to render audio for. * The list of participants to render audio for.
* This list needs to be composed based on the matrixRTC members so that we do not play audio from users * This list needs to be composed based on the matrixRTC members so that we do not play audio from users
* that are not expected to be in the rtc session. * that are not expected to be in the rtc session.
*/ */
members: CallMembership[]; participants: {
livekitRoom: LivekitRoom; participant: Participant;
member: RoomMember;
}[];
/** /**
* If set to `true`, mutes all audio tracks rendered by the component. * If set to `true`, mutes all audio tracks rendered by the component.
* @remarks * @remarks
@@ -52,14 +59,14 @@ export interface MatrixAudioRendererProps {
* @public * @public
*/ */
export function LivekitRoomAudioRenderer({ export function LivekitRoomAudioRenderer({
members, url,
muted,
livekitRoom, livekitRoom,
participants,
muted,
}: MatrixAudioRendererProps): ReactNode { }: MatrixAudioRendererProps): ReactNode {
const validIdentities = useMemo( const participantSet = useMemo(
() => () => new Set(participants.map(({ participant }) => participant)),
new Set(members?.map((member) => `${member.sender}:${member.deviceId}`)), [participants],
[members],
); );
const loggedInvalidIdentities = useRef(new Set<string>()); const loggedInvalidIdentities = useRef(new Set<string>());
@@ -71,11 +78,11 @@ export function LivekitRoomAudioRenderer({
* @param identity The identity of the track that is invalid * @param identity The identity of the track that is invalid
* @param validIdentities The list of valid identities * @param validIdentities The list of valid identities
*/ */
const logInvalid = (identity: string, validIdentities: Set<string>): void => { const logInvalid = (identity: string): void => {
if (loggedInvalidIdentities.current.has(identity)) return; if (loggedInvalidIdentities.current.has(identity)) return;
logger.warn( logger.warn(
`[MatrixAudioRenderer] Audio track ${identity} has no matching matrix call member`, `[MatrixAudioRenderer] Audio track ${identity} from ${url} has no matching matrix call member`,
`current members: ${Array.from(validIdentities.values())}`, `current members: ${participants.map((p) => p.participant.identity)}`,
`track will not get rendered`, `track will not get rendered`,
); );
loggedInvalidIdentities.current.add(identity); loggedInvalidIdentities.current.add(identity);
@@ -93,23 +100,27 @@ export function LivekitRoomAudioRenderer({
room: livekitRoom, room: livekitRoom,
}, },
).filter((ref) => { ).filter((ref) => {
const isValid = validIdentities?.has(ref.participant.identity); const isValid = participantSet?.has(ref.participant);
if (!isValid && !ref.participant.isLocal) if (!isValid && !ref.participant.isLocal)
logInvalid(ref.participant.identity, validIdentities); logInvalid(ref.participant.identity);
return ( return (
!ref.participant.isLocal && !ref.participant.isLocal &&
ref.publication.kind === Track.Kind.Audio && ref.publication.kind === Track.Kind.Audio &&
isValid isValid
); );
}); });
useEffect(() => { useEffect(() => {
if (!tracks.some((t) => !validIdentities.has(t.participant.identity))) { if (
loggedInvalidIdentities.current.size &&
tracks.every((t) => participantSet.has(t.participant))
) {
logger.debug( logger.debug(
`[MatrixAudioRenderer] All audio tracks have a matching matrix call member identity.`, `[MatrixAudioRenderer] All audio tracks from ${url} have a matching matrix call member identity.`,
); );
loggedInvalidIdentities.current.clear(); loggedInvalidIdentities.current.clear();
} }
}, [tracks, validIdentities]); }, [tracks, participantSet, url]);
// This component is also (in addition to the "only play audio for connected members" logic above) // This component is also (in addition to the "only play audio for connected members" logic above)
// responsible for mimicking earpiece audio on iPhones. // responsible for mimicking earpiece audio on iPhones.

View File

@@ -286,6 +286,8 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(), () => void toggleRaisedHand(),
); );
const allLivekitRooms = useBehavior(vm.allLivekitRooms$);
const participantsByRoom = useBehavior(vm.participantsByRoom$);
const participantCount = useBehavior(vm.participantCount$); const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$); const reconnecting = useBehavior(vm.reconnecting$);
const windowMode = useBehavior(vm.windowMode$); const windowMode = useBehavior(vm.windowMode$);
@@ -739,9 +741,6 @@ export const InCallView: FC<InCallViewProps> = ({
matrixRoom.roomId, matrixRoom.roomId,
); );
const allLivekitRooms = useBehavior(vm.allLivekitRooms$);
const memberships = useBehavior(vm.memberships$);
const buttons: JSX.Element[] = []; const buttons: JSX.Element[] = [];
buttons.push( buttons.push(
@@ -862,11 +861,12 @@ export const InCallView: FC<InCallViewProps> = ({
</Text> </Text>
) )
} }
{allLivekitRooms.map((roomItem) => ( {participantsByRoom.map(({ livekitRoom, url, participants }) => (
<LivekitRoomAudioRenderer <LivekitRoomAudioRenderer
key={roomItem.url} key={url}
livekitRoom={roomItem.room} url={url}
members={memberships} livekitRoom={livekitRoom}
participants={participants}
muted={muteAllAudio} muted={muteAllAudio}
/> />
))} ))}

View File

@@ -480,7 +480,7 @@ export class CallViewModel extends ViewModel {
*/ */
// Note that MatrixRTCSession already filters the call memberships by users // Note that MatrixRTCSession already filters the call memberships by users
// that are joined to the room; we don't need to perform extra filtering here. // that are joined to the room; we don't need to perform extra filtering here.
public readonly memberships$ = this.scope.behavior( private readonly memberships$ = this.scope.behavior(
fromEvent( fromEvent(
this.matrixRTCSession, this.matrixRTCSession,
MatrixRTCSessionEvent.MembershipsChanged, MatrixRTCSessionEvent.MembershipsChanged,
@@ -679,16 +679,19 @@ export class CallViewModel extends ViewModel {
// in a split-brained state. // in a split-brained state.
private readonly pretendToBeDisconnected$ = this.reconnecting$; private readonly pretendToBeDisconnected$ = this.reconnecting$;
private readonly participants$ = this.scope.behavior< public readonly participantsByRoom$ = this.scope.behavior<
{ {
participant: LocalParticipant | RemoteParticipant;
member: RoomMember;
livekitRoom: LivekitRoom; livekitRoom: LivekitRoom;
url: string;
participants: {
participant: LocalParticipant | RemoteParticipant;
member: RoomMember;
}[];
}[] }[]
>( >(
from(this.localConnection) combineLatest([this.localConnection, this.localFocus])
.pipe( .pipe(
switchMap((localConnection) => { switchMap(([localConnection, localFocus]) => {
const memberError = (): never => { const memberError = (): never => {
throw new Error("No room member for call membership"); throw new Error("No room member for call membership");
}; };
@@ -696,32 +699,41 @@ export class CallViewModel extends ViewModel {
participant: localConnection.livekitRoom.localParticipant, participant: localConnection.livekitRoom.localParticipant,
member: member:
this.matrixRoom.getMember(this.userId ?? "") ?? memberError(), this.matrixRoom.getMember(this.userId ?? "") ?? memberError(),
livekitRoom: localConnection.livekitRoom,
}; };
return this.remoteConnections$.pipe( return this.remoteConnections$.pipe(
switchMap((connections) => switchMap((connections) =>
combineLatest( combineLatest(
[localConnection, ...connections.values()].map((c) => [
[localFocus.livekit_service_url, localConnection] as const,
...connections,
].map(([url, c]) =>
c.publishingParticipants$.pipe( c.publishingParticipants$.pipe(
map((ps) => map((ps) => {
ps.map(({ participant, membership }) => ({ const participants: {
participant: LocalParticipant | RemoteParticipant;
member: RoomMember;
}[] = ps.map(({ participant, membership }) => ({
participant, participant,
member: member:
getRoomMemberFromRtcMember( getRoomMemberFromRtcMember(
membership, membership,
this.matrixRoom, this.matrixRoom,
)?.member ?? memberError(), )?.member ?? memberError(),
}));
if (c === localConnection)
participants.push(localParticipant);
return {
livekitRoom: c.livekitRoom, livekitRoom: c.livekitRoom,
})), url,
), participants,
};
}),
), ),
), ),
), ),
), ),
map((remoteParticipants) => [
localParticipant,
...remoteParticipants.flat(1),
]),
); );
}), }),
) )
@@ -798,7 +810,7 @@ export class CallViewModel extends ViewModel {
*/ */
private readonly mediaItems$ = this.scope.behavior<MediaItem[]>( private readonly mediaItems$ = this.scope.behavior<MediaItem[]>(
combineLatest([ combineLatest([
this.participants$, this.participantsByRoom$,
duplicateTiles.value$, duplicateTiles.value$,
this.memberships$, this.memberships$,
showNonMemberTiles.value$, showNonMemberTiles.value$,
@@ -806,71 +818,75 @@ export class CallViewModel extends ViewModel {
scan( scan(
( (
prevItems, prevItems,
[participants, duplicateTiles, memberships, showNonMemberTiles], [participantsByRoom, duplicateTiles, memberships, showNonMemberTiles],
) => { ) => {
const newItems: Map<string, UserMedia | ScreenShare> = new Map( const newItems: Map<string, UserMedia | ScreenShare> = new Map(
function* (this: CallViewModel): Iterable<[string, MediaItem]> { function* (this: CallViewModel): Iterable<[string, MediaItem]> {
for (const { participant, member, livekitRoom } of participants) { for (const { livekitRoom, participants } of participantsByRoom) {
const matrixId = participant.isLocal for (const { participant, member } of participants) {
? "local" const matrixId = participant.isLocal
: participant.identity; ? "local"
for (let i = 0; i < 1 + duplicateTiles; i++) { : participant.identity;
const mediaId = `${matrixId}:${i}`;
let prevMedia = prevItems.get(mediaId); for (let i = 0; i < 1 + duplicateTiles; i++) {
if (prevMedia && prevMedia instanceof UserMedia) { const mediaId = `${matrixId}:${i}`;
prevMedia.updateParticipant(participant); let prevMedia = prevItems.get(mediaId);
if (prevMedia.vm.member === undefined) { if (prevMedia && prevMedia instanceof UserMedia) {
// We have a previous media created because of the `debugShowNonMember` flag. prevMedia.updateParticipant(participant);
// In this case we actually replace the media item. if (prevMedia.vm.member === undefined) {
// This "hack" never occurs if we do not use the `debugShowNonMember` debugging // We have a previous media created because of the `debugShowNonMember` flag.
// option and if we always find a room member for each rtc member (which also // In this case we actually replace the media item.
// only fails if we have a fundamental problem) // This "hack" never occurs if we do not use the `debugShowNonMember` debugging
prevMedia = undefined; // option and if we always find a room member for each rtc member (which also
} // only fails if we have a fundamental problem)
} prevMedia = undefined;
yield [ }
mediaId, }
// We create UserMedia with or without a participant.
// This will be the initial value of a BehaviourSubject.
// Once a participant appears we will update the BehaviourSubject. (see above)
prevMedia ??
new UserMedia(
mediaId,
member,
participant,
this.options.encryptionSystem,
livekitRoom,
this.mediaDevices,
this.pretendToBeDisconnected$,
this.memberDisplaynames$.pipe(
map((m) => m.get(matrixId) ?? "[👻]"),
),
this.handsRaised$.pipe(
map((v) => v[matrixId]?.time ?? null),
),
this.reactions$.pipe(
map((v) => v[matrixId] ?? undefined),
),
),
];
if (participant?.isScreenShareEnabled) {
const screenShareId = `${mediaId}:screen-share`;
yield [ yield [
screenShareId, mediaId,
prevItems.get(screenShareId) ?? // We create UserMedia with or without a participant.
new ScreenShare( // This will be the initial value of a BehaviourSubject.
screenShareId, // Once a participant appears we will update the BehaviourSubject. (see above)
prevMedia ??
new UserMedia(
mediaId,
member, member,
participant, participant,
this.options.encryptionSystem, this.options.encryptionSystem,
livekitRoom, livekitRoom,
this.mediaDevices,
this.pretendToBeDisconnected$, this.pretendToBeDisconnected$,
this.memberDisplaynames$.pipe( this.memberDisplaynames$.pipe(
map((m) => m.get(matrixId) ?? "[👻]"), map((m) => m.get(matrixId) ?? "[👻]"),
), ),
this.handsRaised$.pipe(
map((v) => v[matrixId]?.time ?? null),
),
this.reactions$.pipe(
map((v) => v[matrixId] ?? undefined),
),
), ),
]; ];
if (participant?.isScreenShareEnabled) {
const screenShareId = `${mediaId}:screen-share`;
yield [
screenShareId,
prevItems.get(screenShareId) ??
new ScreenShare(
screenShareId,
member,
participant,
this.options.encryptionSystem,
livekitRoom,
this.pretendToBeDisconnected$,
this.memberDisplaynames$.pipe(
map((m) => m.get(matrixId) ?? "[👻]"),
),
),
];
}
} }
} }
} }

View File

@@ -93,11 +93,9 @@ export class Connection {
); );
this.publishingParticipants$ = this.scope.behavior( this.publishingParticipants$ = this.scope.behavior(
combineLatest([ combineLatest(
this.participantsIncludingSubscribers$, [this.participantsIncludingSubscribers$, this.membershipsFocusMap$],
this.membershipsFocusMap$, (participants, membershipsFocusMap) =>
]).pipe(
map(([participants, membershipsFocusMap]) =>
membershipsFocusMap membershipsFocusMap
// Find all members that claim to publish on this connection // Find all members that claim to publish on this connection
.flatMap(({ membership, focus }) => .flatMap(({ membership, focus }) =>
@@ -113,7 +111,6 @@ export class Connection {
); );
return participant ? [{ participant, membership }] : []; return participant ? [{ participant, membership }] : [];
}), }),
),
), ),
[], [],
); );