@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.
@@ -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
+ }