@furious.luke/argus-js 0.5.5 → 0.5.7

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.js CHANGED
@@ -65,6 +65,28 @@ 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;
@@ -85,6 +107,18 @@ var negotiationReconnectGraceMs = 5e3;
85
107
  var minimumIntentionalTrackEndRetentionMs = 35e3;
86
108
  var maxUserTextBytes = 4 * 1024;
87
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
+ };
88
122
  var ReportedPublisherError = class extends Error {
89
123
  constructor(message, fatal = false) {
90
124
  super(message);
@@ -127,6 +161,17 @@ var Publisher = class {
127
161
  gatewayURL = null;
128
162
  lastReportedICEPath = null;
129
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;
130
175
  stopped = true;
131
176
  // Every start/stop boundary advances lifecycleGeneration. Async work captures
132
177
  // the generation it belongs to and may never mutate or terminate a later run.
@@ -339,10 +384,13 @@ var Publisher = class {
339
384
  if (state === "connected") {
340
385
  this.clearPeerConnectionTimeout();
341
386
  void this.reportSelectedICEPath(pc);
387
+ this.startConnectionStatsLoop(generation, pc);
342
388
  this.opts.callbacks?.onConnected?.();
343
389
  } else if (state === "failed") {
344
390
  this.clearPeerConnectionTimeout();
345
391
  this.terminateWithError(new Error("WebRTC connection failed"), true, generation);
392
+ } else {
393
+ this.stopConnectionStatsLoop();
346
394
  }
347
395
  };
348
396
  if (initialTrack) {
@@ -477,6 +525,7 @@ var Publisher = class {
477
525
  this.runAbort?.abort();
478
526
  this.runAbort = null;
479
527
  this.clearPeerConnectionTimeout();
528
+ this.stopConnectionStatsLoop();
480
529
  this.stopped = true;
481
530
  this.cancelAllMediaRecovery();
482
531
  this.reconnectGeneration++;
@@ -965,6 +1014,7 @@ var Publisher = class {
965
1014
  this.runAbort?.abort();
966
1015
  this.runAbort = null;
967
1016
  this.clearPeerConnectionTimeout();
1017
+ this.stopConnectionStatsLoop();
968
1018
  this.stopped = true;
969
1019
  this.cancelAllMediaRecovery();
970
1020
  this.reconnectGeneration++;
@@ -1042,6 +1092,17 @@ var Publisher = class {
1042
1092
  this.completeMediaRecovery(msg.track);
1043
1093
  break;
1044
1094
  }
1095
+ case "speech_slow": {
1096
+ this.opts.callbacks?.onSpeechQualityChange?.({
1097
+ degraded: true,
1098
+ realtimeFactor: msg.realtime_factor
1099
+ });
1100
+ break;
1101
+ }
1102
+ case "speech_recovered": {
1103
+ this.opts.callbacks?.onSpeechQualityChange?.({ degraded: false });
1104
+ break;
1105
+ }
1045
1106
  case "error": {
1046
1107
  const err = new ReportedPublisherError(msg.error, msg.fatal === true);
1047
1108
  const pending = this.pendingAnswer;
@@ -1435,6 +1496,249 @@ var Publisher = class {
1435
1496
  } catch {
1436
1497
  }
1437
1498
  }
1499
+ // startConnectionStatsLoop begins periodic getStats() sampling once the peer
1500
+ // connection is connected. It is a no-op when polling is disabled or no
1501
+ // consumer is listening, and it re-baselines on each call so a reconnect after
1502
+ // an ICE restart starts a fresh quality assessment.
1503
+ startConnectionStatsLoop(generation, pc) {
1504
+ const intervalMs = Math.max(
1505
+ 0,
1506
+ this.opts.connectionStatsIntervalMs ?? defaultConnectionStatsIntervalMs
1507
+ );
1508
+ const listening = !!this.opts.callbacks?.onConnectionStats || !!this.opts.callbacks?.onConnectionQualityChange;
1509
+ if (intervalMs === 0 || !listening) return;
1510
+ this.stopConnectionStatsLoop();
1511
+ const timer = setInterval(() => {
1512
+ if (this.connectionStatsTimer !== timer) return;
1513
+ if (!this.isActiveRun(generation, pc)) {
1514
+ this.stopConnectionStatsLoop();
1515
+ return;
1516
+ }
1517
+ if (this.statsSampleInFlight) return;
1518
+ this.statsSampleInFlight = true;
1519
+ void this.sampleConnectionQuality(generation, pc, timer);
1520
+ }, intervalMs);
1521
+ this.connectionStatsTimer = timer;
1522
+ }
1523
+ stopConnectionStatsLoop() {
1524
+ if (this.connectionStatsTimer !== null) clearInterval(this.connectionStatsTimer);
1525
+ this.connectionStatsTimer = null;
1526
+ this.statsSampleInFlight = false;
1527
+ this.lastStatsSample = null;
1528
+ this.currentQualityLevel = null;
1529
+ this.qualityDowngradeStreak = 0;
1530
+ }
1531
+ async sampleConnectionQuality(generation, pc, timer) {
1532
+ let stats = null;
1533
+ try {
1534
+ stats = await pc.getStats();
1535
+ } catch {
1536
+ }
1537
+ if (this.connectionStatsTimer !== timer) return;
1538
+ this.statsSampleInFlight = false;
1539
+ if (!stats || !this.isActiveRun(generation, pc) || pc.connectionState !== "connected") {
1540
+ return;
1541
+ }
1542
+ const sample = this.buildStatsSample(stats);
1543
+ this.opts.callbacks?.onConnectionStats?.(sample);
1544
+ if (this.connectionStatsTimer !== timer || !this.isActiveRun(generation, pc) || pc.connectionState !== "connected") {
1545
+ return;
1546
+ }
1547
+ this.updateConnectionQuality(sample);
1548
+ }
1549
+ // buildStatsSample derives one sample from a getStats() report. Loss is a mean of
1550
+ // per-stream loss fractions weighted by each stream's packets sent this window, so
1551
+ // every stream carrying traffic contributes. A stream uses its remote fractionLost
1552
+ // when present — the remote's loss ratio over its RR interval, self-aligned and
1553
+ // excluding retransmissions (which ride a separate SSRC); dividing the remote's
1554
+ // Δ(packetsLost) by the local Δ(packetsSent) would instead misalign an RTCP-timed
1555
+ // numerator with a continuously-updated denominator — and otherwise falls back to
1556
+ // its own windowed Δ(packetsLost) / Δ(distinct media packets sent), a denominator
1557
+ // that excludes retransmissions (packetsSent - retransmittedPacketsSent). Mixing the
1558
+ // two per stream keeps a lossy stream from being dropped when a sibling has fractionLost.
1559
+ //
1560
+ // Deltas are taken only over outbound stats-object ids present in BOTH this and the
1561
+ // previous sample. A track replace/unpublish (or a recycled SSRC) deletes the old
1562
+ // stats object and creates a new one with a new id; counting a vanished stream's
1563
+ // missing tail, or a fresh object's cumulative total as an interval delta, would
1564
+ // corrupt loss. Excluding the symmetric difference baselines new objects (they count
1565
+ // from their next sample) and drops departed ones, so continuous streams still measure
1566
+ // loss even when an SSRC number is reused across distinct stats objects.
1567
+ buildStatsSample(stats) {
1568
+ const timestamp = nowMs();
1569
+ let nackCount = 0;
1570
+ let pliCount = 0;
1571
+ let haveOutbound = false;
1572
+ let haveOutboundVideo = false;
1573
+ let rttSeconds = null;
1574
+ let jitterSeconds = null;
1575
+ let limitationReason = null;
1576
+ let selectedPairID;
1577
+ let transportBytes = null;
1578
+ const streams = /* @__PURE__ */ new Map();
1579
+ const ssrcToId = /* @__PURE__ */ new Map();
1580
+ stats.forEach((report) => {
1581
+ const value = report;
1582
+ switch (value.type) {
1583
+ case "outbound-rtp": {
1584
+ haveOutbound = true;
1585
+ if (value.kind === "video") haveOutboundVideo = true;
1586
+ const ssrc = typeof value.ssrc === "number" ? value.ssrc : NaN;
1587
+ const sent = typeof value.packetsSent === "number" ? value.packetsSent : 0;
1588
+ const retransmitted = typeof value.retransmittedPacketsSent === "number" ? value.retransmittedPacketsSent : 0;
1589
+ const bytesSent = typeof value.bytesSent === "number" ? value.bytesSent : 0;
1590
+ if (typeof value.nackCount === "number") nackCount += value.nackCount;
1591
+ if (typeof value.pliCount === "number") pliCount += value.pliCount;
1592
+ if (typeof value.qualityLimitationReason === "string") {
1593
+ limitationReason = worseLimitation(limitationReason, value.qualityLimitationReason);
1594
+ }
1595
+ if (typeof value.id === "string") {
1596
+ const expected = Math.max(0, sent - retransmitted);
1597
+ streams.set(value.id, { expected, lost: null, fractionLost: null, bytesSent });
1598
+ if (!Number.isNaN(ssrc)) ssrcToId.set(ssrc, value.id);
1599
+ }
1600
+ break;
1601
+ }
1602
+ case "remote-inbound-rtp": {
1603
+ if (typeof value.roundTripTime === "number") {
1604
+ rttSeconds = Math.max(rttSeconds ?? 0, value.roundTripTime);
1605
+ }
1606
+ if (typeof value.jitter === "number") {
1607
+ jitterSeconds = Math.max(jitterSeconds ?? 0, value.jitter);
1608
+ }
1609
+ break;
1610
+ }
1611
+ case "transport": {
1612
+ if (typeof value.selectedCandidatePairId === "string") {
1613
+ selectedPairID = value.selectedCandidatePairId;
1614
+ }
1615
+ if (typeof value.bytesSent === "number") {
1616
+ transportBytes = (transportBytes ?? 0) + value.bytesSent;
1617
+ }
1618
+ break;
1619
+ }
1620
+ default:
1621
+ break;
1622
+ }
1623
+ });
1624
+ stats.forEach((report) => {
1625
+ const value = report;
1626
+ if (value.type !== "remote-inbound-rtp") return;
1627
+ const outboundId = typeof value.localId === "string" && streams.has(value.localId) ? value.localId : typeof value.ssrc === "number" ? ssrcToId.get(value.ssrc) : void 0;
1628
+ if (outboundId === void 0) return;
1629
+ const stream = streams.get(outboundId);
1630
+ if (!stream) return;
1631
+ const lost = typeof value.packetsLost === "number" ? value.packetsLost : 0;
1632
+ stream.lost = (stream.lost ?? 0) + lost;
1633
+ if (typeof value.fractionLost === "number" && Number.isFinite(value.fractionLost)) {
1634
+ stream.fractionLost = Math.min(1, Math.max(0, value.fractionLost));
1635
+ }
1636
+ });
1637
+ const pair = (selectedPairID ? stats.get(selectedPairID) : void 0) ?? findNominatedCandidatePair(stats);
1638
+ const availableOutgoingBitrate = pair && typeof pair.availableOutgoingBitrate === "number" ? pair.availableOutgoingBitrate : null;
1639
+ if (rttSeconds === null && pair && typeof pair.currentRoundTripTime === "number") {
1640
+ rttSeconds = pair.currentRoundTripTime;
1641
+ }
1642
+ const prev = this.lastStatsSample;
1643
+ let weightedFractionSum = 0;
1644
+ let weightSum = 0;
1645
+ let intersectionBytesDelta = 0;
1646
+ let hadStreamOverlap = false;
1647
+ streams.forEach((current, id) => {
1648
+ const before = prev?.streams.get(id);
1649
+ if (!before) return;
1650
+ hadStreamOverlap = true;
1651
+ intersectionBytesDelta += Math.max(0, current.bytesSent - before.bytesSent);
1652
+ const deltaExpected = Math.max(0, current.expected - before.expected);
1653
+ if (deltaExpected <= 0) return;
1654
+ let fraction = null;
1655
+ if (current.fractionLost !== null) {
1656
+ fraction = current.fractionLost;
1657
+ } else if (current.lost !== null && before.lost !== null) {
1658
+ fraction = Math.min(1, Math.max(0, current.lost - before.lost) / deltaExpected);
1659
+ }
1660
+ if (fraction === null) return;
1661
+ weightedFractionSum += fraction * deltaExpected;
1662
+ weightSum += deltaExpected;
1663
+ });
1664
+ const deltaSeconds = prev ? (timestamp - prev.timestamp) / 1e3 : 0;
1665
+ this.lastStatsSample = { timestamp, transportBytes, streams };
1666
+ const lossRatio = weightSum > 0 ? Math.min(1, weightedFractionSum / weightSum) : 0;
1667
+ let sendBitrate = null;
1668
+ if (prev && deltaSeconds > 0) {
1669
+ if (transportBytes !== null && prev.transportBytes !== null) {
1670
+ sendBitrate = Math.max(0, transportBytes - prev.transportBytes) * 8 / deltaSeconds;
1671
+ } else if (hadStreamOverlap) {
1672
+ sendBitrate = intersectionBytesDelta * 8 / deltaSeconds;
1673
+ }
1674
+ }
1675
+ return {
1676
+ timestamp,
1677
+ lossRatio,
1678
+ rttMs: rttSeconds !== null ? rttSeconds * 1e3 : null,
1679
+ jitterMs: jitterSeconds !== null ? jitterSeconds * 1e3 : null,
1680
+ availableOutgoingBitrate,
1681
+ sendBitrate,
1682
+ qualityLimitationReason: haveOutboundVideo ? limitationReason ?? "none" : null,
1683
+ nackCount: haveOutbound ? nackCount : null,
1684
+ pliCount: haveOutbound ? pliCount : null
1685
+ };
1686
+ }
1687
+ resolveQualityThresholds() {
1688
+ return { ...defaultConnectionQualityThresholds, ...this.opts.connectionQualityThresholds ?? {} };
1689
+ }
1690
+ // classifyQuality maps a sample to a level. Loss is the primary axis; RTT,
1691
+ // jitter, and a bandwidth-limited encoder can only raise severity.
1692
+ classifyQuality(sample) {
1693
+ const t = this.resolveQualityThresholds();
1694
+ let severity = 0;
1695
+ if (sample.lossRatio >= t.criticalLossRatio) severity = Math.max(severity, 3);
1696
+ else if (sample.lossRatio >= t.poorLossRatio) severity = Math.max(severity, 2);
1697
+ else if (sample.lossRatio >= t.fairLossRatio) severity = Math.max(severity, 1);
1698
+ if (sample.rttMs !== null) {
1699
+ if (sample.rttMs >= t.criticalRttMs) severity = Math.max(severity, 3);
1700
+ else if (sample.rttMs >= t.poorRttMs) severity = Math.max(severity, 2);
1701
+ else if (sample.rttMs >= t.fairRttMs) severity = Math.max(severity, 1);
1702
+ }
1703
+ if (sample.jitterMs !== null) {
1704
+ if (sample.jitterMs >= t.poorJitterMs) severity = Math.max(severity, 2);
1705
+ else if (sample.jitterMs >= t.fairJitterMs) severity = Math.max(severity, 1);
1706
+ }
1707
+ if (sample.qualityLimitationReason === "bandwidth") severity = Math.max(severity, 1);
1708
+ return qualityLevels[severity];
1709
+ }
1710
+ // updateConnectionQuality commits level transitions with hysteresis: an
1711
+ // improvement is reported on the first better sample, while a degradation must
1712
+ // persist for connectionQualityDebounceSamples consecutive samples to commit,
1713
+ // so a single blip does not flap the reported level.
1714
+ updateConnectionQuality(sample) {
1715
+ const candidate = this.classifyQuality(sample);
1716
+ const current = this.currentQualityLevel;
1717
+ if (current === null || candidate === current) {
1718
+ this.qualityDowngradeStreak = 0;
1719
+ if (candidate !== current) {
1720
+ this.currentQualityLevel = candidate;
1721
+ this.opts.callbacks?.onConnectionQualityChange?.({ level: candidate, sample });
1722
+ }
1723
+ return;
1724
+ }
1725
+ if (qualitySeverity(candidate) < qualitySeverity(current)) {
1726
+ this.qualityDowngradeStreak = 0;
1727
+ this.currentQualityLevel = candidate;
1728
+ this.opts.callbacks?.onConnectionQualityChange?.({ level: candidate, sample });
1729
+ return;
1730
+ }
1731
+ const needed = Math.max(
1732
+ 1,
1733
+ this.opts.connectionQualityDebounceSamples ?? defaultConnectionQualityDebounceSamples
1734
+ );
1735
+ this.qualityDowngradeStreak += 1;
1736
+ if (this.qualityDowngradeStreak >= needed) {
1737
+ this.qualityDowngradeStreak = 0;
1738
+ this.currentQualityLevel = candidate;
1739
+ this.opts.callbacks?.onConnectionQualityChange?.({ level: candidate, sample });
1740
+ }
1741
+ }
1438
1742
  async reportSelectedICEPath(pc) {
1439
1743
  if (this.pc !== pc || this.stopped) return;
1440
1744
  let stats;