diff --git a/spec/test-utils/webrtc.ts b/spec/test-utils/webrtc.ts index 46b90b7a5..7bd1d0493 100644 --- a/spec/test-utils/webrtc.ts +++ b/spec/test-utils/webrtc.ts @@ -519,6 +519,7 @@ export class MockMatrixCall extends TypedEventEmitter(); public answerWithCallFeeds = jest.fn(); public hangup = jest.fn(); + public initStats = jest.fn(); public sendMetadataUpdate = jest.fn(); @@ -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"; diff --git a/spec/unit/webrtc/stats/connectionStatsReporter.spec.ts b/spec/unit/webrtc/stats/connectionStatsReporter.spec.ts new file mode 100644 index 000000000..1c9b21233 --- /dev/null +++ b/spec/unit/webrtc/stats/connectionStatsReporter.spec.ts @@ -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 }); + }); + }); +}); diff --git a/spec/unit/webrtc/stats/groupCallStats.spec.ts b/spec/unit/webrtc/stats/groupCallStats.spec.ts new file mode 100644 index 000000000..47e94902c --- /dev/null +++ b/spec/unit/webrtc/stats/groupCallStats.spec.ts @@ -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; +}; diff --git a/spec/unit/webrtc/stats/media/mediaSsrcHandler.spec.ts b/spec/unit/webrtc/stats/media/mediaSsrcHandler.spec.ts new file mode 100644 index 000000000..4b6e93179 --- /dev/null +++ b/spec/unit/webrtc/stats/media/mediaSsrcHandler.spec.ts @@ -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([ + ["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"); + }); + }); +}); diff --git a/spec/unit/webrtc/stats/media/mediaTrackHandler.spec.ts b/spec/unit/webrtc/stats/media/mediaTrackHandler.spec.ts new file mode 100644 index 000000000..66dcc5ebf --- /dev/null +++ b/spec/unit/webrtc/stats/media/mediaTrackHandler.spec.ts @@ -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; +}; diff --git a/spec/unit/webrtc/stats/media/mediaTrackStatsHandler.spec.ts b/spec/unit/webrtc/stats/media/mediaTrackStatsHandler.spec.ts new file mode 100644 index 000000000..d263786fd --- /dev/null +++ b/spec/unit/webrtc/stats/media/mediaTrackStatsHandler.spec.ts @@ -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(); + }); + }); +}); diff --git a/spec/unit/webrtc/stats/statsReportBuilder.spec.ts b/spec/unit/webrtc/stats/statsReportBuilder.spec.ts new file mode 100644 index 000000000..b1843bbfc --- /dev/null +++ b/spec/unit/webrtc/stats/statsReportBuilder.spec.ts @@ -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([ + [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 }); + }; +}); diff --git a/spec/unit/webrtc/stats/statsReportEmitter.spec.ts b/spec/unit/webrtc/stats/statsReportEmitter.spec.ts new file mode 100644 index 000000000..de75d4474 --- /dev/null +++ b/spec/unit/webrtc/stats/statsReportEmitter.spec.ts @@ -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); + }); + }); +}); diff --git a/spec/unit/webrtc/stats/statsReportGatherer.spec.ts b/spec/unit/webrtc/stats/statsReportGatherer.spec.ts new file mode 100644 index 000000000..cd10ebfb2 --- /dev/null +++ b/spec/unit/webrtc/stats/statsReportGatherer.spec.ts @@ -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(() => 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(); + }); + }); +}); diff --git a/spec/unit/webrtc/stats/statsValueFormatter.spec.ts b/spec/unit/webrtc/stats/statsValueFormatter.spec.ts new file mode 100644 index 000000000..1ce563e91 --- /dev/null +++ b/spec/unit/webrtc/stats/statsValueFormatter.spec.ts @@ -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); + }); + }); +}); diff --git a/spec/unit/webrtc/stats/trackStatsReporter.spec.ts b/spec/unit/webrtc/stats/trackStatsReporter.spec.ts new file mode 100644 index 000000000..6a1bb5bf2 --- /dev/null +++ b/spec/unit/webrtc/stats/trackStatsReporter.spec.ts @@ -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 }); + }); + }); +}); diff --git a/spec/unit/webrtc/stats/transportStatsReporter.spec.ts b/spec/unit/webrtc/stats/transportStatsReporter.spec.ts new file mode 100644 index 000000000..bd3288b15 --- /dev/null +++ b/spec/unit/webrtc/stats/transportStatsReporter.spec.ts @@ -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 }; + }; + }); +}); diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index 80851909f..19094d8f5 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -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, enabled: boolean): void { diff --git a/src/webrtc/callEventHandler.ts b/src/webrtc/callEventHandler.ts index a4113c480..4ee183a7f 100644 --- a/src/webrtc/callEventHandler.ts +++ b/src/webrtc/callEventHandler.ts @@ -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) { diff --git a/src/webrtc/groupCall.ts b/src/webrtc/groupCall.ts index d7bf15e99..2e751a50f 100644 --- a/src/webrtc/groupCall.ts +++ b/src/webrtc/groupCall.ts @@ -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) => void; + [GroupCallStatsReportEvent.ByteSentStats]: (report: GroupCallStatsReport) => void; +}; + export enum GroupCallErrorCode { NoUserMedia = "no_user_media", UnknownDevice = "unknown_device", PlaceCallFailed = "place_call_failed", } +export interface GroupCallStatsReport { + 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; + 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 { 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; + } } diff --git a/src/webrtc/stats/connectionStats.ts b/src/webrtc/stats/connectionStats.ts new file mode 100644 index 000000000..dbde6e503 --- /dev/null +++ b/src/webrtc/stats/connectionStats.ts @@ -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[] = []; +} diff --git a/src/webrtc/stats/connectionStatsReporter.ts b/src/webrtc/stats/connectionStatsReporter.ts new file mode 100644 index 000000000..c43b9b40c --- /dev/null +++ b/src/webrtc/stats/connectionStatsReporter.ts @@ -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, + }; + } +} diff --git a/src/webrtc/stats/groupCallStats.ts b/src/webrtc/stats/groupCallStats.ts new file mode 100644 index 000000000..6d8c566aa --- /dev/null +++ b/src/webrtc/stats/groupCallStats.ts @@ -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; + private readonly gatherers: Map = new Map(); + 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)); + } +} diff --git a/src/webrtc/stats/media/mediaSsrcHandler.ts b/src/webrtc/stats/media/mediaSsrcHandler.ts new file mode 100644 index 000000000..e60605152 --- /dev/null +++ b/src/webrtc/stats/media/mediaSsrcHandler.ts @@ -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(), remote: new Map() }; + + 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(); + 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 { + return this.ssrcToMid[type]; + } +} diff --git a/src/webrtc/stats/media/mediaTrackHandler.ts b/src/webrtc/stats/media/mediaTrackHandler.ts new file mode 100644 index 000000000..32580b122 --- /dev/null +++ b/src/webrtc/stats/media/mediaTrackHandler.ts @@ -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; + } +} diff --git a/src/webrtc/stats/media/mediaTrackStats.ts b/src/webrtc/stats/media/mediaTrackStats.ts new file mode 100644 index 000000000..69ee9bdfa --- /dev/null +++ b/src/webrtc/stats/media/mediaTrackStats.ts @@ -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 }; + } +} diff --git a/src/webrtc/stats/media/mediaTrackStatsHandler.ts b/src/webrtc/stats/media/mediaTrackStatsHandler.ts new file mode 100644 index 000000000..6fb119c8a --- /dev/null +++ b/src/webrtc/stats/media/mediaTrackStatsHandler.ts @@ -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(); + + 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 { + return this.track2stats; + } +} diff --git a/src/webrtc/stats/statsReport.ts b/src/webrtc/stats/statsReport.ts new file mode 100644 index 000000000..56d6c4b2e --- /dev/null +++ b/src/webrtc/stats/statsReport.ts @@ -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 { + // 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; + remote: Map; +} + +export interface FramerateMap { + local: Map; + remote: Map; +} + +export interface CodecMap { + local: Map; + remote: Map; +} diff --git a/src/webrtc/stats/statsReportBuilder.ts b/src/webrtc/stats/statsReportBuilder.ts new file mode 100644 index 000000000..c1af471ce --- /dev/null +++ b/src/webrtc/stats/statsReportBuilder.ts @@ -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): 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(), + remote: new Map(), + }; + const framerates: FramerateMap = { local: new Map(), remote: new Map() }; + const codecs: CodecMap = { local: new Map(), remote: new Map() }; + + 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); + } +} diff --git a/src/webrtc/stats/statsReportEmitter.ts b/src/webrtc/stats/statsReportEmitter.ts new file mode 100644 index 000000000..cf014708e --- /dev/null +++ b/src/webrtc/stats/statsReportEmitter.ts @@ -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 { + public emitByteSendReport(byteSentStats: ByteSentStatsReport): void { + this.emit(StatsReport.BYTE_SENT_STATS, byteSentStats); + } + + public emitConnectionStatsReport(report: ConnectionStatsReport): void { + this.emit(StatsReport.CONNECTION_STATS, report); + } +} diff --git a/src/webrtc/stats/statsReportGatherer.ts b/src/webrtc/stats/statsReportGatherer.ts new file mode 100644 index 000000000..769ba6e44 --- /dev/null +++ b/src/webrtc/stats/statsReportGatherer.ts @@ -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(), remote: new Map() }; + + 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 { + 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(); + + 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"); + } + } + } +} diff --git a/src/webrtc/stats/statsValueFormatter.ts b/src/webrtc/stats/statsValueFormatter.ts new file mode 100644 index 000000000..c658fa665 --- /dev/null +++ b/src/webrtc/stats/statsValueFormatter.ts @@ -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); + } +} diff --git a/src/webrtc/stats/trackStatsReporter.ts b/src/webrtc/stats/trackStatsReporter.ts new file mode 100644 index 000000000..1f6fcd6d1 --- /dev/null +++ b/src/webrtc/stats/trackStatsReporter.ts @@ -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; + } +} diff --git a/src/webrtc/stats/transportStats.ts b/src/webrtc/stats/transportStats.ts new file mode 100644 index 000000000..2b6e97548 --- /dev/null +++ b/src/webrtc/stats/transportStats.ts @@ -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; +} diff --git a/src/webrtc/stats/transportStatsReporter.ts b/src/webrtc/stats/transportStatsReporter.ts new file mode 100644 index 000000000..d419a7397 --- /dev/null +++ b/src/webrtc/stats/transportStatsReporter.ts @@ -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; + } +}