You've already forked matrix-js-sdk
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:
@ -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";
|
||||
|
46
spec/unit/webrtc/stats/connectionStatsReporter.spec.ts
Normal file
46
spec/unit/webrtc/stats/connectionStatsReporter.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
136
spec/unit/webrtc/stats/groupCallStats.spec.ts
Normal file
136
spec/unit/webrtc/stats/groupCallStats.spec.ts
Normal 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;
|
||||
};
|
41
spec/unit/webrtc/stats/media/mediaSsrcHandler.spec.ts
Normal file
41
spec/unit/webrtc/stats/media/mediaSsrcHandler.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
113
spec/unit/webrtc/stats/media/mediaTrackHandler.spec.ts
Normal file
113
spec/unit/webrtc/stats/media/mediaTrackHandler.spec.ts
Normal 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;
|
||||
};
|
83
spec/unit/webrtc/stats/media/mediaTrackStatsHandler.spec.ts
Normal file
83
spec/unit/webrtc/stats/media/mediaTrackStatsHandler.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
115
spec/unit/webrtc/stats/statsReportBuilder.spec.ts
Normal file
115
spec/unit/webrtc/stats/statsReportBuilder.spec.ts
Normal 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 });
|
||||
};
|
||||
});
|
48
spec/unit/webrtc/stats/statsReportEmitter.spec.ts
Normal file
48
spec/unit/webrtc/stats/statsReportEmitter.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
68
spec/unit/webrtc/stats/statsReportGatherer.spec.ts
Normal file
68
spec/unit/webrtc/stats/statsReportGatherer.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
28
spec/unit/webrtc/stats/statsValueFormatter.spec.ts
Normal file
28
spec/unit/webrtc/stats/statsValueFormatter.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
132
spec/unit/webrtc/stats/trackStatsReporter.spec.ts
Normal file
132
spec/unit/webrtc/stats/trackStatsReporter.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
126
spec/unit/webrtc/stats/transportStatsReporter.spec.ts
Normal file
126
spec/unit/webrtc/stats/transportStatsReporter.spec.ts
Normal 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 };
|
||||
};
|
||||
});
|
||||
});
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
47
src/webrtc/stats/connectionStats.ts
Normal file
47
src/webrtc/stats/connectionStats.ts
Normal 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[] = [];
|
||||
}
|
28
src/webrtc/stats/connectionStatsReporter.ts
Normal file
28
src/webrtc/stats/connectionStatsReporter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
64
src/webrtc/stats/groupCallStats.ts
Normal file
64
src/webrtc/stats/groupCallStats.ts
Normal 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));
|
||||
}
|
||||
}
|
57
src/webrtc/stats/media/mediaSsrcHandler.ts
Normal file
57
src/webrtc/stats/media/mediaSsrcHandler.ts
Normal 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];
|
||||
}
|
||||
}
|
71
src/webrtc/stats/media/mediaTrackHandler.ts
Normal file
71
src/webrtc/stats/media/mediaTrackHandler.ts
Normal 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;
|
||||
}
|
||||
}
|
104
src/webrtc/stats/media/mediaTrackStats.ts
Normal file
104
src/webrtc/stats/media/mediaTrackStats.ts
Normal 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 };
|
||||
}
|
||||
}
|
86
src/webrtc/stats/media/mediaTrackStatsHandler.ts
Normal file
86
src/webrtc/stats/media/mediaTrackStatsHandler.ts
Normal 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;
|
||||
}
|
||||
}
|
56
src/webrtc/stats/statsReport.ts
Normal file
56
src/webrtc/stats/statsReport.ts
Normal 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>;
|
||||
}
|
110
src/webrtc/stats/statsReportBuilder.ts
Normal file
110
src/webrtc/stats/statsReportBuilder.ts
Normal 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);
|
||||
}
|
||||
}
|
33
src/webrtc/stats/statsReportEmitter.ts
Normal file
33
src/webrtc/stats/statsReportEmitter.ts
Normal 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);
|
||||
}
|
||||
}
|
183
src/webrtc/stats/statsReportGatherer.ts
Normal file
183
src/webrtc/stats/statsReportGatherer.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
27
src/webrtc/stats/statsValueFormatter.ts
Normal file
27
src/webrtc/stats/statsValueFormatter.ts
Normal 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);
|
||||
}
|
||||
}
|
117
src/webrtc/stats/trackStatsReporter.ts
Normal file
117
src/webrtc/stats/trackStatsReporter.ts
Normal 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;
|
||||
}
|
||||
}
|
26
src/webrtc/stats/transportStats.ts
Normal file
26
src/webrtc/stats/transportStats.ts
Normal 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;
|
||||
}
|
48
src/webrtc/stats/transportStatsReporter.ts
Normal file
48
src/webrtc/stats/transportStatsReporter.ts
Normal 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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user