@furious.luke/argus-js 0.5.5 → 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 +53 -0
- package/dist/index.cjs +293 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +124 -1
- package/dist/index.d.ts +124 -1
- package/dist/index.js +293 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,6 +66,7 @@ const publisher = new Publisher({
|
|
|
66
66
|
});
|
|
67
67
|
},
|
|
68
68
|
onConnectionStateChange: (state) => console.log("state:", state),
|
|
69
|
+
onConnectionQualityChange: ({ level }) => console.log("connection quality:", level),
|
|
69
70
|
onRecoveryStateChange: (event) => console.log("media recovery:", event),
|
|
70
71
|
// Show a button or prompt. From its click handler, call captureScreen()
|
|
71
72
|
// and then publisher.publish(newStream, "screen").
|
|
@@ -201,6 +202,9 @@ not the `utterance_finished` lifecycle event, which can overtake the last chunk.
|
|
|
201
202
|
| `peerConnectionTimeoutMs` | `number` | Deadline after the initial offer for WebRTC to reach `connected`. Defaults to 30 seconds. |
|
|
202
203
|
| `signalingReconnectTimeoutMs` | `number` | How long to retry a dropped signaling socket against the selected regional gateway. Defaults to 20 seconds. |
|
|
203
204
|
| `preferredVideoCodecs` | `string[]` | Preferred video codecs, most-preferred first, as RTP MIME types (e.g. `"video/VP9"`, `"video/H264"`). Each published video track offers these ahead of the rest, so the browser sends the first one the media server also accepts. Defaults to `["video/VP9"]`. Pass `[]` to leave the browser's native order untouched. Codecs the browser lacks (or `setCodecPreferences` support, e.g. older Safari) are ignored — negotiation always falls back cleanly. |
|
|
205
|
+
| `connectionStatsIntervalMs` | `number` | How often to poll `RTCPeerConnection.getStats()` for connection-quality assessment while connected. Defaults to 2000. Set to `0` to disable stats polling entirely (`onConnectionStats`/`onConnectionQualityChange` will not fire). |
|
|
206
|
+
| `connectionQualityThresholds` | `Partial<ConnectionQualityThresholds>` | Overrides for the quality classification thresholds. Packet loss is the primary axis; RTT and jitter can only push the level worse, never better. Any omitted field keeps its default (see below). |
|
|
207
|
+
| `connectionQualityDebounceSamples` | `number` | Consecutive worse-than-current samples required before a downgrade is committed, damping transient blips. Improvements are reported on the first better sample. Defaults to 2. |
|
|
204
208
|
| `callbacks` | `PublisherCallbacks` | Optional lifecycle callbacks (see below). |
|
|
205
209
|
|
|
206
210
|
### Methods & properties
|
|
@@ -235,6 +239,8 @@ not the `utterance_finished` lifecycle event, which can overtake the last chunk.
|
|
|
235
239
|
| `onAssistantText({ utteranceId, text })` | One caption chunk arrived; paced with synthesized speech when speech is enabled, immediate in text-only mode. An utterance emits many chunks sharing one `utteranceId` — append them to the bubble keyed by that id rather than rendering each separately. |
|
|
236
240
|
| `onAssistantTextFinished({ utteranceId })` | The utterance's caption stream is complete. Emitted on the same ordered channel after its last `onAssistantText` chunk; finalize the visible caption here rather than on `utterance_finished`. |
|
|
237
241
|
| `onUserTextResult({ messageId, accepted, reason })` | The server accepted or rejected a `sendUserText` message. |
|
|
242
|
+
| `onConnectionStats(sample)` | A periodic `ConnectionStatsSample` (loss ratio, RTT, jitter, send/available bitrate, encoder limitation) — the raw feed behind `onConnectionQualityChange`. Fires every `connectionStatsIntervalMs` while connected. |
|
|
243
|
+
| `onConnectionQualityChange({ level, sample })` | The derived connection-quality level (`"good" \| "fair" \| "poor" \| "critical"`) changed, including the first assessment after connecting. Downgrades are debounced; upgrades fire immediately. React to a degrading uplink here — warn the user, or drop a secondary track. |
|
|
238
244
|
| `onError(error)` | A fatal error occurred (signaling error, WebRTC connection failure/timeout, or signaling resume timed out). |
|
|
239
245
|
|
|
240
246
|
## How `start()` works
|
|
@@ -287,6 +293,53 @@ pass it to `publish` (or `replaceStream`) for that track type. A browser cannot
|
|
|
287
293
|
silently reacquire a screen share after the user or operating system ends it, so
|
|
288
294
|
`capture_ended` always requires host UI and a fresh `captureScreen()` call.
|
|
289
295
|
|
|
296
|
+
## Connection quality
|
|
297
|
+
|
|
298
|
+
While connected, the publisher polls the peer connection's WebRTC stats every
|
|
299
|
+
`connectionStatsIntervalMs` (default 2s) and derives a coarse quality level so
|
|
300
|
+
you can react to a degrading uplink without parsing `getStats()` yourself. Each
|
|
301
|
+
poll delivers a raw `ConnectionStatsSample` to `onConnectionStats`; whenever the
|
|
302
|
+
derived level changes it delivers a `ConnectionQuality` to
|
|
303
|
+
`onConnectionQualityChange`.
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
const pub = new Publisher({
|
|
307
|
+
gatewayURLs,
|
|
308
|
+
token,
|
|
309
|
+
callbacks: {
|
|
310
|
+
onConnectionQualityChange: ({ level, sample }) => {
|
|
311
|
+
if (level === "poor" || level === "critical") {
|
|
312
|
+
showBanner(`Weak connection — ${Math.round(sample.lossRatio * 100)}% packet loss`);
|
|
313
|
+
} else {
|
|
314
|
+
hideBanner();
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Packet loss is the primary signal; round-trip time, jitter, and a
|
|
322
|
+
bandwidth-limited encoder can only push the level worse, never better. To avoid
|
|
323
|
+
flapping on a momentary blip, **downgrades** are debounced — they require
|
|
324
|
+
`connectionQualityDebounceSamples` (default 2) consecutive worse samples before
|
|
325
|
+
committing — while **upgrades** are reported on the first improved sample so
|
|
326
|
+
recovery is reflected promptly. The first sample after connecting always emits
|
|
327
|
+
the baseline level.
|
|
328
|
+
|
|
329
|
+
The default `ConnectionQualityThresholds` (all overridable via
|
|
330
|
+
`connectionQualityThresholds`):
|
|
331
|
+
|
|
332
|
+
| Level | Packet loss | RTT | Jitter |
|
|
333
|
+
| --- | --- | --- | --- |
|
|
334
|
+
| `fair` | ≥ 2% | ≥ 300 ms | ≥ 50 ms |
|
|
335
|
+
| `poor` | ≥ 5% | ≥ 600 ms | ≥ 150 ms |
|
|
336
|
+
| `critical` | ≥ 12% | ≥ 1000 ms | — |
|
|
337
|
+
|
|
338
|
+
Loss ratio and send bitrate are windowed over each interval; RTT, jitter, and
|
|
339
|
+
available bitrate are point-in-time. A sample field is `null` when the browser
|
|
340
|
+
did not report the underlying stat. This is a detection surface only — the
|
|
341
|
+
publisher does not itself lower bitrate or resolution in response.
|
|
342
|
+
|
|
290
343
|
## Browser support
|
|
291
344
|
|
|
292
345
|
Requires a browser with WebRTC (`RTCPeerConnection`) and `WebSocket` — all current evergreen browsers. There is no Node.js runtime support; this is a browser-only package.
|
package/dist/index.cjs
CHANGED
|
@@ -94,6 +94,28 @@ 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;
|
|
@@ -114,6 +136,18 @@ var negotiationReconnectGraceMs = 5e3;
|
|
|
114
136
|
var minimumIntentionalTrackEndRetentionMs = 35e3;
|
|
115
137
|
var maxUserTextBytes = 4 * 1024;
|
|
116
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
|
+
};
|
|
117
151
|
var ReportedPublisherError = class extends Error {
|
|
118
152
|
constructor(message, fatal = false) {
|
|
119
153
|
super(message);
|
|
@@ -156,6 +190,17 @@ var Publisher = class {
|
|
|
156
190
|
gatewayURL = null;
|
|
157
191
|
lastReportedICEPath = null;
|
|
158
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;
|
|
159
204
|
stopped = true;
|
|
160
205
|
// Every start/stop boundary advances lifecycleGeneration. Async work captures
|
|
161
206
|
// the generation it belongs to and may never mutate or terminate a later run.
|
|
@@ -368,10 +413,13 @@ var Publisher = class {
|
|
|
368
413
|
if (state === "connected") {
|
|
369
414
|
this.clearPeerConnectionTimeout();
|
|
370
415
|
void this.reportSelectedICEPath(pc);
|
|
416
|
+
this.startConnectionStatsLoop(generation, pc);
|
|
371
417
|
this.opts.callbacks?.onConnected?.();
|
|
372
418
|
} else if (state === "failed") {
|
|
373
419
|
this.clearPeerConnectionTimeout();
|
|
374
420
|
this.terminateWithError(new Error("WebRTC connection failed"), true, generation);
|
|
421
|
+
} else {
|
|
422
|
+
this.stopConnectionStatsLoop();
|
|
375
423
|
}
|
|
376
424
|
};
|
|
377
425
|
if (initialTrack) {
|
|
@@ -506,6 +554,7 @@ var Publisher = class {
|
|
|
506
554
|
this.runAbort?.abort();
|
|
507
555
|
this.runAbort = null;
|
|
508
556
|
this.clearPeerConnectionTimeout();
|
|
557
|
+
this.stopConnectionStatsLoop();
|
|
509
558
|
this.stopped = true;
|
|
510
559
|
this.cancelAllMediaRecovery();
|
|
511
560
|
this.reconnectGeneration++;
|
|
@@ -994,6 +1043,7 @@ var Publisher = class {
|
|
|
994
1043
|
this.runAbort?.abort();
|
|
995
1044
|
this.runAbort = null;
|
|
996
1045
|
this.clearPeerConnectionTimeout();
|
|
1046
|
+
this.stopConnectionStatsLoop();
|
|
997
1047
|
this.stopped = true;
|
|
998
1048
|
this.cancelAllMediaRecovery();
|
|
999
1049
|
this.reconnectGeneration++;
|
|
@@ -1464,6 +1514,249 @@ var Publisher = class {
|
|
|
1464
1514
|
} catch {
|
|
1465
1515
|
}
|
|
1466
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
|
+
}
|
|
1467
1760
|
async reportSelectedICEPath(pc) {
|
|
1468
1761
|
if (this.pc !== pc || this.stopped) return;
|
|
1469
1762
|
let stats;
|