@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/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.onmessage = null;
579
- s.onerror = null;
580
- s.onclose = null;
669
+ detach(s);
581
670
  s.close();
582
671
  }
583
672
  }
584
673
  };
585
- const checkAllFailed = () => {
586
- if (settled) return;
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
- settled = true;
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 abort = () => {
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
- settled = true;
597
- clearTimeoutTimer();
725
+ if (redirects >= maxPlacementRedirects) {
726
+ fail(new Error("too many placement redirects"));
727
+ return;
728
+ }
729
+ redirects++;
730
+ clearFailoverTimer();
598
731
  closeAll();
599
- reject(new PublisherStoppedError("publisher stopped"));
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
- settled = true;
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.onmessage = null;
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
- settled = true;
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
- const msg = JSON.parse(ev.data);
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
- clearAttemptTimer(ws);
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
- settled = true;
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;