mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
[SDK] make stats$ a polled method and add audioLevel$ to audio tracks
This commit is contained in:
+11
-5
@@ -599,11 +599,11 @@ export interface MediaTrack {
|
||||
/** False when the SFU reports the track as unencrypted. Fixed for the life of the track. */
|
||||
encrypted: boolean;
|
||||
/**
|
||||
* Inbound statistics for a remote track, outbound for a local one. Polled
|
||||
* once a second, but only while someone is subscribed, so a statistics
|
||||
* panel costs nothing while closed.
|
||||
* Inbound statistics for a remote track, outbound for a local one, polled
|
||||
* every `intervalMs` while the observable is subscribed and not at all
|
||||
* otherwise, so a statistics panel costs nothing while closed.
|
||||
*/
|
||||
stats$: Behavior<MediaStreamStats>;
|
||||
stats$(intervalMs: number): Observable<MediaStreamStats>;
|
||||
/**
|
||||
* Rendering. The view hands its <video> or <audio> element over; the SDK
|
||||
* sets its stream and, for video, registers the size and on-screen
|
||||
@@ -624,6 +624,12 @@ export interface AudioMediaTrack extends MediaTrack {
|
||||
* member reports the same value.
|
||||
*/
|
||||
isActive$: Behavior<boolean>;
|
||||
/**
|
||||
* How loud the track is right now, 0 to 1, as the backend measures it. 0
|
||||
* while muted. Not what decides `isActive$`: the backend applies its own
|
||||
* threshold and hysteresis to that.
|
||||
*/
|
||||
audioLevel$: Behavior<number>;
|
||||
/** Route playback through Web Audio, for earpiece pan and gain. Undefined resets. */
|
||||
setAudioContext(ctx: AudioContext | undefined, plugins?: AudioNode[]): void;
|
||||
/** No-op for our own audio, which is never played back. */
|
||||
@@ -721,7 +727,7 @@ sdk/
|
||||
metadata, transports, discovery, the js-sdk join
|
||||
src/media-backend/ the MediaBackend interface in api.ts; livekit/ is the one
|
||||
backend: connections, publisher, JWT, keys, the tracks
|
||||
src/utils/ LazyBehavior, mapScoped, network retry, display names, test helpers
|
||||
src/utils/ mapScoped, network retry, display names, test helpers
|
||||
dev/ the harness (below)
|
||||
SdkArchitecture.md this document
|
||||
SdkMigration.md how Element Call gets from CallViewModel to the client
|
||||
|
||||
+1
-1
@@ -269,7 +269,7 @@ function render(
|
||||
"active",
|
||||
of$((t) => (t.kind === "audio" ? t.isActive$ : of(undefined))),
|
||||
);
|
||||
const stats$ = of$((t) => t.stats$);
|
||||
const stats$ = of$((t) => t.stats$(1000));
|
||||
label("frameWidth", stats$.pipe(switchMap((s) => of(frames(s)?.frameWidth))));
|
||||
label("frames", stats$.pipe(switchMap((s) => of(frames(s)?.count))));
|
||||
}
|
||||
|
||||
+15
-10
@@ -94,13 +94,12 @@ export interface MediaTrack {
|
||||
* of the track: a publisher that changes its encryption publishes anew.
|
||||
*/
|
||||
encrypted: boolean;
|
||||
/** Polled while subscribed. */
|
||||
// REVIEW: we want the perf gain of not having it always run.
|
||||
//
|
||||
// proposals
|
||||
// - obs -> on subscribe start polling timer
|
||||
// - add public method the MediaTrack: for example: actiavteStatsPolling(ms)
|
||||
stats$: Behavior<MediaStreamStats>;
|
||||
/**
|
||||
* Inbound statistics for a remote track, outbound for a local one, polled
|
||||
* every `intervalMs` while the observable is subscribed and not at all
|
||||
* otherwise. Each subscription runs its own poll, so a view shares one.
|
||||
*/
|
||||
stats$(intervalMs: number): Observable<MediaStreamStats>;
|
||||
/**
|
||||
* Rendering. The view hands its `<video>` or `<audio>` element over; the SDK
|
||||
* sets its stream and, for video, registers the size and on-screen observers
|
||||
@@ -117,11 +116,17 @@ export interface AudioMediaTrack extends MediaTrack {
|
||||
/**
|
||||
* Whether the track carries sound right now, as the backend measures it.
|
||||
* False while muted. A call reads this on the microphone track and calls it
|
||||
* "speaking". LiveKit measures it per member, so every audio track of a
|
||||
* member reports the same value.
|
||||
* "speaking".
|
||||
* This is different to audioLevel = 0. The backend might run some hysteresis on it.
|
||||
*/
|
||||
// REVIEW: audio Level (could even super-seed isActive "audioLevel != 0")
|
||||
isActive$: Behavior<boolean>;
|
||||
/**
|
||||
* How loud the track is right now, 0 to 1, as the backend measures it. 0
|
||||
* while muted. Not what decides `isActive$`: the backend applies its own
|
||||
* threshold and hysteresis to that, so a level above 0 may not be active and
|
||||
* an active track may briefly sit at 0.
|
||||
*/
|
||||
audioLevel$: Behavior<number>;
|
||||
/** Route playback through Web Audio, for earpiece pan and gain. Undefined resets. */
|
||||
setAudioContext(ctx: AudioContext | undefined, plugins?: AudioNode[]): void;
|
||||
setVolume(volume: number): void;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RemoteAudioTrack,
|
||||
type RemoteParticipant,
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent,
|
||||
Track,
|
||||
TrackEvent,
|
||||
type TrackPublication,
|
||||
@@ -30,14 +31,18 @@ describe("createLivekitMediaTrack", () => {
|
||||
beforeEach(() => {
|
||||
scope = new ObservableScope();
|
||||
});
|
||||
afterEach(() => scope.end());
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("describes the publication", () => {
|
||||
const { participant, publication } = fakes();
|
||||
const { participant, publication, room } = fakes();
|
||||
const track = createLivekitMediaTrack(
|
||||
scope,
|
||||
participant,
|
||||
publication as unknown as TrackPublication,
|
||||
room,
|
||||
);
|
||||
expect(track.kind).toBe("video");
|
||||
expect(track.source).toBe("camera");
|
||||
@@ -46,7 +51,7 @@ describe("createLivekitMediaTrack", () => {
|
||||
});
|
||||
|
||||
it("attaches elements to the track, even one that arrives later", () => {
|
||||
const { participant, publication, emitter } = fakes({
|
||||
const { participant, publication, room, emitter } = fakes({
|
||||
track: undefined,
|
||||
});
|
||||
const element = document.createElement("video");
|
||||
@@ -54,6 +59,7 @@ describe("createLivekitMediaTrack", () => {
|
||||
scope,
|
||||
participant,
|
||||
publication as unknown as TrackPublication,
|
||||
room,
|
||||
);
|
||||
track.attach(element);
|
||||
track.attach(element);
|
||||
@@ -69,13 +75,14 @@ describe("createLivekitMediaTrack", () => {
|
||||
});
|
||||
|
||||
it("detaches everything when its scope ends", () => {
|
||||
const { participant, publication } = fakes();
|
||||
const { participant, publication, room } = fakes();
|
||||
const element = document.createElement("video");
|
||||
const scope = new ObservableScope();
|
||||
const track = createLivekitMediaTrack(
|
||||
scope,
|
||||
participant,
|
||||
publication as unknown as TrackPublication,
|
||||
room,
|
||||
);
|
||||
track.attach(element);
|
||||
scope.end();
|
||||
@@ -83,11 +90,12 @@ describe("createLivekitMediaTrack", () => {
|
||||
});
|
||||
|
||||
it("follows the mute state", () => {
|
||||
const { participant, publication, emitter } = fakes();
|
||||
const { participant, publication, room, emitter } = fakes();
|
||||
const track = createLivekitMediaTrack(
|
||||
scope,
|
||||
participant,
|
||||
publication as unknown as TrackPublication,
|
||||
room,
|
||||
);
|
||||
expect(track.muted$.value).toBe(false);
|
||||
publication.isMuted = true;
|
||||
@@ -96,26 +104,28 @@ describe("createLivekitMediaTrack", () => {
|
||||
});
|
||||
|
||||
it("scales a remote member's volume on the participant", () => {
|
||||
const { participant, publication } = fakes({
|
||||
const { participant, publication, room } = fakes({
|
||||
kind: Track.Kind.Audio,
|
||||
});
|
||||
const track = createLivekitMediaTrack(
|
||||
scope,
|
||||
participant,
|
||||
publication as unknown as TrackPublication,
|
||||
room,
|
||||
) as AudioMediaTrack;
|
||||
track.setVolume(0.5);
|
||||
expect(publication.track!.setVolume).toHaveBeenCalledWith(0.5);
|
||||
});
|
||||
|
||||
it("is active while the member speaks and the track is not muted", () => {
|
||||
const { participant, publication, emitter } = fakes({
|
||||
const { participant, publication, room, emitter } = fakes({
|
||||
kind: Track.Kind.Audio,
|
||||
});
|
||||
const track = createLivekitMediaTrack(
|
||||
scope,
|
||||
participant,
|
||||
publication as unknown as TrackPublication,
|
||||
room,
|
||||
) as AudioMediaTrack;
|
||||
expect(track.isActive$.value).toBe(false);
|
||||
participant.isSpeaking = true;
|
||||
@@ -126,6 +136,57 @@ describe("createLivekitMediaTrack", () => {
|
||||
expect(track.isActive$.value).toBe(false);
|
||||
});
|
||||
|
||||
it("reports the audio level the room announces, 0 while muted", () => {
|
||||
const { participant, publication, room, emitter } = fakes({
|
||||
kind: Track.Kind.Audio,
|
||||
});
|
||||
const track = createLivekitMediaTrack(
|
||||
scope,
|
||||
participant,
|
||||
publication as unknown as TrackPublication,
|
||||
room,
|
||||
) as AudioMediaTrack;
|
||||
expect(track.audioLevel$.value).toBe(0);
|
||||
participant.audioLevel = 0.4;
|
||||
(room as unknown as EventEmitter).emit(RoomEvent.ActiveSpeakersChanged, [
|
||||
participant,
|
||||
]);
|
||||
expect(track.audioLevel$.value).toBe(0.4);
|
||||
publication.isMuted = true;
|
||||
emitter.emit(ParticipantEvent.TrackMuted, publication);
|
||||
expect(track.audioLevel$.value).toBe(0);
|
||||
});
|
||||
|
||||
it("polls statistics at the given interval, only while subscribed", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { participant, publication, room } = fakes();
|
||||
const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats;
|
||||
publication.track!.getRTCStatsReport.mockResolvedValue(
|
||||
new Map([["s", stats]]),
|
||||
);
|
||||
const track = createLivekitMediaTrack(
|
||||
scope,
|
||||
participant,
|
||||
publication as unknown as TrackPublication,
|
||||
room,
|
||||
);
|
||||
const stats$ = track.stats$(500);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(publication.track!.getRTCStatsReport).not.toHaveBeenCalled();
|
||||
|
||||
const seen = vi.fn();
|
||||
const subscription = stats$.subscribe(seen);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(publication.track!.getRTCStatsReport).toHaveBeenCalledTimes(1);
|
||||
expect(seen).toHaveBeenLastCalledWith(stats);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(publication.track!.getRTCStatsReport).toHaveBeenCalledTimes(3);
|
||||
|
||||
subscription.unsubscribe();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(publication.track!.getRTCStatsReport).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("gives our own track its controls", async () => {
|
||||
const { participant, publication, room } = fakes({
|
||||
kind: Track.Kind.Audio,
|
||||
@@ -152,6 +213,7 @@ type FakeTrack = Track & {
|
||||
attach: ReturnType<typeof vi.fn>;
|
||||
detach: ReturnType<typeof vi.fn>;
|
||||
setVolume: ReturnType<typeof vi.fn>;
|
||||
getRTCStatsReport: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type FakePublication = Omit<TrackPublication, "track" | "isMuted"> & {
|
||||
@@ -166,6 +228,7 @@ function fakeTrack(): FakeTrack {
|
||||
setVolume: vi.fn(),
|
||||
setAudioContext: vi.fn(),
|
||||
setWebAudioPlugins: vi.fn(),
|
||||
getRTCStatsReport: vi.fn().mockResolvedValue(undefined),
|
||||
}) as FakeTrack;
|
||||
}
|
||||
|
||||
@@ -173,7 +236,7 @@ function fakes({
|
||||
track = fakeTrack(),
|
||||
kind = Track.Kind.Video,
|
||||
}: { track?: FakeTrack | undefined; kind?: Track.Kind } = {}): {
|
||||
participant: RemoteParticipant & { isSpeaking: boolean };
|
||||
participant: RemoteParticipant & { isSpeaking: boolean; audioLevel: number };
|
||||
publication: FakePublication;
|
||||
room: LivekitRoom;
|
||||
emitter: EventEmitter;
|
||||
@@ -182,10 +245,14 @@ function fakes({
|
||||
const participant = Object.assign(emitter, {
|
||||
isLocal: false,
|
||||
isSpeaking: false,
|
||||
audioLevel: 0,
|
||||
identity: "@alice:example.org:DEVICE",
|
||||
getTrackPublication: (): TrackPublication =>
|
||||
publication as unknown as TrackPublication,
|
||||
}) as unknown as RemoteParticipant & { isSpeaking: boolean };
|
||||
}) as unknown as RemoteParticipant & {
|
||||
isSpeaking: boolean;
|
||||
audioLevel: number;
|
||||
};
|
||||
const publication = Object.assign(emitter, {
|
||||
kind,
|
||||
source:
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import {
|
||||
observeParticipantEvents,
|
||||
observeParticipantMedia,
|
||||
roomEventSelector,
|
||||
} from "@livekit/components-core";
|
||||
import {
|
||||
facingModeFromLocalTrack,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
RemoteAudioTrack,
|
||||
RemoteTrack,
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent,
|
||||
Track,
|
||||
TrackEvent,
|
||||
type TrackPublication,
|
||||
@@ -27,13 +29,13 @@ import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
fromEvent,
|
||||
interval,
|
||||
map,
|
||||
merge,
|
||||
type Observable,
|
||||
of,
|
||||
share,
|
||||
startWith,
|
||||
switchMap,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
@@ -48,7 +50,6 @@ import {
|
||||
type VideoMediaTrack,
|
||||
} from "../../media-api";
|
||||
import { MatrixRTCError } from "../../errors";
|
||||
import { LazyBehavior } from "../../utils/LazyBehavior";
|
||||
import { convertToLivekitProcessor } from "./videoProcessor";
|
||||
|
||||
export const mediaSources: Record<Track.Source, MediaSource> = {
|
||||
@@ -67,10 +68,6 @@ export const livekitSources: Record<MediaSource, Track.Source> = {
|
||||
unknown: Track.Source.Unknown,
|
||||
};
|
||||
|
||||
// One timer for every track so that a large session does not keep hundreds of
|
||||
// them, each firing a statistics request, in the event loop.
|
||||
const refreshStats$ = interval(1000).pipe(startWith(0), share());
|
||||
|
||||
/**
|
||||
* One publication of a participant as a `MediaTrack`. The publication is
|
||||
* fixed; the track behind it may come and go (a remote track arrives on
|
||||
@@ -80,6 +77,7 @@ export function createLivekitMediaTrack(
|
||||
scope: ObservableScope,
|
||||
participant: Participant,
|
||||
publication: TrackPublication,
|
||||
room: LivekitRoom,
|
||||
): AudioMediaTrack | VideoMediaTrack {
|
||||
const mediaChanged$ = observeParticipantMedia(participant);
|
||||
const track$ = publicationTrack$(scope, participant, publication);
|
||||
@@ -117,13 +115,11 @@ export function createLivekitMediaTrack(
|
||||
id: publication.trackSid,
|
||||
muted$,
|
||||
encrypted: publication.isEncrypted,
|
||||
stats$: new LazyBehavior<MediaStreamStats>(
|
||||
refreshStats$.pipe(
|
||||
stats$: (intervalMs): Observable<MediaStreamStats> =>
|
||||
timer(0, intervalMs).pipe(
|
||||
switchMap(async () => rtpStreamStats(publication, participant.isLocal)),
|
||||
scope.bind(),
|
||||
),
|
||||
undefined,
|
||||
),
|
||||
attach: (element) => {
|
||||
if (attached.has(element)) return;
|
||||
attached.add(element);
|
||||
@@ -154,6 +150,20 @@ export function createLivekitMediaTrack(
|
||||
),
|
||||
participant.isSpeaking && !publication.isMuted,
|
||||
),
|
||||
// The level has no event of its own: it is written to the participant
|
||||
// with each speaker update, which the room announces
|
||||
audioLevel$: scope.behavior(
|
||||
combineLatest(
|
||||
[
|
||||
roomEventSelector(room, RoomEvent.ActiveSpeakersChanged).pipe(
|
||||
map(() => participant.audioLevel),
|
||||
startWith(participant.audioLevel),
|
||||
),
|
||||
muted$,
|
||||
],
|
||||
(level, muted) => (muted ? 0 : level),
|
||||
),
|
||||
),
|
||||
setAudioContext: (ctx, plugins = []) => {
|
||||
audioContext = ctx;
|
||||
audioPlugins = plugins;
|
||||
@@ -181,7 +191,7 @@ export function createLocalLivekitMediaTrack(
|
||||
room: LivekitRoom,
|
||||
setEnabled: (source: MediaSource, enabled: boolean) => Promise<boolean>,
|
||||
): LocalAudioMediaTrack | LocalVideoMediaTrack {
|
||||
const base = createLivekitMediaTrack(scope, participant, publication);
|
||||
const base = createLivekitMediaTrack(scope, participant, publication, room);
|
||||
const local = {
|
||||
setEnabled: async (enabled: boolean) => setEnabled(base.source, enabled),
|
||||
setDevice: async (deviceId: string): Promise<void> => {
|
||||
|
||||
@@ -54,8 +54,8 @@ export function createLivekitMemberMedia(
|
||||
tracks$: memberTracks$(
|
||||
scope,
|
||||
source$,
|
||||
(trackScope, participant, publication) =>
|
||||
createLivekitMediaTrack(trackScope, participant, publication),
|
||||
(trackScope, participant, publication, room) =>
|
||||
createLivekitMediaTrack(trackScope, participant, publication, room),
|
||||
),
|
||||
encryptionError$: encryptionErrors$(source$, encryptionSystem),
|
||||
};
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Observable, Subject } from "rxjs";
|
||||
|
||||
import { LazyBehavior } from "./LazyBehavior";
|
||||
|
||||
describe("LazyBehavior", () => {
|
||||
it("runs its source only while subscribed", () => {
|
||||
const teardown = vi.fn();
|
||||
let starts = 0;
|
||||
const values$ = new Subject<number>();
|
||||
const source$ = new Observable<number>((subscriber) => {
|
||||
starts++;
|
||||
const subscription = values$.subscribe(subscriber);
|
||||
return (): void => {
|
||||
teardown();
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
});
|
||||
const lazy$ = new LazyBehavior(source$, 0);
|
||||
expect(starts).toBe(0);
|
||||
expect(lazy$.value).toBe(0);
|
||||
|
||||
const seenByFirst: number[] = [];
|
||||
const first = lazy$.subscribe((v) => seenByFirst.push(v));
|
||||
expect(starts).toBe(1);
|
||||
values$.next(1);
|
||||
expect(seenByFirst).toEqual([0, 1]);
|
||||
expect(lazy$.value).toBe(1);
|
||||
|
||||
const seenBySecond: number[] = [];
|
||||
const second = lazy$.subscribe((v) => seenBySecond.push(v));
|
||||
expect(starts).toBe(1);
|
||||
expect(seenBySecond).toEqual([1]);
|
||||
|
||||
first.unsubscribe();
|
||||
expect(teardown).not.toHaveBeenCalled();
|
||||
second.unsubscribe();
|
||||
expect(teardown).toHaveBeenCalledTimes(1);
|
||||
|
||||
values$.next(2);
|
||||
expect(lazy$.value).toBe(1);
|
||||
lazy$.subscribe().unsubscribe();
|
||||
expect(starts).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
BehaviorSubject,
|
||||
type Observable,
|
||||
type Subscriber,
|
||||
type Subscription,
|
||||
} from "rxjs";
|
||||
|
||||
/**
|
||||
* A behavior that only runs its source while someone is subscribed. The source
|
||||
* is subscribed with the first subscriber and unsubscribed with the last, so a
|
||||
* value that is expensive to keep current, such as polled statistics, costs
|
||||
* nothing while nobody looks at it. `value` is whatever the source last
|
||||
* produced, or the initial value.
|
||||
*/
|
||||
export class LazyBehavior<T> extends BehaviorSubject<T> {
|
||||
private subscribers = 0;
|
||||
private upstream: Subscription | undefined;
|
||||
|
||||
public constructor(
|
||||
private readonly source$: Observable<T>,
|
||||
initialValue: T,
|
||||
) {
|
||||
super(initialValue);
|
||||
}
|
||||
|
||||
// Every subscription of an Observable goes through this hook, which rxjs
|
||||
// declares as internal and so leaves out of its types. The base class's
|
||||
// version replays the current value and registers the subscriber; it is
|
||||
// wrapped rather than replaced.
|
||||
protected _subscribe(subscriber: Subscriber<T>): Subscription {
|
||||
if (this.subscribers++ === 0)
|
||||
this.upstream = this.source$.subscribe((value) => this.next(value));
|
||||
const subscription = (
|
||||
BehaviorSubject.prototype as unknown as {
|
||||
_subscribe(this: BehaviorSubject<T>, s: Subscriber<T>): Subscription;
|
||||
}
|
||||
)._subscribe.call(this, subscriber);
|
||||
subscription.add(() => {
|
||||
if (--this.subscribers === 0) {
|
||||
this.upstream?.unsubscribe();
|
||||
this.upstream = undefined;
|
||||
}
|
||||
});
|
||||
return subscription;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user