@pexip/peer-connection-stats 17.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,478 @@
1
+ // src/statsCollector.types.ts
2
+ var Quality = /* @__PURE__ */ ((Quality2) => {
3
+ Quality2[Quality2["GOOD"] = 0] = "GOOD";
4
+ Quality2[Quality2["OK"] = 1] = "OK";
5
+ Quality2[Quality2["BAD"] = 2] = "BAD";
6
+ Quality2[Quality2["TERRIBLE"] = 3] = "TERRIBLE";
7
+ return Quality2;
8
+ })(Quality || {});
9
+
10
+ // src/statsCollector.ts
11
+ var STATS_SIZE = 60;
12
+ var createResolver = (statsReport) => {
13
+ const idLookup = statsReport.reduce(
14
+ (map, entry) => map.set(entry.id, entry),
15
+ /* @__PURE__ */ new Map()
16
+ );
17
+ const expandTail = (stats, seenIds = []) => Object.keys(stats).reduce((expanded, key) => {
18
+ const id = stats[key];
19
+ expanded[key] = id;
20
+ if (!id || typeof id !== "string" || seenIds.includes(id)) {
21
+ return expanded;
22
+ }
23
+ switch (key) {
24
+ case "codecId": {
25
+ const codec = idLookup.get(id);
26
+ if (codec) {
27
+ expanded.codec = expandTail(codec, [...seenIds, id]);
28
+ }
29
+ break;
30
+ }
31
+ case "remoteCandidateId": {
32
+ const remoteCandidate = idLookup.get(id);
33
+ if (remoteCandidate) {
34
+ expanded.remoteCandidate = expandTail(remoteCandidate, [
35
+ ...seenIds,
36
+ id
37
+ ]);
38
+ }
39
+ break;
40
+ }
41
+ case "remoteId": {
42
+ const remote = idLookup.get(id);
43
+ if (remote) {
44
+ expanded.remote = expandTail(remote, [...seenIds, id]);
45
+ }
46
+ break;
47
+ }
48
+ case "selectedCandidatePairId": {
49
+ const selectedCandidatePair = idLookup.get(id);
50
+ if (selectedCandidatePair) {
51
+ expanded.selectedCandidatePair = expandTail(
52
+ selectedCandidatePair,
53
+ [...seenIds, id]
54
+ );
55
+ }
56
+ break;
57
+ }
58
+ case "trackId": {
59
+ const track = idLookup.get(id);
60
+ if (track) {
61
+ expanded.track = expandTail(track, [...seenIds, id]);
62
+ }
63
+ break;
64
+ }
65
+ case "trackIds": {
66
+ if (Array.isArray(id)) {
67
+ const ids = id;
68
+ const tracks = ids.flatMap((id2) => {
69
+ const track = idLookup.get(id2);
70
+ return !track ? [] : [expandTail(track, [...seenIds, ...ids])];
71
+ });
72
+ expanded.tracks = tracks;
73
+ }
74
+ break;
75
+ }
76
+ case "transportId": {
77
+ const transport = idLookup.get(id);
78
+ if (transport) {
79
+ expanded.transport = expandTail(transport, [
80
+ ...seenIds,
81
+ id
82
+ ]);
83
+ }
84
+ break;
85
+ }
86
+ case "localCandidateId":
87
+ case "localCertificateId":
88
+ case "localId":
89
+ case "mediaSourceId":
90
+ case "remoteCertificateId":
91
+ default:
92
+ break;
93
+ }
94
+ return expanded;
95
+ }, {});
96
+ function expand(rawStats) {
97
+ return expandTail(rawStats);
98
+ }
99
+ return {
100
+ expand
101
+ };
102
+ };
103
+ var inboundAudio = (statsData) => {
104
+ const packetsReceived = statsData.packetsReceived ?? 0;
105
+ const packetsLost = statsData.packetsLost ?? 0;
106
+ const totalPackets = packetsReceived + packetsLost;
107
+ return {
108
+ type: statsData.type,
109
+ kind: statsData.kind,
110
+ jitter: statsData.jitter ?? 0,
111
+ timestamp: statsData.timestamp,
112
+ packetsTransmitted: packetsReceived,
113
+ packetsLost,
114
+ bytesTransmitted: statsData.bytesReceived,
115
+ codec: statsData.codec?.mimeType,
116
+ roundTripTime: statsData.transport?.selectedCandidatePair?.currentRoundTripTime,
117
+ totalPercentageLost: totalPackets && packetsLost / totalPackets
118
+ };
119
+ };
120
+ var outboundAudio = (statsData) => {
121
+ const packetsSent = statsData.packetsSent ?? 0;
122
+ const packetsLost = statsData.remote?.packetsLost ?? 0;
123
+ const totalPackets = packetsSent + packetsLost;
124
+ const roundTripTime = statsData.remote?.roundTripTime ?? statsData.transport?.selectedCandidatePair?.currentRoundTripTime;
125
+ return {
126
+ type: statsData.type,
127
+ kind: statsData.kind,
128
+ jitter: statsData?.remote?.jitter ?? 0,
129
+ timestamp: statsData.timestamp,
130
+ packetsTransmitted: packetsSent,
131
+ packetsLost,
132
+ bytesTransmitted: statsData.bytesSent,
133
+ codec: statsData.codec?.mimeType,
134
+ roundTripTime,
135
+ totalPercentageLost: totalPackets && packetsLost / totalPackets
136
+ };
137
+ };
138
+ var inboundVideo = (statsData) => {
139
+ const packetsReceived = statsData.packetsReceived ?? 0;
140
+ const packetsLost = statsData.packetsLost ?? 0;
141
+ const totalPackets = packetsReceived + packetsLost;
142
+ return {
143
+ type: statsData.type,
144
+ kind: statsData.kind,
145
+ timestamp: statsData.timestamp,
146
+ packetsTransmitted: packetsReceived,
147
+ packetsLost,
148
+ bytesTransmitted: statsData.bytesReceived,
149
+ // firefox typically uses bitrateMean while chrome does not
150
+ bitrate: statsData.bitrateMean,
151
+ codec: statsData.codec?.mimeType,
152
+ resolutionWidth: statsData.frameWidth,
153
+ resolutionHeight: statsData.frameHeight,
154
+ resolution: statsData.frameWidth && statsData.frameHeight ? `${statsData.frameWidth}x${statsData.frameHeight}` : void 0,
155
+ // firefox typically uses framerateMean while chrome does not
156
+ framesPerSecond: statsData.framerateMean ?? statsData.framesPerSecond,
157
+ roundTripTime: statsData.transport?.selectedCandidatePair?.currentRoundTripTime,
158
+ totalPercentageLost: totalPackets && packetsLost / totalPackets
159
+ };
160
+ };
161
+ var outboundVideo = (statsData) => {
162
+ const totalPacketSendDelay = statsData?.totalPacketSendDelay ?? 0;
163
+ const packetsSent = statsData.packetsSent ?? 0;
164
+ const packetsLost = statsData.remote?.packetsLost ?? 0;
165
+ const totalPackets = packetsSent + packetsLost;
166
+ const roundTripTime = statsData.remote?.roundTripTime ?? statsData.transport?.selectedCandidatePair?.currentRoundTripTime;
167
+ return {
168
+ type: statsData.type,
169
+ kind: statsData.kind,
170
+ timestamp: statsData.timestamp,
171
+ packetsTransmitted: packetsSent,
172
+ packetsLost,
173
+ bytesTransmitted: statsData.bytesSent,
174
+ totalPacketSendDelay: statsData.totalPacketSendDelay,
175
+ averagePacketSendDelay: totalPacketSendDelay && totalPacketSendDelay / packetsSent,
176
+ // firefox typically uses bitrateMean while chrome does not
177
+ bitrate: statsData.bitrateMean,
178
+ codec: statsData.codec?.mimeType,
179
+ resolutionWidth: statsData?.frameWidth,
180
+ resolutionHeight: statsData?.frameHeight,
181
+ resolution: statsData.frameWidth && statsData.frameHeight ? `${statsData.frameWidth}x${statsData.frameHeight}` : void 0,
182
+ // firefox typically uses framerateMean while chrome does not
183
+ framesPerSecond: statsData.framerateMean ?? statsData.framesPerSecond,
184
+ roundTripTime,
185
+ totalPercentageLost: totalPackets && packetsLost / totalPackets
186
+ };
187
+ };
188
+ var isInbound = (entry) => entry.type === "inbound-rtp";
189
+ var isOutbound = (entry) => entry.type === "outbound-rtp";
190
+ var isAudio = (entry) => entry.kind === "audio";
191
+ var isVideo = (entry) => entry.kind === "video";
192
+ var isAudioInbound = (entry) => isInbound(entry) && isAudio(entry);
193
+ var isAudioOutbound = (entry) => isOutbound(entry) && isAudio(entry);
194
+ var isVideoInbound = (entry) => isInbound(entry) && isVideo(entry);
195
+ var isVideoOutbound = (entry) => isOutbound(entry) && isVideo(entry);
196
+ var statsFrom = (statsReports) => {
197
+ const resolver = createResolver(statsReports);
198
+ const audioIn = statsReports.find(isAudioInbound);
199
+ if (audioIn) {
200
+ return inboundAudio(resolver.expand(audioIn));
201
+ }
202
+ const audioOut = statsReports.find(isAudioOutbound);
203
+ if (audioOut) {
204
+ return outboundAudio(resolver.expand(audioOut));
205
+ }
206
+ const videoIn = statsReports.find(isVideoInbound);
207
+ if (videoIn) {
208
+ return inboundVideo(resolver.expand(videoIn));
209
+ }
210
+ const videoOut = statsReports.find(isVideoOutbound);
211
+ if (videoOut) {
212
+ return outboundVideo(resolver.expand(videoOut));
213
+ }
214
+ };
215
+ var statsFromRTCPeer = async (rtcPeer) => {
216
+ const stats = await rtcPeer?.getStats(null);
217
+ const reports = [];
218
+ stats.forEach((report) => reports.push(report));
219
+ return statsFrom(reports) ?? {
220
+ type: "inbound-rtp",
221
+ kind: "audio",
222
+ packetsLost: 0,
223
+ packetsTransmitted: 0
224
+ };
225
+ };
226
+ var getRecentPacketsLost = (oldMetrics, newMetrics) => Math.max(newMetrics.packetsLost - oldMetrics.packetsLost, 0);
227
+ var getRecentTotalPackets = (oldMetrics, newMetrics) => {
228
+ const totalPacketsDelta = newMetrics.packetsTransmitted + newMetrics.packetsLost - (oldMetrics.packetsTransmitted + oldMetrics.packetsLost);
229
+ return Math.max(totalPacketsDelta, 1);
230
+ };
231
+ var getPacketStats = (oldMetrics, newMetrics) => [getRecentPacketsLost, getRecentTotalPackets].map(
232
+ (fn) => fn(oldMetrics, newMetrics)
233
+ );
234
+ var removeObsolete = (metric, qualityHistorySize = STATS_SIZE) => {
235
+ if (metric.length === qualityHistorySize) {
236
+ metric.pop();
237
+ }
238
+ };
239
+ var addPacketsStats = (metric, data) => {
240
+ removeObsolete(metric);
241
+ metric.unshift(data);
242
+ };
243
+ var recordCallPacketsStats = (oldStats, newStats, callPacketsStats) => {
244
+ if (oldStats && newStats) {
245
+ if (!callPacketsStats) {
246
+ callPacketsStats = [];
247
+ }
248
+ addPacketsStats(callPacketsStats, getPacketStats(oldStats, newStats));
249
+ }
250
+ return callPacketsStats;
251
+ };
252
+ var addAudioQualityMetric = (metric, data) => {
253
+ removeObsolete(metric);
254
+ metric.unshift(data);
255
+ };
256
+ var addVideoQualityMetric = (metric, data) => {
257
+ removeObsolete(metric);
258
+ metric.unshift(data);
259
+ };
260
+ var recordCallQualityStats = (prevStats, stats, callQualityStats) => {
261
+ const calcRecentPacketLoss = ({ pT = 0, prevPT = 0, pL = 0, prevPL = 0 }) => {
262
+ if (pT <= 0) {
263
+ return 0;
264
+ }
265
+ if (pT - prevPT <= 0) {
266
+ return pL / pT;
267
+ }
268
+ return (pL - prevPL) / (pT - prevPT);
269
+ };
270
+ if (stats) {
271
+ if (!callQualityStats) {
272
+ callQualityStats = [];
273
+ }
274
+ if (stats.kind === "audio") {
275
+ addAudioQualityMetric(
276
+ callQualityStats || [],
277
+ [
278
+ calcRecentPacketLoss({
279
+ pT: stats.packetsTransmitted,
280
+ prevPT: prevStats?.packetsTransmitted,
281
+ pL: stats.packetsLost,
282
+ prevPL: prevStats?.packetsLost
283
+ }),
284
+ stats.jitter ?? 0
285
+ ]
286
+ );
287
+ }
288
+ if (stats.kind === "video") {
289
+ addVideoQualityMetric(
290
+ callQualityStats || [],
291
+ calcRecentPacketLoss({
292
+ pT: stats.packetsTransmitted,
293
+ prevPT: prevStats?.packetsTransmitted,
294
+ pL: stats.packetsLost,
295
+ prevPL: prevStats?.packetsLost
296
+ })
297
+ );
298
+ }
299
+ }
300
+ return callQualityStats;
301
+ };
302
+ var getQuality = (stats) => {
303
+ const qualityOverTime = stats.map((stats2) => calculateQuality(stats2));
304
+ const goodOrOk = qualityOverTime.filter(
305
+ (stat) => stat === 0 /* GOOD */ || stat === 1 /* OK */
306
+ );
307
+ const qualitySum = qualityOverTime.reduce((acc, val) => acc + val, 0);
308
+ return {
309
+ quality: Math.round(qualitySum / qualityOverTime.length),
310
+ goodOrOkQuality: goodOrOk.length / qualityOverTime.length,
311
+ qualityOverTime
312
+ };
313
+ };
314
+ var addDeltaStats = (newStats, cache) => {
315
+ const oldStats = cache.previous ?? Object.keys(newStats).reduce((stats, key) => {
316
+ const statKey = key;
317
+ if (typeof newStats[statKey] === "number" && statKey !== "timestamp") {
318
+ return { ...stats, [statKey]: 0 };
319
+ }
320
+ return { ...stats, [statKey]: newStats[statKey] };
321
+ }, {});
322
+ const deltaStats = {
323
+ ...newStats
324
+ };
325
+ const callPacketsStats = recordCallPacketsStats(
326
+ oldStats,
327
+ newStats,
328
+ cache.callPacketsStats
329
+ );
330
+ const metrics = [
331
+ [newStats, oldStats, deltaStats, callPacketsStats ?? []]
332
+ ];
333
+ metrics.forEach(([newMetrics, oldMetrics, deltaMetrics, packets]) => {
334
+ if (newMetrics?.bytesTransmitted && newMetrics?.timestamp && oldMetrics?.bytesTransmitted && oldMetrics?.timestamp && deltaMetrics) {
335
+ const dMs = newMetrics.timestamp - oldMetrics.timestamp;
336
+ const dBytes = newMetrics.bytesTransmitted - oldMetrics.bytesTransmitted;
337
+ if (dMs !== 0) {
338
+ deltaMetrics.bitrate = Math.round(dBytes * 8 / (dMs / 1e3));
339
+ }
340
+ }
341
+ if (deltaMetrics) {
342
+ const [recentPacketsLost, recentTotalPackets] = packets.reduce(
343
+ (acc, [lost, total]) => {
344
+ acc[0] += lost;
345
+ acc[1] += total;
346
+ return acc;
347
+ },
348
+ [0, 0]
349
+ );
350
+ deltaMetrics.recentPercentageLost = recentTotalPackets === 0 ? 0 : recentPacketsLost / recentTotalPackets;
351
+ }
352
+ });
353
+ const callQualityStats = recordCallQualityStats(
354
+ oldStats,
355
+ deltaStats,
356
+ cache.callQualityStats
357
+ );
358
+ return [
359
+ deltaStats,
360
+ getQuality(callQualityStats ?? []).quality,
361
+ callQualityStats
362
+ ];
363
+ };
364
+ var calculateQuality = (stats) => {
365
+ let packetLoss;
366
+ let jitter;
367
+ if (typeof stats === "number") {
368
+ packetLoss == stats;
369
+ } else {
370
+ [packetLoss, jitter] = stats;
371
+ }
372
+ let callQuality = 1 /* OK */;
373
+ if (typeof packetLoss === "number") {
374
+ if (packetLoss < 0.01) {
375
+ callQuality = 0 /* GOOD */;
376
+ } else if (packetLoss < 0.03) {
377
+ callQuality = 1 /* OK */;
378
+ } else if (packetLoss < 0.1) {
379
+ callQuality = 2 /* BAD */;
380
+ } else {
381
+ callQuality = 3 /* TERRIBLE */;
382
+ }
383
+ }
384
+ if (jitter && jitter > 0.04) {
385
+ if (callQuality === 0 /* GOOD */) {
386
+ callQuality = 1 /* OK */;
387
+ } else if (callQuality === 1 /* OK */) {
388
+ callQuality = 2 /* BAD */;
389
+ } else if (callQuality === 2 /* BAD */) {
390
+ callQuality = 3 /* TERRIBLE */;
391
+ }
392
+ }
393
+ return callQuality;
394
+ };
395
+ var createStatsCollector = ({
396
+ input,
397
+ signals: { onCallQuality, onCallQualityStats, onRtcStats },
398
+ interval = 1e3
399
+ }) => {
400
+ const newCache = () => ({
401
+ callQuality: 0 /* GOOD */,
402
+ callPacketsStats: [],
403
+ callQualityStats: []
404
+ });
405
+ let cache = newCache();
406
+ const pushStats = () => {
407
+ void statsFromRTCPeer(input).then((newStats) => {
408
+ const [stats, callQuality, callQualityStats] = addDeltaStats(
409
+ newStats,
410
+ cache
411
+ );
412
+ if (callQuality != cache.callQuality) {
413
+ onCallQuality.emit(callQuality);
414
+ cache.callQuality = callQuality;
415
+ }
416
+ cache.previous = newStats;
417
+ onRtcStats.emit(stats);
418
+ onCallQualityStats.emit(callQualityStats);
419
+ });
420
+ };
421
+ let it = window.setInterval(pushStats, interval);
422
+ const clearInterval = () => {
423
+ window.clearInterval(it);
424
+ it = 0;
425
+ };
426
+ const resumeStats = () => {
427
+ if (it === 0) {
428
+ cache = newCache();
429
+ pushStats();
430
+ it = window.setInterval(pushStats, interval);
431
+ }
432
+ };
433
+ return {
434
+ resetStats: () => {
435
+ clearInterval();
436
+ return resumeStats;
437
+ },
438
+ cleanup: clearInterval
439
+ };
440
+ };
441
+
442
+ // src/utils.ts
443
+ import { createSignal } from "@pexip/signal";
444
+ var SIGNAL_PREFIX = "call:peerConnection:stats";
445
+ var REQUIRED_STATS_SIGNAL_KEYS = [
446
+ "onRtcStats",
447
+ "onCallQualityStats",
448
+ "onCallQuality"
449
+ ];
450
+ var createStatsSignals = (scope = "") => {
451
+ const signalScope = scope && [scope, ":"].join("");
452
+ return REQUIRED_STATS_SIGNAL_KEYS.reduce(
453
+ (signals, key) => ({
454
+ ...signals,
455
+ [key]: createSignal({
456
+ name: `${SIGNAL_PREFIX}:${signalScope}:${key}`,
457
+ allowEmittingWithoutObserver: true
458
+ })
459
+ }),
460
+ {}
461
+ );
462
+ };
463
+ export {
464
+ Quality,
465
+ STATS_SIZE,
466
+ addDeltaStats,
467
+ calculateQuality,
468
+ createResolver,
469
+ createStatsCollector,
470
+ createStatsSignals,
471
+ getQuality,
472
+ inboundAudio,
473
+ inboundVideo,
474
+ outboundAudio,
475
+ outboundVideo,
476
+ statsFrom,
477
+ statsFromRTCPeer
478
+ };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@pexip/peer-connection-stats",
3
+ "version": "17.2.0",
4
+ "description": "Normalizer of RTCStats and related",
5
+ "homepage": "https://gitlab.com/pexip/zoo",
6
+ "bugs": "https://gitlab.com/pexip/zoo/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://gitlab.com/pexip/zoo.git",
10
+ "directory": "src/aquila/packages/peer-connection-stats"
11
+ },
12
+ "license": "Apache-2.0",
13
+ "module": "dist/index.mjs",
14
+ "types": "dist/index.d.ts",
15
+ "files": [
16
+ "src",
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build": "pexip-bundler build",
21
+ "clean": "rm -fr dist",
22
+ "dev": "pexip-bundler dev",
23
+ "prepack": "yarn build",
24
+ "test": "pexip-bundler test",
25
+ "tsc": "tsc --noEmit",
26
+ "check-format": "prettier --ignore-path=../../.prettierignore --check .",
27
+ "format": "prettier --ignore-path=../../.prettierignore --write .",
28
+ "lint": "yarn run -T eslint --ext .js,.ts,.tsx --report-unused-disable-directives .",
29
+ "typecheck": "yarn tsc --noEmit -p ."
30
+ },
31
+ "dependencies": {
32
+ "@pexip/signal": "16.5.0",
33
+ "@pexip/utils": "16.5.0"
34
+ },
35
+ "devDependencies": {
36
+ "@pexip/bundler": "16.3.0",
37
+ "typescript": "~5.0.0"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "registry": "https://registry.npmjs.org"
42
+ }
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './statsCollector.types';
2
+ export * from './statsCollector';
3
+ export * from './utils';