@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.cjs
CHANGED
|
@@ -94,10 +94,38 @@ function selectGatewayTURNURLs(advertised, policy = "all") {
|
|
|
94
94
|
}
|
|
95
95
|
return selected;
|
|
96
96
|
}
|
|
97
|
+
var qualityLevels = ["good", "fair", "poor", "critical"];
|
|
98
|
+
function qualitySeverity(level) {
|
|
99
|
+
return qualityLevels.indexOf(level);
|
|
100
|
+
}
|
|
101
|
+
function nowMs() {
|
|
102
|
+
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
103
|
+
}
|
|
104
|
+
var limitationSeverity = { none: 0, other: 1, cpu: 2, bandwidth: 3 };
|
|
105
|
+
function worseLimitation(current, next) {
|
|
106
|
+
if (current === null) return next;
|
|
107
|
+
return (limitationSeverity[next] ?? 1) > (limitationSeverity[current] ?? 1) ? next : current;
|
|
108
|
+
}
|
|
109
|
+
function findNominatedCandidatePair(stats) {
|
|
110
|
+
let found;
|
|
111
|
+
stats.forEach((report) => {
|
|
112
|
+
const value = report;
|
|
113
|
+
if (!found && value.type === "candidate-pair" && value.state === "succeeded" && value.nominated === true) {
|
|
114
|
+
found = value;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
97
119
|
var defaultSignalingReconnectTimeoutMs = 2e4;
|
|
98
120
|
var defaultGatewayHandshakeTimeoutMs = 2e4;
|
|
99
121
|
var defaultPeerConnectionTimeoutMs = 3e4;
|
|
100
122
|
var initialGatewayAttemptTimeoutMs = 3e3;
|
|
123
|
+
var defaultGatewayFailoverTimeoutMs = 8e3;
|
|
124
|
+
var maxGatewayFailoverTimeoutMs = 2e4;
|
|
125
|
+
var defaultGatewayRetryBackoffMs = 3e3;
|
|
126
|
+
var minGatewayRetryBackoffMs = 250;
|
|
127
|
+
var maxGatewayRetryBackoffMs = 5e3;
|
|
128
|
+
var maxPlacementRedirects = 2;
|
|
101
129
|
var signalingResumeAttemptTimeoutMs = 3e3;
|
|
102
130
|
var signalingResumeMaxBackoffMs = 3e3;
|
|
103
131
|
var senderRestartPauseMs = 100;
|
|
@@ -108,6 +136,18 @@ var negotiationReconnectGraceMs = 5e3;
|
|
|
108
136
|
var minimumIntentionalTrackEndRetentionMs = 35e3;
|
|
109
137
|
var maxUserTextBytes = 4 * 1024;
|
|
110
138
|
var maxRetainedICECandidates = 64;
|
|
139
|
+
var defaultConnectionStatsIntervalMs = 2e3;
|
|
140
|
+
var defaultConnectionQualityDebounceSamples = 2;
|
|
141
|
+
var defaultConnectionQualityThresholds = {
|
|
142
|
+
fairLossRatio: 0.02,
|
|
143
|
+
poorLossRatio: 0.05,
|
|
144
|
+
criticalLossRatio: 0.12,
|
|
145
|
+
fairRttMs: 300,
|
|
146
|
+
poorRttMs: 600,
|
|
147
|
+
criticalRttMs: 1e3,
|
|
148
|
+
fairJitterMs: 50,
|
|
149
|
+
poorJitterMs: 150
|
|
150
|
+
};
|
|
111
151
|
var ReportedPublisherError = class extends Error {
|
|
112
152
|
constructor(message, fatal = false) {
|
|
113
153
|
super(message);
|
|
@@ -150,6 +190,17 @@ var Publisher = class {
|
|
|
150
190
|
gatewayURL = null;
|
|
151
191
|
lastReportedICEPath = null;
|
|
152
192
|
watchedICETransports = /* @__PURE__ */ new WeakSet();
|
|
193
|
+
// Periodic getStats() polling for connection-quality assessment. The timer runs
|
|
194
|
+
// only while connected; lastStatsSample anchors the windowed loss/bitrate deltas
|
|
195
|
+
// (loss is tracked per outbound SSRC so it can be paired to the remote's report),
|
|
196
|
+
// and currentQualityLevel/qualityDowngradeStreak drive the debounced classifier.
|
|
197
|
+
connectionStatsTimer = null;
|
|
198
|
+
// Guards against overlapping getStats() calls: if one outlives the interval,
|
|
199
|
+
// ticks are skipped until it resolves so samples never complete out of order.
|
|
200
|
+
statsSampleInFlight = false;
|
|
201
|
+
lastStatsSample = null;
|
|
202
|
+
currentQualityLevel = null;
|
|
203
|
+
qualityDowngradeStreak = 0;
|
|
153
204
|
stopped = true;
|
|
154
205
|
// Every start/stop boundary advances lifecycleGeneration. Async work captures
|
|
155
206
|
// the generation it belongs to and may never mutate or terminate a later run.
|
|
@@ -362,10 +413,13 @@ var Publisher = class {
|
|
|
362
413
|
if (state === "connected") {
|
|
363
414
|
this.clearPeerConnectionTimeout();
|
|
364
415
|
void this.reportSelectedICEPath(pc);
|
|
416
|
+
this.startConnectionStatsLoop(generation, pc);
|
|
365
417
|
this.opts.callbacks?.onConnected?.();
|
|
366
418
|
} else if (state === "failed") {
|
|
367
419
|
this.clearPeerConnectionTimeout();
|
|
368
420
|
this.terminateWithError(new Error("WebRTC connection failed"), true, generation);
|
|
421
|
+
} else {
|
|
422
|
+
this.stopConnectionStatsLoop();
|
|
369
423
|
}
|
|
370
424
|
};
|
|
371
425
|
if (initialTrack) {
|
|
@@ -500,6 +554,7 @@ var Publisher = class {
|
|
|
500
554
|
this.runAbort?.abort();
|
|
501
555
|
this.runAbort = null;
|
|
502
556
|
this.clearPeerConnectionTimeout();
|
|
557
|
+
this.stopConnectionStatsLoop();
|
|
503
558
|
this.stopped = true;
|
|
504
559
|
this.cancelAllMediaRecovery();
|
|
505
560
|
this.reconnectGeneration++;
|
|
@@ -551,6 +606,16 @@ var Publisher = class {
|
|
|
551
606
|
// -------------------------------------------------------------------------
|
|
552
607
|
// Private helpers
|
|
553
608
|
// -------------------------------------------------------------------------
|
|
609
|
+
// raceGateways opens every candidate gateway at once, then decides in two
|
|
610
|
+
// separate moments. SELECTION: the first socket to deliver `accepted` (a cheap,
|
|
611
|
+
// control-plane-free acknowledgement) is chosen on network path; the browser
|
|
612
|
+
// sends `proceed` on that one only and keeps the rest as standbys. PLACEMENT:
|
|
613
|
+
// the selected region does its control-plane work and returns `ready`. If the
|
|
614
|
+
// selection dies (socket close/error → immediately) or stalls past the failover
|
|
615
|
+
// deadline (a hung-but-open socket), the browser abandons it — closing the
|
|
616
|
+
// socket cancels that region's placement server-side — and selects the
|
|
617
|
+
// next-fastest standby. A `placement_redirect` points the browser at the region
|
|
618
|
+
// that already holds the stream so a mistimed failover self-heals.
|
|
554
619
|
raceGateways(signal) {
|
|
555
620
|
return new Promise((resolve, reject) => {
|
|
556
621
|
const { gatewayURLs, token } = this.opts;
|
|
@@ -560,43 +625,149 @@ var Publisher = class {
|
|
|
560
625
|
}
|
|
561
626
|
const sockets = [];
|
|
562
627
|
const attemptTimers = /* @__PURE__ */ new Map();
|
|
628
|
+
const reopenTimers = /* @__PURE__ */ new Set();
|
|
629
|
+
const standbys = [];
|
|
630
|
+
let selected = null;
|
|
631
|
+
let failoverTimer = null;
|
|
632
|
+
let redirects = 0;
|
|
563
633
|
let settled = false;
|
|
564
634
|
let timeoutTimer = null;
|
|
635
|
+
const failoverMs = Math.min(
|
|
636
|
+
maxGatewayFailoverTimeoutMs,
|
|
637
|
+
Math.max(0, this.opts.gatewayFailoverTimeoutMs ?? defaultGatewayFailoverTimeoutMs)
|
|
638
|
+
);
|
|
565
639
|
const clearTimeoutTimer = () => {
|
|
566
640
|
if (timeoutTimer !== null) clearTimeout(timeoutTimer);
|
|
567
641
|
timeoutTimer = null;
|
|
568
642
|
};
|
|
643
|
+
const clearFailoverTimer = () => {
|
|
644
|
+
if (failoverTimer !== null) clearTimeout(failoverTimer);
|
|
645
|
+
failoverTimer = null;
|
|
646
|
+
};
|
|
647
|
+
const clearReopenTimers = () => {
|
|
648
|
+
for (const timer of reopenTimers) clearTimeout(timer);
|
|
649
|
+
reopenTimers.clear();
|
|
650
|
+
};
|
|
569
651
|
const clearAttemptTimer = (socket) => {
|
|
570
652
|
const timer = attemptTimers.get(socket);
|
|
571
653
|
if (timer !== void 0) clearTimeout(timer);
|
|
572
654
|
attemptTimers.delete(socket);
|
|
573
655
|
};
|
|
656
|
+
const detach = (socket) => {
|
|
657
|
+
clearAttemptTimer(socket);
|
|
658
|
+
socket.onmessage = null;
|
|
659
|
+
socket.onerror = null;
|
|
660
|
+
socket.onclose = null;
|
|
661
|
+
};
|
|
662
|
+
const dropStandby = (socket) => {
|
|
663
|
+
const i = standbys.indexOf(socket);
|
|
664
|
+
if (i !== -1) standbys.splice(i, 1);
|
|
665
|
+
};
|
|
574
666
|
const closeAll = (except) => {
|
|
575
667
|
for (const s of sockets) {
|
|
576
|
-
clearAttemptTimer(s);
|
|
577
668
|
if (s !== except) {
|
|
578
|
-
s
|
|
579
|
-
s.onerror = null;
|
|
580
|
-
s.onclose = null;
|
|
669
|
+
detach(s);
|
|
581
670
|
s.close();
|
|
582
671
|
}
|
|
583
672
|
}
|
|
584
673
|
};
|
|
585
|
-
const
|
|
586
|
-
|
|
674
|
+
const win = (ws, readyInfo, gatewayURL) => {
|
|
675
|
+
settled = true;
|
|
676
|
+
clearTimeoutTimer();
|
|
677
|
+
clearFailoverTimer();
|
|
678
|
+
clearReopenTimers();
|
|
679
|
+
signal.removeEventListener("abort", abort);
|
|
680
|
+
closeAll(ws);
|
|
681
|
+
resolve({ ws, readyInfo, gatewayURL });
|
|
682
|
+
};
|
|
683
|
+
const fail = (err) => {
|
|
684
|
+
settled = true;
|
|
685
|
+
clearTimeoutTimer();
|
|
686
|
+
clearFailoverTimer();
|
|
687
|
+
clearReopenTimers();
|
|
688
|
+
signal.removeEventListener("abort", abort);
|
|
689
|
+
closeAll();
|
|
690
|
+
reject(err);
|
|
691
|
+
};
|
|
692
|
+
const checkExhausted = () => {
|
|
693
|
+
if (settled || selected !== null || standbys.length > 0 || reopenTimers.size > 0) return;
|
|
587
694
|
if (sockets.every((s) => s.readyState === WebSocket.CLOSED || s.readyState === WebSocket.CLOSING)) {
|
|
588
|
-
|
|
589
|
-
clearTimeoutTimer();
|
|
590
|
-
signal.removeEventListener("abort", abort);
|
|
591
|
-
reject(new Error("all gateways failed to connect"));
|
|
695
|
+
fail(new Error("all gateways failed to connect"));
|
|
592
696
|
}
|
|
593
697
|
};
|
|
594
|
-
const
|
|
698
|
+
const select = (ws) => {
|
|
699
|
+
selected = ws;
|
|
700
|
+
dropStandby(ws);
|
|
701
|
+
try {
|
|
702
|
+
ws.send(JSON.stringify({ type: "proceed" }));
|
|
703
|
+
} catch {
|
|
704
|
+
socketDown(ws);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
clearFailoverTimer();
|
|
708
|
+
failoverTimer = setTimeout(() => failover(ws), failoverMs);
|
|
709
|
+
};
|
|
710
|
+
const failover = (deadSocket) => {
|
|
711
|
+
if (settled || deadSocket !== selected) return;
|
|
712
|
+
clearFailoverTimer();
|
|
713
|
+
detach(deadSocket);
|
|
714
|
+
deadSocket.close();
|
|
715
|
+
selected = null;
|
|
716
|
+
const next = standbys.shift();
|
|
717
|
+
if (next) {
|
|
718
|
+
select(next);
|
|
719
|
+
} else {
|
|
720
|
+
checkExhausted();
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
const redirect = (gatewayURL) => {
|
|
595
724
|
if (settled) return;
|
|
596
|
-
|
|
597
|
-
|
|
725
|
+
if (redirects >= maxPlacementRedirects) {
|
|
726
|
+
fail(new Error("too many placement redirects"));
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
redirects++;
|
|
730
|
+
clearFailoverTimer();
|
|
598
731
|
closeAll();
|
|
599
|
-
|
|
732
|
+
standbys.length = 0;
|
|
733
|
+
selected = null;
|
|
734
|
+
try {
|
|
735
|
+
openGateway(gatewayURL);
|
|
736
|
+
} catch (err) {
|
|
737
|
+
fail(err instanceof Error ? err : new Error(String(err)));
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
const retryUnavailable = (ws, gatewayURL, retryAfterMs) => {
|
|
741
|
+
detach(ws);
|
|
742
|
+
ws.close();
|
|
743
|
+
const delay = Math.min(
|
|
744
|
+
maxGatewayRetryBackoffMs,
|
|
745
|
+
Math.max(minGatewayRetryBackoffMs, retryAfterMs ?? defaultGatewayRetryBackoffMs)
|
|
746
|
+
);
|
|
747
|
+
const timer = setTimeout(() => {
|
|
748
|
+
reopenTimers.delete(timer);
|
|
749
|
+
if (settled) return;
|
|
750
|
+
try {
|
|
751
|
+
openGateway(gatewayURL);
|
|
752
|
+
} catch (err) {
|
|
753
|
+
fail(err instanceof Error ? err : new Error(String(err)));
|
|
754
|
+
}
|
|
755
|
+
}, delay);
|
|
756
|
+
reopenTimers.add(timer);
|
|
757
|
+
};
|
|
758
|
+
const socketDown = (ws) => {
|
|
759
|
+
if (settled) return;
|
|
760
|
+
clearAttemptTimer(ws);
|
|
761
|
+
dropStandby(ws);
|
|
762
|
+
if (ws === selected) {
|
|
763
|
+
failover(ws);
|
|
764
|
+
} else {
|
|
765
|
+
checkExhausted();
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
const abort = () => {
|
|
769
|
+
if (settled) return;
|
|
770
|
+
fail(new PublisherStoppedError("publisher stopped"));
|
|
600
771
|
};
|
|
601
772
|
if (signal.aborted) {
|
|
602
773
|
abort();
|
|
@@ -609,10 +780,7 @@ var Publisher = class {
|
|
|
609
780
|
);
|
|
610
781
|
timeoutTimer = setTimeout(() => {
|
|
611
782
|
if (settled) return;
|
|
612
|
-
|
|
613
|
-
signal.removeEventListener("abort", abort);
|
|
614
|
-
closeAll();
|
|
615
|
-
reject(new Error(`gateway handshake timed out after ${timeoutMs}ms`));
|
|
783
|
+
fail(new Error(`gateway handshake timed out after ${timeoutMs}ms`));
|
|
616
784
|
}, timeoutMs);
|
|
617
785
|
const openGateway = (gatewayURL) => {
|
|
618
786
|
if (settled) return;
|
|
@@ -624,58 +792,51 @@ var Publisher = class {
|
|
|
624
792
|
const attemptTimer = setTimeout(() => {
|
|
625
793
|
attemptTimers.delete(ws);
|
|
626
794
|
if (settled || accepted) return;
|
|
627
|
-
ws
|
|
628
|
-
ws.onerror = null;
|
|
629
|
-
ws.onclose = null;
|
|
795
|
+
detach(ws);
|
|
630
796
|
ws.close();
|
|
631
797
|
try {
|
|
632
798
|
openGateway(gatewayURL);
|
|
633
799
|
} catch (err) {
|
|
634
|
-
|
|
635
|
-
clearTimeoutTimer();
|
|
636
|
-
signal.removeEventListener("abort", abort);
|
|
637
|
-
closeAll();
|
|
638
|
-
reject(err);
|
|
800
|
+
fail(err instanceof Error ? err : new Error(String(err)));
|
|
639
801
|
}
|
|
640
802
|
}, initialGatewayAttemptTimeoutMs);
|
|
641
803
|
attemptTimers.set(ws, attemptTimer);
|
|
642
804
|
ws.onmessage = (ev) => {
|
|
643
805
|
if (settled) return;
|
|
806
|
+
let msg;
|
|
644
807
|
try {
|
|
645
|
-
|
|
646
|
-
if (!accepted && msg.type === "accepted") {
|
|
647
|
-
accepted = true;
|
|
648
|
-
clearAttemptTimer(ws);
|
|
649
|
-
ws.send(JSON.stringify({ type: "proceed" }));
|
|
650
|
-
} else if (accepted && msg.type === "ready") {
|
|
651
|
-
settled = true;
|
|
652
|
-
clearTimeoutTimer();
|
|
653
|
-
signal.removeEventListener("abort", abort);
|
|
654
|
-
closeAll(ws);
|
|
655
|
-
resolve({ ws, readyInfo: msg, gatewayURL });
|
|
656
|
-
}
|
|
808
|
+
msg = JSON.parse(ev.data);
|
|
657
809
|
} catch {
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (!accepted) {
|
|
813
|
+
if (msg.type === "unavailable") {
|
|
814
|
+
retryUnavailable(ws, gatewayURL, msg.retry_after_ms);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (msg.type !== "accepted") return;
|
|
818
|
+
accepted = true;
|
|
819
|
+
clearAttemptTimer(ws);
|
|
820
|
+
if (selected === null) select(ws);
|
|
821
|
+
else standbys.push(ws);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
if (ws !== selected) return;
|
|
825
|
+
if (msg.type === "ready") {
|
|
826
|
+
win(ws, msg, gatewayURL);
|
|
827
|
+
} else if (msg.type === "placement_redirect" && msg.gateway_url) {
|
|
828
|
+
redirect(msg.gateway_url);
|
|
658
829
|
}
|
|
659
830
|
};
|
|
660
|
-
ws.onerror = () =>
|
|
661
|
-
|
|
662
|
-
checkAllFailed();
|
|
663
|
-
};
|
|
664
|
-
ws.onclose = () => {
|
|
665
|
-
clearAttemptTimer(ws);
|
|
666
|
-
checkAllFailed();
|
|
667
|
-
};
|
|
831
|
+
ws.onerror = () => socketDown(ws);
|
|
832
|
+
ws.onclose = () => socketDown(ws);
|
|
668
833
|
};
|
|
669
834
|
try {
|
|
670
835
|
for (const gatewayURL of gatewayURLs) {
|
|
671
836
|
openGateway(gatewayURL);
|
|
672
837
|
}
|
|
673
838
|
} catch (err) {
|
|
674
|
-
|
|
675
|
-
clearTimeoutTimer();
|
|
676
|
-
signal.removeEventListener("abort", abort);
|
|
677
|
-
closeAll();
|
|
678
|
-
reject(err);
|
|
839
|
+
fail(err instanceof Error ? err : new Error(String(err)));
|
|
679
840
|
}
|
|
680
841
|
});
|
|
681
842
|
}
|
|
@@ -882,6 +1043,7 @@ var Publisher = class {
|
|
|
882
1043
|
this.runAbort?.abort();
|
|
883
1044
|
this.runAbort = null;
|
|
884
1045
|
this.clearPeerConnectionTimeout();
|
|
1046
|
+
this.stopConnectionStatsLoop();
|
|
885
1047
|
this.stopped = true;
|
|
886
1048
|
this.cancelAllMediaRecovery();
|
|
887
1049
|
this.reconnectGeneration++;
|
|
@@ -1352,6 +1514,249 @@ var Publisher = class {
|
|
|
1352
1514
|
} catch {
|
|
1353
1515
|
}
|
|
1354
1516
|
}
|
|
1517
|
+
// startConnectionStatsLoop begins periodic getStats() sampling once the peer
|
|
1518
|
+
// connection is connected. It is a no-op when polling is disabled or no
|
|
1519
|
+
// consumer is listening, and it re-baselines on each call so a reconnect after
|
|
1520
|
+
// an ICE restart starts a fresh quality assessment.
|
|
1521
|
+
startConnectionStatsLoop(generation, pc) {
|
|
1522
|
+
const intervalMs = Math.max(
|
|
1523
|
+
0,
|
|
1524
|
+
this.opts.connectionStatsIntervalMs ?? defaultConnectionStatsIntervalMs
|
|
1525
|
+
);
|
|
1526
|
+
const listening = !!this.opts.callbacks?.onConnectionStats || !!this.opts.callbacks?.onConnectionQualityChange;
|
|
1527
|
+
if (intervalMs === 0 || !listening) return;
|
|
1528
|
+
this.stopConnectionStatsLoop();
|
|
1529
|
+
const timer = setInterval(() => {
|
|
1530
|
+
if (this.connectionStatsTimer !== timer) return;
|
|
1531
|
+
if (!this.isActiveRun(generation, pc)) {
|
|
1532
|
+
this.stopConnectionStatsLoop();
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
if (this.statsSampleInFlight) return;
|
|
1536
|
+
this.statsSampleInFlight = true;
|
|
1537
|
+
void this.sampleConnectionQuality(generation, pc, timer);
|
|
1538
|
+
}, intervalMs);
|
|
1539
|
+
this.connectionStatsTimer = timer;
|
|
1540
|
+
}
|
|
1541
|
+
stopConnectionStatsLoop() {
|
|
1542
|
+
if (this.connectionStatsTimer !== null) clearInterval(this.connectionStatsTimer);
|
|
1543
|
+
this.connectionStatsTimer = null;
|
|
1544
|
+
this.statsSampleInFlight = false;
|
|
1545
|
+
this.lastStatsSample = null;
|
|
1546
|
+
this.currentQualityLevel = null;
|
|
1547
|
+
this.qualityDowngradeStreak = 0;
|
|
1548
|
+
}
|
|
1549
|
+
async sampleConnectionQuality(generation, pc, timer) {
|
|
1550
|
+
let stats = null;
|
|
1551
|
+
try {
|
|
1552
|
+
stats = await pc.getStats();
|
|
1553
|
+
} catch {
|
|
1554
|
+
}
|
|
1555
|
+
if (this.connectionStatsTimer !== timer) return;
|
|
1556
|
+
this.statsSampleInFlight = false;
|
|
1557
|
+
if (!stats || !this.isActiveRun(generation, pc) || pc.connectionState !== "connected") {
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
const sample = this.buildStatsSample(stats);
|
|
1561
|
+
this.opts.callbacks?.onConnectionStats?.(sample);
|
|
1562
|
+
if (this.connectionStatsTimer !== timer || !this.isActiveRun(generation, pc) || pc.connectionState !== "connected") {
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
this.updateConnectionQuality(sample);
|
|
1566
|
+
}
|
|
1567
|
+
// buildStatsSample derives one sample from a getStats() report. Loss is a mean of
|
|
1568
|
+
// per-stream loss fractions weighted by each stream's packets sent this window, so
|
|
1569
|
+
// every stream carrying traffic contributes. A stream uses its remote fractionLost
|
|
1570
|
+
// when present — the remote's loss ratio over its RR interval, self-aligned and
|
|
1571
|
+
// excluding retransmissions (which ride a separate SSRC); dividing the remote's
|
|
1572
|
+
// Δ(packetsLost) by the local Δ(packetsSent) would instead misalign an RTCP-timed
|
|
1573
|
+
// numerator with a continuously-updated denominator — and otherwise falls back to
|
|
1574
|
+
// its own windowed Δ(packetsLost) / Δ(distinct media packets sent), a denominator
|
|
1575
|
+
// that excludes retransmissions (packetsSent - retransmittedPacketsSent). Mixing the
|
|
1576
|
+
// two per stream keeps a lossy stream from being dropped when a sibling has fractionLost.
|
|
1577
|
+
//
|
|
1578
|
+
// Deltas are taken only over outbound stats-object ids present in BOTH this and the
|
|
1579
|
+
// previous sample. A track replace/unpublish (or a recycled SSRC) deletes the old
|
|
1580
|
+
// stats object and creates a new one with a new id; counting a vanished stream's
|
|
1581
|
+
// missing tail, or a fresh object's cumulative total as an interval delta, would
|
|
1582
|
+
// corrupt loss. Excluding the symmetric difference baselines new objects (they count
|
|
1583
|
+
// from their next sample) and drops departed ones, so continuous streams still measure
|
|
1584
|
+
// loss even when an SSRC number is reused across distinct stats objects.
|
|
1585
|
+
buildStatsSample(stats) {
|
|
1586
|
+
const timestamp = nowMs();
|
|
1587
|
+
let nackCount = 0;
|
|
1588
|
+
let pliCount = 0;
|
|
1589
|
+
let haveOutbound = false;
|
|
1590
|
+
let haveOutboundVideo = false;
|
|
1591
|
+
let rttSeconds = null;
|
|
1592
|
+
let jitterSeconds = null;
|
|
1593
|
+
let limitationReason = null;
|
|
1594
|
+
let selectedPairID;
|
|
1595
|
+
let transportBytes = null;
|
|
1596
|
+
const streams = /* @__PURE__ */ new Map();
|
|
1597
|
+
const ssrcToId = /* @__PURE__ */ new Map();
|
|
1598
|
+
stats.forEach((report) => {
|
|
1599
|
+
const value = report;
|
|
1600
|
+
switch (value.type) {
|
|
1601
|
+
case "outbound-rtp": {
|
|
1602
|
+
haveOutbound = true;
|
|
1603
|
+
if (value.kind === "video") haveOutboundVideo = true;
|
|
1604
|
+
const ssrc = typeof value.ssrc === "number" ? value.ssrc : NaN;
|
|
1605
|
+
const sent = typeof value.packetsSent === "number" ? value.packetsSent : 0;
|
|
1606
|
+
const retransmitted = typeof value.retransmittedPacketsSent === "number" ? value.retransmittedPacketsSent : 0;
|
|
1607
|
+
const bytesSent = typeof value.bytesSent === "number" ? value.bytesSent : 0;
|
|
1608
|
+
if (typeof value.nackCount === "number") nackCount += value.nackCount;
|
|
1609
|
+
if (typeof value.pliCount === "number") pliCount += value.pliCount;
|
|
1610
|
+
if (typeof value.qualityLimitationReason === "string") {
|
|
1611
|
+
limitationReason = worseLimitation(limitationReason, value.qualityLimitationReason);
|
|
1612
|
+
}
|
|
1613
|
+
if (typeof value.id === "string") {
|
|
1614
|
+
const expected = Math.max(0, sent - retransmitted);
|
|
1615
|
+
streams.set(value.id, { expected, lost: null, fractionLost: null, bytesSent });
|
|
1616
|
+
if (!Number.isNaN(ssrc)) ssrcToId.set(ssrc, value.id);
|
|
1617
|
+
}
|
|
1618
|
+
break;
|
|
1619
|
+
}
|
|
1620
|
+
case "remote-inbound-rtp": {
|
|
1621
|
+
if (typeof value.roundTripTime === "number") {
|
|
1622
|
+
rttSeconds = Math.max(rttSeconds ?? 0, value.roundTripTime);
|
|
1623
|
+
}
|
|
1624
|
+
if (typeof value.jitter === "number") {
|
|
1625
|
+
jitterSeconds = Math.max(jitterSeconds ?? 0, value.jitter);
|
|
1626
|
+
}
|
|
1627
|
+
break;
|
|
1628
|
+
}
|
|
1629
|
+
case "transport": {
|
|
1630
|
+
if (typeof value.selectedCandidatePairId === "string") {
|
|
1631
|
+
selectedPairID = value.selectedCandidatePairId;
|
|
1632
|
+
}
|
|
1633
|
+
if (typeof value.bytesSent === "number") {
|
|
1634
|
+
transportBytes = (transportBytes ?? 0) + value.bytesSent;
|
|
1635
|
+
}
|
|
1636
|
+
break;
|
|
1637
|
+
}
|
|
1638
|
+
default:
|
|
1639
|
+
break;
|
|
1640
|
+
}
|
|
1641
|
+
});
|
|
1642
|
+
stats.forEach((report) => {
|
|
1643
|
+
const value = report;
|
|
1644
|
+
if (value.type !== "remote-inbound-rtp") return;
|
|
1645
|
+
const outboundId = typeof value.localId === "string" && streams.has(value.localId) ? value.localId : typeof value.ssrc === "number" ? ssrcToId.get(value.ssrc) : void 0;
|
|
1646
|
+
if (outboundId === void 0) return;
|
|
1647
|
+
const stream = streams.get(outboundId);
|
|
1648
|
+
if (!stream) return;
|
|
1649
|
+
const lost = typeof value.packetsLost === "number" ? value.packetsLost : 0;
|
|
1650
|
+
stream.lost = (stream.lost ?? 0) + lost;
|
|
1651
|
+
if (typeof value.fractionLost === "number" && Number.isFinite(value.fractionLost)) {
|
|
1652
|
+
stream.fractionLost = Math.min(1, Math.max(0, value.fractionLost));
|
|
1653
|
+
}
|
|
1654
|
+
});
|
|
1655
|
+
const pair = (selectedPairID ? stats.get(selectedPairID) : void 0) ?? findNominatedCandidatePair(stats);
|
|
1656
|
+
const availableOutgoingBitrate = pair && typeof pair.availableOutgoingBitrate === "number" ? pair.availableOutgoingBitrate : null;
|
|
1657
|
+
if (rttSeconds === null && pair && typeof pair.currentRoundTripTime === "number") {
|
|
1658
|
+
rttSeconds = pair.currentRoundTripTime;
|
|
1659
|
+
}
|
|
1660
|
+
const prev = this.lastStatsSample;
|
|
1661
|
+
let weightedFractionSum = 0;
|
|
1662
|
+
let weightSum = 0;
|
|
1663
|
+
let intersectionBytesDelta = 0;
|
|
1664
|
+
let hadStreamOverlap = false;
|
|
1665
|
+
streams.forEach((current, id) => {
|
|
1666
|
+
const before = prev?.streams.get(id);
|
|
1667
|
+
if (!before) return;
|
|
1668
|
+
hadStreamOverlap = true;
|
|
1669
|
+
intersectionBytesDelta += Math.max(0, current.bytesSent - before.bytesSent);
|
|
1670
|
+
const deltaExpected = Math.max(0, current.expected - before.expected);
|
|
1671
|
+
if (deltaExpected <= 0) return;
|
|
1672
|
+
let fraction = null;
|
|
1673
|
+
if (current.fractionLost !== null) {
|
|
1674
|
+
fraction = current.fractionLost;
|
|
1675
|
+
} else if (current.lost !== null && before.lost !== null) {
|
|
1676
|
+
fraction = Math.min(1, Math.max(0, current.lost - before.lost) / deltaExpected);
|
|
1677
|
+
}
|
|
1678
|
+
if (fraction === null) return;
|
|
1679
|
+
weightedFractionSum += fraction * deltaExpected;
|
|
1680
|
+
weightSum += deltaExpected;
|
|
1681
|
+
});
|
|
1682
|
+
const deltaSeconds = prev ? (timestamp - prev.timestamp) / 1e3 : 0;
|
|
1683
|
+
this.lastStatsSample = { timestamp, transportBytes, streams };
|
|
1684
|
+
const lossRatio = weightSum > 0 ? Math.min(1, weightedFractionSum / weightSum) : 0;
|
|
1685
|
+
let sendBitrate = null;
|
|
1686
|
+
if (prev && deltaSeconds > 0) {
|
|
1687
|
+
if (transportBytes !== null && prev.transportBytes !== null) {
|
|
1688
|
+
sendBitrate = Math.max(0, transportBytes - prev.transportBytes) * 8 / deltaSeconds;
|
|
1689
|
+
} else if (hadStreamOverlap) {
|
|
1690
|
+
sendBitrate = intersectionBytesDelta * 8 / deltaSeconds;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
return {
|
|
1694
|
+
timestamp,
|
|
1695
|
+
lossRatio,
|
|
1696
|
+
rttMs: rttSeconds !== null ? rttSeconds * 1e3 : null,
|
|
1697
|
+
jitterMs: jitterSeconds !== null ? jitterSeconds * 1e3 : null,
|
|
1698
|
+
availableOutgoingBitrate,
|
|
1699
|
+
sendBitrate,
|
|
1700
|
+
qualityLimitationReason: haveOutboundVideo ? limitationReason ?? "none" : null,
|
|
1701
|
+
nackCount: haveOutbound ? nackCount : null,
|
|
1702
|
+
pliCount: haveOutbound ? pliCount : null
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
resolveQualityThresholds() {
|
|
1706
|
+
return { ...defaultConnectionQualityThresholds, ...this.opts.connectionQualityThresholds ?? {} };
|
|
1707
|
+
}
|
|
1708
|
+
// classifyQuality maps a sample to a level. Loss is the primary axis; RTT,
|
|
1709
|
+
// jitter, and a bandwidth-limited encoder can only raise severity.
|
|
1710
|
+
classifyQuality(sample) {
|
|
1711
|
+
const t = this.resolveQualityThresholds();
|
|
1712
|
+
let severity = 0;
|
|
1713
|
+
if (sample.lossRatio >= t.criticalLossRatio) severity = Math.max(severity, 3);
|
|
1714
|
+
else if (sample.lossRatio >= t.poorLossRatio) severity = Math.max(severity, 2);
|
|
1715
|
+
else if (sample.lossRatio >= t.fairLossRatio) severity = Math.max(severity, 1);
|
|
1716
|
+
if (sample.rttMs !== null) {
|
|
1717
|
+
if (sample.rttMs >= t.criticalRttMs) severity = Math.max(severity, 3);
|
|
1718
|
+
else if (sample.rttMs >= t.poorRttMs) severity = Math.max(severity, 2);
|
|
1719
|
+
else if (sample.rttMs >= t.fairRttMs) severity = Math.max(severity, 1);
|
|
1720
|
+
}
|
|
1721
|
+
if (sample.jitterMs !== null) {
|
|
1722
|
+
if (sample.jitterMs >= t.poorJitterMs) severity = Math.max(severity, 2);
|
|
1723
|
+
else if (sample.jitterMs >= t.fairJitterMs) severity = Math.max(severity, 1);
|
|
1724
|
+
}
|
|
1725
|
+
if (sample.qualityLimitationReason === "bandwidth") severity = Math.max(severity, 1);
|
|
1726
|
+
return qualityLevels[severity];
|
|
1727
|
+
}
|
|
1728
|
+
// updateConnectionQuality commits level transitions with hysteresis: an
|
|
1729
|
+
// improvement is reported on the first better sample, while a degradation must
|
|
1730
|
+
// persist for connectionQualityDebounceSamples consecutive samples to commit,
|
|
1731
|
+
// so a single blip does not flap the reported level.
|
|
1732
|
+
updateConnectionQuality(sample) {
|
|
1733
|
+
const candidate = this.classifyQuality(sample);
|
|
1734
|
+
const current = this.currentQualityLevel;
|
|
1735
|
+
if (current === null || candidate === current) {
|
|
1736
|
+
this.qualityDowngradeStreak = 0;
|
|
1737
|
+
if (candidate !== current) {
|
|
1738
|
+
this.currentQualityLevel = candidate;
|
|
1739
|
+
this.opts.callbacks?.onConnectionQualityChange?.({ level: candidate, sample });
|
|
1740
|
+
}
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
if (qualitySeverity(candidate) < qualitySeverity(current)) {
|
|
1744
|
+
this.qualityDowngradeStreak = 0;
|
|
1745
|
+
this.currentQualityLevel = candidate;
|
|
1746
|
+
this.opts.callbacks?.onConnectionQualityChange?.({ level: candidate, sample });
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
const needed = Math.max(
|
|
1750
|
+
1,
|
|
1751
|
+
this.opts.connectionQualityDebounceSamples ?? defaultConnectionQualityDebounceSamples
|
|
1752
|
+
);
|
|
1753
|
+
this.qualityDowngradeStreak += 1;
|
|
1754
|
+
if (this.qualityDowngradeStreak >= needed) {
|
|
1755
|
+
this.qualityDowngradeStreak = 0;
|
|
1756
|
+
this.currentQualityLevel = candidate;
|
|
1757
|
+
this.opts.callbacks?.onConnectionQualityChange?.({ level: candidate, sample });
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1355
1760
|
async reportSelectedICEPath(pc) {
|
|
1356
1761
|
if (this.pc !== pc || this.stopped) return;
|
|
1357
1762
|
let stats;
|