@unboundcx/video-sdk-client 1.1.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/AudioMixer.js +235 -0
- package/README.md +400 -0
- package/VideoMeetingClient.js +1210 -0
- package/VideoProcessor.js +375 -0
- package/index.js +42 -0
- package/managers/ConnectionManager.js +243 -0
- package/managers/LocalMediaManager.js +1051 -0
- package/managers/MediasoupManager.js +789 -0
- package/managers/RemoteMediaManager.js +972 -0
- package/managers/StatsCollector.js +710 -0
- package/package.json +56 -0
- package/utils/EventEmitter.js +103 -0
- package/utils/Logger.js +114 -0
- package/utils/errors.js +136 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StatsCollector
|
|
3
|
+
* Collects WebRTC stats from mediasoup transports and calculates quality metrics
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class StatsCollector {
|
|
7
|
+
constructor(logger) {
|
|
8
|
+
this.logger = logger;
|
|
9
|
+
this.sendTransportStatsTimeout = null;
|
|
10
|
+
this.recvTransportStatsInterval = null;
|
|
11
|
+
this.tracks = new Map(); // trackId -> { participantId, kind }
|
|
12
|
+
this.onStatsCallback = null; // Callback for local stats updates
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Set callback for stats updates (for local UI display)
|
|
17
|
+
*/
|
|
18
|
+
setStatsCallback(callback) {
|
|
19
|
+
this.onStatsCallback = callback;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Register a track for stats collection
|
|
24
|
+
*/
|
|
25
|
+
registerTrack(trackId, participantId, kind) {
|
|
26
|
+
this.tracks.set(trackId, { participantId, kind });
|
|
27
|
+
this.logger.log('StatsCollector :: Track registered', {
|
|
28
|
+
trackId,
|
|
29
|
+
participantId,
|
|
30
|
+
kind,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Unregister a track
|
|
36
|
+
*/
|
|
37
|
+
unregisterTrack(trackId) {
|
|
38
|
+
this.tracks.delete(trackId);
|
|
39
|
+
this.logger.log('StatsCollector :: Track unregistered', { trackId });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Start collecting stats for send transport
|
|
44
|
+
*/
|
|
45
|
+
startSendStats(transport, socket, virtualBackgroundStore) {
|
|
46
|
+
this.logger.info('StatsCollector :: Starting send stats collection');
|
|
47
|
+
this.sendTransportStatsTimeout = setTimeout(() => {
|
|
48
|
+
this.collectAudioVideoStats(
|
|
49
|
+
transport,
|
|
50
|
+
'send',
|
|
51
|
+
socket,
|
|
52
|
+
virtualBackgroundStore,
|
|
53
|
+
);
|
|
54
|
+
}, 10000);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Start collecting stats for recv transport
|
|
59
|
+
*/
|
|
60
|
+
startRecvStats(transport, socket, virtualBackgroundStore) {
|
|
61
|
+
this.logger.info('StatsCollector :: Starting recv stats collection');
|
|
62
|
+
this.recvTransportStatsInterval = setInterval(() => {
|
|
63
|
+
this.collectAudioVideoStats(
|
|
64
|
+
transport,
|
|
65
|
+
'recv',
|
|
66
|
+
socket,
|
|
67
|
+
virtualBackgroundStore,
|
|
68
|
+
);
|
|
69
|
+
}, 10000);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Stop all stats collection
|
|
74
|
+
*/
|
|
75
|
+
stopAll() {
|
|
76
|
+
this.logger.info('StatsCollector :: Stopping all stats collection');
|
|
77
|
+
if (this.sendTransportStatsTimeout) {
|
|
78
|
+
clearTimeout(this.sendTransportStatsTimeout);
|
|
79
|
+
this.sendTransportStatsTimeout = null;
|
|
80
|
+
}
|
|
81
|
+
if (this.recvTransportStatsInterval) {
|
|
82
|
+
clearInterval(this.recvTransportStatsInterval);
|
|
83
|
+
this.recvTransportStatsInterval = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validate and convert value to number
|
|
89
|
+
*/
|
|
90
|
+
validateNumber(value, defaultValue = 0) {
|
|
91
|
+
const number = Number(value);
|
|
92
|
+
return isNaN(number) ? defaultValue : number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Calculate Audio MOS (Mean Opinion Score)
|
|
97
|
+
* Based on ITU-T G.107 E-Model with codec-specific adjustments
|
|
98
|
+
*/
|
|
99
|
+
calculateAudioMOS({
|
|
100
|
+
jitter,
|
|
101
|
+
packetLossPercentage,
|
|
102
|
+
roundTripTime,
|
|
103
|
+
codecMimeType,
|
|
104
|
+
}) {
|
|
105
|
+
// Codec-specific baseline quality
|
|
106
|
+
const codecBaselines = {
|
|
107
|
+
'audio/opus': 4.4, // Opus is excellent
|
|
108
|
+
'audio/PCMU': 4.0, // G.711 mu-law
|
|
109
|
+
'audio/PCMA': 4.0, // G.711 a-law
|
|
110
|
+
default: 4.2, // Conservative baseline
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
let mos =
|
|
114
|
+
codecBaselines[codecMimeType] || codecBaselines['default'];
|
|
115
|
+
|
|
116
|
+
// Packet Loss Impact (exponential, not linear)
|
|
117
|
+
if (packetLossPercentage > 0) {
|
|
118
|
+
const lossImpact =
|
|
119
|
+
packetLossPercentage < 1
|
|
120
|
+
? packetLossPercentage * 0.05 // Minimal impact <1%
|
|
121
|
+
: Math.min(packetLossPercentage * 0.15, 2.5); // Cap at 2.5
|
|
122
|
+
mos -= lossImpact;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Jitter Impact (convert to ms)
|
|
126
|
+
const jitterMs = jitter * 1000;
|
|
127
|
+
if (jitterMs > 20) {
|
|
128
|
+
// 20ms threshold for audio
|
|
129
|
+
const jitterImpact = Math.min((jitterMs - 20) * 0.01, 1.0);
|
|
130
|
+
mos -= jitterImpact;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// RTT/Latency Impact (one-way delay)
|
|
134
|
+
const oneWayDelay = (roundTripTime * 1000) / 2;
|
|
135
|
+
if (oneWayDelay > 150) {
|
|
136
|
+
// ITU-T G.114 threshold
|
|
137
|
+
const delayImpact =
|
|
138
|
+
oneWayDelay < 300
|
|
139
|
+
? (oneWayDelay - 150) * 0.002 // 0.3 deduction at 300ms
|
|
140
|
+
: (oneWayDelay - 150) * 0.004; // Steeper after 300ms
|
|
141
|
+
mos -= Math.min(delayImpact, 1.5);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Clamp to valid MOS range
|
|
145
|
+
return parseFloat(Math.max(Math.min(mos, 4.5), 1.0).toFixed(2));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Calculate Video MOS (Mean Opinion Score)
|
|
150
|
+
* Video has different tolerances than audio
|
|
151
|
+
*/
|
|
152
|
+
calculateVideoMOS({
|
|
153
|
+
jitter,
|
|
154
|
+
packetLossPercentage,
|
|
155
|
+
roundTripTime,
|
|
156
|
+
framesPerSecond,
|
|
157
|
+
frameWidth,
|
|
158
|
+
frameHeight,
|
|
159
|
+
}) {
|
|
160
|
+
let mos = 4.3; // Start slightly lower than audio
|
|
161
|
+
|
|
162
|
+
// Packet Loss (video is more resilient but artifacts are visible)
|
|
163
|
+
if (packetLossPercentage > 0) {
|
|
164
|
+
const lossImpact =
|
|
165
|
+
packetLossPercentage < 2
|
|
166
|
+
? packetLossPercentage * 0.03 // Video handles <2% well
|
|
167
|
+
: packetLossPercentage * 0.12; // Visible artifacts >2%
|
|
168
|
+
mos -= Math.min(lossImpact, 2.0);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Jitter (video is more tolerant due to jitter buffer)
|
|
172
|
+
const jitterMs = jitter * 1000;
|
|
173
|
+
if (jitterMs > 30) {
|
|
174
|
+
// 30ms threshold for video
|
|
175
|
+
mos -= Math.min((jitterMs - 30) * 0.008, 0.8);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Frame rate impact (critical for perceived quality)
|
|
179
|
+
if (framesPerSecond > 0) {
|
|
180
|
+
if (framesPerSecond < 15) {
|
|
181
|
+
mos -= 1.0; // Very choppy below 15fps
|
|
182
|
+
} else if (framesPerSecond < 24) {
|
|
183
|
+
mos -= (24 - framesPerSecond) * 0.05; // Each frame below 24 hurts
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// RTT less critical for video but still matters
|
|
188
|
+
const oneWayDelay = (roundTripTime * 1000) / 2;
|
|
189
|
+
if (oneWayDelay > 200) {
|
|
190
|
+
// Video can tolerate more delay
|
|
191
|
+
mos -= Math.min((oneWayDelay - 200) * 0.001, 0.8);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return parseFloat(Math.max(Math.min(mos, 4.5), 1.0).toFixed(2));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Calculate bandwidth from stats
|
|
199
|
+
*/
|
|
200
|
+
calculateBandwidth(stats, transportType) {
|
|
201
|
+
const bandwidth = {
|
|
202
|
+
upload: 0,
|
|
203
|
+
download: 0,
|
|
204
|
+
unit: 'Mbps',
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
for (const report of stats.values()) {
|
|
208
|
+
if (transportType === 'send') {
|
|
209
|
+
if (report.type === 'outbound-rtp') {
|
|
210
|
+
// Calculate upload bandwidth
|
|
211
|
+
if (report.bytesSent) {
|
|
212
|
+
bandwidth.upload += report.bytesSent * 8; // Convert to bits
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
if (report.type === 'inbound-rtp') {
|
|
217
|
+
// Calculate download bandwidth
|
|
218
|
+
if (report.bytesReceived) {
|
|
219
|
+
bandwidth.download += report.bytesReceived * 8; // Convert to bits
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Convert to Mbps
|
|
226
|
+
bandwidth.upload = parseFloat((bandwidth.upload / 1000000).toFixed(2));
|
|
227
|
+
bandwidth.download = parseFloat((bandwidth.download / 1000000).toFixed(2));
|
|
228
|
+
|
|
229
|
+
return bandwidth;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Calculate connection quality score
|
|
234
|
+
*/
|
|
235
|
+
async calculateConnectionQualityScore(sendStats, recvStats) {
|
|
236
|
+
const scores = {
|
|
237
|
+
audio: { send: 5, recv: 5 },
|
|
238
|
+
video: { send: 5, recv: 5 },
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Send quality
|
|
242
|
+
if (sendStats.audio?.mos) {
|
|
243
|
+
scores.audio.send = sendStats.audio.mos;
|
|
244
|
+
}
|
|
245
|
+
if (sendStats.video?.mos) {
|
|
246
|
+
scores.video.send = sendStats.video.mos;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Recv quality (averages)
|
|
250
|
+
if (recvStats.audio?.mos) {
|
|
251
|
+
scores.audio.recv = recvStats.audio.mos;
|
|
252
|
+
}
|
|
253
|
+
if (recvStats.video?.mos) {
|
|
254
|
+
scores.video.recv = recvStats.video.mos;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Calculate overall score
|
|
258
|
+
const audioScore = (scores.audio.send + scores.audio.recv) / 2;
|
|
259
|
+
const videoScore = (scores.video.send + scores.video.recv) / 2;
|
|
260
|
+
const finalScore = (audioScore + videoScore) / 2;
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
finalScore: parseFloat(finalScore.toFixed(2)),
|
|
264
|
+
audio: {
|
|
265
|
+
sendScore: parseFloat(scores.audio.send.toFixed(2)),
|
|
266
|
+
recvScore: parseFloat(scores.audio.recv.toFixed(2)),
|
|
267
|
+
},
|
|
268
|
+
video: {
|
|
269
|
+
sendScore: parseFloat(scores.video.send.toFixed(2)),
|
|
270
|
+
recvScore: parseFloat(scores.video.recv.toFixed(2)),
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Main stats collection function
|
|
277
|
+
*/
|
|
278
|
+
async collectAudioVideoStats(transport, transportType, socket, virtualBackgroundStore) {
|
|
279
|
+
let stats;
|
|
280
|
+
try {
|
|
281
|
+
stats = await transport.getStats();
|
|
282
|
+
} catch (err) {
|
|
283
|
+
if (err.name === 'InvalidStateError' && err.message.includes('closed')) {
|
|
284
|
+
this.logger.warn('StatsCollector :: Transport closed, stopping stats');
|
|
285
|
+
this.stopAll();
|
|
286
|
+
} else {
|
|
287
|
+
this.logger.error('StatsCollector :: Error getting stats', err);
|
|
288
|
+
}
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!stats) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let audioStats = {};
|
|
297
|
+
let videoStats = {};
|
|
298
|
+
let network = {
|
|
299
|
+
local: {},
|
|
300
|
+
remote: {},
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const codecs = {};
|
|
304
|
+
const avgArrays = {
|
|
305
|
+
video: {
|
|
306
|
+
mos: [],
|
|
307
|
+
jitter: [],
|
|
308
|
+
packetsLost: [],
|
|
309
|
+
packetsReceived: [],
|
|
310
|
+
},
|
|
311
|
+
audio: {
|
|
312
|
+
mos: [],
|
|
313
|
+
jitter: [],
|
|
314
|
+
packetsLost: [],
|
|
315
|
+
packetsReceived: [],
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Extract network info from candidate pairs (send only)
|
|
320
|
+
if (transportType === 'send') {
|
|
321
|
+
const candidatePairArray = Array.from(stats.values()).filter(
|
|
322
|
+
(report) => report.type === 'candidate-pair' && report.nominated,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
if (candidatePairArray?.[0]) {
|
|
326
|
+
const candidate = candidatePairArray[0];
|
|
327
|
+
network.roundTripTime = candidate.currentRoundTripTime;
|
|
328
|
+
network.localCandidateId = candidate.localCandidateId;
|
|
329
|
+
network.remoteCandidateId = candidate.remoteCandidateId;
|
|
330
|
+
network.availableOutgoingBitrate = candidate.availableOutgoingBitrate;
|
|
331
|
+
|
|
332
|
+
const localCandidate = stats.get(candidate.localCandidateId);
|
|
333
|
+
if (localCandidate) {
|
|
334
|
+
network.local.wanIp = localCandidate.ip;
|
|
335
|
+
network.local.lanIp = localCandidate.relatedAddress;
|
|
336
|
+
network.local.type = localCandidate.networkType;
|
|
337
|
+
network.local.port = localCandidate.port;
|
|
338
|
+
network.local.protocol = localCandidate.protocol;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const remoteCandidate = stats.get(candidate.remoteCandidateId);
|
|
342
|
+
if (remoteCandidate) {
|
|
343
|
+
network.remote.wanIp = remoteCandidate.ip;
|
|
344
|
+
network.remote.port = remoteCandidate.port;
|
|
345
|
+
network.remote.protocol = remoteCandidate.protocol;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Process stats reports
|
|
351
|
+
for (const report of stats.values()) {
|
|
352
|
+
// Normalize kind field
|
|
353
|
+
if (report?.type === 'codec' && report?.mimeType?.includes('video')) {
|
|
354
|
+
report.kind = 'video';
|
|
355
|
+
} else if (report?.type === 'codec' && report?.mimeType?.includes('audio')) {
|
|
356
|
+
report.kind = 'audio';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Store codec info
|
|
360
|
+
if (report.type === 'codec') {
|
|
361
|
+
codecs[report.id] = {
|
|
362
|
+
codecMimeType: report.mimeType,
|
|
363
|
+
codecClockRate: report.clockRate,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Process SEND stats
|
|
368
|
+
if (transportType === 'send') {
|
|
369
|
+
if (report.kind === 'audio') {
|
|
370
|
+
if (report.type === 'codec') {
|
|
371
|
+
audioStats.codecMimeType = report.mimeType;
|
|
372
|
+
audioStats.codecClockRate = this.validateNumber(report.clockRate);
|
|
373
|
+
} else if (report.type === 'outbound-rtp') {
|
|
374
|
+
audioStats.packetsSent = this.validateNumber(report.packetsSent);
|
|
375
|
+
audioStats.bytesSent = this.validateNumber(report.bytesSent);
|
|
376
|
+
audioStats.totalPacketSendDelay = this.validateNumber(
|
|
377
|
+
report.totalPacketSendDelay,
|
|
378
|
+
);
|
|
379
|
+
} else if (
|
|
380
|
+
report.type === 'remote-inbound-rtp' ||
|
|
381
|
+
report.type === 'inbound-rtp'
|
|
382
|
+
) {
|
|
383
|
+
audioStats.jitter = this.validateNumber(report.jitter);
|
|
384
|
+
audioStats.roundTripTime = this.validateNumber(
|
|
385
|
+
report?.roundTripTime || network.roundTripTime,
|
|
386
|
+
);
|
|
387
|
+
audioStats.packetsLost = this.validateNumber(report.packetsLost);
|
|
388
|
+
}
|
|
389
|
+
} else if (report.kind === 'video') {
|
|
390
|
+
if (report.type === 'codec') {
|
|
391
|
+
videoStats.codecMimeType = report.mimeType;
|
|
392
|
+
videoStats.codecClockRate = this.validateNumber(report.clockRate);
|
|
393
|
+
} else if (
|
|
394
|
+
report.type === 'outbound-rtp' ||
|
|
395
|
+
report.type === 'remote-outbound-rtp'
|
|
396
|
+
) {
|
|
397
|
+
videoStats.packetsSent = this.validateNumber(report.packetsSent);
|
|
398
|
+
videoStats.bytesSent = this.validateNumber(report.bytesSent);
|
|
399
|
+
videoStats.framesSent = this.validateNumber(report.framesSent);
|
|
400
|
+
videoStats.framesPerSecond = this.validateNumber(
|
|
401
|
+
report.framesPerSecond,
|
|
402
|
+
);
|
|
403
|
+
videoStats.frameHeight = this.validateNumber(report.frameHeight);
|
|
404
|
+
videoStats.frameWidth = this.validateNumber(report.frameWidth);
|
|
405
|
+
videoStats.scalabilityMode = report.scalabilityMode;
|
|
406
|
+
videoStats.qpSum = report.qpSum;
|
|
407
|
+
videoStats.totalPacketSendDelay = this.validateNumber(
|
|
408
|
+
report.totalPacketSendDelay,
|
|
409
|
+
);
|
|
410
|
+
videoStats.qLdBandwidth =
|
|
411
|
+
report?.qualityLimitationDurations?.bandwidth;
|
|
412
|
+
videoStats.qLdCpu = report?.qualityLimitationDurations?.cpu;
|
|
413
|
+
videoStats.qLdOther = report?.qualityLimitationDurations?.other;
|
|
414
|
+
videoStats.qlResolutionChanges =
|
|
415
|
+
report?.qualityLimitationResolutionChanges;
|
|
416
|
+
} else if (report.type === 'remote-inbound-rtp') {
|
|
417
|
+
videoStats.jitter = this.validateNumber(report.jitter);
|
|
418
|
+
videoStats.roundTripTime = this.validateNumber(
|
|
419
|
+
report?.roundTripTime || network.roundTripTime,
|
|
420
|
+
);
|
|
421
|
+
videoStats.packetsLost = this.validateNumber(report.packetsLost);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Process RECV stats
|
|
426
|
+
else {
|
|
427
|
+
if (report.kind === 'audio' && report.type === 'inbound-rtp') {
|
|
428
|
+
const audioStat = {};
|
|
429
|
+
const trackInfo = this.tracks.get(report.trackIdentifier);
|
|
430
|
+
const participantId = trackInfo?.participantId;
|
|
431
|
+
|
|
432
|
+
const codecId = report.codecId;
|
|
433
|
+
if (codecs[codecId]) {
|
|
434
|
+
audioStat.codecMimeType = codecs[codecId].codecMimeType;
|
|
435
|
+
audioStat.codecClockRate = codecs[codecId].codecClockRate;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
audioStat.packetsReceived = this.validateNumber(
|
|
439
|
+
report.packetsReceived,
|
|
440
|
+
);
|
|
441
|
+
audioStat.bytesReceived = this.validateNumber(report.bytesReceived);
|
|
442
|
+
audioStat.packetsLost = this.validateNumber(report.packetsLost);
|
|
443
|
+
audioStat.jitter = this.validateNumber(report.jitter);
|
|
444
|
+
audioStat.roundTripTime = this.validateNumber(network.roundTripTime);
|
|
445
|
+
|
|
446
|
+
// Calculate packet loss percentage
|
|
447
|
+
if (audioStat?.packetsReceived && audioStat.packetsLost) {
|
|
448
|
+
const { packetsLost, packetsReceived } = audioStat;
|
|
449
|
+
audioStat.packetLossPercentage = parseFloat(
|
|
450
|
+
((packetsLost / (packetsReceived + packetsLost)) * 100).toFixed(
|
|
451
|
+
2,
|
|
452
|
+
),
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Calculate MOS
|
|
457
|
+
const mos = this.calculateAudioMOS({
|
|
458
|
+
...audioStat,
|
|
459
|
+
transportType,
|
|
460
|
+
});
|
|
461
|
+
audioStat.mos = mos;
|
|
462
|
+
|
|
463
|
+
// Add to averages
|
|
464
|
+
avgArrays.audio.mos.push(mos);
|
|
465
|
+
avgArrays.audio.jitter.push(audioStat.jitter);
|
|
466
|
+
avgArrays.audio.packetsLost.push(audioStat.packetsLost);
|
|
467
|
+
avgArrays.audio.packetsReceived.push(audioStat.packetsReceived);
|
|
468
|
+
|
|
469
|
+
if (participantId) {
|
|
470
|
+
audioStats[participantId] = audioStat;
|
|
471
|
+
}
|
|
472
|
+
} else if (report.kind === 'video' && report.type === 'inbound-rtp') {
|
|
473
|
+
const videoStat = {};
|
|
474
|
+
const trackInfo = this.tracks.get(report.trackIdentifier);
|
|
475
|
+
const participantId = trackInfo?.participantId;
|
|
476
|
+
|
|
477
|
+
const codecId = report.codecId;
|
|
478
|
+
if (codecs[codecId]) {
|
|
479
|
+
videoStat.codecMimeType = codecs[codecId].codecMimeType;
|
|
480
|
+
videoStat.codecClockRate = codecs[codecId].codecClockRate;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
videoStat.packetsReceived = this.validateNumber(
|
|
484
|
+
report.packetsReceived,
|
|
485
|
+
);
|
|
486
|
+
videoStat.bytesReceived = this.validateNumber(report.bytesReceived);
|
|
487
|
+
videoStat.framesReceived = this.validateNumber(report.framesReceived);
|
|
488
|
+
videoStat.framesPerSecond = this.validateNumber(
|
|
489
|
+
report.framesPerSecond,
|
|
490
|
+
);
|
|
491
|
+
videoStat.frameHeight = this.validateNumber(report.frameHeight);
|
|
492
|
+
videoStat.frameWidth = this.validateNumber(report.frameWidth);
|
|
493
|
+
videoStat.scalabilityMode = report.scalabilityMode;
|
|
494
|
+
videoStat.jitter = this.validateNumber(report.jitter);
|
|
495
|
+
videoStat.roundTripTime = this.validateNumber(network.roundTripTime);
|
|
496
|
+
videoStat.packetsLost = this.validateNumber(report.packetsLost);
|
|
497
|
+
|
|
498
|
+
// Calculate packet loss percentage
|
|
499
|
+
if (videoStat?.packetsReceived && videoStat.packetsLost) {
|
|
500
|
+
const { packetsLost, packetsReceived } = videoStat;
|
|
501
|
+
videoStat.packetLossPercentage = parseFloat(
|
|
502
|
+
((packetsLost / (packetsReceived + packetsLost)) * 100).toFixed(
|
|
503
|
+
2,
|
|
504
|
+
),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Calculate MOS
|
|
509
|
+
if (participantId) {
|
|
510
|
+
const mos = this.calculateVideoMOS({
|
|
511
|
+
...videoStat,
|
|
512
|
+
transportType,
|
|
513
|
+
});
|
|
514
|
+
videoStat.mos = mos;
|
|
515
|
+
|
|
516
|
+
// Add to averages
|
|
517
|
+
avgArrays.video.mos.push(mos);
|
|
518
|
+
avgArrays.video.jitter.push(videoStat.jitter);
|
|
519
|
+
avgArrays.video.packetsLost.push(videoStat.packetsLost);
|
|
520
|
+
avgArrays.video.packetsReceived.push(videoStat.packetsReceived);
|
|
521
|
+
|
|
522
|
+
videoStats[participantId] = videoStat;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Calculate averages for recv
|
|
529
|
+
let recvAvg = {
|
|
530
|
+
audio: {
|
|
531
|
+
mos: 0,
|
|
532
|
+
jitter: 0,
|
|
533
|
+
packetsLost: 0,
|
|
534
|
+
packetLostPercentage: 0,
|
|
535
|
+
},
|
|
536
|
+
video: {
|
|
537
|
+
mos: 0,
|
|
538
|
+
jitter: 0,
|
|
539
|
+
packetsLost: 0,
|
|
540
|
+
packetLostPercentage: 0,
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
if (transportType === 'send') {
|
|
545
|
+
// Calculate packet loss percentage for send
|
|
546
|
+
if (audioStats) {
|
|
547
|
+
const { packetsLost = 0, packetsSent = 0 } = audioStats;
|
|
548
|
+
audioStats.packetLossPercentage = parseFloat(
|
|
549
|
+
((packetsLost / (packetsSent + packetsLost)) * 100).toFixed(2),
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
if (videoStats) {
|
|
553
|
+
const { packetsLost = 0, packetsSent = 0 } = videoStats;
|
|
554
|
+
videoStats.packetLossPercentage = parseFloat(
|
|
555
|
+
((packetsLost / (packetsSent + packetsLost)) * 100).toFixed(2),
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Calculate MOS for send
|
|
560
|
+
audioStats.mos = this.calculateAudioMOS({
|
|
561
|
+
...audioStats,
|
|
562
|
+
transportType,
|
|
563
|
+
});
|
|
564
|
+
videoStats.mos = this.calculateVideoMOS({
|
|
565
|
+
...videoStats,
|
|
566
|
+
transportType,
|
|
567
|
+
});
|
|
568
|
+
} else {
|
|
569
|
+
// Calculate averages for recv
|
|
570
|
+
if (avgArrays.video.mos.length > 0) {
|
|
571
|
+
recvAvg.video.mos = parseFloat(
|
|
572
|
+
(
|
|
573
|
+
avgArrays.video.mos.reduce((acc, val) => acc + val, 0) /
|
|
574
|
+
avgArrays.video.mos.length
|
|
575
|
+
).toFixed(2),
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
if (avgArrays.video.jitter.length > 0) {
|
|
579
|
+
recvAvg.video.jitter = parseFloat(
|
|
580
|
+
(
|
|
581
|
+
avgArrays.video.jitter.reduce((acc, val) => acc + val, 0) /
|
|
582
|
+
avgArrays.video.jitter.length
|
|
583
|
+
).toFixed(6),
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
if (avgArrays.video.packetsLost.length > 0) {
|
|
587
|
+
recvAvg.video.packetsLost = Math.round(
|
|
588
|
+
avgArrays.video.packetsLost.reduce((acc, val) => acc + val, 0) /
|
|
589
|
+
avgArrays.video.packetsLost.length,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
if (avgArrays.video.packetsReceived.length > 0) {
|
|
593
|
+
const totalReceived = avgArrays.video.packetsReceived.reduce(
|
|
594
|
+
(acc, val) => acc + val,
|
|
595
|
+
0,
|
|
596
|
+
);
|
|
597
|
+
const totalLost = avgArrays.video.packetsLost.reduce(
|
|
598
|
+
(acc, val) => acc + val,
|
|
599
|
+
0,
|
|
600
|
+
);
|
|
601
|
+
recvAvg.video.packetLostPercentage = parseFloat(
|
|
602
|
+
((totalLost / (totalReceived + totalLost)) * 100).toFixed(3),
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (avgArrays.audio.mos.length > 0) {
|
|
607
|
+
recvAvg.audio.mos = parseFloat(
|
|
608
|
+
(
|
|
609
|
+
avgArrays.audio.mos.reduce((acc, val) => acc + val, 0) /
|
|
610
|
+
avgArrays.audio.mos.length
|
|
611
|
+
).toFixed(2),
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
if (avgArrays.audio.jitter.length > 0) {
|
|
615
|
+
recvAvg.audio.jitter = parseFloat(
|
|
616
|
+
(
|
|
617
|
+
avgArrays.audio.jitter.reduce((acc, val) => acc + val, 0) /
|
|
618
|
+
avgArrays.audio.jitter.length
|
|
619
|
+
).toFixed(6),
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
if (avgArrays.audio.packetsLost.length > 0) {
|
|
623
|
+
recvAvg.audio.packetsLost = Math.round(
|
|
624
|
+
avgArrays.audio.packetsLost.reduce((acc, val) => acc + val, 0) /
|
|
625
|
+
avgArrays.audio.packetsLost.length,
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
if (avgArrays.audio.packetsReceived.length > 0) {
|
|
629
|
+
const totalReceived = avgArrays.audio.packetsReceived.reduce(
|
|
630
|
+
(acc, val) => acc + val,
|
|
631
|
+
0,
|
|
632
|
+
);
|
|
633
|
+
const totalLost = avgArrays.audio.packetsLost.reduce(
|
|
634
|
+
(acc, val) => acc + val,
|
|
635
|
+
0,
|
|
636
|
+
);
|
|
637
|
+
recvAvg.audio.packetLostPercentage = parseFloat(
|
|
638
|
+
((totalLost / (totalReceived + totalLost)) * 100).toFixed(3),
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Get virtual background info
|
|
644
|
+
let isVirtualBackground = false;
|
|
645
|
+
let virtualBackgroundType;
|
|
646
|
+
|
|
647
|
+
if (virtualBackgroundStore) {
|
|
648
|
+
const vbState = virtualBackgroundStore();
|
|
649
|
+
if (vbState?.type === 'image' || vbState?.type === 'blur') {
|
|
650
|
+
isVirtualBackground = true;
|
|
651
|
+
virtualBackgroundType = vbState?.name || vbState?.type;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Calculate bandwidth
|
|
656
|
+
const bandwidth = this.calculateBandwidth(stats, transportType);
|
|
657
|
+
|
|
658
|
+
// Calculate connection quality
|
|
659
|
+
const sendStatsForQuality = transportType === 'send' ? { audio: audioStats, video: videoStats } : {};
|
|
660
|
+
const recvStatsForQuality = transportType === 'recv' ? recvAvg : {};
|
|
661
|
+
const connectionQuality = await this.calculateConnectionQualityScore(
|
|
662
|
+
sendStatsForQuality,
|
|
663
|
+
recvStatsForQuality,
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
// Prepare data to emit
|
|
667
|
+
const statData = {
|
|
668
|
+
transportType,
|
|
669
|
+
connectionQuality,
|
|
670
|
+
recvAvg,
|
|
671
|
+
network,
|
|
672
|
+
audio: audioStats,
|
|
673
|
+
video: videoStats,
|
|
674
|
+
isVirtualBackground,
|
|
675
|
+
virtualBackgroundType,
|
|
676
|
+
bandwidth,
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
// Call local callback first (for UI display)
|
|
680
|
+
if (this.onStatsCallback) {
|
|
681
|
+
this.onStatsCallback(statData);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Emit to server
|
|
685
|
+
if (socket) {
|
|
686
|
+
socket.emit('media.stats', statData);
|
|
687
|
+
this.logger.log('StatsCollector :: Stats emitted', {
|
|
688
|
+
transportType,
|
|
689
|
+
mos: {
|
|
690
|
+
audio: audioStats.mos,
|
|
691
|
+
video: videoStats.mos,
|
|
692
|
+
},
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Schedule next collection for send
|
|
697
|
+
if (transportType === 'send') {
|
|
698
|
+
this.sendTransportStatsTimeout = setTimeout(() => {
|
|
699
|
+
this.collectAudioVideoStats(
|
|
700
|
+
transport,
|
|
701
|
+
'send',
|
|
702
|
+
socket,
|
|
703
|
+
virtualBackgroundStore,
|
|
704
|
+
);
|
|
705
|
+
}, 10000);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
}
|