1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-31 15:24:23 +03:00

Add webrtc stats in full mesh calls (#3232)

* stats: merge stats classes in this branch

* stats: merge call object

* stats: merge export metric events

* stats: fix code style changes

* stats: add missing stats value formatter

* stats: add missing methode to call mock

* stats: add stats for callee

* stats: stop sending stats if call finish

* stats: rename StatsCollector to StatsReportGatherer
This commit is contained in:
Enrico Schwendig
2023-03-30 17:17:59 +02:00
committed by GitHub
parent fd3f53e814
commit 62f1dd79bc
30 changed files with 2173 additions and 2 deletions

View File

@ -519,6 +519,7 @@ export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandle
public reject = jest.fn<void, []>();
public answerWithCallFeeds = jest.fn<void, [CallFeed[]]>();
public hangup = jest.fn<void, []>();
public initStats = jest.fn<void, []>();
public sendMetadataUpdate = jest.fn<void, []>();
@ -619,3 +620,124 @@ export function makeMockGroupCallMemberStateEvent(roomId: string, groupCallId: s
getStateKey: jest.fn().mockReturnValue(groupCallId),
} as unknown as MatrixEvent;
}
export const REMOTE_SFU_DESCRIPTION =
"v=0\n" +
"o=- 3242942315779688438 1678878001 IN IP4 0.0.0.0\n" +
"s=-\n" +
"t=0 0\n" +
"a=fingerprint:sha-256 EA:30:B2:7F:49:B5:46:D6:40:72:BF:79:95:C1:65:08:6E:35:09:FB:90:89:DA:EF:6B:82:D1:38:8C:25:39:B2\n" +
"a=group:BUNDLE 0 1 2\n" +
"m=audio 9 UDP/TLS/RTP/SAVPF 111 9 0 8\n" +
"c=IN IP4 0.0.0.0\n" +
"a=setup:actpass\n" +
"a=mid:0\n" +
"a=ice-ufrag:obZwzAcRtxwuozPZ\n" +
"a=ice-pwd:TWXNaPeyKTTvRLyIQhWHfHlZHJjtcoKs\n" +
"a=rtcp-mux\n" +
"a=rtcp-rsize\n" +
"a=rtpmap:111 opus/48000/2\n" +
"a=fmtp:111 minptime=10;usedtx=1;useinbandfec=1\n" +
"a=rtcp-fb:111 transport-cc \n" +
"a=rtpmap:9 G722/8000\n" +
"a=rtpmap:0 PCMU/8000\n" +
"a=rtpmap:8 PCMA/8000\n" +
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n" +
"a=ssrc:2963372119 cname:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90\n" +
"a=ssrc:2963372119 msid:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90 4b811ab6-6926-473d-8ca5-ac45f268c507\n" +
"a=ssrc:2963372119 mslabel:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90\n" +
"a=ssrc:2963372119 label:4b811ab6-6926-473d-8ca5-ac45f268c507\n" +
"a=msid:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90 4b811ab6-6926-473d-8ca5-ac45f268c507\n" +
"a=sendrecv\n" +
"a=candidate:1155505470 1 udp 2130706431 13.41.173.213 41385 typ host\n" +
"a=candidate:1155505470 2 udp 2130706431 13.41.173.213 41385 typ host\n" +
"a=candidate:1155505470 1 udp 2130706431 13.41.173.213 40026 typ host\n" +
"a=candidate:1155505470 2 udp 2130706431 13.41.173.213 40026 typ host\n" +
"a=end-of-candidates\n" +
"m=video 9 UDP/TLS/RTP/SAVPF 96 97 102 103 104 106 108 109 98 99 112 116\n" +
"c=IN IP4 0.0.0.0\n" +
"a=setup:actpass\n" +
"a=mid:1\n" +
"a=ice-ufrag:obZwzAcRtxwuozPZ\n" +
"a=ice-pwd:TWXNaPeyKTTvRLyIQhWHfHlZHJjtcoKs\n" +
"a=rtcp-mux\n" +
"a=rtcp-rsize\n" +
"a=rtpmap:96 VP8/90000\n" +
"a=rtcp-fb:96 goog-remb \n" +
"a=rtcp-fb:96 transport-cc \n" +
"a=rtcp-fb:96 ccm fir\n" +
"a=rtcp-fb:96 nack \n" +
"a=rtcp-fb:96 nack pli\n" +
"a=rtpmap:97 rtx/90000\n" +
"a=fmtp:97 apt=96\n" +
"a=rtpmap:102 H264/90000\n" +
"a=fmtp:102 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f\n" +
"a=rtcp-fb:102 goog-remb \n" +
"a=rtcp-fb:102 transport-cc \n" +
"a=rtcp-fb:102 ccm fir\n" +
"a=rtcp-fb:102 nack \n" +
"a=rtcp-fb:102 nack pli\n" +
"a=rtpmap:103 rtx/90000\n" +
"a=fmtp:103 apt=102\n" +
"a=rtpmap:104 H264/90000\n" +
"a=fmtp:104 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f\n" +
"a=rtcp-fb:104 goog-remb \n" +
"a=rtcp-fb:104 transport-cc \n" +
"a=rtcp-fb:104 ccm fir\n" +
"a=rtcp-fb:104 nack \n" +
"a=rtcp-fb:104 nack pli\n" +
"a=rtpmap:106 H264/90000\n" +
"a=fmtp:106 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\n" +
"a=rtcp-fb:106 goog-remb \n" +
"a=rtcp-fb:106 transport-cc \n" +
"a=rtcp-fb:106 ccm fir\n" +
"a=rtcp-fb:106 nack \n" +
"a=rtcp-fb:106 nack pli\n" +
"a=rtpmap:108 H264/90000\n" +
"a=fmtp:108 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f\n" +
"a=rtcp-fb:108 goog-remb \n" +
"a=rtcp-fb:108 transport-cc \n" +
"a=rtcp-fb:108 ccm fir\n" +
"a=rtcp-fb:108 nack \n" +
"a=rtcp-fb:108 nack pli\n" +
"a=rtpmap:109 rtx/90000\n" +
"a=fmtp:109 apt=108\n" +
"a=rtpmap:98 VP9/90000\n" +
"a=fmtp:98 profile-id=0\n" +
"a=rtcp-fb:98 goog-remb \n" +
"a=rtcp-fb:98 transport-cc \n" +
"a=rtcp-fb:98 ccm fir\n" +
"a=rtcp-fb:98 nack \n" +
"a=rtcp-fb:98 nack pli\n" +
"a=rtpmap:99 rtx/90000\n" +
"a=fmtp:99 apt=98\n" +
"a=rtpmap:112 H264/90000\n" +
"a=fmtp:112 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f\n" +
"a=rtcp-fb:112 goog-remb \n" +
"a=rtcp-fb:112 transport-cc \n" +
"a=rtcp-fb:112 ccm fir\n" +
"a=rtcp-fb:112 nack \n" +
"a=rtcp-fb:112 nack pli\n" +
"a=rtpmap:116 ulpfec/90000\n" +
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n" +
"a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\n" +
"a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\n" +
"a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\n" +
"a=rid:f recv\n" +
"a=rid:h recv\n" +
"a=rid:q recv\n" +
"a=simulcast:recv f;h;q\n" +
"a=ssrc:1212931603 cname:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90\n" +
"a=ssrc:1212931603 msid:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90 12905f48-75b9-499f-ba50-fc00f56a86c6\n" +
"a=ssrc:1212931603 mslabel:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90\n" +
"a=ssrc:1212931603 label:12905f48-75b9-499f-ba50-fc00f56a86c6\n" +
"a=msid:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90 12905f48-75b9-499f-ba50-fc00f56a86c6\n" +
"a=sendrecv\n" +
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\n" +
"c=IN IP4 0.0.0.0\n" +
"a=setup:actpass\n" +
"a=mid:2\n" +
"a=sendrecv\n" +
"a=sctp-port:5000\n" +
"a=ice-ufrag:obZwzAcRtxwuozPZ\n" +
"a=ice-pwd:TWXNaPeyKTTvRLyIQhWHfHlZHJjtcoKs";

View File

@ -0,0 +1,46 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ConnectionStatsReporter } from "../../../../src/webrtc/stats/connectionStatsReporter";
describe("ConnectionStatsReporter", () => {
describe("should on bandwidth stats", () => {
it("build bandwidth report if chromium starts attributes available", () => {
const stats = {
availableIncomingBitrate: 1000,
availableOutgoingBitrate: 2000,
} as RTCIceCandidatePairStats;
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
});
it("build empty bandwidth report if chromium starts attributes not available", () => {
const stats = {} as RTCIceCandidatePairStats;
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
});
});
describe("should on connection stats", () => {
it("build bandwidth report if chromium starts attributes available", () => {
const stats = {
availableIncomingBitrate: 1000,
availableOutgoingBitrate: 2000,
} as RTCIceCandidatePairStats;
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
});
it("build empty bandwidth report if chromium starts attributes not available", () => {
const stats = {} as RTCIceCandidatePairStats;
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
});
});
});

View File

@ -0,0 +1,136 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { GroupCallStats } from "../../../../src/webrtc/stats/groupCallStats";
const GROUP_CALL_ID = "GROUP_ID";
const LOCAL_USER_ID = "LOCAL_USER_ID";
const TIME_INTERVAL = 10000;
describe("GroupCallStats", () => {
let stats: GroupCallStats;
beforeEach(() => {
stats = new GroupCallStats(GROUP_CALL_ID, LOCAL_USER_ID, TIME_INTERVAL);
});
describe("should on adding a stats collector", () => {
it("creating a new one if not existing.", async () => {
expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeTruthy();
});
it("creating only one when trying add the same collector multiple times.", async () => {
expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeTruthy();
expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeFalsy();
// The User ID is not relevant! Because for stats the call is needed and the user id is for monitoring
expect(stats.addStatsReportGatherer("CALL_ID", "SOME_OTHER_USER_ID", mockRTCPeerConnection())).toBeFalsy();
});
});
describe("should on removing a stats collector", () => {
it("returning `true` if the collector exists", async () => {
expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeTruthy();
expect(stats.removeStatsReportGatherer("CALL_ID")).toBeTruthy();
});
it("returning false if the collector not exists", async () => {
expect(stats.removeStatsReportGatherer("CALL_ID_NOT_EXIST")).toBeFalsy();
});
});
describe("should on get stats collector", () => {
it("returning `undefined` if collector not existing", async () => {
expect(stats.getStatsReportGatherer("CALL_ID")).toBeUndefined();
});
it("returning Collector if collector existing", async () => {
expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeTruthy();
expect(stats.getStatsReportGatherer("CALL_ID")).toBeDefined();
});
});
describe("should on start", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("starting processing as well without stats collectors", async () => {
// @ts-ignore
stats.processStats = jest.fn();
stats.start();
jest.advanceTimersByTime(TIME_INTERVAL);
// @ts-ignore
expect(stats.processStats).toHaveBeenCalled();
});
it("starting processing and calling the collectors", async () => {
stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection());
const collector = stats.getStatsReportGatherer("CALL_ID");
let processStatsSpy;
if (collector) {
processStatsSpy = jest.spyOn(collector, "processStats");
stats.start();
jest.advanceTimersByTime(TIME_INTERVAL);
} else {
throw new Error("Test failed, because no Collector found!");
}
expect(processStatsSpy).toHaveBeenCalledWith(GROUP_CALL_ID, LOCAL_USER_ID);
});
it("doing nothing if process already running", async () => {
// @ts-ignore
jest.spyOn(global, "setInterval").mockReturnValue(22);
stats.start();
expect(setInterval).toHaveBeenCalledTimes(1);
stats.start();
stats.start();
stats.start();
stats.start();
expect(setInterval).toHaveBeenCalledTimes(1);
});
});
describe("should on stop", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("finish stats process if was started", async () => {
// @ts-ignore
jest.spyOn(global, "setInterval").mockReturnValue(22);
jest.spyOn(global, "clearInterval");
stats.start();
expect(setInterval).toHaveBeenCalledTimes(1);
stats.stop();
expect(clearInterval).toHaveBeenCalledWith(22);
});
it("do nothing if stats process was not started", async () => {
jest.spyOn(global, "clearInterval");
stats.stop();
expect(clearInterval).not.toHaveBeenCalled();
});
});
});
const mockRTCPeerConnection = (): RTCPeerConnection => {
const pc = {} as RTCPeerConnection;
pc.addEventListener = jest.fn();
pc.getStats = jest.fn().mockResolvedValue(null);
return pc;
};

View File

@ -0,0 +1,41 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Mid, Ssrc, MediaSsrcHandler } from "../../../../../src/webrtc/stats/media/mediaSsrcHandler";
import { REMOTE_SFU_DESCRIPTION } from "../../../../test-utils/webrtc";
describe("MediaSsrcHandler", () => {
const remoteMap = new Map<Mid, Ssrc[]>([
["0", ["2963372119"]],
["1", ["1212931603"]],
]);
let handler: MediaSsrcHandler;
beforeEach(() => {
handler = new MediaSsrcHandler();
});
describe("should parse description", () => {
it("and build mid ssrc map", () => {
handler.parse(REMOTE_SFU_DESCRIPTION, "remote");
expect(handler.getSsrcToMidMap("remote")).toEqual(remoteMap);
});
});
describe("should on find mid by ssrc", () => {
it("and return mid if mapping exists.", () => {
handler.parse(REMOTE_SFU_DESCRIPTION, "remote");
expect(handler.findMidBySsrc("2963372119", "remote")).toEqual("0");
});
});
});

View File

@ -0,0 +1,113 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MediaTrackHandler } from "../../../../../src/webrtc/stats/media/mediaTrackHandler";
describe("TrackHandler", () => {
let pc: RTCPeerConnection;
let handler: MediaTrackHandler;
beforeEach(() => {
pc = {
getTransceivers: (): RTCRtpTransceiver[] => [mockTransceiver("1", "audio"), mockTransceiver("2", "video")],
} as RTCPeerConnection;
handler = new MediaTrackHandler(pc);
});
describe("should get local tracks", () => {
it("returns video track", () => {
expect(handler.getLocalTracks("video")).toEqual([
{
id: `sender-track-2`,
kind: "video",
} as MediaStreamTrack,
]);
});
it("returns audio track", () => {
expect(handler.getLocalTracks("audio")).toEqual([
{
id: `sender-track-1`,
kind: "audio",
} as MediaStreamTrack,
]);
});
});
describe("should get local track by mid", () => {
it("returns video track", () => {
expect(handler.getLocalTrackIdByMid("2")).toEqual("sender-track-2");
});
it("returns audio track", () => {
expect(handler.getLocalTrackIdByMid("1")).toEqual("sender-track-1");
});
it("returns undefined if not exists", () => {
expect(handler.getLocalTrackIdByMid("3")).toBeUndefined();
});
});
describe("should get remote track by mid", () => {
it("returns video track", () => {
expect(handler.getRemoteTrackIdByMid("2")).toEqual("receiver-track-2");
});
it("returns audio track", () => {
expect(handler.getRemoteTrackIdByMid("1")).toEqual("receiver-track-1");
});
it("returns undefined if not exists", () => {
expect(handler.getRemoteTrackIdByMid("3")).toBeUndefined();
});
});
describe("should get track by id", () => {
it("returns remote track", () => {
expect(handler.getTackById("receiver-track-2")).toEqual({
id: `receiver-track-2`,
kind: "video",
} as MediaStreamTrack);
});
it("returns local track", () => {
expect(handler.getTackById("sender-track-1")).toEqual({
id: `sender-track-1`,
kind: "audio",
} as MediaStreamTrack);
});
it("returns undefined if not exists", () => {
expect(handler.getTackById("sender-track-3")).toBeUndefined();
});
});
describe("should get simulcast track count", () => {
it("returns 2", () => {
expect(handler.getActiveSimulcastStreams()).toEqual(3);
});
});
});
const mockTransceiver = (mid: string, kind: "video" | "audio"): RTCRtpTransceiver => {
return {
mid,
currentDirection: "sendrecv",
sender: {
track: { id: `sender-track-${mid}`, kind } as MediaStreamTrack,
} as RTCRtpSender,
receiver: {
track: { id: `receiver-track-${mid}`, kind } as MediaStreamTrack,
} as RTCRtpReceiver,
} as RTCRtpTransceiver;
};

View File

@ -0,0 +1,83 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MediaTrackHandler } from "../../../../../src/webrtc/stats/media/mediaTrackHandler";
import { MediaTrackStatsHandler } from "../../../../../src/webrtc/stats/media/mediaTrackStatsHandler";
import { MediaSsrcHandler } from "../../../../../src/webrtc/stats/media/mediaSsrcHandler";
describe("MediaTrackStatsHandler", () => {
let statsHandler: MediaTrackStatsHandler;
let ssrcHandler: MediaSsrcHandler;
let trackHandler: MediaTrackHandler;
beforeEach(() => {
ssrcHandler = {} as MediaSsrcHandler;
trackHandler = {} as MediaTrackHandler;
trackHandler.getLocalTrackIdByMid = jest.fn().mockReturnValue("2222");
trackHandler.getRemoteTrackIdByMid = jest.fn().mockReturnValue("5555");
trackHandler.getLocalTracks = jest.fn().mockReturnValue([{ id: "2222" } as MediaStreamTrack]);
trackHandler.getTackById = jest.fn().mockReturnValue([{ id: "2222", kind: "audio" } as MediaStreamTrack]);
statsHandler = new MediaTrackStatsHandler(ssrcHandler, trackHandler);
});
describe("should find track stats", () => {
it("and returns stats if `trackIdentifier` exists in report", () => {
const report = { trackIdentifier: "123" };
expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toEqual("123");
});
it("and returns stats if `mid` exists in report", () => {
const reportIn = { mid: "1", type: "inbound-rtp" };
expect(statsHandler.findTrack2Stats(reportIn, "remote")?.trackId).toEqual("5555");
const reportOut = { mid: "1", type: "outbound-rtp" };
expect(statsHandler.findTrack2Stats(reportOut, "local")?.trackId).toEqual("2222");
});
it("and returns undefined if `ssrc` exists in report but not on connection", () => {
const report = { ssrc: "142443", type: "inbound-rtp" };
ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue(undefined);
expect(statsHandler.findTrack2Stats(report, "local")?.trackId).toBeUndefined();
});
it("and returns undefined if `ssrc` exists in inbound-rtp report", () => {
const report = { ssrc: "142443", type: "inbound-rtp" };
ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue("2");
expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toEqual("5555");
});
it("and returns undefined if `ssrc` exists in outbound-rtp report", () => {
const report = { ssrc: "142443", type: "outbound-rtp" };
ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue("2");
expect(statsHandler.findTrack2Stats(report, "local")?.trackId).toEqual("2222");
});
it("and returns undefined if needed property not existing", () => {
const report = {};
expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toBeUndefined();
});
});
describe("should find local video track stats", () => {
it("and returns stats if `trackIdentifier` exists in report", () => {
const report = { trackIdentifier: "2222" };
expect(statsHandler.findLocalVideoTrackStats(report)?.trackId).toEqual("2222");
});
it("and returns stats if `mid` exists in report", () => {
const report = { mid: "1" };
expect(statsHandler.findLocalVideoTrackStats(report)?.trackId).toEqual("2222");
});
it("and returns undefined if `ssrc` exists", () => {
const report = { ssrc: "142443", type: "outbound-rtp" };
ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue("2");
expect(statsHandler.findTrack2Stats(report, "local")?.trackId).toEqual("2222");
});
it("and returns undefined if needed property not existing", () => {
const report = {};
expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toBeUndefined();
});
});
});

View File

@ -0,0 +1,115 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TrackID } from "../../../../src/webrtc/stats/statsReport";
import { MediaTrackStats } from "../../../../src/webrtc/stats/media/mediaTrackStats";
import { StatsReportBuilder } from "../../../../src/webrtc/stats/statsReportBuilder";
describe("StatsReportBuilder", () => {
const LOCAL_VIDEO_TRACK_ID = "LOCAL_VIDEO_TRACK_ID";
const LOCAL_AUDIO_TRACK_ID = "LOCAL_AUDIO_TRACK_ID";
const REMOTE_AUDIO_TRACK_ID = "REMOTE_AUDIO_TRACK_ID";
const REMOTE_VIDEO_TRACK_ID = "REMOTE_VIDEO_TRACK_ID";
const localAudioTrack = new MediaTrackStats(LOCAL_AUDIO_TRACK_ID, "local", "audio");
const localVideoTrack = new MediaTrackStats(LOCAL_VIDEO_TRACK_ID, "local", "video");
const remoteAudioTrack = new MediaTrackStats(REMOTE_AUDIO_TRACK_ID, "remote", "audio");
const remoteVideoTrack = new MediaTrackStats(REMOTE_VIDEO_TRACK_ID, "remote", "video");
const stats = new Map<TrackID, MediaTrackStats>([
[LOCAL_AUDIO_TRACK_ID, localAudioTrack],
[LOCAL_VIDEO_TRACK_ID, localVideoTrack],
[REMOTE_AUDIO_TRACK_ID, remoteAudioTrack],
[REMOTE_VIDEO_TRACK_ID, remoteVideoTrack],
]);
beforeEach(() => {
buildData();
});
describe("should build stats", () => {
it("by media track stats.", async () => {
expect(StatsReportBuilder.build(stats)).toEqual({
bitrate: {
audio: {
download: 4000,
upload: 5000,
},
download: 5004000,
upload: 3005000,
video: {
download: 5000000,
upload: 3000000,
},
},
codec: {
local: new Map([
["LOCAL_AUDIO_TRACK_ID", "opus"],
["LOCAL_VIDEO_TRACK_ID", "v8"],
]),
remote: new Map([
["REMOTE_AUDIO_TRACK_ID", "opus"],
["REMOTE_VIDEO_TRACK_ID", "v9"],
]),
},
framerate: {
local: new Map([
["LOCAL_AUDIO_TRACK_ID", 0],
["LOCAL_VIDEO_TRACK_ID", 30],
]),
remote: new Map([
["REMOTE_AUDIO_TRACK_ID", 0],
["REMOTE_VIDEO_TRACK_ID", 60],
]),
},
packetLoss: {
download: 7,
total: 15,
upload: 28,
},
resolution: {
local: new Map([
["LOCAL_AUDIO_TRACK_ID", { height: -1, width: -1 }],
["LOCAL_VIDEO_TRACK_ID", { height: 460, width: 780 }],
]),
remote: new Map([
["REMOTE_AUDIO_TRACK_ID", { height: -1, width: -1 }],
["REMOTE_VIDEO_TRACK_ID", { height: 960, width: 1080 }],
]),
},
});
});
});
const buildData = (): void => {
localAudioTrack.setCodec("opus");
localAudioTrack.setLoss({ packetsTotal: 10, packetsLost: 5, isDownloadStream: false });
localAudioTrack.setBitrate({ download: 0, upload: 5000 });
remoteAudioTrack.setCodec("opus");
remoteAudioTrack.setLoss({ packetsTotal: 20, packetsLost: 0, isDownloadStream: true });
remoteAudioTrack.setBitrate({ download: 4000, upload: 0 });
localVideoTrack.setCodec("v8");
localVideoTrack.setLoss({ packetsTotal: 30, packetsLost: 6, isDownloadStream: false });
localVideoTrack.setBitrate({ download: 0, upload: 3000000 });
localVideoTrack.setFramerate(30);
localVideoTrack.setResolution({ width: 780, height: 460 });
remoteVideoTrack.setCodec("v9");
remoteVideoTrack.setLoss({ packetsTotal: 40, packetsLost: 4, isDownloadStream: true });
remoteVideoTrack.setBitrate({ download: 5000000, upload: 0 });
remoteVideoTrack.setFramerate(60);
remoteVideoTrack.setResolution({ width: 1080, height: 960 });
};
});

View File

@ -0,0 +1,48 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
import { ByteSentStatsReport, ConnectionStatsReport, StatsReport } from "../../../../src/webrtc/stats/statsReport";
describe("StatsReportEmitter", () => {
let emitter: StatsReportEmitter;
beforeEach(() => {
emitter = new StatsReportEmitter();
});
it("should emit and receive ByteSendStatsReport", async () => {
const report = {} as ByteSentStatsReport;
return new Promise((resolve, _) => {
emitter.on(StatsReport.BYTE_SENT_STATS, (r) => {
expect(r).toBe(report);
resolve(null);
return;
});
emitter.emitByteSendReport(report);
});
});
it("should emit and receive ConnectionStatsReport", async () => {
const report = {} as ConnectionStatsReport;
return new Promise((resolve, _) => {
emitter.on(StatsReport.CONNECTION_STATS, (r) => {
expect(r).toBe(report);
resolve(null);
return;
});
emitter.emitConnectionStatsReport(report);
});
});
});

View File

@ -0,0 +1,68 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { StatsReportGatherer } from "../../../../src/webrtc/stats/statsReportGatherer";
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
const CALL_ID = "CALL_ID";
const USER_ID = "USER_ID";
describe("StatsReportGatherer", () => {
let collector: StatsReportGatherer;
let rtcSpy: RTCPeerConnection;
let emitter: StatsReportEmitter;
beforeEach(() => {
rtcSpy = { getStats: () => new Promise<RTCStatsReport>(() => null) } as RTCPeerConnection;
rtcSpy.addEventListener = jest.fn();
emitter = new StatsReportEmitter();
collector = new StatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
});
describe("on process stats", () => {
it("if active calculate stats reports", async () => {
const getStats = jest.spyOn(rtcSpy, "getStats");
getStats.mockResolvedValue({} as RTCStatsReport);
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).toHaveBeenCalled();
});
it("if not active do not calculate stats reports", async () => {
collector.setActive(false);
const getStats = jest.spyOn(rtcSpy, "getStats");
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).not.toHaveBeenCalled();
});
it("if get reports fails, the collector becomes inactive", async () => {
expect(collector.getActive()).toBeTruthy();
const getStats = jest.spyOn(rtcSpy, "getStats");
getStats.mockRejectedValue(new Error("unknown"));
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).toHaveBeenCalled();
expect(collector.getActive()).toBeFalsy();
});
it("if active an RTCStatsReport not a promise the collector becomes inactive", async () => {
const getStats = jest.spyOn(rtcSpy, "getStats");
// @ts-ignore
getStats.mockReturnValue({});
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(actual).toBeFalsy();
expect(getStats).toHaveBeenCalled();
expect(collector.getActive()).toBeFalsy();
});
});
});

View File

@ -0,0 +1,28 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { StatsValueFormatter } from "../../../../src/webrtc/stats/statsValueFormatter";
describe("StatsValueFormatter", () => {
describe("on get non negative values", () => {
it("formatter shod return number", async () => {
expect(StatsValueFormatter.getNonNegativeValue("2")).toEqual(2);
expect(StatsValueFormatter.getNonNegativeValue(0)).toEqual(0);
expect(StatsValueFormatter.getNonNegativeValue("-2")).toEqual(0);
expect(StatsValueFormatter.getNonNegativeValue("")).toEqual(0);
expect(StatsValueFormatter.getNonNegativeValue(NaN)).toEqual(0);
});
});
});

View File

@ -0,0 +1,132 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TrackStatsReporter } from "../../../../src/webrtc/stats/trackStatsReporter";
import { MediaTrackStats } from "../../../../src/webrtc/stats/media/mediaTrackStats";
describe("TrackStatsReporter", () => {
describe("should on frame and resolution stats", () => {
it("creating empty frame and resolution report, if no data available.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsReporter.buildFramerateResolution(trackStats, {});
expect(trackStats.getFramerate()).toEqual(0);
expect(trackStats.getResolution()).toEqual({ width: -1, height: -1 });
});
it("creating empty frame and resolution report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildFramerateResolution(trackStats, {
framesPerSecond: 22.2,
frameHeight: 180,
frameWidth: 360,
});
expect(trackStats.getFramerate()).toEqual(22);
expect(trackStats.getResolution()).toEqual({ width: 360, height: 180 });
});
});
describe("should on simulcast", () => {
it("creating simulcast framerate.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsReporter.calculateSimulcastFramerate(
trackStats,
{
framesSent: 100,
timestamp: 1678957001000,
},
{
framesSent: 10,
timestamp: 1678957000000,
},
3,
);
expect(trackStats.getFramerate()).toEqual(30);
});
});
describe("should on bytes received stats", () => {
it("creating build bitrate received report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildBitrateReceived(
trackStats,
{
bytesReceived: 2001000,
timestamp: 1678957010,
},
{ bytesReceived: 2000000, timestamp: 1678957000 },
);
expect(trackStats.getBitrate()).toEqual({ download: 800, upload: 0 });
});
});
describe("should on bytes send stats", () => {
it("creating build bitrate send report.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsReporter.buildBitrateSend(
trackStats,
{
bytesSent: 2001000,
timestamp: 1678957010,
},
{ bytesSent: 2000000, timestamp: 1678957000 },
);
expect(trackStats.getBitrate()).toEqual({ download: 0, upload: 800 });
});
});
describe("should on codec stats", () => {
it("creating build bitrate send report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
const remote = {} as RTCStatsReport;
remote.get = jest.fn().mockReturnValue({ mimeType: "video/v8" });
TrackStatsReporter.buildCodec(remote, trackStats, { codecId: "codecID" });
expect(trackStats.getCodec()).toEqual("v8");
});
});
describe("should on package lost stats", () => {
it("creating build package lost on send report.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsReporter.buildPacketsLost(
trackStats,
{
type: "outbound-rtp",
packetsSent: 200,
packetsLost: 120,
},
{
packetsSent: 100,
packetsLost: 30,
},
);
expect(trackStats.getLoss()).toEqual({ packetsTotal: 190, packetsLost: 90, isDownloadStream: false });
});
it("creating build package lost on received report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildPacketsLost(
trackStats,
{
type: "inbound-rtp",
packetsReceived: 300,
packetsLost: 100,
},
{
packetsReceived: 100,
packetsLost: 20,
},
);
expect(trackStats.getLoss()).toEqual({ packetsTotal: 280, packetsLost: 80, isDownloadStream: true });
});
});
});

View File

@ -0,0 +1,126 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TransportStatsReporter } from "../../../../src/webrtc/stats/transportStatsReporter";
import { TransportStats } from "../../../../src/webrtc/stats/transportStats";
describe("TransportStatsReporter", () => {
describe("should on build report", () => {
const REMOTE_CANDIDATE_ID = "REMOTE_CANDIDATE_ID";
const LOCAL_CANDIDATE_ID = "LOCAL_CANDIDATE_ID";
const localIC = { ip: "88.88.99.1", port: 56670, protocol: "tcp", candidateType: "local", networkType: "lan" };
const remoteIC = {
ip: "123.88.99.1",
port: 46670,
protocol: "udp",
candidateType: "srfx",
networkType: "wifi",
};
const isFocus = false;
const rtt = 200000;
it("build new transport stats if all properties there", () => {
const { report, stats } = mockStatsReport(isFocus, 0);
const conferenceStatsTransport: TransportStats[] = [];
const transportStats = TransportStatsReporter.buildReport(report, stats, conferenceStatsTransport, isFocus);
expect(transportStats).toEqual([
{
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
type: remoteIC.protocol,
localIp: `${localIC.ip + 0}:${localIC.port}`,
isFocus,
localCandidateType: localIC.candidateType,
remoteCandidateType: remoteIC.candidateType,
networkType: localIC.networkType,
rtt,
},
]);
});
it("build next transport stats if candidates different", () => {
const mock1 = mockStatsReport(isFocus, 0);
const mock2 = mockStatsReport(isFocus, 1);
let transportStats: TransportStats[] = [];
transportStats = TransportStatsReporter.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
transportStats = TransportStatsReporter.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
expect(transportStats).toEqual([
{
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
type: remoteIC.protocol,
localIp: `${localIC.ip + 0}:${localIC.port}`,
isFocus,
localCandidateType: localIC.candidateType,
remoteCandidateType: remoteIC.candidateType,
networkType: localIC.networkType,
rtt,
},
{
ip: `${remoteIC.ip + 1}:${remoteIC.port}`,
type: remoteIC.protocol,
localIp: `${localIC.ip + 1}:${localIC.port}`,
isFocus,
localCandidateType: localIC.candidateType,
remoteCandidateType: remoteIC.candidateType,
networkType: localIC.networkType,
rtt,
},
]);
});
it("build not a second transport stats if candidates the same", () => {
const mock1 = mockStatsReport(isFocus, 0);
const mock2 = mockStatsReport(isFocus, 0);
let transportStats: TransportStats[] = [];
transportStats = TransportStatsReporter.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
transportStats = TransportStatsReporter.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
expect(transportStats).toEqual([
{
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
type: remoteIC.protocol,
localIp: `${localIC.ip + 0}:${localIC.port}`,
isFocus,
localCandidateType: localIC.candidateType,
remoteCandidateType: remoteIC.candidateType,
networkType: localIC.networkType,
rtt,
},
]);
});
const mockStatsReport = (
isFocus: boolean,
prifix: number,
): { report: RTCStatsReport; stats: RTCIceCandidatePairStats } => {
const report = {} as RTCStatsReport;
report.get = (key: string) => {
if (key === LOCAL_CANDIDATE_ID) {
return { ...localIC, ip: localIC.ip + prifix };
}
if (key === REMOTE_CANDIDATE_ID) {
return { ...remoteIC, ip: remoteIC.ip + prifix };
}
// remote
return {};
};
const stats = {
remoteCandidateId: REMOTE_CANDIDATE_ID,
localCandidateId: LOCAL_CANDIDATE_ID,
currentRoundTripTime: 200,
} as RTCIceCandidatePairStats;
return { report, stats };
};
});
});

View File

@ -52,6 +52,7 @@ import { DeviceInfo } from "../crypto/deviceinfo";
import { GroupCallUnknownDeviceError } from "./groupCall";
import { IScreensharingOpts } from "./mediaHandler";
import { MatrixError } from "../http-api";
import { GroupCallStats } from "./stats/groupCallStats";
interface CallOpts {
// The room ID for this call.
@ -416,6 +417,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
// Used to allow connection without Video and Audio. To establish a webrtc connection without media a Data channel is
// needed At the moment this property is true if we allow MatrixClient with isVoipWithNoMediaAllowed = true
private readonly isOnlyDataChannelAllowed: boolean;
private stats: GroupCallStats | undefined;
/**
* Construct a new Matrix Call.
@ -997,6 +999,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
if (this.peerConn!.signalingState != "closed") {
this.peerConn!.close();
}
this.stats?.removeStatsReportGatherer(this.callId);
this.emit(CallEvent.Hangup, this);
}
}, invite.lifetime - event.getLocalAge());
@ -2626,6 +2629,8 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
if (this.peerConn && this.peerConn.signalingState !== "closed") {
this.peerConn.close();
}
this.stats?.removeStatsReportGatherer(this.callId);
if (shouldEmit) {
this.emit(CallEvent.Hangup, this);
}
@ -2804,6 +2809,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
pc.addEventListener("negotiationneeded", this.onNegotiationNeeded);
pc.addEventListener("datachannel", this.onDataChannel);
this.stats?.addStatsReportGatherer(this.callId, "unknown", pc);
return pc;
}
@ -2876,6 +2882,11 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
public get hasPeerConnection(): boolean {
return Boolean(this.peerConn);
}
public initStats(stats: GroupCallStats, peerId = "unknown"): void {
this.stats = stats;
this.stats.start();
}
}
export function setTracksEnabled(tracks: Array<MediaStreamTrack>, enabled: boolean): void {

View File

@ -277,6 +277,11 @@ export class CallEventHandler {
}
call.callId = content.call_id;
const stats = groupCall?.getGroupCallStats();
if (stats) {
call.initStats(stats);
}
try {
await call.initWithInvite(event);
} catch (e) {

View File

@ -24,6 +24,8 @@ import { CallEventHandlerEvent } from "./callEventHandler";
import { GroupCallEventHandlerEvent } from "./groupCallEventHandler";
import { IScreensharingOpts } from "./mediaHandler";
import { mapsEqual } from "../utils";
import { GroupCallStats } from "./stats/groupCallStats";
import { ByteSentStatsReport, ConnectionStatsReport, StatsReport } from "./stats/statsReport";
export enum GroupCallIntent {
Ring = "m.ring",
@ -88,12 +90,26 @@ export type GroupCallEventHandlerMap = {
[GroupCallEvent.Error]: (error: GroupCallError) => void;
};
export enum GroupCallStatsReportEvent {
ConnectionStats = "GroupCall.connection_stats",
ByteSentStats = "GroupCall.byte_sent_stats",
}
export type GroupCallStatsReportEventHandlerMap = {
[GroupCallStatsReportEvent.ConnectionStats]: (report: GroupCallStatsReport<ConnectionStatsReport>) => void;
[GroupCallStatsReportEvent.ByteSentStats]: (report: GroupCallStatsReport<ByteSentStatsReport>) => void;
};
export enum GroupCallErrorCode {
NoUserMedia = "no_user_media",
UnknownDevice = "unknown_device",
PlaceCallFailed = "place_call_failed",
}
export interface GroupCallStatsReport<T extends ConnectionStatsReport | ByteSentStatsReport> {
report: T;
}
export class GroupCallError extends Error {
public code: string;
@ -185,8 +201,8 @@ function getCallUserId(call: MatrixCall): string | null {
}
export class GroupCall extends TypedEventEmitter<
GroupCallEvent | CallEvent,
GroupCallEventHandlerMap & CallEventHandlerMap
GroupCallEvent | CallEvent | GroupCallStatsReportEvent,
GroupCallEventHandlerMap & CallEventHandlerMap & GroupCallStatsReportEventHandlerMap
> {
// Config
public activeSpeakerInterval = 1000;
@ -216,6 +232,8 @@ export class GroupCall extends TypedEventEmitter<
private initWithVideoMuted = false;
private initCallFeedPromise?: Promise<void>;
private readonly stats: GroupCallStats;
public constructor(
private client: MatrixClient,
public room: Room,
@ -239,8 +257,23 @@ export class GroupCall extends TypedEventEmitter<
this.on(GroupCallEvent.GroupCallStateChanged, this.onStateChanged);
this.on(GroupCallEvent.LocalScreenshareStateChanged, this.onLocalFeedsChanged);
this.allowCallWithoutVideoAndAudio = !!isCallWithoutVideoAndAudio;
const userID = this.client.getUserId() || "unknown";
this.stats = new GroupCallStats(this.groupCallId, userID);
this.stats.reports.on(StatsReport.CONNECTION_STATS, this.onConnectionStats);
this.stats.reports.on(StatsReport.BYTE_SENT_STATS, this.onByteSentStats);
}
private onConnectionStats = (report: ConnectionStatsReport): void => {
// @TODO: Implement data argumentation
this.emit(GroupCallStatsReportEvent.ConnectionStats, { report });
};
private onByteSentStats = (report: ByteSentStatsReport): void => {
// @TODO: Implement data argumentation
this.emit(GroupCallStatsReportEvent.ByteSentStats, { report });
};
public async create(): Promise<GroupCall> {
this.creationTs = Date.now();
this.client.groupCallEventHandler!.groupCalls.set(this.room.roomId, this);
@ -509,6 +542,7 @@ export class GroupCall extends TypedEventEmitter<
clearInterval(this.retryCallLoopInterval);
this.client.removeListener(CallEventHandlerEvent.Incoming, this.onIncomingCall);
this.stats.stop();
}
public leave(): void {
@ -1038,6 +1072,8 @@ export class GroupCall extends TypedEventEmitter<
this.reEmitter.reEmit(call, Object.values(CallEvent));
call.initStats(this.stats);
onCallFeedsChanged();
}
@ -1550,4 +1586,8 @@ export class GroupCall extends TypedEventEmitter<
);
}
};
public getGroupCallStats(): GroupCallStats {
return this.stats;
}
}

View File

@ -0,0 +1,47 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TransportStats } from "./transportStats";
import { Bitrate } from "./media/mediaTrackStats";
export interface ConnectionStatsBandwidth {
/**
* bytes per second
*/
download: number;
/**
* bytes per second
*/
upload: number;
}
export interface ConnectionStatsBitrate extends Bitrate {
audio?: Bitrate;
video?: Bitrate;
}
export interface PacketLoos {
total: number;
download: number;
upload: number;
}
export class ConnectionStats {
public bandwidth: ConnectionStatsBitrate = {} as ConnectionStatsBitrate;
public bitrate: ConnectionStatsBitrate = {} as ConnectionStatsBitrate;
public packetLoss: PacketLoos = {} as PacketLoos;
public transport: TransportStats[] = [];
}

View File

@ -0,0 +1,28 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Bitrate } from "./media/mediaTrackStats";
export class ConnectionStatsReporter {
public static buildBandwidthReport(now: RTCIceCandidatePairStats): Bitrate {
const availableIncomingBitrate = now.availableIncomingBitrate;
const availableOutgoingBitrate = now.availableOutgoingBitrate;
return {
download: availableIncomingBitrate ? Math.round(availableIncomingBitrate / 1000) : 0,
upload: availableOutgoingBitrate ? Math.round(availableOutgoingBitrate / 1000) : 0,
};
}
}

View File

@ -0,0 +1,64 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { StatsReportGatherer } from "./statsReportGatherer";
import { StatsReportEmitter } from "./statsReportEmitter";
export class GroupCallStats {
private timer: undefined | ReturnType<typeof setTimeout>;
private readonly gatherers: Map<string, StatsReportGatherer> = new Map<string, StatsReportGatherer>();
public readonly reports = new StatsReportEmitter();
public constructor(private groupCallId: string, private userId: string, private interval: number = 10000) {}
public start(): void {
if (this.timer === undefined) {
this.timer = setInterval(() => {
this.processStats();
}, this.interval);
}
}
public stop(): void {
if (this.timer !== undefined) {
clearInterval(this.timer);
this.gatherers.forEach((c) => c.stopProcessingStats());
}
}
public hasStatsReportGatherer(callId: string): boolean {
return this.gatherers.has(callId);
}
public addStatsReportGatherer(callId: string, userId: string, peerConnection: RTCPeerConnection): boolean {
if (this.hasStatsReportGatherer(callId)) {
return false;
}
this.gatherers.set(callId, new StatsReportGatherer(callId, userId, peerConnection, this.reports));
return true;
}
public removeStatsReportGatherer(callId: string): boolean {
return this.gatherers.delete(callId);
}
public getStatsReportGatherer(callId: string): StatsReportGatherer | undefined {
return this.hasStatsReportGatherer(callId) ? this.gatherers.get(callId) : undefined;
}
private processStats(): void {
this.gatherers.forEach((c) => c.processStats(this.groupCallId, this.userId));
}
}

View File

@ -0,0 +1,57 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { parse as parseSdp } from "sdp-transform";
export type Mid = string;
export type Ssrc = string;
export type MapType = "local" | "remote";
export class MediaSsrcHandler {
private readonly ssrcToMid = { local: new Map<Mid, Ssrc[]>(), remote: new Map<Mid, Ssrc[]>() };
public findMidBySsrc(ssrc: Ssrc, type: "local" | "remote"): Mid | undefined {
let mid: Mid | undefined;
this.ssrcToMid[type].forEach((ssrcs, m) => {
if (ssrcs.find((s) => s == ssrc)) {
mid = m;
return;
}
});
return mid;
}
public parse(description: string, type: MapType): void {
const sdp = parseSdp(description);
const ssrcToMid = new Map<Mid, Ssrc[]>();
sdp.media.forEach((m) => {
if ((!!m.mid && m.type === "video") || m.type === "audio") {
const ssrcs: Ssrc[] = [];
m.ssrcs?.forEach((ssrc) => {
if (ssrc.attribute === "cname") {
ssrcs.push(`${ssrc.id}`);
}
});
ssrcToMid.set(`${m.mid}`, ssrcs);
}
});
this.ssrcToMid[type] = ssrcToMid;
}
public getSsrcToMidMap(type: MapType): Map<Mid, Ssrc[]> {
return this.ssrcToMid[type];
}
}

View File

@ -0,0 +1,71 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export type TrackId = string;
export class MediaTrackHandler {
public constructor(private readonly pc: RTCPeerConnection) {}
public getLocalTracks(kind: "audio" | "video"): MediaStreamTrack[] {
const isNotNullAndKind = (track: MediaStreamTrack | null): boolean => {
return track !== null && track.kind === kind;
};
// @ts-ignore The linter don't get it
return this.pc
.getTransceivers()
.filter((t) => t.currentDirection === "sendonly" || t.currentDirection === "sendrecv")
.filter((t) => t.sender !== null)
.map((t) => t.sender)
.map((s) => s.track)
.filter(isNotNullAndKind);
}
public getTackById(trackId: string): MediaStreamTrack | undefined {
return this.pc
.getTransceivers()
.map((t) => {
if (t?.sender.track !== null && t.sender.track.id === trackId) {
return t.sender.track;
}
if (t?.receiver.track !== null && t.receiver.track.id === trackId) {
return t.receiver.track;
}
return undefined;
})
.find((t) => t !== undefined);
}
public getLocalTrackIdByMid(mid: string): string | undefined {
const transceiver = this.pc.getTransceivers().find((t) => t.mid === mid);
if (transceiver !== undefined && !!transceiver.sender && !!transceiver.sender.track) {
return transceiver.sender.track.id;
}
return undefined;
}
public getRemoteTrackIdByMid(mid: string): string | undefined {
const transceiver = this.pc.getTransceivers().find((t) => t.mid === mid);
if (transceiver !== undefined && !!transceiver.receiver && !!transceiver.receiver.track) {
return transceiver.receiver.track.id;
}
return undefined;
}
public getActiveSimulcastStreams(): number {
//@TODO implement this right.. Check how many layer configured
return 3;
}
}

View File

@ -0,0 +1,104 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TrackId } from "./mediaTrackHandler";
export interface PacketLoss {
packetsTotal: number;
packetsLost: number;
isDownloadStream: boolean;
}
export interface Bitrate {
/**
* bytes per second
*/
download: number;
/**
* bytes per second
*/
upload: number;
}
export interface Resolution {
width: number;
height: number;
}
export type TrackStatsType = "local" | "remote";
export class MediaTrackStats {
private loss: PacketLoss = { packetsTotal: 0, packetsLost: 0, isDownloadStream: false };
private bitrate: Bitrate = { download: 0, upload: 0 };
private resolution: Resolution = { width: -1, height: -1 };
private framerate = 0;
private codec = "";
public constructor(
public readonly trackId: TrackId,
public readonly type: TrackStatsType,
public readonly kind: "audio" | "video",
) {}
public getType(): TrackStatsType {
return this.type;
}
public setLoss(loos: PacketLoss): void {
this.loss = loos;
}
public getLoss(): PacketLoss {
return this.loss;
}
public setResolution(resolution: Resolution): void {
this.resolution = resolution;
}
public getResolution(): Resolution {
return this.resolution;
}
public setFramerate(framerate: number): void {
this.framerate = framerate;
}
public getFramerate(): number {
return this.framerate;
}
public setBitrate(bitrate: Bitrate): void {
this.bitrate = bitrate;
}
public getBitrate(): Bitrate {
return this.bitrate;
}
public setCodec(codecShortType: string): boolean {
this.codec = codecShortType;
return true;
}
public getCodec(): string {
return this.codec;
}
public resetBitrate(): void {
this.bitrate = { download: 0, upload: 0 };
}
}

View File

@ -0,0 +1,86 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TrackID } from "../statsReport";
import { MediaTrackStats } from "./mediaTrackStats";
import { MediaTrackHandler } from "./mediaTrackHandler";
import { MediaSsrcHandler } from "./mediaSsrcHandler";
export class MediaTrackStatsHandler {
private readonly track2stats = new Map<TrackID, MediaTrackStats>();
public constructor(
public readonly mediaSsrcHandler: MediaSsrcHandler,
public readonly mediaTrackHandler: MediaTrackHandler,
) {}
/**
* Find tracks by rtc stats
* Argument report is any because the stats api is not consistent:
* For example `trackIdentifier`, `mid` not existing in every implementations
* https://www.w3.org/TR/webrtc-stats/#dom-rtcinboundrtpstreamstats
* https://developer.mozilla.org/en-US/docs/Web/API/RTCInboundRtpStreamStats
*/
public findTrack2Stats(report: any, type: "remote" | "local"): MediaTrackStats | undefined {
let trackID;
if (report.trackIdentifier) {
trackID = report.trackIdentifier;
} else if (report.mid) {
trackID =
type === "remote"
? this.mediaTrackHandler.getRemoteTrackIdByMid(report.mid)
: this.mediaTrackHandler.getLocalTrackIdByMid(report.mid);
} else if (report.ssrc) {
const mid = this.mediaSsrcHandler.findMidBySsrc(report.ssrc, type);
if (!mid) {
return undefined;
}
trackID =
type === "remote"
? this.mediaTrackHandler.getRemoteTrackIdByMid(report.mid)
: this.mediaTrackHandler.getLocalTrackIdByMid(report.mid);
}
if (!trackID) {
return undefined;
}
let trackStats = this.track2stats.get(trackID);
if (!trackStats) {
const track = this.mediaTrackHandler.getTackById(trackID);
if (track !== undefined) {
const kind: "audio" | "video" = track.kind === "audio" ? track.kind : "video";
trackStats = new MediaTrackStats(trackID, type, kind);
this.track2stats.set(trackID, trackStats);
} else {
return undefined;
}
}
return trackStats;
}
public findLocalVideoTrackStats(report: any): MediaTrackStats | undefined {
const localVideoTracks = this.mediaTrackHandler.getLocalTracks("video");
if (localVideoTracks.length === 0) {
return undefined;
}
return this.findTrack2Stats(report, "local");
}
public getTrack2stats(): Map<TrackID, MediaTrackStats> {
return this.track2stats;
}
}

View File

@ -0,0 +1,56 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ConnectionStatsBandwidth, ConnectionStatsBitrate, PacketLoos } from "./connectionStats";
import { TransportStats } from "./transportStats";
import { Resolution } from "./media/mediaTrackStats";
export enum StatsReport {
CONNECTION_STATS = "StatsReport.connection_stats",
BYTE_SENT_STATS = "StatsReport.byte_sent_stats",
}
export type TrackID = string;
export type ByteSend = number;
export interface ByteSentStatsReport extends Map<TrackID, ByteSend> {
// is a map: `local trackID` => byte send
}
export interface ConnectionStatsReport {
bandwidth: ConnectionStatsBandwidth;
bitrate: ConnectionStatsBitrate;
packetLoss: PacketLoos;
resolution: ResolutionMap;
framerate: FramerateMap;
codec: CodecMap;
transport: TransportStats[];
}
export interface ResolutionMap {
local: Map<TrackID, Resolution>;
remote: Map<TrackID, Resolution>;
}
export interface FramerateMap {
local: Map<TrackID, number>;
remote: Map<TrackID, number>;
}
export interface CodecMap {
local: Map<TrackID, string>;
remote: Map<TrackID, string>;
}

View File

@ -0,0 +1,110 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { CodecMap, ConnectionStatsReport, FramerateMap, ResolutionMap, TrackID } from "./statsReport";
import { MediaTrackStats, Resolution } from "./media/mediaTrackStats";
export class StatsReportBuilder {
public static build(stats: Map<TrackID, MediaTrackStats>): ConnectionStatsReport {
const report = {} as ConnectionStatsReport;
// process stats
const totalPackets = {
download: 0,
upload: 0,
};
const lostPackets = {
download: 0,
upload: 0,
};
let bitrateDownload = 0;
let bitrateUpload = 0;
const resolutions: ResolutionMap = {
local: new Map<TrackID, Resolution>(),
remote: new Map<TrackID, Resolution>(),
};
const framerates: FramerateMap = { local: new Map<TrackID, number>(), remote: new Map<TrackID, number>() };
const codecs: CodecMap = { local: new Map<TrackID, string>(), remote: new Map<TrackID, string>() };
let audioBitrateDownload = 0;
let audioBitrateUpload = 0;
let videoBitrateDownload = 0;
let videoBitrateUpload = 0;
for (const [trackId, trackStats] of stats) {
// process packet loss stats
const loss = trackStats.getLoss();
const type = loss.isDownloadStream ? "download" : "upload";
totalPackets[type] += loss.packetsTotal;
lostPackets[type] += loss.packetsLost;
// process bitrate stats
bitrateDownload += trackStats.getBitrate().download;
bitrateUpload += trackStats.getBitrate().upload;
// collect resolutions and framerates
if (trackStats.kind === "audio") {
audioBitrateDownload += trackStats.getBitrate().download;
audioBitrateUpload += trackStats.getBitrate().upload;
} else {
videoBitrateDownload += trackStats.getBitrate().download;
videoBitrateUpload += trackStats.getBitrate().upload;
}
resolutions[trackStats.getType()].set(trackId, trackStats.getResolution());
framerates[trackStats.getType()].set(trackId, trackStats.getFramerate());
codecs[trackStats.getType()].set(trackId, trackStats.getCodec());
trackStats.resetBitrate();
}
report.bitrate = {
upload: bitrateUpload,
download: bitrateDownload,
};
report.bitrate.audio = {
upload: audioBitrateUpload,
download: audioBitrateDownload,
};
report.bitrate.video = {
upload: videoBitrateUpload,
download: videoBitrateDownload,
};
report.packetLoss = {
total: StatsReportBuilder.calculatePacketLoss(
lostPackets.download + lostPackets.upload,
totalPackets.download + totalPackets.upload,
),
download: StatsReportBuilder.calculatePacketLoss(lostPackets.download, totalPackets.download),
upload: StatsReportBuilder.calculatePacketLoss(lostPackets.upload, totalPackets.upload),
};
report.framerate = framerates;
report.resolution = resolutions;
report.codec = codecs;
return report;
}
private static calculatePacketLoss(lostPackets: number, totalPackets: number): number {
if (!totalPackets || totalPackets <= 0 || !lostPackets || lostPackets <= 0) {
return 0;
}
return Math.round((lostPackets / totalPackets) * 100);
}
}

View File

@ -0,0 +1,33 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TypedEventEmitter } from "../../models/typed-event-emitter";
import { ByteSentStatsReport, ConnectionStatsReport, StatsReport } from "./statsReport";
export type StatsReportHandlerMap = {
[StatsReport.BYTE_SENT_STATS]: (report: ByteSentStatsReport) => void;
[StatsReport.CONNECTION_STATS]: (report: ConnectionStatsReport) => void;
};
export class StatsReportEmitter extends TypedEventEmitter<StatsReport, StatsReportHandlerMap> {
public emitByteSendReport(byteSentStats: ByteSentStatsReport): void {
this.emit(StatsReport.BYTE_SENT_STATS, byteSentStats);
}
public emitConnectionStatsReport(report: ConnectionStatsReport): void {
this.emit(StatsReport.CONNECTION_STATS, report);
}
}

View File

@ -0,0 +1,183 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ConnectionStats } from "./connectionStats";
import { StatsReportEmitter } from "./statsReportEmitter";
import { ByteSend, ByteSentStatsReport, TrackID } from "./statsReport";
import { ConnectionStatsReporter } from "./connectionStatsReporter";
import { TransportStatsReporter } from "./transportStatsReporter";
import { MediaSsrcHandler } from "./media/mediaSsrcHandler";
import { MediaTrackHandler } from "./media/mediaTrackHandler";
import { MediaTrackStatsHandler } from "./media/mediaTrackStatsHandler";
import { TrackStatsReporter } from "./trackStatsReporter";
import { StatsReportBuilder } from "./statsReportBuilder";
import { StatsValueFormatter } from "./statsValueFormatter";
export class StatsReportGatherer {
private isActive = true;
private previousStatsReport: RTCStatsReport | undefined;
private currentStatsReport: RTCStatsReport | undefined;
private readonly connectionStats = new ConnectionStats();
private readonly trackStats: MediaTrackStatsHandler;
// private readonly ssrcToMid = { local: new Map<Mid, Ssrc[]>(), remote: new Map<Mid, Ssrc[]>() };
public constructor(
public readonly callId: string,
public readonly remoteUserId: string,
private readonly pc: RTCPeerConnection,
private readonly emitter: StatsReportEmitter,
private readonly isFocus = true,
) {
pc.addEventListener("signalingstatechange", this.onSignalStateChange.bind(this));
this.trackStats = new MediaTrackStatsHandler(new MediaSsrcHandler(), new MediaTrackHandler(pc));
}
public async processStats(groupCallId: string, localUserId: string): Promise<boolean> {
if (this.isActive) {
const statsPromise = this.pc.getStats();
if (typeof statsPromise?.then === "function") {
return statsPromise
.then((report) => {
// @ts-ignore
this.currentStatsReport = typeof report?.result === "function" ? report.result() : report;
try {
this.processStatsReport(groupCallId, localUserId);
} catch (error) {
this.isActive = false;
return false;
}
this.previousStatsReport = this.currentStatsReport;
return true;
})
.catch((error) => {
this.handleError(error);
return false;
});
}
this.isActive = false;
}
return Promise.resolve(false);
}
private processStatsReport(groupCallId: string, localUserId: string): void {
const byteSentStats: ByteSentStatsReport = new Map<TrackID, ByteSend>();
this.currentStatsReport?.forEach((now) => {
const before = this.previousStatsReport ? this.previousStatsReport.get(now.id) : null;
// RTCIceCandidatePairStats - https://w3c.github.io/webrtc-stats/#candidatepair-dict*
if (now.type === "candidate-pair" && now.nominated && now.state === "succeeded") {
this.connectionStats.bandwidth = ConnectionStatsReporter.buildBandwidthReport(now);
this.connectionStats.transport = TransportStatsReporter.buildReport(
this.currentStatsReport,
now,
this.connectionStats.transport,
this.isFocus,
);
// RTCReceivedRtpStreamStats
// https://w3c.github.io/webrtc-stats/#receivedrtpstats-dict*
// RTCSentRtpStreamStats
// https://w3c.github.io/webrtc-stats/#sentrtpstats-dict*
} else if (now.type === "inbound-rtp" || now.type === "outbound-rtp") {
const trackStats = this.trackStats.findTrack2Stats(
now,
now.type === "inbound-rtp" ? "remote" : "local",
);
if (!trackStats) {
return;
}
if (before) {
TrackStatsReporter.buildPacketsLost(trackStats, now, before);
}
// Get the resolution and framerate for only remote video sources here. For the local video sources,
// 'track' stats will be used since they have the updated resolution based on the simulcast streams
// currently being sent. Promise based getStats reports three 'outbound-rtp' streams and there will be
// more calculations needed to determine what is the highest resolution stream sent by the client if the
// 'outbound-rtp' stats are used.
if (now.type === "inbound-rtp") {
TrackStatsReporter.buildFramerateResolution(trackStats, now);
if (before) {
TrackStatsReporter.buildBitrateReceived(trackStats, now, before);
}
} else if (before) {
byteSentStats.set(trackStats.trackId, StatsValueFormatter.getNonNegativeValue(now.bytesSent));
TrackStatsReporter.buildBitrateSend(trackStats, now, before);
}
TrackStatsReporter.buildCodec(this.currentStatsReport, trackStats, now);
} else if (now.type === "track" && now.kind === "video" && !now.remoteSource) {
const trackStats = this.trackStats.findLocalVideoTrackStats(now);
if (!trackStats) {
return;
}
TrackStatsReporter.buildFramerateResolution(trackStats, now);
TrackStatsReporter.calculateSimulcastFramerate(
trackStats,
now,
before,
this.trackStats.mediaTrackHandler.getActiveSimulcastStreams(),
);
}
});
this.emitter.emitByteSendReport(byteSentStats);
this.processAndEmitReport();
}
public setActive(isActive: boolean): void {
this.isActive = isActive;
}
public getActive(): boolean {
return this.isActive;
}
private handleError(_: any): void {
this.isActive = false;
}
private processAndEmitReport(): void {
const report = StatsReportBuilder.build(this.trackStats.getTrack2stats());
this.connectionStats.bandwidth = report.bandwidth;
this.connectionStats.bitrate = report.bitrate;
this.connectionStats.packetLoss = report.packetLoss;
this.emitter.emitConnectionStatsReport({
...report,
transport: this.connectionStats.transport,
});
this.connectionStats.transport = [];
}
public stopProcessingStats(): void {}
private onSignalStateChange(): void {
if (this.pc.signalingState === "stable") {
if (this.pc.currentRemoteDescription) {
this.trackStats.mediaSsrcHandler.parse(this.pc.currentRemoteDescription.sdp, "remote");
}
if (this.pc.currentLocalDescription) {
this.trackStats.mediaSsrcHandler.parse(this.pc.currentLocalDescription.sdp, "local");
}
}
}
}

View File

@ -0,0 +1,27 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export class StatsValueFormatter {
public static getNonNegativeValue(imput: any): number {
let value = imput;
if (typeof value !== "number") {
value = Number(value);
}
if (isNaN(value)) {
return 0;
}
return Math.max(0, value);
}
}

View File

@ -0,0 +1,117 @@
import { MediaTrackStats } from "./media/mediaTrackStats";
import { StatsValueFormatter } from "./statsValueFormatter";
export class TrackStatsReporter {
public static buildFramerateResolution(trackStats: MediaTrackStats, now: any): void {
const resolution = {
height: now.frameHeight,
width: now.frameWidth,
};
const frameRate = now.framesPerSecond;
if (resolution.height && resolution.width) {
trackStats.setResolution(resolution);
}
trackStats.setFramerate(Math.round(frameRate || 0));
}
public static calculateSimulcastFramerate(trackStats: MediaTrackStats, now: any, before: any, layer: number): void {
let frameRate = trackStats.getFramerate();
if (!frameRate) {
if (before) {
const timeMs = now.timestamp - before.timestamp;
if (timeMs > 0 && now.framesSent) {
const numberOfFramesSinceBefore = now.framesSent - before.framesSent;
frameRate = (numberOfFramesSinceBefore / timeMs) * 1000;
}
}
if (!frameRate) {
return;
}
}
// Reset frame rate to 0 when video is suspended as a result of endpoint falling out of last-n.
frameRate = layer ? Math.round(frameRate / layer) : 0;
trackStats.setFramerate(frameRate);
}
public static buildCodec(report: RTCStatsReport | undefined, trackStats: MediaTrackStats, now: any): void {
const codec = report?.get(now.codecId);
if (codec) {
/**
* The mime type has the following form: video/VP8 or audio/ISAC,
* so we what to keep just the type after the '/', audio and video
* keys will be added on the processing side.
*/
const codecShortType = codec.mimeType.split("/")[1];
codecShortType && trackStats.setCodec(codecShortType);
}
}
public static buildBitrateReceived(trackStats: MediaTrackStats, now: any, before: any): void {
trackStats.setBitrate({
download: TrackStatsReporter.calculateBitrate(
now.bytesReceived,
before.bytesReceived,
now.timestamp,
before.timestamp,
),
upload: 0,
});
}
public static buildBitrateSend(trackStats: MediaTrackStats, now: any, before: any): void {
trackStats.setBitrate({
download: 0,
upload: this.calculateBitrate(now.bytesSent, before.bytesSent, now.timestamp, before.timestamp),
});
}
public static buildPacketsLost(trackStats: MediaTrackStats, now: any, before: any): void {
const key = now.type === "outbound-rtp" ? "packetsSent" : "packetsReceived";
let packetsNow = now[key];
if (!packetsNow || packetsNow < 0) {
packetsNow = 0;
}
const packetsBefore = StatsValueFormatter.getNonNegativeValue(before[key]);
const packetsDiff = Math.max(0, packetsNow - packetsBefore);
const packetsLostNow = StatsValueFormatter.getNonNegativeValue(now.packetsLost);
const packetsLostBefore = StatsValueFormatter.getNonNegativeValue(before.packetsLost);
const packetsLostDiff = Math.max(0, packetsLostNow - packetsLostBefore);
trackStats.setLoss({
packetsTotal: packetsDiff + packetsLostDiff,
packetsLost: packetsLostDiff,
isDownloadStream: now.type !== "outbound-rtp",
});
}
private static calculateBitrate(
bytesNowAny: any,
bytesBeforeAny: any,
nowTimestamp: number,
beforeTimestamp: number,
): number {
const bytesNow = StatsValueFormatter.getNonNegativeValue(bytesNowAny);
const bytesBefore = StatsValueFormatter.getNonNegativeValue(bytesBeforeAny);
const bytesProcessed = Math.max(0, bytesNow - bytesBefore);
const timeMs = nowTimestamp - beforeTimestamp;
let bitrateKbps = 0;
if (timeMs > 0) {
// TODO is there any reason to round here?
bitrateKbps = Math.round((bytesProcessed * 8) / timeMs);
}
return bitrateKbps;
}
}

View File

@ -0,0 +1,26 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export interface TransportStats {
ip: string;
type: string;
localIp: string;
isFocus: boolean;
localCandidateType: string;
remoteCandidateType: string;
networkType: string;
rtt: number;
}

View File

@ -0,0 +1,48 @@
import { TransportStats } from "./transportStats";
export class TransportStatsReporter {
public static buildReport(
report: RTCStatsReport | undefined,
now: RTCIceCandidatePairStats,
conferenceStatsTransport: TransportStats[],
isFocus: boolean,
): TransportStats[] {
const localUsedCandidate = report?.get(now.localCandidateId);
const remoteUsedCandidate = report?.get(now.remoteCandidateId);
// RTCIceCandidateStats
// https://w3c.github.io/webrtc-stats/#icecandidate-dict*
if (remoteUsedCandidate && localUsedCandidate) {
const remoteIpAddress =
remoteUsedCandidate.ip !== undefined ? remoteUsedCandidate.ip : remoteUsedCandidate.address;
const remotePort = remoteUsedCandidate.port;
const ip = `${remoteIpAddress}:${remotePort}`;
const localIpAddress =
localUsedCandidate.ip !== undefined ? localUsedCandidate.ip : localUsedCandidate.address;
const localPort = localUsedCandidate.port;
const localIp = `${localIpAddress}:${localPort}`;
const type = remoteUsedCandidate.protocol;
// Save the address unless it has been saved already.
if (
!conferenceStatsTransport.some(
(t: TransportStats) => t.ip === ip && t.type === type && t.localIp === localIp,
)
) {
conferenceStatsTransport.push({
ip,
type,
localIp,
isFocus,
localCandidateType: localUsedCandidate.candidateType,
remoteCandidateType: remoteUsedCandidate.candidateType,
networkType: localUsedCandidate.networkType,
rtt: now.currentRoundTripTime ? now.currentRoundTripTime * 1000 : NaN,
} as TransportStats);
}
}
return conferenceStatsTransport;
}
}