@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/dist/index.d.cts CHANGED
@@ -131,6 +131,19 @@ interface PublisherCallbacks {
131
131
  onUserTextResult?: (event: UserTextResultEvent) => void;
132
132
  /** Called once the explicitly requested outbound speech track arrives. */
133
133
  onSpeechTrack?: (track: MediaStreamTrack, streams: readonly MediaStream[]) => void;
134
+ /**
135
+ * Called for every periodic connection-stats sample while connected — the raw
136
+ * feed behind {@link onConnectionQualityChange}. Fires at
137
+ * {@link PublisherOptions.connectionStatsIntervalMs}.
138
+ */
139
+ onConnectionStats?: (sample: ConnectionStatsSample) => void;
140
+ /**
141
+ * Called when the derived connection-quality level changes (including the
142
+ * first assessment after connecting). Downgrades are debounced; upgrades fire
143
+ * immediately. Use this to react to a degrading network — e.g. warn the user
144
+ * or drop a secondary track.
145
+ */
146
+ onConnectionQualityChange?: (quality: ConnectionQuality) => void;
134
147
  }
135
148
  interface AssistantTextEvent {
136
149
  utteranceId: string;
@@ -144,6 +157,86 @@ interface UserTextResultEvent {
144
157
  accepted: boolean;
145
158
  reason?: string;
146
159
  }
160
+ /**
161
+ * A coarse assessment of the uplink to the media server, derived from periodic
162
+ * WebRTC stats. Ordered worst-last by severity: `"good"` is a healthy
163
+ * connection, `"critical"` is barely usable.
164
+ */
165
+ type ConnectionQualityLevel = "good" | "fair" | "poor" | "critical";
166
+ /**
167
+ * One periodic sample of the outbound connection's health, taken from
168
+ * `RTCPeerConnection.getStats()`. Packet loss and send bitrate are windowed over
169
+ * the interval since the previous sample (the first sample covers the session so
170
+ * far); the rest are point-in-time readings. A field is `null` when the browser
171
+ * did not report the underlying stat for this sample.
172
+ */
173
+ interface ConnectionStatsSample {
174
+ /** Monotonic clock timestamp (ms) when the sample was taken. */
175
+ timestamp: number;
176
+ /**
177
+ * Fraction of outbound RTP packets lost, in `0..1` — the primary quality
178
+ * signal. A traffic-weighted mean of each stream's loss fraction: a stream uses
179
+ * its remote `fractionLost` (its loss ratio over the last RTCP report interval)
180
+ * when present, otherwise its windowed
181
+ * `Δpacketslost / Δ(packetsSent - retransmittedPacketsSent)`. Both kinds of
182
+ * stream are combined, so a lossy stream is never dropped for lacking
183
+ * `fractionLost`.
184
+ */
185
+ lossRatio: number;
186
+ /** Round-trip time in milliseconds, or `null` if unknown. */
187
+ rttMs: number | null;
188
+ /** Inter-arrival jitter in milliseconds, or `null` if unknown. */
189
+ jitterMs: number | null;
190
+ /** ICE-estimated available outgoing bitrate in bits/sec, or `null` if unknown. */
191
+ availableOutgoingBitrate: number | null;
192
+ /** Actual send bitrate over the interval in bits/sec, or `null` if unknown. */
193
+ sendBitrate: number | null;
194
+ /**
195
+ * Why the encoder is limiting quality: `"none"`, `"cpu"`, `"bandwidth"`, or
196
+ * `"other"`. `null` when no video is being sent. `"bandwidth"` is treated as a
197
+ * degradation signal by the default classifier.
198
+ */
199
+ qualityLimitationReason: string | null;
200
+ /** Cumulative NACKs received on outbound media, or `null` if unknown. */
201
+ nackCount: number | null;
202
+ /** Cumulative PLIs received on outbound media, or `null` if unknown. */
203
+ pliCount: number | null;
204
+ }
205
+ /**
206
+ * A committed connection-quality assessment, delivered to
207
+ * {@link PublisherCallbacks.onConnectionQualityChange} whenever the level
208
+ * changes. `sample` is the reading that triggered the transition.
209
+ */
210
+ interface ConnectionQuality {
211
+ level: ConnectionQualityLevel;
212
+ sample: ConnectionStatsSample;
213
+ }
214
+ /**
215
+ * Thresholds mapping a {@link ConnectionStatsSample} to a
216
+ * {@link ConnectionQualityLevel}. Packet loss is the primary axis; RTT and
217
+ * jitter can only push the assessment to a worse level, never a better one, and
218
+ * a `"bandwidth"` encoder limitation forces at least `"fair"`. Any subset may be
219
+ * overridden via {@link PublisherOptions.connectionQualityThresholds}; the rest
220
+ * keep their defaults.
221
+ */
222
+ interface ConnectionQualityThresholds {
223
+ /** Loss ratio (`0..1`) at or above which quality is at least `"fair"`. Default `0.02`. */
224
+ fairLossRatio: number;
225
+ /** Loss ratio at or above which quality is at least `"poor"`. Default `0.05`. */
226
+ poorLossRatio: number;
227
+ /** Loss ratio at or above which quality is `"critical"`. Default `0.12`. */
228
+ criticalLossRatio: number;
229
+ /** RTT (ms) at or above which quality is at least `"fair"`. Default `300`. */
230
+ fairRttMs: number;
231
+ /** RTT (ms) at or above which quality is at least `"poor"`. Default `600`. */
232
+ poorRttMs: number;
233
+ /** RTT (ms) at or above which quality is `"critical"`. Default `1000`. */
234
+ criticalRttMs: number;
235
+ /** Jitter (ms) at or above which quality is at least `"fair"`. Default `50`. */
236
+ fairJitterMs: number;
237
+ /** Jitter (ms) at or above which quality is at least `"poor"`. Default `150`. */
238
+ poorJitterMs: number;
239
+ }
147
240
  type PublisherRecoveryState = "recovering" | "recovered" | "failed";
148
241
  type PublisherRecoveryAction = "sender_restart" | "ice_restart";
149
242
  type PublisherRecoveryFailureReason = "capture_ended" | "automatic_recovery_failed";
@@ -232,6 +325,24 @@ interface PublisherOptions {
232
325
  preferredVideoCodecs?: string[];
233
326
  /** How long to retry a dropped signaling connection to the selected gateway. Defaults to 20 seconds. */
234
327
  signalingReconnectTimeoutMs?: number;
328
+ /**
329
+ * How often, in milliseconds, to poll `RTCPeerConnection.getStats()` for
330
+ * connection-quality assessment once connected. Defaults to 2000. Set to 0 to
331
+ * disable stats polling entirely — neither {@link PublisherCallbacks.onConnectionStats}
332
+ * nor {@link PublisherCallbacks.onConnectionQualityChange} will fire.
333
+ */
334
+ connectionStatsIntervalMs?: number;
335
+ /**
336
+ * Overrides for the connection-quality classification thresholds. Any omitted
337
+ * field keeps its default. See {@link ConnectionQualityThresholds}.
338
+ */
339
+ connectionQualityThresholds?: Partial<ConnectionQualityThresholds>;
340
+ /**
341
+ * Number of consecutive worse-than-current samples required before a quality
342
+ * downgrade is committed, damping transient blips. Improvements are reported
343
+ * on the first better sample. Defaults to 2; values below 1 are treated as 1.
344
+ */
345
+ connectionQualityDebounceSamples?: number;
235
346
  /** Callbacks for lifecycle events. */
236
347
  callbacks?: PublisherCallbacks;
237
348
  }
@@ -277,6 +388,11 @@ declare class Publisher {
277
388
  private gatewayURL;
278
389
  private lastReportedICEPath;
279
390
  private watchedICETransports;
391
+ private connectionStatsTimer;
392
+ private statsSampleInFlight;
393
+ private lastStatsSample;
394
+ private currentQualityLevel;
395
+ private qualityDowngradeStreak;
280
396
  private stopped;
281
397
  private lifecycleGeneration;
282
398
  private runAbort;
@@ -459,6 +575,13 @@ declare class Publisher {
459
575
  private releaseLocalCandidateBatch;
460
576
  private applyAnswered;
461
577
  private watchSelectedICEPairChanges;
578
+ private startConnectionStatsLoop;
579
+ private stopConnectionStatsLoop;
580
+ private sampleConnectionQuality;
581
+ private buildStatsSample;
582
+ private resolveQualityThresholds;
583
+ private classifyQuality;
584
+ private updateConnectionQuality;
462
585
  private reportSelectedICEPath;
463
586
  private completeMediaRecovery;
464
587
  private failMediaRecovery;
@@ -688,4 +811,4 @@ interface CaptureMicrophoneOptions {
688
811
  */
689
812
  declare function captureMicrophone(opts?: CaptureMicrophoneOptions): Promise<MediaStream>;
690
813
 
691
- export { type AssistantTextEvent, type CaptureCameraOptions, type CaptureMicrophoneOptions, type CaptureScreenOptions, type GatewayReadyInfo, Publisher, type PublisherCallbacks, type PublisherOptions, type PublisherRecoveryAction, type PublisherRecoveryEvent, type PublisherRecoveryFailureReason, type PublisherRecoveryState, type SignalMessage, type TrackLabel, type TrackType, type TurnTransportPolicy, type UserTextResultEvent, type VideoTrackType, captureCamera, captureMicrophone, captureScreen };
814
+ export { type AssistantTextEvent, type CaptureCameraOptions, type CaptureMicrophoneOptions, type CaptureScreenOptions, type ConnectionQuality, type ConnectionQualityLevel, type ConnectionQualityThresholds, type ConnectionStatsSample, type GatewayReadyInfo, Publisher, type PublisherCallbacks, type PublisherOptions, type PublisherRecoveryAction, type PublisherRecoveryEvent, type PublisherRecoveryFailureReason, type PublisherRecoveryState, type SignalMessage, type TrackLabel, type TrackType, type TurnTransportPolicy, type UserTextResultEvent, type VideoTrackType, captureCamera, captureMicrophone, captureScreen };
package/dist/index.d.ts CHANGED
@@ -131,6 +131,19 @@ interface PublisherCallbacks {
131
131
  onUserTextResult?: (event: UserTextResultEvent) => void;
132
132
  /** Called once the explicitly requested outbound speech track arrives. */
133
133
  onSpeechTrack?: (track: MediaStreamTrack, streams: readonly MediaStream[]) => void;
134
+ /**
135
+ * Called for every periodic connection-stats sample while connected — the raw
136
+ * feed behind {@link onConnectionQualityChange}. Fires at
137
+ * {@link PublisherOptions.connectionStatsIntervalMs}.
138
+ */
139
+ onConnectionStats?: (sample: ConnectionStatsSample) => void;
140
+ /**
141
+ * Called when the derived connection-quality level changes (including the
142
+ * first assessment after connecting). Downgrades are debounced; upgrades fire
143
+ * immediately. Use this to react to a degrading network — e.g. warn the user
144
+ * or drop a secondary track.
145
+ */
146
+ onConnectionQualityChange?: (quality: ConnectionQuality) => void;
134
147
  }
135
148
  interface AssistantTextEvent {
136
149
  utteranceId: string;
@@ -144,6 +157,86 @@ interface UserTextResultEvent {
144
157
  accepted: boolean;
145
158
  reason?: string;
146
159
  }
160
+ /**
161
+ * A coarse assessment of the uplink to the media server, derived from periodic
162
+ * WebRTC stats. Ordered worst-last by severity: `"good"` is a healthy
163
+ * connection, `"critical"` is barely usable.
164
+ */
165
+ type ConnectionQualityLevel = "good" | "fair" | "poor" | "critical";
166
+ /**
167
+ * One periodic sample of the outbound connection's health, taken from
168
+ * `RTCPeerConnection.getStats()`. Packet loss and send bitrate are windowed over
169
+ * the interval since the previous sample (the first sample covers the session so
170
+ * far); the rest are point-in-time readings. A field is `null` when the browser
171
+ * did not report the underlying stat for this sample.
172
+ */
173
+ interface ConnectionStatsSample {
174
+ /** Monotonic clock timestamp (ms) when the sample was taken. */
175
+ timestamp: number;
176
+ /**
177
+ * Fraction of outbound RTP packets lost, in `0..1` — the primary quality
178
+ * signal. A traffic-weighted mean of each stream's loss fraction: a stream uses
179
+ * its remote `fractionLost` (its loss ratio over the last RTCP report interval)
180
+ * when present, otherwise its windowed
181
+ * `Δpacketslost / Δ(packetsSent - retransmittedPacketsSent)`. Both kinds of
182
+ * stream are combined, so a lossy stream is never dropped for lacking
183
+ * `fractionLost`.
184
+ */
185
+ lossRatio: number;
186
+ /** Round-trip time in milliseconds, or `null` if unknown. */
187
+ rttMs: number | null;
188
+ /** Inter-arrival jitter in milliseconds, or `null` if unknown. */
189
+ jitterMs: number | null;
190
+ /** ICE-estimated available outgoing bitrate in bits/sec, or `null` if unknown. */
191
+ availableOutgoingBitrate: number | null;
192
+ /** Actual send bitrate over the interval in bits/sec, or `null` if unknown. */
193
+ sendBitrate: number | null;
194
+ /**
195
+ * Why the encoder is limiting quality: `"none"`, `"cpu"`, `"bandwidth"`, or
196
+ * `"other"`. `null` when no video is being sent. `"bandwidth"` is treated as a
197
+ * degradation signal by the default classifier.
198
+ */
199
+ qualityLimitationReason: string | null;
200
+ /** Cumulative NACKs received on outbound media, or `null` if unknown. */
201
+ nackCount: number | null;
202
+ /** Cumulative PLIs received on outbound media, or `null` if unknown. */
203
+ pliCount: number | null;
204
+ }
205
+ /**
206
+ * A committed connection-quality assessment, delivered to
207
+ * {@link PublisherCallbacks.onConnectionQualityChange} whenever the level
208
+ * changes. `sample` is the reading that triggered the transition.
209
+ */
210
+ interface ConnectionQuality {
211
+ level: ConnectionQualityLevel;
212
+ sample: ConnectionStatsSample;
213
+ }
214
+ /**
215
+ * Thresholds mapping a {@link ConnectionStatsSample} to a
216
+ * {@link ConnectionQualityLevel}. Packet loss is the primary axis; RTT and
217
+ * jitter can only push the assessment to a worse level, never a better one, and
218
+ * a `"bandwidth"` encoder limitation forces at least `"fair"`. Any subset may be
219
+ * overridden via {@link PublisherOptions.connectionQualityThresholds}; the rest
220
+ * keep their defaults.
221
+ */
222
+ interface ConnectionQualityThresholds {
223
+ /** Loss ratio (`0..1`) at or above which quality is at least `"fair"`. Default `0.02`. */
224
+ fairLossRatio: number;
225
+ /** Loss ratio at or above which quality is at least `"poor"`. Default `0.05`. */
226
+ poorLossRatio: number;
227
+ /** Loss ratio at or above which quality is `"critical"`. Default `0.12`. */
228
+ criticalLossRatio: number;
229
+ /** RTT (ms) at or above which quality is at least `"fair"`. Default `300`. */
230
+ fairRttMs: number;
231
+ /** RTT (ms) at or above which quality is at least `"poor"`. Default `600`. */
232
+ poorRttMs: number;
233
+ /** RTT (ms) at or above which quality is `"critical"`. Default `1000`. */
234
+ criticalRttMs: number;
235
+ /** Jitter (ms) at or above which quality is at least `"fair"`. Default `50`. */
236
+ fairJitterMs: number;
237
+ /** Jitter (ms) at or above which quality is at least `"poor"`. Default `150`. */
238
+ poorJitterMs: number;
239
+ }
147
240
  type PublisherRecoveryState = "recovering" | "recovered" | "failed";
148
241
  type PublisherRecoveryAction = "sender_restart" | "ice_restart";
149
242
  type PublisherRecoveryFailureReason = "capture_ended" | "automatic_recovery_failed";
@@ -232,6 +325,24 @@ interface PublisherOptions {
232
325
  preferredVideoCodecs?: string[];
233
326
  /** How long to retry a dropped signaling connection to the selected gateway. Defaults to 20 seconds. */
234
327
  signalingReconnectTimeoutMs?: number;
328
+ /**
329
+ * How often, in milliseconds, to poll `RTCPeerConnection.getStats()` for
330
+ * connection-quality assessment once connected. Defaults to 2000. Set to 0 to
331
+ * disable stats polling entirely — neither {@link PublisherCallbacks.onConnectionStats}
332
+ * nor {@link PublisherCallbacks.onConnectionQualityChange} will fire.
333
+ */
334
+ connectionStatsIntervalMs?: number;
335
+ /**
336
+ * Overrides for the connection-quality classification thresholds. Any omitted
337
+ * field keeps its default. See {@link ConnectionQualityThresholds}.
338
+ */
339
+ connectionQualityThresholds?: Partial<ConnectionQualityThresholds>;
340
+ /**
341
+ * Number of consecutive worse-than-current samples required before a quality
342
+ * downgrade is committed, damping transient blips. Improvements are reported
343
+ * on the first better sample. Defaults to 2; values below 1 are treated as 1.
344
+ */
345
+ connectionQualityDebounceSamples?: number;
235
346
  /** Callbacks for lifecycle events. */
236
347
  callbacks?: PublisherCallbacks;
237
348
  }
@@ -277,6 +388,11 @@ declare class Publisher {
277
388
  private gatewayURL;
278
389
  private lastReportedICEPath;
279
390
  private watchedICETransports;
391
+ private connectionStatsTimer;
392
+ private statsSampleInFlight;
393
+ private lastStatsSample;
394
+ private currentQualityLevel;
395
+ private qualityDowngradeStreak;
280
396
  private stopped;
281
397
  private lifecycleGeneration;
282
398
  private runAbort;
@@ -459,6 +575,13 @@ declare class Publisher {
459
575
  private releaseLocalCandidateBatch;
460
576
  private applyAnswered;
461
577
  private watchSelectedICEPairChanges;
578
+ private startConnectionStatsLoop;
579
+ private stopConnectionStatsLoop;
580
+ private sampleConnectionQuality;
581
+ private buildStatsSample;
582
+ private resolveQualityThresholds;
583
+ private classifyQuality;
584
+ private updateConnectionQuality;
462
585
  private reportSelectedICEPath;
463
586
  private completeMediaRecovery;
464
587
  private failMediaRecovery;
@@ -688,4 +811,4 @@ interface CaptureMicrophoneOptions {
688
811
  */
689
812
  declare function captureMicrophone(opts?: CaptureMicrophoneOptions): Promise<MediaStream>;
690
813
 
691
- export { type AssistantTextEvent, type CaptureCameraOptions, type CaptureMicrophoneOptions, type CaptureScreenOptions, type GatewayReadyInfo, Publisher, type PublisherCallbacks, type PublisherOptions, type PublisherRecoveryAction, type PublisherRecoveryEvent, type PublisherRecoveryFailureReason, type PublisherRecoveryState, type SignalMessage, type TrackLabel, type TrackType, type TurnTransportPolicy, type UserTextResultEvent, type VideoTrackType, captureCamera, captureMicrophone, captureScreen };
814
+ export { type AssistantTextEvent, type CaptureCameraOptions, type CaptureMicrophoneOptions, type CaptureScreenOptions, type ConnectionQuality, type ConnectionQualityLevel, type ConnectionQualityThresholds, type ConnectionStatsSample, type GatewayReadyInfo, Publisher, type PublisherCallbacks, type PublisherOptions, type PublisherRecoveryAction, type PublisherRecoveryEvent, type PublisherRecoveryFailureReason, type PublisherRecoveryState, type SignalMessage, type TrackLabel, type TrackType, type TurnTransportPolicy, type UserTextResultEvent, type VideoTrackType, captureCamera, captureMicrophone, captureScreen };
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++;
@@ -1435,6 +1485,249 @@ var Publisher = class {
1435
1485
  } catch {
1436
1486
  }
1437
1487
  }
1488
+ // startConnectionStatsLoop begins periodic getStats() sampling once the peer
1489
+ // connection is connected. It is a no-op when polling is disabled or no
1490
+ // consumer is listening, and it re-baselines on each call so a reconnect after
1491
+ // an ICE restart starts a fresh quality assessment.
1492
+ startConnectionStatsLoop(generation, pc) {
1493
+ const intervalMs = Math.max(
1494
+ 0,
1495
+ this.opts.connectionStatsIntervalMs ?? defaultConnectionStatsIntervalMs
1496
+ );
1497
+ const listening = !!this.opts.callbacks?.onConnectionStats || !!this.opts.callbacks?.onConnectionQualityChange;
1498
+ if (intervalMs === 0 || !listening) return;
1499
+ this.stopConnectionStatsLoop();
1500
+ const timer = setInterval(() => {
1501
+ if (this.connectionStatsTimer !== timer) return;
1502
+ if (!this.isActiveRun(generation, pc)) {
1503
+ this.stopConnectionStatsLoop();
1504
+ return;
1505
+ }
1506
+ if (this.statsSampleInFlight) return;
1507
+ this.statsSampleInFlight = true;
1508
+ void this.sampleConnectionQuality(generation, pc, timer);
1509
+ }, intervalMs);
1510
+ this.connectionStatsTimer = timer;
1511
+ }
1512
+ stopConnectionStatsLoop() {
1513
+ if (this.connectionStatsTimer !== null) clearInterval(this.connectionStatsTimer);
1514
+ this.connectionStatsTimer = null;
1515
+ this.statsSampleInFlight = false;
1516
+ this.lastStatsSample = null;
1517
+ this.currentQualityLevel = null;
1518
+ this.qualityDowngradeStreak = 0;
1519
+ }
1520
+ async sampleConnectionQuality(generation, pc, timer) {
1521
+ let stats = null;
1522
+ try {
1523
+ stats = await pc.getStats();
1524
+ } catch {
1525
+ }
1526
+ if (this.connectionStatsTimer !== timer) return;
1527
+ this.statsSampleInFlight = false;
1528
+ if (!stats || !this.isActiveRun(generation, pc) || pc.connectionState !== "connected") {
1529
+ return;
1530
+ }
1531
+ const sample = this.buildStatsSample(stats);
1532
+ this.opts.callbacks?.onConnectionStats?.(sample);
1533
+ if (this.connectionStatsTimer !== timer || !this.isActiveRun(generation, pc) || pc.connectionState !== "connected") {
1534
+ return;
1535
+ }
1536
+ this.updateConnectionQuality(sample);
1537
+ }
1538
+ // buildStatsSample derives one sample from a getStats() report. Loss is a mean of
1539
+ // per-stream loss fractions weighted by each stream's packets sent this window, so
1540
+ // every stream carrying traffic contributes. A stream uses its remote fractionLost
1541
+ // when present — the remote's loss ratio over its RR interval, self-aligned and
1542
+ // excluding retransmissions (which ride a separate SSRC); dividing the remote's
1543
+ // Δ(packetsLost) by the local Δ(packetsSent) would instead misalign an RTCP-timed
1544
+ // numerator with a continuously-updated denominator — and otherwise falls back to
1545
+ // its own windowed Δ(packetsLost) / Δ(distinct media packets sent), a denominator
1546
+ // that excludes retransmissions (packetsSent - retransmittedPacketsSent). Mixing the
1547
+ // two per stream keeps a lossy stream from being dropped when a sibling has fractionLost.
1548
+ //
1549
+ // Deltas are taken only over outbound stats-object ids present in BOTH this and the
1550
+ // previous sample. A track replace/unpublish (or a recycled SSRC) deletes the old
1551
+ // stats object and creates a new one with a new id; counting a vanished stream's
1552
+ // missing tail, or a fresh object's cumulative total as an interval delta, would
1553
+ // corrupt loss. Excluding the symmetric difference baselines new objects (they count
1554
+ // from their next sample) and drops departed ones, so continuous streams still measure
1555
+ // loss even when an SSRC number is reused across distinct stats objects.
1556
+ buildStatsSample(stats) {
1557
+ const timestamp = nowMs();
1558
+ let nackCount = 0;
1559
+ let pliCount = 0;
1560
+ let haveOutbound = false;
1561
+ let haveOutboundVideo = false;
1562
+ let rttSeconds = null;
1563
+ let jitterSeconds = null;
1564
+ let limitationReason = null;
1565
+ let selectedPairID;
1566
+ let transportBytes = null;
1567
+ const streams = /* @__PURE__ */ new Map();
1568
+ const ssrcToId = /* @__PURE__ */ new Map();
1569
+ stats.forEach((report) => {
1570
+ const value = report;
1571
+ switch (value.type) {
1572
+ case "outbound-rtp": {
1573
+ haveOutbound = true;
1574
+ if (value.kind === "video") haveOutboundVideo = true;
1575
+ const ssrc = typeof value.ssrc === "number" ? value.ssrc : NaN;
1576
+ const sent = typeof value.packetsSent === "number" ? value.packetsSent : 0;
1577
+ const retransmitted = typeof value.retransmittedPacketsSent === "number" ? value.retransmittedPacketsSent : 0;
1578
+ const bytesSent = typeof value.bytesSent === "number" ? value.bytesSent : 0;
1579
+ if (typeof value.nackCount === "number") nackCount += value.nackCount;
1580
+ if (typeof value.pliCount === "number") pliCount += value.pliCount;
1581
+ if (typeof value.qualityLimitationReason === "string") {
1582
+ limitationReason = worseLimitation(limitationReason, value.qualityLimitationReason);
1583
+ }
1584
+ if (typeof value.id === "string") {
1585
+ const expected = Math.max(0, sent - retransmitted);
1586
+ streams.set(value.id, { expected, lost: null, fractionLost: null, bytesSent });
1587
+ if (!Number.isNaN(ssrc)) ssrcToId.set(ssrc, value.id);
1588
+ }
1589
+ break;
1590
+ }
1591
+ case "remote-inbound-rtp": {
1592
+ if (typeof value.roundTripTime === "number") {
1593
+ rttSeconds = Math.max(rttSeconds ?? 0, value.roundTripTime);
1594
+ }
1595
+ if (typeof value.jitter === "number") {
1596
+ jitterSeconds = Math.max(jitterSeconds ?? 0, value.jitter);
1597
+ }
1598
+ break;
1599
+ }
1600
+ case "transport": {
1601
+ if (typeof value.selectedCandidatePairId === "string") {
1602
+ selectedPairID = value.selectedCandidatePairId;
1603
+ }
1604
+ if (typeof value.bytesSent === "number") {
1605
+ transportBytes = (transportBytes ?? 0) + value.bytesSent;
1606
+ }
1607
+ break;
1608
+ }
1609
+ default:
1610
+ break;
1611
+ }
1612
+ });
1613
+ stats.forEach((report) => {
1614
+ const value = report;
1615
+ if (value.type !== "remote-inbound-rtp") return;
1616
+ const outboundId = typeof value.localId === "string" && streams.has(value.localId) ? value.localId : typeof value.ssrc === "number" ? ssrcToId.get(value.ssrc) : void 0;
1617
+ if (outboundId === void 0) return;
1618
+ const stream = streams.get(outboundId);
1619
+ if (!stream) return;
1620
+ const lost = typeof value.packetsLost === "number" ? value.packetsLost : 0;
1621
+ stream.lost = (stream.lost ?? 0) + lost;
1622
+ if (typeof value.fractionLost === "number" && Number.isFinite(value.fractionLost)) {
1623
+ stream.fractionLost = Math.min(1, Math.max(0, value.fractionLost));
1624
+ }
1625
+ });
1626
+ const pair = (selectedPairID ? stats.get(selectedPairID) : void 0) ?? findNominatedCandidatePair(stats);
1627
+ const availableOutgoingBitrate = pair && typeof pair.availableOutgoingBitrate === "number" ? pair.availableOutgoingBitrate : null;
1628
+ if (rttSeconds === null && pair && typeof pair.currentRoundTripTime === "number") {
1629
+ rttSeconds = pair.currentRoundTripTime;
1630
+ }
1631
+ const prev = this.lastStatsSample;
1632
+ let weightedFractionSum = 0;
1633
+ let weightSum = 0;
1634
+ let intersectionBytesDelta = 0;
1635
+ let hadStreamOverlap = false;
1636
+ streams.forEach((current, id) => {
1637
+ const before = prev?.streams.get(id);
1638
+ if (!before) return;
1639
+ hadStreamOverlap = true;
1640
+ intersectionBytesDelta += Math.max(0, current.bytesSent - before.bytesSent);
1641
+ const deltaExpected = Math.max(0, current.expected - before.expected);
1642
+ if (deltaExpected <= 0) return;
1643
+ let fraction = null;
1644
+ if (current.fractionLost !== null) {
1645
+ fraction = current.fractionLost;
1646
+ } else if (current.lost !== null && before.lost !== null) {
1647
+ fraction = Math.min(1, Math.max(0, current.lost - before.lost) / deltaExpected);
1648
+ }
1649
+ if (fraction === null) return;
1650
+ weightedFractionSum += fraction * deltaExpected;
1651
+ weightSum += deltaExpected;
1652
+ });
1653
+ const deltaSeconds = prev ? (timestamp - prev.timestamp) / 1e3 : 0;
1654
+ this.lastStatsSample = { timestamp, transportBytes, streams };
1655
+ const lossRatio = weightSum > 0 ? Math.min(1, weightedFractionSum / weightSum) : 0;
1656
+ let sendBitrate = null;
1657
+ if (prev && deltaSeconds > 0) {
1658
+ if (transportBytes !== null && prev.transportBytes !== null) {
1659
+ sendBitrate = Math.max(0, transportBytes - prev.transportBytes) * 8 / deltaSeconds;
1660
+ } else if (hadStreamOverlap) {
1661
+ sendBitrate = intersectionBytesDelta * 8 / deltaSeconds;
1662
+ }
1663
+ }
1664
+ return {
1665
+ timestamp,
1666
+ lossRatio,
1667
+ rttMs: rttSeconds !== null ? rttSeconds * 1e3 : null,
1668
+ jitterMs: jitterSeconds !== null ? jitterSeconds * 1e3 : null,
1669
+ availableOutgoingBitrate,
1670
+ sendBitrate,
1671
+ qualityLimitationReason: haveOutboundVideo ? limitationReason ?? "none" : null,
1672
+ nackCount: haveOutbound ? nackCount : null,
1673
+ pliCount: haveOutbound ? pliCount : null
1674
+ };
1675
+ }
1676
+ resolveQualityThresholds() {
1677
+ return { ...defaultConnectionQualityThresholds, ...this.opts.connectionQualityThresholds ?? {} };
1678
+ }
1679
+ // classifyQuality maps a sample to a level. Loss is the primary axis; RTT,
1680
+ // jitter, and a bandwidth-limited encoder can only raise severity.
1681
+ classifyQuality(sample) {
1682
+ const t = this.resolveQualityThresholds();
1683
+ let severity = 0;
1684
+ if (sample.lossRatio >= t.criticalLossRatio) severity = Math.max(severity, 3);
1685
+ else if (sample.lossRatio >= t.poorLossRatio) severity = Math.max(severity, 2);
1686
+ else if (sample.lossRatio >= t.fairLossRatio) severity = Math.max(severity, 1);
1687
+ if (sample.rttMs !== null) {
1688
+ if (sample.rttMs >= t.criticalRttMs) severity = Math.max(severity, 3);
1689
+ else if (sample.rttMs >= t.poorRttMs) severity = Math.max(severity, 2);
1690
+ else if (sample.rttMs >= t.fairRttMs) severity = Math.max(severity, 1);
1691
+ }
1692
+ if (sample.jitterMs !== null) {
1693
+ if (sample.jitterMs >= t.poorJitterMs) severity = Math.max(severity, 2);
1694
+ else if (sample.jitterMs >= t.fairJitterMs) severity = Math.max(severity, 1);
1695
+ }
1696
+ if (sample.qualityLimitationReason === "bandwidth") severity = Math.max(severity, 1);
1697
+ return qualityLevels[severity];
1698
+ }
1699
+ // updateConnectionQuality commits level transitions with hysteresis: an
1700
+ // improvement is reported on the first better sample, while a degradation must
1701
+ // persist for connectionQualityDebounceSamples consecutive samples to commit,
1702
+ // so a single blip does not flap the reported level.
1703
+ updateConnectionQuality(sample) {
1704
+ const candidate = this.classifyQuality(sample);
1705
+ const current = this.currentQualityLevel;
1706
+ if (current === null || candidate === current) {
1707
+ this.qualityDowngradeStreak = 0;
1708
+ if (candidate !== current) {
1709
+ this.currentQualityLevel = candidate;
1710
+ this.opts.callbacks?.onConnectionQualityChange?.({ level: candidate, sample });
1711
+ }
1712
+ return;
1713
+ }
1714
+ if (qualitySeverity(candidate) < qualitySeverity(current)) {
1715
+ this.qualityDowngradeStreak = 0;
1716
+ this.currentQualityLevel = candidate;
1717
+ this.opts.callbacks?.onConnectionQualityChange?.({ level: candidate, sample });
1718
+ return;
1719
+ }
1720
+ const needed = Math.max(
1721
+ 1,
1722
+ this.opts.connectionQualityDebounceSamples ?? defaultConnectionQualityDebounceSamples
1723
+ );
1724
+ this.qualityDowngradeStreak += 1;
1725
+ if (this.qualityDowngradeStreak >= needed) {
1726
+ this.qualityDowngradeStreak = 0;
1727
+ this.currentQualityLevel = candidate;
1728
+ this.opts.callbacks?.onConnectionQualityChange?.({ level: candidate, sample });
1729
+ }
1730
+ }
1438
1731
  async reportSelectedICEPath(pc) {
1439
1732
  if (this.pc !== pc || this.stopped) return;
1440
1733
  let stats;