@furious.luke/argus-js 0.5.4 → 0.5.6
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/README.md +75 -7
- package/dist/index.cjs +456 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +154 -5
- package/dist/index.d.ts +154 -5
- package/dist/index.js +456 -51
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -65,10 +65,38 @@ function selectGatewayTURNURLs(advertised, policy = "all") {
|
|
|
65
65
|
}
|
|
66
66
|
return selected;
|
|
67
67
|
}
|
|
68
|
+
var qualityLevels = ["good", "fair", "poor", "critical"];
|
|
69
|
+
function qualitySeverity(level) {
|
|
70
|
+
return qualityLevels.indexOf(level);
|
|
71
|
+
}
|
|
72
|
+
function nowMs() {
|
|
73
|
+
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
74
|
+
}
|
|
75
|
+
var limitationSeverity = { none: 0, other: 1, cpu: 2, bandwidth: 3 };
|
|
76
|
+
function worseLimitation(current, next) {
|
|
77
|
+
if (current === null) return next;
|
|
78
|
+
return (limitationSeverity[next] ?? 1) > (limitationSeverity[current] ?? 1) ? next : current;
|
|
79
|
+
}
|
|
80
|
+
function findNominatedCandidatePair(stats) {
|
|
81
|
+
let found;
|
|
82
|
+
stats.forEach((report) => {
|
|
83
|
+
const value = report;
|
|
84
|
+
if (!found && value.type === "candidate-pair" && value.state === "succeeded" && value.nominated === true) {
|
|
85
|
+
found = value;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return found;
|
|
89
|
+
}
|
|
68
90
|
var defaultSignalingReconnectTimeoutMs = 2e4;
|
|
69
91
|
var defaultGatewayHandshakeTimeoutMs = 2e4;
|
|
70
92
|
var defaultPeerConnectionTimeoutMs = 3e4;
|
|
71
93
|
var initialGatewayAttemptTimeoutMs = 3e3;
|
|
94
|
+
var defaultGatewayFailoverTimeoutMs = 8e3;
|
|
95
|
+
var maxGatewayFailoverTimeoutMs = 2e4;
|
|
96
|
+
var defaultGatewayRetryBackoffMs = 3e3;
|
|
97
|
+
var minGatewayRetryBackoffMs = 250;
|
|
98
|
+
var maxGatewayRetryBackoffMs = 5e3;
|
|
99
|
+
var maxPlacementRedirects = 2;
|
|
72
100
|
var signalingResumeAttemptTimeoutMs = 3e3;
|
|
73
101
|
var signalingResumeMaxBackoffMs = 3e3;
|
|
74
102
|
var senderRestartPauseMs = 100;
|
|
@@ -79,6 +107,18 @@ var negotiationReconnectGraceMs = 5e3;
|
|
|
79
107
|
var minimumIntentionalTrackEndRetentionMs = 35e3;
|
|
80
108
|
var maxUserTextBytes = 4 * 1024;
|
|
81
109
|
var maxRetainedICECandidates = 64;
|
|
110
|
+
var defaultConnectionStatsIntervalMs = 2e3;
|
|
111
|
+
var defaultConnectionQualityDebounceSamples = 2;
|
|
112
|
+
var defaultConnectionQualityThresholds = {
|
|
113
|
+
fairLossRatio: 0.02,
|
|
114
|
+
poorLossRatio: 0.05,
|
|
115
|
+
criticalLossRatio: 0.12,
|
|
116
|
+
fairRttMs: 300,
|
|
117
|
+
poorRttMs: 600,
|
|
118
|
+
criticalRttMs: 1e3,
|
|
119
|
+
fairJitterMs: 50,
|
|
120
|
+
poorJitterMs: 150
|
|
121
|
+
};
|
|
82
122
|
var ReportedPublisherError = class extends Error {
|
|
83
123
|
constructor(message, fatal = false) {
|
|
84
124
|
super(message);
|
|
@@ -121,6 +161,17 @@ var Publisher = class {
|
|
|
121
161
|
gatewayURL = null;
|
|
122
162
|
lastReportedICEPath = null;
|
|
123
163
|
watchedICETransports = /* @__PURE__ */ new WeakSet();
|
|
164
|
+
// Periodic getStats() polling for connection-quality assessment. The timer runs
|
|
165
|
+
// only while connected; lastStatsSample anchors the windowed loss/bitrate deltas
|
|
166
|
+
// (loss is tracked per outbound SSRC so it can be paired to the remote's report),
|
|
167
|
+
// and currentQualityLevel/qualityDowngradeStreak drive the debounced classifier.
|
|
168
|
+
connectionStatsTimer = null;
|
|
169
|
+
// Guards against overlapping getStats() calls: if one outlives the interval,
|
|
170
|
+
// ticks are skipped until it resolves so samples never complete out of order.
|
|
171
|
+
statsSampleInFlight = false;
|
|
172
|
+
lastStatsSample = null;
|
|
173
|
+
currentQualityLevel = null;
|
|
174
|
+
qualityDowngradeStreak = 0;
|
|
124
175
|
stopped = true;
|
|
125
176
|
// Every start/stop boundary advances lifecycleGeneration. Async work captures
|
|
126
177
|
// the generation it belongs to and may never mutate or terminate a later run.
|
|
@@ -333,10 +384,13 @@ var Publisher = class {
|
|
|
333
384
|
if (state === "connected") {
|
|
334
385
|
this.clearPeerConnectionTimeout();
|
|
335
386
|
void this.reportSelectedICEPath(pc);
|
|
387
|
+
this.startConnectionStatsLoop(generation, pc);
|
|
336
388
|
this.opts.callbacks?.onConnected?.();
|
|
337
389
|
} else if (state === "failed") {
|
|
338
390
|
this.clearPeerConnectionTimeout();
|
|
339
391
|
this.terminateWithError(new Error("WebRTC connection failed"), true, generation);
|
|
392
|
+
} else {
|
|
393
|
+
this.stopConnectionStatsLoop();
|
|
340
394
|
}
|
|
341
395
|
};
|
|
342
396
|
if (initialTrack) {
|
|
@@ -471,6 +525,7 @@ var Publisher = class {
|
|
|
471
525
|
this.runAbort?.abort();
|
|
472
526
|
this.runAbort = null;
|
|
473
527
|
this.clearPeerConnectionTimeout();
|
|
528
|
+
this.stopConnectionStatsLoop();
|
|
474
529
|
this.stopped = true;
|
|
475
530
|
this.cancelAllMediaRecovery();
|
|
476
531
|
this.reconnectGeneration++;
|
|
@@ -522,6 +577,16 @@ var Publisher = class {
|
|
|
522
577
|
// -------------------------------------------------------------------------
|
|
523
578
|
// Private helpers
|
|
524
579
|
// -------------------------------------------------------------------------
|
|
580
|
+
// raceGateways opens every candidate gateway at once, then decides in two
|
|
581
|
+
// separate moments. SELECTION: the first socket to deliver `accepted` (a cheap,
|
|
582
|
+
// control-plane-free acknowledgement) is chosen on network path; the browser
|
|
583
|
+
// sends `proceed` on that one only and keeps the rest as standbys. PLACEMENT:
|
|
584
|
+
// the selected region does its control-plane work and returns `ready`. If the
|
|
585
|
+
// selection dies (socket close/error → immediately) or stalls past the failover
|
|
586
|
+
// deadline (a hung-but-open socket), the browser abandons it — closing the
|
|
587
|
+
// socket cancels that region's placement server-side — and selects the
|
|
588
|
+
// next-fastest standby. A `placement_redirect` points the browser at the region
|
|
589
|
+
// that already holds the stream so a mistimed failover self-heals.
|
|
525
590
|
raceGateways(signal) {
|
|
526
591
|
return new Promise((resolve, reject) => {
|
|
527
592
|
const { gatewayURLs, token } = this.opts;
|
|
@@ -531,43 +596,149 @@ var Publisher = class {
|
|
|
531
596
|
}
|
|
532
597
|
const sockets = [];
|
|
533
598
|
const attemptTimers = /* @__PURE__ */ new Map();
|
|
599
|
+
const reopenTimers = /* @__PURE__ */ new Set();
|
|
600
|
+
const standbys = [];
|
|
601
|
+
let selected = null;
|
|
602
|
+
let failoverTimer = null;
|
|
603
|
+
let redirects = 0;
|
|
534
604
|
let settled = false;
|
|
535
605
|
let timeoutTimer = null;
|
|
606
|
+
const failoverMs = Math.min(
|
|
607
|
+
maxGatewayFailoverTimeoutMs,
|
|
608
|
+
Math.max(0, this.opts.gatewayFailoverTimeoutMs ?? defaultGatewayFailoverTimeoutMs)
|
|
609
|
+
);
|
|
536
610
|
const clearTimeoutTimer = () => {
|
|
537
611
|
if (timeoutTimer !== null) clearTimeout(timeoutTimer);
|
|
538
612
|
timeoutTimer = null;
|
|
539
613
|
};
|
|
614
|
+
const clearFailoverTimer = () => {
|
|
615
|
+
if (failoverTimer !== null) clearTimeout(failoverTimer);
|
|
616
|
+
failoverTimer = null;
|
|
617
|
+
};
|
|
618
|
+
const clearReopenTimers = () => {
|
|
619
|
+
for (const timer of reopenTimers) clearTimeout(timer);
|
|
620
|
+
reopenTimers.clear();
|
|
621
|
+
};
|
|
540
622
|
const clearAttemptTimer = (socket) => {
|
|
541
623
|
const timer = attemptTimers.get(socket);
|
|
542
624
|
if (timer !== void 0) clearTimeout(timer);
|
|
543
625
|
attemptTimers.delete(socket);
|
|
544
626
|
};
|
|
627
|
+
const detach = (socket) => {
|
|
628
|
+
clearAttemptTimer(socket);
|
|
629
|
+
socket.onmessage = null;
|
|
630
|
+
socket.onerror = null;
|
|
631
|
+
socket.onclose = null;
|
|
632
|
+
};
|
|
633
|
+
const dropStandby = (socket) => {
|
|
634
|
+
const i = standbys.indexOf(socket);
|
|
635
|
+
if (i !== -1) standbys.splice(i, 1);
|
|
636
|
+
};
|
|
545
637
|
const closeAll = (except) => {
|
|
546
638
|
for (const s of sockets) {
|
|
547
|
-
clearAttemptTimer(s);
|
|
548
639
|
if (s !== except) {
|
|
549
|
-
s
|
|
550
|
-
s.onerror = null;
|
|
551
|
-
s.onclose = null;
|
|
640
|
+
detach(s);
|
|
552
641
|
s.close();
|
|
553
642
|
}
|
|
554
643
|
}
|
|
555
644
|
};
|
|
556
|
-
const
|
|
557
|
-
|
|
645
|
+
const win = (ws, readyInfo, gatewayURL) => {
|
|
646
|
+
settled = true;
|
|
647
|
+
clearTimeoutTimer();
|
|
648
|
+
clearFailoverTimer();
|
|
649
|
+
clearReopenTimers();
|
|
650
|
+
signal.removeEventListener("abort", abort);
|
|
651
|
+
closeAll(ws);
|
|
652
|
+
resolve({ ws, readyInfo, gatewayURL });
|
|
653
|
+
};
|
|
654
|
+
const fail = (err) => {
|
|
655
|
+
settled = true;
|
|
656
|
+
clearTimeoutTimer();
|
|
657
|
+
clearFailoverTimer();
|
|
658
|
+
clearReopenTimers();
|
|
659
|
+
signal.removeEventListener("abort", abort);
|
|
660
|
+
closeAll();
|
|
661
|
+
reject(err);
|
|
662
|
+
};
|
|
663
|
+
const checkExhausted = () => {
|
|
664
|
+
if (settled || selected !== null || standbys.length > 0 || reopenTimers.size > 0) return;
|
|
558
665
|
if (sockets.every((s) => s.readyState === WebSocket.CLOSED || s.readyState === WebSocket.CLOSING)) {
|
|
559
|
-
|
|
560
|
-
clearTimeoutTimer();
|
|
561
|
-
signal.removeEventListener("abort", abort);
|
|
562
|
-
reject(new Error("all gateways failed to connect"));
|
|
666
|
+
fail(new Error("all gateways failed to connect"));
|
|
563
667
|
}
|
|
564
668
|
};
|
|
565
|
-
const
|
|
669
|
+
const select = (ws) => {
|
|
670
|
+
selected = ws;
|
|
671
|
+
dropStandby(ws);
|
|
672
|
+
try {
|
|
673
|
+
ws.send(JSON.stringify({ type: "proceed" }));
|
|
674
|
+
} catch {
|
|
675
|
+
socketDown(ws);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
clearFailoverTimer();
|
|
679
|
+
failoverTimer = setTimeout(() => failover(ws), failoverMs);
|
|
680
|
+
};
|
|
681
|
+
const failover = (deadSocket) => {
|
|
682
|
+
if (settled || deadSocket !== selected) return;
|
|
683
|
+
clearFailoverTimer();
|
|
684
|
+
detach(deadSocket);
|
|
685
|
+
deadSocket.close();
|
|
686
|
+
selected = null;
|
|
687
|
+
const next = standbys.shift();
|
|
688
|
+
if (next) {
|
|
689
|
+
select(next);
|
|
690
|
+
} else {
|
|
691
|
+
checkExhausted();
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
const redirect = (gatewayURL) => {
|
|
566
695
|
if (settled) return;
|
|
567
|
-
|
|
568
|
-
|
|
696
|
+
if (redirects >= maxPlacementRedirects) {
|
|
697
|
+
fail(new Error("too many placement redirects"));
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
redirects++;
|
|
701
|
+
clearFailoverTimer();
|
|
569
702
|
closeAll();
|
|
570
|
-
|
|
703
|
+
standbys.length = 0;
|
|
704
|
+
selected = null;
|
|
705
|
+
try {
|
|
706
|
+
openGateway(gatewayURL);
|
|
707
|
+
} catch (err) {
|
|
708
|
+
fail(err instanceof Error ? err : new Error(String(err)));
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
const retryUnavailable = (ws, gatewayURL, retryAfterMs) => {
|
|
712
|
+
detach(ws);
|
|
713
|
+
ws.close();
|
|
714
|
+
const delay = Math.min(
|
|
715
|
+
maxGatewayRetryBackoffMs,
|
|
716
|
+
Math.max(minGatewayRetryBackoffMs, retryAfterMs ?? defaultGatewayRetryBackoffMs)
|
|
717
|
+
);
|
|
718
|
+
const timer = setTimeout(() => {
|
|
719
|
+
reopenTimers.delete(timer);
|
|
720
|
+
if (settled) return;
|
|
721
|
+
try {
|
|
722
|
+
openGateway(gatewayURL);
|
|
723
|
+
} catch (err) {
|
|
724
|
+
fail(err instanceof Error ? err : new Error(String(err)));
|
|
725
|
+
}
|
|
726
|
+
}, delay);
|
|
727
|
+
reopenTimers.add(timer);
|
|
728
|
+
};
|
|
729
|
+
const socketDown = (ws) => {
|
|
730
|
+
if (settled) return;
|
|
731
|
+
clearAttemptTimer(ws);
|
|
732
|
+
dropStandby(ws);
|
|
733
|
+
if (ws === selected) {
|
|
734
|
+
failover(ws);
|
|
735
|
+
} else {
|
|
736
|
+
checkExhausted();
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
const abort = () => {
|
|
740
|
+
if (settled) return;
|
|
741
|
+
fail(new PublisherStoppedError("publisher stopped"));
|
|
571
742
|
};
|
|
572
743
|
if (signal.aborted) {
|
|
573
744
|
abort();
|
|
@@ -580,10 +751,7 @@ var Publisher = class {
|
|
|
580
751
|
);
|
|
581
752
|
timeoutTimer = setTimeout(() => {
|
|
582
753
|
if (settled) return;
|
|
583
|
-
|
|
584
|
-
signal.removeEventListener("abort", abort);
|
|
585
|
-
closeAll();
|
|
586
|
-
reject(new Error(`gateway handshake timed out after ${timeoutMs}ms`));
|
|
754
|
+
fail(new Error(`gateway handshake timed out after ${timeoutMs}ms`));
|
|
587
755
|
}, timeoutMs);
|
|
588
756
|
const openGateway = (gatewayURL) => {
|
|
589
757
|
if (settled) return;
|
|
@@ -595,58 +763,51 @@ var Publisher = class {
|
|
|
595
763
|
const attemptTimer = setTimeout(() => {
|
|
596
764
|
attemptTimers.delete(ws);
|
|
597
765
|
if (settled || accepted) return;
|
|
598
|
-
ws
|
|
599
|
-
ws.onerror = null;
|
|
600
|
-
ws.onclose = null;
|
|
766
|
+
detach(ws);
|
|
601
767
|
ws.close();
|
|
602
768
|
try {
|
|
603
769
|
openGateway(gatewayURL);
|
|
604
770
|
} catch (err) {
|
|
605
|
-
|
|
606
|
-
clearTimeoutTimer();
|
|
607
|
-
signal.removeEventListener("abort", abort);
|
|
608
|
-
closeAll();
|
|
609
|
-
reject(err);
|
|
771
|
+
fail(err instanceof Error ? err : new Error(String(err)));
|
|
610
772
|
}
|
|
611
773
|
}, initialGatewayAttemptTimeoutMs);
|
|
612
774
|
attemptTimers.set(ws, attemptTimer);
|
|
613
775
|
ws.onmessage = (ev) => {
|
|
614
776
|
if (settled) return;
|
|
777
|
+
let msg;
|
|
615
778
|
try {
|
|
616
|
-
|
|
617
|
-
if (!accepted && msg.type === "accepted") {
|
|
618
|
-
accepted = true;
|
|
619
|
-
clearAttemptTimer(ws);
|
|
620
|
-
ws.send(JSON.stringify({ type: "proceed" }));
|
|
621
|
-
} else if (accepted && msg.type === "ready") {
|
|
622
|
-
settled = true;
|
|
623
|
-
clearTimeoutTimer();
|
|
624
|
-
signal.removeEventListener("abort", abort);
|
|
625
|
-
closeAll(ws);
|
|
626
|
-
resolve({ ws, readyInfo: msg, gatewayURL });
|
|
627
|
-
}
|
|
779
|
+
msg = JSON.parse(ev.data);
|
|
628
780
|
} catch {
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (!accepted) {
|
|
784
|
+
if (msg.type === "unavailable") {
|
|
785
|
+
retryUnavailable(ws, gatewayURL, msg.retry_after_ms);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (msg.type !== "accepted") return;
|
|
789
|
+
accepted = true;
|
|
790
|
+
clearAttemptTimer(ws);
|
|
791
|
+
if (selected === null) select(ws);
|
|
792
|
+
else standbys.push(ws);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (ws !== selected) return;
|
|
796
|
+
if (msg.type === "ready") {
|
|
797
|
+
win(ws, msg, gatewayURL);
|
|
798
|
+
} else if (msg.type === "placement_redirect" && msg.gateway_url) {
|
|
799
|
+
redirect(msg.gateway_url);
|
|
629
800
|
}
|
|
630
801
|
};
|
|
631
|
-
ws.onerror = () =>
|
|
632
|
-
|
|
633
|
-
checkAllFailed();
|
|
634
|
-
};
|
|
635
|
-
ws.onclose = () => {
|
|
636
|
-
clearAttemptTimer(ws);
|
|
637
|
-
checkAllFailed();
|
|
638
|
-
};
|
|
802
|
+
ws.onerror = () => socketDown(ws);
|
|
803
|
+
ws.onclose = () => socketDown(ws);
|
|
639
804
|
};
|
|
640
805
|
try {
|
|
641
806
|
for (const gatewayURL of gatewayURLs) {
|
|
642
807
|
openGateway(gatewayURL);
|
|
643
808
|
}
|
|
644
809
|
} catch (err) {
|
|
645
|
-
|
|
646
|
-
clearTimeoutTimer();
|
|
647
|
-
signal.removeEventListener("abort", abort);
|
|
648
|
-
closeAll();
|
|
649
|
-
reject(err);
|
|
810
|
+
fail(err instanceof Error ? err : new Error(String(err)));
|
|
650
811
|
}
|
|
651
812
|
});
|
|
652
813
|
}
|
|
@@ -853,6 +1014,7 @@ var Publisher = class {
|
|
|
853
1014
|
this.runAbort?.abort();
|
|
854
1015
|
this.runAbort = null;
|
|
855
1016
|
this.clearPeerConnectionTimeout();
|
|
1017
|
+
this.stopConnectionStatsLoop();
|
|
856
1018
|
this.stopped = true;
|
|
857
1019
|
this.cancelAllMediaRecovery();
|
|
858
1020
|
this.reconnectGeneration++;
|
|
@@ -1323,6 +1485,249 @@ var Publisher = class {
|
|
|
1323
1485
|
} catch {
|
|
1324
1486
|
}
|
|
1325
1487
|
}
|
|
1488
|
+
// startConnectionStatsLoop begins periodic getStats() sampling once the peer
|
|
1489
|
+
// connection is connected. It is a no-op when polling is disabled or no
|
|
1490
|
+
// consumer is listening, and it re-baselines on each call so a reconnect after
|
|
1491
|
+
// an ICE restart starts a fresh quality assessment.
|
|
1492
|
+
startConnectionStatsLoop(generation, pc) {
|
|
1493
|
+
const intervalMs = Math.max(
|
|
1494
|
+
0,
|
|
1495
|
+
this.opts.connectionStatsIntervalMs ?? defaultConnectionStatsIntervalMs
|
|
1496
|
+
);
|
|
1497
|
+
const listening = !!this.opts.callbacks?.onConnectionStats || !!this.opts.callbacks?.onConnectionQualityChange;
|
|
1498
|
+
if (intervalMs === 0 || !listening) return;
|
|
1499
|
+
this.stopConnectionStatsLoop();
|
|
1500
|
+
const timer = setInterval(() => {
|
|
1501
|
+
if (this.connectionStatsTimer !== timer) return;
|
|
1502
|
+
if (!this.isActiveRun(generation, pc)) {
|
|
1503
|
+
this.stopConnectionStatsLoop();
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
if (this.statsSampleInFlight) return;
|
|
1507
|
+
this.statsSampleInFlight = true;
|
|
1508
|
+
void this.sampleConnectionQuality(generation, pc, timer);
|
|
1509
|
+
}, intervalMs);
|
|
1510
|
+
this.connectionStatsTimer = timer;
|
|
1511
|
+
}
|
|
1512
|
+
stopConnectionStatsLoop() {
|
|
1513
|
+
if (this.connectionStatsTimer !== null) clearInterval(this.connectionStatsTimer);
|
|
1514
|
+
this.connectionStatsTimer = null;
|
|
1515
|
+
this.statsSampleInFlight = false;
|
|
1516
|
+
this.lastStatsSample = null;
|
|
1517
|
+
this.currentQualityLevel = null;
|
|
1518
|
+
this.qualityDowngradeStreak = 0;
|
|
1519
|
+
}
|
|
1520
|
+
async sampleConnectionQuality(generation, pc, timer) {
|
|
1521
|
+
let stats = null;
|
|
1522
|
+
try {
|
|
1523
|
+
stats = await pc.getStats();
|
|
1524
|
+
} catch {
|
|
1525
|
+
}
|
|
1526
|
+
if (this.connectionStatsTimer !== timer) return;
|
|
1527
|
+
this.statsSampleInFlight = false;
|
|
1528
|
+
if (!stats || !this.isActiveRun(generation, pc) || pc.connectionState !== "connected") {
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
const sample = this.buildStatsSample(stats);
|
|
1532
|
+
this.opts.callbacks?.onConnectionStats?.(sample);
|
|
1533
|
+
if (this.connectionStatsTimer !== timer || !this.isActiveRun(generation, pc) || pc.connectionState !== "connected") {
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
this.updateConnectionQuality(sample);
|
|
1537
|
+
}
|
|
1538
|
+
// buildStatsSample derives one sample from a getStats() report. Loss is a mean of
|
|
1539
|
+
// per-stream loss fractions weighted by each stream's packets sent this window, so
|
|
1540
|
+
// every stream carrying traffic contributes. A stream uses its remote fractionLost
|
|
1541
|
+
// when present — the remote's loss ratio over its RR interval, self-aligned and
|
|
1542
|
+
// excluding retransmissions (which ride a separate SSRC); dividing the remote's
|
|
1543
|
+
// Δ(packetsLost) by the local Δ(packetsSent) would instead misalign an RTCP-timed
|
|
1544
|
+
// numerator with a continuously-updated denominator — and otherwise falls back to
|
|
1545
|
+
// its own windowed Δ(packetsLost) / Δ(distinct media packets sent), a denominator
|
|
1546
|
+
// that excludes retransmissions (packetsSent - retransmittedPacketsSent). Mixing the
|
|
1547
|
+
// two per stream keeps a lossy stream from being dropped when a sibling has fractionLost.
|
|
1548
|
+
//
|
|
1549
|
+
// Deltas are taken only over outbound stats-object ids present in BOTH this and the
|
|
1550
|
+
// previous sample. A track replace/unpublish (or a recycled SSRC) deletes the old
|
|
1551
|
+
// stats object and creates a new one with a new id; counting a vanished stream's
|
|
1552
|
+
// missing tail, or a fresh object's cumulative total as an interval delta, would
|
|
1553
|
+
// corrupt loss. Excluding the symmetric difference baselines new objects (they count
|
|
1554
|
+
// from their next sample) and drops departed ones, so continuous streams still measure
|
|
1555
|
+
// loss even when an SSRC number is reused across distinct stats objects.
|
|
1556
|
+
buildStatsSample(stats) {
|
|
1557
|
+
const timestamp = nowMs();
|
|
1558
|
+
let nackCount = 0;
|
|
1559
|
+
let pliCount = 0;
|
|
1560
|
+
let haveOutbound = false;
|
|
1561
|
+
let haveOutboundVideo = false;
|
|
1562
|
+
let rttSeconds = null;
|
|
1563
|
+
let jitterSeconds = null;
|
|
1564
|
+
let limitationReason = null;
|
|
1565
|
+
let selectedPairID;
|
|
1566
|
+
let transportBytes = null;
|
|
1567
|
+
const streams = /* @__PURE__ */ new Map();
|
|
1568
|
+
const ssrcToId = /* @__PURE__ */ new Map();
|
|
1569
|
+
stats.forEach((report) => {
|
|
1570
|
+
const value = report;
|
|
1571
|
+
switch (value.type) {
|
|
1572
|
+
case "outbound-rtp": {
|
|
1573
|
+
haveOutbound = true;
|
|
1574
|
+
if (value.kind === "video") haveOutboundVideo = true;
|
|
1575
|
+
const ssrc = typeof value.ssrc === "number" ? value.ssrc : NaN;
|
|
1576
|
+
const sent = typeof value.packetsSent === "number" ? value.packetsSent : 0;
|
|
1577
|
+
const retransmitted = typeof value.retransmittedPacketsSent === "number" ? value.retransmittedPacketsSent : 0;
|
|
1578
|
+
const bytesSent = typeof value.bytesSent === "number" ? value.bytesSent : 0;
|
|
1579
|
+
if (typeof value.nackCount === "number") nackCount += value.nackCount;
|
|
1580
|
+
if (typeof value.pliCount === "number") pliCount += value.pliCount;
|
|
1581
|
+
if (typeof value.qualityLimitationReason === "string") {
|
|
1582
|
+
limitationReason = worseLimitation(limitationReason, value.qualityLimitationReason);
|
|
1583
|
+
}
|
|
1584
|
+
if (typeof value.id === "string") {
|
|
1585
|
+
const expected = Math.max(0, sent - retransmitted);
|
|
1586
|
+
streams.set(value.id, { expected, lost: null, fractionLost: null, bytesSent });
|
|
1587
|
+
if (!Number.isNaN(ssrc)) ssrcToId.set(ssrc, value.id);
|
|
1588
|
+
}
|
|
1589
|
+
break;
|
|
1590
|
+
}
|
|
1591
|
+
case "remote-inbound-rtp": {
|
|
1592
|
+
if (typeof value.roundTripTime === "number") {
|
|
1593
|
+
rttSeconds = Math.max(rttSeconds ?? 0, value.roundTripTime);
|
|
1594
|
+
}
|
|
1595
|
+
if (typeof value.jitter === "number") {
|
|
1596
|
+
jitterSeconds = Math.max(jitterSeconds ?? 0, value.jitter);
|
|
1597
|
+
}
|
|
1598
|
+
break;
|
|
1599
|
+
}
|
|
1600
|
+
case "transport": {
|
|
1601
|
+
if (typeof value.selectedCandidatePairId === "string") {
|
|
1602
|
+
selectedPairID = value.selectedCandidatePairId;
|
|
1603
|
+
}
|
|
1604
|
+
if (typeof value.bytesSent === "number") {
|
|
1605
|
+
transportBytes = (transportBytes ?? 0) + value.bytesSent;
|
|
1606
|
+
}
|
|
1607
|
+
break;
|
|
1608
|
+
}
|
|
1609
|
+
default:
|
|
1610
|
+
break;
|
|
1611
|
+
}
|
|
1612
|
+
});
|
|
1613
|
+
stats.forEach((report) => {
|
|
1614
|
+
const value = report;
|
|
1615
|
+
if (value.type !== "remote-inbound-rtp") return;
|
|
1616
|
+
const outboundId = typeof value.localId === "string" && streams.has(value.localId) ? value.localId : typeof value.ssrc === "number" ? ssrcToId.get(value.ssrc) : void 0;
|
|
1617
|
+
if (outboundId === void 0) return;
|
|
1618
|
+
const stream = streams.get(outboundId);
|
|
1619
|
+
if (!stream) return;
|
|
1620
|
+
const lost = typeof value.packetsLost === "number" ? value.packetsLost : 0;
|
|
1621
|
+
stream.lost = (stream.lost ?? 0) + lost;
|
|
1622
|
+
if (typeof value.fractionLost === "number" && Number.isFinite(value.fractionLost)) {
|
|
1623
|
+
stream.fractionLost = Math.min(1, Math.max(0, value.fractionLost));
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
const pair = (selectedPairID ? stats.get(selectedPairID) : void 0) ?? findNominatedCandidatePair(stats);
|
|
1627
|
+
const availableOutgoingBitrate = pair && typeof pair.availableOutgoingBitrate === "number" ? pair.availableOutgoingBitrate : null;
|
|
1628
|
+
if (rttSeconds === null && pair && typeof pair.currentRoundTripTime === "number") {
|
|
1629
|
+
rttSeconds = pair.currentRoundTripTime;
|
|
1630
|
+
}
|
|
1631
|
+
const prev = this.lastStatsSample;
|
|
1632
|
+
let weightedFractionSum = 0;
|
|
1633
|
+
let weightSum = 0;
|
|
1634
|
+
let intersectionBytesDelta = 0;
|
|
1635
|
+
let hadStreamOverlap = false;
|
|
1636
|
+
streams.forEach((current, id) => {
|
|
1637
|
+
const before = prev?.streams.get(id);
|
|
1638
|
+
if (!before) return;
|
|
1639
|
+
hadStreamOverlap = true;
|
|
1640
|
+
intersectionBytesDelta += Math.max(0, current.bytesSent - before.bytesSent);
|
|
1641
|
+
const deltaExpected = Math.max(0, current.expected - before.expected);
|
|
1642
|
+
if (deltaExpected <= 0) return;
|
|
1643
|
+
let fraction = null;
|
|
1644
|
+
if (current.fractionLost !== null) {
|
|
1645
|
+
fraction = current.fractionLost;
|
|
1646
|
+
} else if (current.lost !== null && before.lost !== null) {
|
|
1647
|
+
fraction = Math.min(1, Math.max(0, current.lost - before.lost) / deltaExpected);
|
|
1648
|
+
}
|
|
1649
|
+
if (fraction === null) return;
|
|
1650
|
+
weightedFractionSum += fraction * deltaExpected;
|
|
1651
|
+
weightSum += deltaExpected;
|
|
1652
|
+
});
|
|
1653
|
+
const deltaSeconds = prev ? (timestamp - prev.timestamp) / 1e3 : 0;
|
|
1654
|
+
this.lastStatsSample = { timestamp, transportBytes, streams };
|
|
1655
|
+
const lossRatio = weightSum > 0 ? Math.min(1, weightedFractionSum / weightSum) : 0;
|
|
1656
|
+
let sendBitrate = null;
|
|
1657
|
+
if (prev && deltaSeconds > 0) {
|
|
1658
|
+
if (transportBytes !== null && prev.transportBytes !== null) {
|
|
1659
|
+
sendBitrate = Math.max(0, transportBytes - prev.transportBytes) * 8 / deltaSeconds;
|
|
1660
|
+
} else if (hadStreamOverlap) {
|
|
1661
|
+
sendBitrate = intersectionBytesDelta * 8 / deltaSeconds;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
return {
|
|
1665
|
+
timestamp,
|
|
1666
|
+
lossRatio,
|
|
1667
|
+
rttMs: rttSeconds !== null ? rttSeconds * 1e3 : null,
|
|
1668
|
+
jitterMs: jitterSeconds !== null ? jitterSeconds * 1e3 : null,
|
|
1669
|
+
availableOutgoingBitrate,
|
|
1670
|
+
sendBitrate,
|
|
1671
|
+
qualityLimitationReason: haveOutboundVideo ? limitationReason ?? "none" : null,
|
|
1672
|
+
nackCount: haveOutbound ? nackCount : null,
|
|
1673
|
+
pliCount: haveOutbound ? pliCount : null
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
resolveQualityThresholds() {
|
|
1677
|
+
return { ...defaultConnectionQualityThresholds, ...this.opts.connectionQualityThresholds ?? {} };
|
|
1678
|
+
}
|
|
1679
|
+
// classifyQuality maps a sample to a level. Loss is the primary axis; RTT,
|
|
1680
|
+
// jitter, and a bandwidth-limited encoder can only raise severity.
|
|
1681
|
+
classifyQuality(sample) {
|
|
1682
|
+
const t = this.resolveQualityThresholds();
|
|
1683
|
+
let severity = 0;
|
|
1684
|
+
if (sample.lossRatio >= t.criticalLossRatio) severity = Math.max(severity, 3);
|
|
1685
|
+
else if (sample.lossRatio >= t.poorLossRatio) severity = Math.max(severity, 2);
|
|
1686
|
+
else if (sample.lossRatio >= t.fairLossRatio) severity = Math.max(severity, 1);
|
|
1687
|
+
if (sample.rttMs !== null) {
|
|
1688
|
+
if (sample.rttMs >= t.criticalRttMs) severity = Math.max(severity, 3);
|
|
1689
|
+
else if (sample.rttMs >= t.poorRttMs) severity = Math.max(severity, 2);
|
|
1690
|
+
else if (sample.rttMs >= t.fairRttMs) severity = Math.max(severity, 1);
|
|
1691
|
+
}
|
|
1692
|
+
if (sample.jitterMs !== null) {
|
|
1693
|
+
if (sample.jitterMs >= t.poorJitterMs) severity = Math.max(severity, 2);
|
|
1694
|
+
else if (sample.jitterMs >= t.fairJitterMs) severity = Math.max(severity, 1);
|
|
1695
|
+
}
|
|
1696
|
+
if (sample.qualityLimitationReason === "bandwidth") severity = Math.max(severity, 1);
|
|
1697
|
+
return qualityLevels[severity];
|
|
1698
|
+
}
|
|
1699
|
+
// updateConnectionQuality commits level transitions with hysteresis: an
|
|
1700
|
+
// improvement is reported on the first better sample, while a degradation must
|
|
1701
|
+
// persist for connectionQualityDebounceSamples consecutive samples to commit,
|
|
1702
|
+
// so a single blip does not flap the reported level.
|
|
1703
|
+
updateConnectionQuality(sample) {
|
|
1704
|
+
const candidate = this.classifyQuality(sample);
|
|
1705
|
+
const current = this.currentQualityLevel;
|
|
1706
|
+
if (current === null || candidate === current) {
|
|
1707
|
+
this.qualityDowngradeStreak = 0;
|
|
1708
|
+
if (candidate !== current) {
|
|
1709
|
+
this.currentQualityLevel = candidate;
|
|
1710
|
+
this.opts.callbacks?.onConnectionQualityChange?.({ level: candidate, sample });
|
|
1711
|
+
}
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
if (qualitySeverity(candidate) < qualitySeverity(current)) {
|
|
1715
|
+
this.qualityDowngradeStreak = 0;
|
|
1716
|
+
this.currentQualityLevel = candidate;
|
|
1717
|
+
this.opts.callbacks?.onConnectionQualityChange?.({ level: candidate, sample });
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
const needed = Math.max(
|
|
1721
|
+
1,
|
|
1722
|
+
this.opts.connectionQualityDebounceSamples ?? defaultConnectionQualityDebounceSamples
|
|
1723
|
+
);
|
|
1724
|
+
this.qualityDowngradeStreak += 1;
|
|
1725
|
+
if (this.qualityDowngradeStreak >= needed) {
|
|
1726
|
+
this.qualityDowngradeStreak = 0;
|
|
1727
|
+
this.currentQualityLevel = candidate;
|
|
1728
|
+
this.opts.callbacks?.onConnectionQualityChange?.({ level: candidate, sample });
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1326
1731
|
async reportSelectedICEPath(pc) {
|
|
1327
1732
|
if (this.pc !== pc || this.stopped) return;
|
|
1328
1733
|
let stats;
|