@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/CHANGELOG.md +253 -0
- package/LICENSE +188 -0
- package/README.md +9 -0
- package/dist/index.d.ts +325 -0
- package/dist/index.mjs +478 -0
- package/package.json +43 -0
- package/src/index.ts +3 -0
- package/src/statsCollector.ts +665 -0
- package/src/statsCollector.types.ts +233 -0
- package/src/tests/__snapshots__/statsCollector.test.ts.snap +208 -0
- package/src/tests/statsCollector.chrome-inbound-presentation.fixtures.ts +990 -0
- package/src/tests/statsCollector.chrome-multiple.fixtures.ts +2692 -0
- package/src/tests/statsCollector.chrome-outbound-presentation.fixtures.ts +925 -0
- package/src/tests/statsCollector.firefox.fixtures.ts +473 -0
- package/src/tests/statsCollector.test.ts +480 -0
- package/src/utils.ts +41 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnyStats,
|
|
3
|
+
AudioQualityStats,
|
|
4
|
+
CacheStats,
|
|
5
|
+
CallPacketsStats,
|
|
6
|
+
CallQualityStats,
|
|
7
|
+
InboundAudio,
|
|
8
|
+
InboundAudioMetrics,
|
|
9
|
+
InboundVideo,
|
|
10
|
+
InboundVideoMetrics,
|
|
11
|
+
Metrics,
|
|
12
|
+
NormalizedRTCStats,
|
|
13
|
+
OutboundAudio,
|
|
14
|
+
OutboundAudioMetrics,
|
|
15
|
+
OutboundVideo,
|
|
16
|
+
OutboundVideoMetrics,
|
|
17
|
+
PacketsStats,
|
|
18
|
+
RTCStats,
|
|
19
|
+
StatsCollector,
|
|
20
|
+
StatsCollectorOptions,
|
|
21
|
+
VideoQualityStats,
|
|
22
|
+
} from './statsCollector.types';
|
|
23
|
+
import {Quality} from './statsCollector.types';
|
|
24
|
+
|
|
25
|
+
export const STATS_SIZE = 60;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The WebRTC stats are given to us as a flat structure (an array of objects
|
|
29
|
+
* that contain id-fields pointing to other objects in the same array).
|
|
30
|
+
*
|
|
31
|
+
* This class takes such an array as input and can expand an entry so that
|
|
32
|
+
* references become nested objects.
|
|
33
|
+
*
|
|
34
|
+
* https://www.w3.org/TR/webrtc-stats/#summary
|
|
35
|
+
* https://www.w3.org/TR/webrtc-stats/#rtctatstype-*
|
|
36
|
+
*/
|
|
37
|
+
export const createResolver = (statsReport: AnyStats[]) => {
|
|
38
|
+
const idLookup = statsReport.reduce(
|
|
39
|
+
(map, entry) => map.set(entry.id, entry),
|
|
40
|
+
new Map<string, AnyStats>(),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const expandTail = (stats: AnyStats, seenIds: string[] = []) =>
|
|
44
|
+
Object.keys(stats).reduce((expanded, key) => {
|
|
45
|
+
const id = stats[key];
|
|
46
|
+
expanded[key] = id;
|
|
47
|
+
|
|
48
|
+
if (!id || typeof id !== 'string' || seenIds.includes(id)) {
|
|
49
|
+
return expanded;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
switch (key) {
|
|
53
|
+
case 'codecId': {
|
|
54
|
+
const codec = idLookup.get(id);
|
|
55
|
+
if (codec) {
|
|
56
|
+
expanded.codec = expandTail(codec, [...seenIds, id]);
|
|
57
|
+
}
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
case 'remoteCandidateId': {
|
|
61
|
+
const remoteCandidate = idLookup.get(id);
|
|
62
|
+
if (remoteCandidate) {
|
|
63
|
+
expanded.remoteCandidate = expandTail(remoteCandidate, [
|
|
64
|
+
...seenIds,
|
|
65
|
+
id,
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case 'remoteId': {
|
|
71
|
+
const remote = idLookup.get(id);
|
|
72
|
+
if (remote) {
|
|
73
|
+
expanded.remote = expandTail(remote, [...seenIds, id]);
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
case 'selectedCandidatePairId': {
|
|
78
|
+
const selectedCandidatePair = idLookup.get(id);
|
|
79
|
+
if (selectedCandidatePair) {
|
|
80
|
+
expanded.selectedCandidatePair = expandTail(
|
|
81
|
+
selectedCandidatePair,
|
|
82
|
+
[...seenIds, id],
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case 'trackId': {
|
|
88
|
+
const track = idLookup.get(id);
|
|
89
|
+
if (track) {
|
|
90
|
+
expanded.track = expandTail(track, [...seenIds, id]);
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case 'trackIds': {
|
|
95
|
+
if (Array.isArray(id)) {
|
|
96
|
+
const ids: string[] = id;
|
|
97
|
+
const tracks = ids.flatMap(id => {
|
|
98
|
+
const track = idLookup.get(id);
|
|
99
|
+
return !track
|
|
100
|
+
? []
|
|
101
|
+
: [expandTail(track, [...seenIds, ...ids])];
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expanded.tracks = tracks;
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case 'transportId': {
|
|
109
|
+
const transport = idLookup.get(id);
|
|
110
|
+
if (transport) {
|
|
111
|
+
expanded.transport = expandTail(transport, [
|
|
112
|
+
...seenIds,
|
|
113
|
+
id,
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case 'localCandidateId':
|
|
119
|
+
case 'localCertificateId':
|
|
120
|
+
case 'localId':
|
|
121
|
+
case 'mediaSourceId':
|
|
122
|
+
case 'remoteCertificateId':
|
|
123
|
+
default:
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return expanded;
|
|
128
|
+
}, {} as AnyStats);
|
|
129
|
+
|
|
130
|
+
function expand(audioIn: InboundAudio): InboundAudio;
|
|
131
|
+
function expand(audioOut: OutboundAudio): OutboundAudio;
|
|
132
|
+
function expand(videoIn: InboundVideo): InboundVideo;
|
|
133
|
+
function expand(videoOut: OutboundVideo): OutboundVideo;
|
|
134
|
+
function expand(rawStats: AnyStats): AnyStats {
|
|
135
|
+
return expandTail(rawStats);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
expand,
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Normalize inbound audio stats
|
|
145
|
+
*
|
|
146
|
+
* @param statsData - Raw inbound audio stats @see InboundAudio
|
|
147
|
+
*
|
|
148
|
+
* @returns normalized stats for inbound audio from PeerConnection
|
|
149
|
+
*/
|
|
150
|
+
export const inboundAudio = (statsData: InboundAudio): InboundAudioMetrics => {
|
|
151
|
+
const packetsReceived = statsData.packetsReceived ?? 0;
|
|
152
|
+
const packetsLost = statsData.packetsLost ?? 0;
|
|
153
|
+
const totalPackets = packetsReceived + packetsLost;
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
type: statsData.type,
|
|
157
|
+
kind: statsData.kind,
|
|
158
|
+
jitter: statsData.jitter ?? 0,
|
|
159
|
+
timestamp: statsData.timestamp,
|
|
160
|
+
packetsTransmitted: packetsReceived,
|
|
161
|
+
packetsLost,
|
|
162
|
+
bytesTransmitted: statsData.bytesReceived,
|
|
163
|
+
codec: statsData.codec?.mimeType,
|
|
164
|
+
roundTripTime:
|
|
165
|
+
statsData.transport?.selectedCandidatePair?.currentRoundTripTime,
|
|
166
|
+
totalPercentageLost: totalPackets && packetsLost / totalPackets,
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Normalize outbound audio stats
|
|
172
|
+
*
|
|
173
|
+
* @param statsData - Raw outbound audio stats @see OutboundAudio
|
|
174
|
+
*
|
|
175
|
+
* @returns normalized stats for outbound audio from PeerConnection
|
|
176
|
+
*/
|
|
177
|
+
export const outboundAudio = (
|
|
178
|
+
statsData: OutboundAudio,
|
|
179
|
+
): OutboundAudioMetrics => {
|
|
180
|
+
const packetsSent = statsData.packetsSent ?? 0;
|
|
181
|
+
const packetsLost = statsData.remote?.packetsLost ?? 0;
|
|
182
|
+
const totalPackets = packetsSent + packetsLost;
|
|
183
|
+
|
|
184
|
+
// firefox typically uses remote.roundTripTime and chrome uses currentRoundTripTime
|
|
185
|
+
const roundTripTime =
|
|
186
|
+
statsData.remote?.roundTripTime ??
|
|
187
|
+
statsData.transport?.selectedCandidatePair?.currentRoundTripTime;
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
type: statsData.type,
|
|
191
|
+
kind: statsData.kind,
|
|
192
|
+
jitter: statsData?.remote?.jitter ?? 0,
|
|
193
|
+
timestamp: statsData.timestamp,
|
|
194
|
+
packetsTransmitted: packetsSent,
|
|
195
|
+
packetsLost,
|
|
196
|
+
bytesTransmitted: statsData.bytesSent,
|
|
197
|
+
codec: statsData.codec?.mimeType,
|
|
198
|
+
roundTripTime,
|
|
199
|
+
totalPercentageLost: totalPackets && packetsLost / totalPackets,
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Normalize inbound video stats
|
|
205
|
+
*
|
|
206
|
+
* @param statsData - Raw inbound video stats @see InboundVideo
|
|
207
|
+
*
|
|
208
|
+
* @returns normalized stats for inbound video from PeerConnection
|
|
209
|
+
*/
|
|
210
|
+
export const inboundVideo = (statsData: InboundVideo): InboundVideoMetrics => {
|
|
211
|
+
const packetsReceived = statsData.packetsReceived ?? 0;
|
|
212
|
+
const packetsLost = statsData.packetsLost ?? 0;
|
|
213
|
+
const totalPackets = packetsReceived + packetsLost;
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
type: statsData.type,
|
|
217
|
+
kind: statsData.kind,
|
|
218
|
+
timestamp: statsData.timestamp,
|
|
219
|
+
packetsTransmitted: packetsReceived,
|
|
220
|
+
packetsLost,
|
|
221
|
+
bytesTransmitted: statsData.bytesReceived,
|
|
222
|
+
// firefox typically uses bitrateMean while chrome does not
|
|
223
|
+
bitrate: statsData.bitrateMean,
|
|
224
|
+
codec: statsData.codec?.mimeType,
|
|
225
|
+
resolutionWidth: statsData.frameWidth,
|
|
226
|
+
resolutionHeight: statsData.frameHeight,
|
|
227
|
+
resolution:
|
|
228
|
+
statsData.frameWidth && statsData.frameHeight
|
|
229
|
+
? `${statsData.frameWidth}x${statsData.frameHeight}`
|
|
230
|
+
: undefined,
|
|
231
|
+
// firefox typically uses framerateMean while chrome does not
|
|
232
|
+
framesPerSecond: statsData.framerateMean ?? statsData.framesPerSecond,
|
|
233
|
+
roundTripTime:
|
|
234
|
+
statsData.transport?.selectedCandidatePair?.currentRoundTripTime,
|
|
235
|
+
totalPercentageLost: totalPackets && packetsLost / totalPackets,
|
|
236
|
+
};
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Normalize outbound video stats
|
|
241
|
+
*
|
|
242
|
+
* @param statsData - Raw outbound video stats @see OutboundVideo
|
|
243
|
+
*
|
|
244
|
+
* @returns normalized stats for outbound video from PeerConnection
|
|
245
|
+
*/
|
|
246
|
+
export const outboundVideo = (
|
|
247
|
+
statsData: OutboundVideo,
|
|
248
|
+
): OutboundVideoMetrics => {
|
|
249
|
+
const totalPacketSendDelay = statsData?.totalPacketSendDelay ?? 0;
|
|
250
|
+
const packetsSent = statsData.packetsSent ?? 0;
|
|
251
|
+
const packetsLost = statsData.remote?.packetsLost ?? 0;
|
|
252
|
+
const totalPackets = packetsSent + packetsLost;
|
|
253
|
+
|
|
254
|
+
// firefox typically uses remote.roundTripTime and chrome uses currentRoundTripTime
|
|
255
|
+
const roundTripTime =
|
|
256
|
+
statsData.remote?.roundTripTime ??
|
|
257
|
+
statsData.transport?.selectedCandidatePair?.currentRoundTripTime;
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
type: statsData.type,
|
|
261
|
+
kind: statsData.kind,
|
|
262
|
+
timestamp: statsData.timestamp,
|
|
263
|
+
packetsTransmitted: packetsSent,
|
|
264
|
+
packetsLost,
|
|
265
|
+
bytesTransmitted: statsData.bytesSent,
|
|
266
|
+
totalPacketSendDelay: statsData.totalPacketSendDelay,
|
|
267
|
+
averagePacketSendDelay:
|
|
268
|
+
totalPacketSendDelay && totalPacketSendDelay / packetsSent,
|
|
269
|
+
// firefox typically uses bitrateMean while chrome does not
|
|
270
|
+
bitrate: statsData.bitrateMean,
|
|
271
|
+
codec: statsData.codec?.mimeType,
|
|
272
|
+
resolutionWidth: statsData?.frameWidth,
|
|
273
|
+
resolutionHeight: statsData?.frameHeight,
|
|
274
|
+
resolution:
|
|
275
|
+
statsData.frameWidth && statsData.frameHeight
|
|
276
|
+
? `${statsData.frameWidth}x${statsData.frameHeight}`
|
|
277
|
+
: undefined,
|
|
278
|
+
// firefox typically uses framerateMean while chrome does not
|
|
279
|
+
framesPerSecond: statsData.framerateMean ?? statsData.framesPerSecond,
|
|
280
|
+
roundTripTime,
|
|
281
|
+
totalPercentageLost: totalPackets && packetsLost / totalPackets,
|
|
282
|
+
};
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const isInbound = (entry: AnyStats) => entry.type === 'inbound-rtp';
|
|
286
|
+
const isOutbound = (entry: AnyStats) => entry.type === 'outbound-rtp';
|
|
287
|
+
const isAudio = (entry: AnyStats) => entry.kind === 'audio';
|
|
288
|
+
const isVideo = (entry: AnyStats) => entry.kind === 'video';
|
|
289
|
+
|
|
290
|
+
const isAudioInbound = (entry: AnyStats): entry is InboundAudio =>
|
|
291
|
+
isInbound(entry) && isAudio(entry);
|
|
292
|
+
const isAudioOutbound = (entry: AnyStats): entry is OutboundAudio =>
|
|
293
|
+
isOutbound(entry) && isAudio(entry);
|
|
294
|
+
const isVideoInbound = (entry: AnyStats): entry is InboundVideo =>
|
|
295
|
+
isInbound(entry) && isVideo(entry);
|
|
296
|
+
const isVideoOutbound = (entry: AnyStats): entry is OutboundVideo =>
|
|
297
|
+
isOutbound(entry) && isVideo(entry);
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Normalize stats into inbound and outbound video and audio stats
|
|
301
|
+
*
|
|
302
|
+
* @param statsReports - reports to for mapping
|
|
303
|
+
*
|
|
304
|
+
* @returns normalized stats for inbound, outbound audio and video
|
|
305
|
+
*/
|
|
306
|
+
export const statsFrom = (statsReports: AnyStats[]) => {
|
|
307
|
+
const resolver = createResolver(statsReports);
|
|
308
|
+
|
|
309
|
+
// TODO: support them combined if we pass peer-connection obj instead of transceiver
|
|
310
|
+
// as now we return first found
|
|
311
|
+
const audioIn = statsReports.find(isAudioInbound);
|
|
312
|
+
if (audioIn) {
|
|
313
|
+
return inboundAudio(resolver.expand(audioIn));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const audioOut = statsReports.find(isAudioOutbound);
|
|
317
|
+
if (audioOut) {
|
|
318
|
+
return outboundAudio(resolver.expand(audioOut));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const videoIn = statsReports.find(isVideoInbound);
|
|
322
|
+
if (videoIn) {
|
|
323
|
+
return inboundVideo(resolver.expand(videoIn));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const videoOut = statsReports.find(isVideoOutbound);
|
|
327
|
+
if (videoOut) {
|
|
328
|
+
return outboundVideo(resolver.expand(videoOut));
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Gets raw stats from peerConnection and normalized them
|
|
334
|
+
*
|
|
335
|
+
* https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_Statistics_API
|
|
336
|
+
*
|
|
337
|
+
* A RTCPeerConnection has getStats()
|
|
338
|
+
* - getStats() returns promise which resolve to a RTCStatsReport
|
|
339
|
+
* - RTCStatsReport behaves like an array of RTCStats objects, or more
|
|
340
|
+
* specific RTCRtpStreamStats objects
|
|
341
|
+
*
|
|
342
|
+
* https://developer.mozilla.org/en-US/docs/Web/API/RTCStatsReport
|
|
343
|
+
* https://developer.mozilla.org/en-US/docs/Web/API/RTCStats
|
|
344
|
+
* https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpStreamStats
|
|
345
|
+
*
|
|
346
|
+
*/
|
|
347
|
+
|
|
348
|
+
export const statsFromRTCPeer = async (rtcPeer: {
|
|
349
|
+
getStats: (selector?: MediaStreamTrack | null) => Promise<RTCStatsReport>;
|
|
350
|
+
}): Promise<NormalizedRTCStats> => {
|
|
351
|
+
const stats = await rtcPeer?.getStats(null);
|
|
352
|
+
const reports: RTCStats[] = [];
|
|
353
|
+
stats.forEach(report => reports.push(report));
|
|
354
|
+
return (
|
|
355
|
+
statsFrom(reports) ?? {
|
|
356
|
+
type: 'inbound-rtp',
|
|
357
|
+
kind: 'audio',
|
|
358
|
+
packetsLost: 0,
|
|
359
|
+
packetsTransmitted: 0,
|
|
360
|
+
}
|
|
361
|
+
);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const getRecentPacketsLost = (oldMetrics: Metrics, newMetrics: Metrics) =>
|
|
365
|
+
Math.max(newMetrics.packetsLost - oldMetrics.packetsLost, 0);
|
|
366
|
+
|
|
367
|
+
const getRecentTotalPackets = (oldMetrics: Metrics, newMetrics: Metrics) => {
|
|
368
|
+
const totalPacketsDelta =
|
|
369
|
+
newMetrics.packetsTransmitted +
|
|
370
|
+
newMetrics.packetsLost -
|
|
371
|
+
(oldMetrics.packetsTransmitted + oldMetrics.packetsLost);
|
|
372
|
+
return Math.max(totalPacketsDelta, 1);
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const getPacketStats = (oldMetrics: Metrics, newMetrics: Metrics) =>
|
|
376
|
+
[getRecentPacketsLost, getRecentTotalPackets].map(fn =>
|
|
377
|
+
fn(oldMetrics, newMetrics),
|
|
378
|
+
) as [number, number];
|
|
379
|
+
|
|
380
|
+
const removeObsolete = (metric: unknown[], qualityHistorySize = STATS_SIZE) => {
|
|
381
|
+
if (metric.length === qualityHistorySize) {
|
|
382
|
+
metric.pop();
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const addPacketsStats = (metric: PacketsStats, data: PacketsStats[0]) => {
|
|
387
|
+
removeObsolete(metric);
|
|
388
|
+
metric.unshift(data);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const recordCallPacketsStats = (
|
|
392
|
+
oldStats: NormalizedRTCStats,
|
|
393
|
+
newStats: NormalizedRTCStats,
|
|
394
|
+
callPacketsStats: CallPacketsStats,
|
|
395
|
+
) => {
|
|
396
|
+
if (oldStats && newStats) {
|
|
397
|
+
if (!callPacketsStats) {
|
|
398
|
+
callPacketsStats = [];
|
|
399
|
+
}
|
|
400
|
+
addPacketsStats(callPacketsStats, getPacketStats(oldStats, newStats));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return callPacketsStats;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const addAudioQualityMetric = (
|
|
407
|
+
metric: AudioQualityStats,
|
|
408
|
+
data: AudioQualityStats[0],
|
|
409
|
+
) => {
|
|
410
|
+
removeObsolete(metric);
|
|
411
|
+
metric.unshift(data);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const addVideoQualityMetric = (
|
|
415
|
+
metric: VideoQualityStats,
|
|
416
|
+
data: VideoQualityStats[0],
|
|
417
|
+
) => {
|
|
418
|
+
removeObsolete(metric);
|
|
419
|
+
metric.unshift(data);
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const recordCallQualityStats = (
|
|
423
|
+
prevStats: NormalizedRTCStats,
|
|
424
|
+
stats: NormalizedRTCStats,
|
|
425
|
+
callQualityStats: CallQualityStats,
|
|
426
|
+
) => {
|
|
427
|
+
const calcRecentPacketLoss = ({pT = 0, prevPT = 0, pL = 0, prevPL = 0}) => {
|
|
428
|
+
if (pT <= 0) {
|
|
429
|
+
return 0;
|
|
430
|
+
}
|
|
431
|
+
if (pT - prevPT <= 0) {
|
|
432
|
+
return pL / pT;
|
|
433
|
+
}
|
|
434
|
+
return (pL - prevPL) / (pT - prevPT);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
if (stats) {
|
|
438
|
+
if (!callQualityStats) {
|
|
439
|
+
callQualityStats = [];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (stats.kind === 'audio') {
|
|
443
|
+
addAudioQualityMetric(
|
|
444
|
+
(callQualityStats || []) as AudioQualityStats,
|
|
445
|
+
[
|
|
446
|
+
calcRecentPacketLoss({
|
|
447
|
+
pT: stats.packetsTransmitted,
|
|
448
|
+
prevPT: prevStats?.packetsTransmitted,
|
|
449
|
+
pL: stats.packetsLost,
|
|
450
|
+
prevPL: prevStats?.packetsLost,
|
|
451
|
+
}),
|
|
452
|
+
stats.jitter ?? 0,
|
|
453
|
+
],
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (stats.kind === 'video') {
|
|
458
|
+
addVideoQualityMetric(
|
|
459
|
+
(callQualityStats || []) as VideoQualityStats,
|
|
460
|
+
calcRecentPacketLoss({
|
|
461
|
+
pT: stats.packetsTransmitted,
|
|
462
|
+
prevPT: prevStats?.packetsTransmitted,
|
|
463
|
+
pL: stats.packetsLost,
|
|
464
|
+
prevPL: prevStats?.packetsLost,
|
|
465
|
+
}),
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return callQualityStats;
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
export const getQuality = (stats: CallQualityStats) => {
|
|
474
|
+
const qualityOverTime = stats.map(stats => calculateQuality(stats));
|
|
475
|
+
|
|
476
|
+
const goodOrOk = qualityOverTime.filter(
|
|
477
|
+
stat => stat === Quality.GOOD || stat === Quality.OK,
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
const qualitySum = qualityOverTime.reduce((acc, val) => acc + val, 0);
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
quality: Math.round(qualitySum / qualityOverTime.length) as Quality,
|
|
484
|
+
goodOrOkQuality: goodOrOk.length / qualityOverTime.length,
|
|
485
|
+
qualityOverTime,
|
|
486
|
+
};
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
export const addDeltaStats = (
|
|
490
|
+
newStats: NormalizedRTCStats,
|
|
491
|
+
cache: CacheStats,
|
|
492
|
+
) => {
|
|
493
|
+
const oldStats =
|
|
494
|
+
cache.previous ??
|
|
495
|
+
Object.keys(newStats).reduce<NormalizedRTCStats>((stats, key) => {
|
|
496
|
+
const statKey = key as keyof NormalizedRTCStats;
|
|
497
|
+
if (
|
|
498
|
+
typeof newStats[statKey] === 'number' &&
|
|
499
|
+
statKey !== 'timestamp'
|
|
500
|
+
) {
|
|
501
|
+
return {...stats, [statKey]: 0};
|
|
502
|
+
}
|
|
503
|
+
return {...stats, [statKey]: newStats[statKey]};
|
|
504
|
+
}, {} as NormalizedRTCStats);
|
|
505
|
+
|
|
506
|
+
const deltaStats = {
|
|
507
|
+
...newStats,
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const callPacketsStats = recordCallPacketsStats(
|
|
511
|
+
oldStats,
|
|
512
|
+
newStats,
|
|
513
|
+
cache.callPacketsStats,
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
const metrics = [
|
|
517
|
+
[newStats, oldStats, deltaStats, callPacketsStats ?? []],
|
|
518
|
+
] as const;
|
|
519
|
+
|
|
520
|
+
metrics.forEach(([newMetrics, oldMetrics, deltaMetrics, packets]) => {
|
|
521
|
+
if (
|
|
522
|
+
newMetrics?.bytesTransmitted &&
|
|
523
|
+
newMetrics?.timestamp &&
|
|
524
|
+
oldMetrics?.bytesTransmitted &&
|
|
525
|
+
oldMetrics?.timestamp &&
|
|
526
|
+
deltaMetrics // && deltaMetrics.bitrate == null
|
|
527
|
+
) {
|
|
528
|
+
const dMs = newMetrics.timestamp - oldMetrics.timestamp;
|
|
529
|
+
|
|
530
|
+
const dBytes =
|
|
531
|
+
newMetrics.bytesTransmitted - oldMetrics.bytesTransmitted;
|
|
532
|
+
|
|
533
|
+
if (dMs !== 0) {
|
|
534
|
+
deltaMetrics.bitrate = Math.round((dBytes * 8) / (dMs / 1000));
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (deltaMetrics) {
|
|
539
|
+
const [recentPacketsLost, recentTotalPackets] = packets.reduce(
|
|
540
|
+
(acc, [lost, total]) => {
|
|
541
|
+
acc[0] += lost;
|
|
542
|
+
acc[1] += total;
|
|
543
|
+
return acc;
|
|
544
|
+
},
|
|
545
|
+
[0, 0],
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
deltaMetrics.recentPercentageLost =
|
|
549
|
+
recentTotalPackets === 0
|
|
550
|
+
? 0
|
|
551
|
+
: recentPacketsLost / recentTotalPackets;
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
const callQualityStats = recordCallQualityStats(
|
|
556
|
+
oldStats,
|
|
557
|
+
deltaStats,
|
|
558
|
+
cache.callQualityStats,
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
return [
|
|
562
|
+
deltaStats,
|
|
563
|
+
getQuality(callQualityStats ?? []).quality,
|
|
564
|
+
callQualityStats,
|
|
565
|
+
] as const;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
// https://docs.pexip.com/admin/media_statistics.htm
|
|
569
|
+
export const calculateQuality = (stats: number | [number, number]) => {
|
|
570
|
+
let packetLoss;
|
|
571
|
+
let jitter;
|
|
572
|
+
|
|
573
|
+
if (typeof stats === 'number') {
|
|
574
|
+
packetLoss == stats;
|
|
575
|
+
} else {
|
|
576
|
+
[packetLoss, jitter] = stats;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
let callQuality = Quality.OK;
|
|
580
|
+
|
|
581
|
+
if (typeof packetLoss === 'number') {
|
|
582
|
+
if (packetLoss < 0.01) {
|
|
583
|
+
callQuality = Quality.GOOD;
|
|
584
|
+
} else if (packetLoss < 0.03) {
|
|
585
|
+
callQuality = Quality.OK;
|
|
586
|
+
} else if (packetLoss < 0.1) {
|
|
587
|
+
callQuality = Quality.BAD;
|
|
588
|
+
} else {
|
|
589
|
+
callQuality = Quality.TERRIBLE;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (jitter && jitter > 0.04) {
|
|
594
|
+
if (callQuality === Quality.GOOD) {
|
|
595
|
+
callQuality = Quality.OK;
|
|
596
|
+
} else if (callQuality === Quality.OK) {
|
|
597
|
+
callQuality = Quality.BAD;
|
|
598
|
+
} else if (callQuality === Quality.BAD) {
|
|
599
|
+
callQuality = Quality.TERRIBLE;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return callQuality;
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Creates stats collector
|
|
608
|
+
*
|
|
609
|
+
* @param input - input to get stats from
|
|
610
|
+
* @param signal - Signal which distributes stats
|
|
611
|
+
* @param interval - how often signal with fire with new stats
|
|
612
|
+
*
|
|
613
|
+
* @returns \{function to reset stats window, cleanup func\}
|
|
614
|
+
*/
|
|
615
|
+
export const createStatsCollector = ({
|
|
616
|
+
input,
|
|
617
|
+
signals: {onCallQuality, onCallQualityStats, onRtcStats},
|
|
618
|
+
interval = 1000,
|
|
619
|
+
}: StatsCollectorOptions): StatsCollector => {
|
|
620
|
+
const newCache = (): CacheStats => ({
|
|
621
|
+
callQuality: Quality.GOOD,
|
|
622
|
+
callPacketsStats: [],
|
|
623
|
+
callQualityStats: [],
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
let cache = newCache();
|
|
627
|
+
|
|
628
|
+
const pushStats = () => {
|
|
629
|
+
void statsFromRTCPeer(input).then(newStats => {
|
|
630
|
+
const [stats, callQuality, callQualityStats] = addDeltaStats(
|
|
631
|
+
newStats,
|
|
632
|
+
cache,
|
|
633
|
+
);
|
|
634
|
+
if (callQuality != cache.callQuality) {
|
|
635
|
+
onCallQuality.emit(callQuality);
|
|
636
|
+
cache.callQuality = callQuality;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
cache.previous = newStats;
|
|
640
|
+
onRtcStats.emit(stats);
|
|
641
|
+
onCallQualityStats.emit(callQualityStats);
|
|
642
|
+
});
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
let it = window.setInterval(pushStats, interval);
|
|
646
|
+
const clearInterval = () => {
|
|
647
|
+
window.clearInterval(it);
|
|
648
|
+
it = 0;
|
|
649
|
+
};
|
|
650
|
+
const resumeStats = () => {
|
|
651
|
+
if (it === 0) {
|
|
652
|
+
cache = newCache();
|
|
653
|
+
pushStats();
|
|
654
|
+
it = window.setInterval(pushStats, interval);
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
resetStats: () => {
|
|
660
|
+
clearInterval();
|
|
661
|
+
return resumeStats;
|
|
662
|
+
},
|
|
663
|
+
cleanup: clearInterval,
|
|
664
|
+
};
|
|
665
|
+
};
|