@unboundcx/video-sdk-client 2.0.0 → 2.0.1

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.
@@ -0,0 +1,845 @@
1
+ // Per-participant adaptive quality monitor. Subscribes to StatsCollector
2
+ // `onStats` cycles (~1 Hz) and runs three independent rolling-window state
3
+ // machines:
4
+ //
5
+ // outbound — what we're encoding & sending. Driven by Chrome's
6
+ // qualityLimitationReason + availableOutgoingBitrate.
7
+ // Acts by silently capping our outgoing simulcast layers
8
+ // via RTCRtpSender.setParameters.
9
+ //
10
+ // network — our path to the SFU. Driven by RTT and packet loss
11
+ // (both send + recv sides). Acts by warning the user
12
+ // and, if severe, capping outbound + suggesting actions.
13
+ //
14
+ // inbound — what we're receiving from each remote peer. Driven by
15
+ // freezeCount/totalFreezesDuration deltas on inbound
16
+ // video tracks. Acts by asking the server to deliver a
17
+ // lower simulcast layer for that specific producer.
18
+ //
19
+ // All emissions go through `emit(level, reason, detail)` so the host app
20
+ // can render a single coherent UX (toast, tile badge, etc) without
21
+ // having to understand the underlying signals.
22
+ //
23
+ // Thresholds are public + tunable so the host app or server can override
24
+ // them per-tenant if it ever wants to.
25
+
26
+ // Reaction-time thresholds. Tuned to feel ~Meet/Zoom-fast on real bad
27
+ // connections while staying noise-resistant against momentary blips.
28
+ // Stats arrive every ~1s so the *Ms numbers translate roughly to
29
+ // "this many seconds of sustained badness before action". See
30
+ // quality-monitor-thresholds.test.js for the user-visible reaction
31
+ // times these collectively produce.
32
+ const DEFAULTS = {
33
+ // SignalState gates — outer gate per level transition. Tuned to be
34
+ // patient: a blip that clears within ~20s isn't worth telling the
35
+ // user about; below that we let the SDK adapt invisibly.
36
+ warnSustainedMs: 20000,
37
+ critSustainedMs: 35000,
38
+ // network thresholds (round trip)
39
+ rttWarnMs: 300,
40
+ rttCritMs: 500,
41
+ // loss thresholds (fraction, 0–1)
42
+ lossWarnRatio: 0.02,
43
+ lossCritRatio: 0.05,
44
+ // Available-outgoing-bitrate floors (Mbps). A throttled uplink often
45
+ // shows no RTT spike and no loss — Chrome's BWE just collapses and
46
+ // the encoder backs off cleanly. We treat that as a network problem
47
+ // because, from the user's POV, it is one. The warn floor is set
48
+ // below the bandwidth needed for the mid simulcast layer: Chrome
49
+ // routinely dips toward 1Mbps during BWE probes on healthy networks,
50
+ // so a higher floor would fire on normal behavior.
51
+ availMbpsWarn: 0.6,
52
+ availMbpsCrit: 0.3,
53
+ // Fraction of the last sample window the encoder spent bandwidth-
54
+ // constrained (qLdBandwidth delta / sample interval). > warn → severity 1;
55
+ // > crit → severity 2. Threshold matches encoder constraint check
56
+ // (0.7) — both want "sustained throughout the window," not blips.
57
+ encoderBwLimitedWarnRatio: 0.7,
58
+ encoderBwLimitedCritRatio: 0.9,
59
+ // encoder pressure inner gates (severity bump 1 / 2). Tuned to be
60
+ // patient — a single Chrome BWE backoff under simulcast easily
61
+ // registers a brief constrained window even on healthy networks.
62
+ encoderConstrainedMs: 6000,
63
+ encoderSevereConstrainedMs: 20000,
64
+ // receiver freezes — accumulator delta over the rolling window
65
+ freezeDurationDegradedMs: 3000,
66
+ freezeDurationSevereMs: 10000,
67
+ // auto-disable-camera suggestion — both outbound + network critical
68
+ // for this long before we surface the prompt. We never actually
69
+ // disable the camera automatically; the banner offers a one-click
70
+ // "Disable camera" button and a "Keep on" dismiss.
71
+ autoDisableCameraMs: 15000,
72
+ // Time the suggestion banner stays open before auto-dismissing
73
+ // (matches the "if user is distracted, just clear the prompt and
74
+ // try again later" UX of Meet).
75
+ autoDisableGraceMs: 10000,
76
+ // anti-flap
77
+ recoveryHoldMs: 15000,
78
+ toastCooldownMs: 60000,
79
+ };
80
+
81
+ const LEVELS = ["healthy", "warning", "critical"];
82
+
83
+ function now() {
84
+ return Date.now();
85
+ }
86
+
87
+ // Rolling time-bucketed state machine. We track entries of {ts, severity}
88
+ // and decide the current level by looking at how long the worst severity
89
+ // has been sustained.
90
+ class SignalState {
91
+ constructor(name, thresholds) {
92
+ this.name = name;
93
+ this.thresholds = thresholds;
94
+ this.level = "healthy";
95
+ this.levelSince = now();
96
+ this.recoveryStartedAt = null;
97
+ this.lastToastAt = 0;
98
+ }
99
+
100
+ // severity = 0 (good) | 1 (bad) | 2 (very bad)
101
+ observe(severity, sustainedMs) {
102
+ const t = now();
103
+ const targetLevel =
104
+ severity === 2 && sustainedMs >= this.thresholds.critSustainedMs
105
+ ? "critical"
106
+ : severity >= 1 && sustainedMs >= this.thresholds.warnSustainedMs
107
+ ? "warning"
108
+ : "healthy";
109
+
110
+ if (targetLevel === this.level) {
111
+ this.recoveryStartedAt = null;
112
+ return null;
113
+ }
114
+ // upward transitions go immediately; downward transitions require a
115
+ // sustained recovery hold to avoid flapping.
116
+ if (LEVELS.indexOf(targetLevel) > LEVELS.indexOf(this.level)) {
117
+ this.level = targetLevel;
118
+ this.levelSince = t;
119
+ this.recoveryStartedAt = null;
120
+ return targetLevel;
121
+ }
122
+ if (!this.recoveryStartedAt) this.recoveryStartedAt = t;
123
+ if (t - this.recoveryStartedAt >= this.thresholds.recoveryHoldMs) {
124
+ this.level = targetLevel;
125
+ this.levelSince = t;
126
+ this.recoveryStartedAt = null;
127
+ return targetLevel;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ // Per-LEVEL cooldown: a critical event must be allowed even if a
133
+ // recent warning was just toasted. We track the last-toast time per
134
+ // level so a crit transition fires its own toast immediately.
135
+ shouldToast(level = 'warning') {
136
+ const t = now();
137
+ this._lastToastByLevel = this._lastToastByLevel || {};
138
+ const last = this._lastToastByLevel[level] || 0;
139
+ if (t - last < this.thresholds.toastCooldownMs) return false;
140
+ this._lastToastByLevel[level] = t;
141
+ return true;
142
+ }
143
+ }
144
+
145
+ export class QualityMonitor {
146
+ // host: { statsCollector, mediasoupManager, connectionManager,
147
+ // onQualityEvent }
148
+ // onQualityEvent(evt): {kind, level, reason, action, detail}
149
+ // kinds:
150
+ // 'outbound' — our own encoder/network state
151
+ // 'inbound' — a specific remote peer's stream into us
152
+ // 'network' — our path to SFU
153
+ // 'autoDisableCamera' — countdown / cancelled / disabled
154
+ constructor({
155
+ statsCollector,
156
+ mediasoupManager,
157
+ onQualityEvent,
158
+ getRemotePeerCount,
159
+ thresholds,
160
+ }) {
161
+ this.statsCollector = statsCollector;
162
+ this.mediasoupManager = mediasoupManager;
163
+ this.onQualityEvent = onQualityEvent || (() => {});
164
+ // Solo gate. When this returns 0, we skip evaluation: with no
165
+ // remote consumers the SFU isn't pulling our media, so encoder /
166
+ // BWE pressure isn't a real user-facing problem and would
167
+ // mis-fire toasts/banners on an empty room (e.g. host waiting).
168
+ this.getRemotePeerCount =
169
+ typeof getRemotePeerCount === 'function' ? getRemotePeerCount : () => 1;
170
+ this.thresholds = { ...DEFAULTS, ...(thresholds || {}) };
171
+
172
+ this.outbound = new SignalState("outbound", this.thresholds);
173
+ this.network = new SignalState("network", this.thresholds);
174
+ // inbound state is per-peer; we lazily create one per producerParticipantId
175
+ this.inboundByPeer = new Map();
176
+
177
+ // for sustained-condition detection
178
+ this._encoderBadSince = null;
179
+ this._netBadSince = null;
180
+ this._allCriticalSince = null;
181
+ this._autoDisableCountdownTimer = null;
182
+
183
+ // running totals from prior samples so we can compute deltas
184
+ this._lastQLd = { cpu: 0, bandwidth: 0, other: 0 };
185
+ this._lastSendSampleTs = 0;
186
+ // Most recent encoder-bandwidth-limited ratio (0–1) computed in
187
+ // _evalOutbound and consumed by _evalNetwork on the same sample.
188
+ this._lastEncoderBwLimitedRatio = 0;
189
+ this._freezeBaselineByPeer = new Map();
190
+
191
+ this._capState = { outboundCapped: false, byPeerLow: new Set() };
192
+
193
+ // Debug ring buffer — last 100 samples + last 50 emitted events. Used
194
+ // by the in-app debug modal (Ctrl+Shift+Q) so we can copy the full
195
+ // monitor state without waiting for a toast.
196
+ this._debugSamples = [];
197
+ this._debugEvents = [];
198
+ this._debugMax = 100;
199
+ this._lastSampleAt = { send: 0, recv: 0 };
200
+ }
201
+
202
+ start() {
203
+ if (!this.statsCollector) {
204
+ this._log(`start :: no statsCollector — monitor inactive`);
205
+ return;
206
+ }
207
+ // Use the SDK-internal callback slot so the host app's
208
+ // setStatsCallback() doesn't clobber us. (Earlier we used the public
209
+ // slot, which the meet UI overwrote — silently breaking detection
210
+ // because the monitor never received another sample.)
211
+ if (typeof this.statsCollector.addInternalCallback === 'function') {
212
+ this._unsubscribe = this.statsCollector.addInternalCallback((data) =>
213
+ this._onSample(data),
214
+ );
215
+ } else {
216
+ // Older SDK without addInternalCallback — fall back to chaining.
217
+ this._prevCb = this.statsCollector.onStatsCallback;
218
+ this.statsCollector.setStatsCallback((data) => {
219
+ try {
220
+ if (this._prevCb) this._prevCb(data);
221
+ } catch (_e) {}
222
+ this._onSample(data);
223
+ });
224
+ }
225
+ this._log(`started :: thresholds ${JSON.stringify(this.thresholds)}`);
226
+ }
227
+
228
+ stop() {
229
+ if (typeof this._unsubscribe === 'function') {
230
+ this._unsubscribe();
231
+ this._unsubscribe = null;
232
+ } else if (this.statsCollector && this._prevCb !== undefined) {
233
+ this.statsCollector.setStatsCallback(this._prevCb);
234
+ }
235
+ if (this._autoDisableCountdownTimer) {
236
+ clearTimeout(this._autoDisableCountdownTimer);
237
+ this._autoDisableCountdownTimer = null;
238
+ }
239
+ }
240
+
241
+ // ---------- per-sample evaluation ----------
242
+
243
+ _onSample(data) {
244
+ if (!data) return;
245
+ // Solo gate. With no remote peers, our media isn't being consumed
246
+ // and encoder/BWE numbers don't reflect a real problem. Reset all
247
+ // sustained-bad timers so the moment a peer joins we measure
248
+ // fresh — otherwise the first post-join sample would inherit
249
+ // accumulated "bad" time from the solo period and could
250
+ // immediately trip the auto-disable banner.
251
+ if (this.getRemotePeerCount() === 0) {
252
+ this._encoderBadSince = null;
253
+ this._netBadSince = null;
254
+ this._allCriticalSince = null;
255
+ if (this._autoDisableCountdownTimer) {
256
+ clearTimeout(this._autoDisableCountdownTimer);
257
+ this._autoDisableCountdownTimer = null;
258
+ }
259
+ // Hold all three state machines at healthy so any in-flight
260
+ // pending.autoDisableCamera banner on the UI is cleared via the
261
+ // 'cancelCountdown' event the next eval would emit. We do this
262
+ // by forcing the outbound machine back to healthy if it isn't,
263
+ // which the existing _evalAutoDisableCamera path will pick up
264
+ // — but since we're skipping eval here, just clear the UI
265
+ // signal directly through an explicit event if a banner was up.
266
+ if (this.outbound.level !== 'healthy' || this.network.level !== 'healthy') {
267
+ this.outbound.level = 'healthy';
268
+ this.outbound.levelSince = now();
269
+ this.network.level = 'healthy';
270
+ this.network.levelSince = now();
271
+ this._emit({
272
+ kind: 'autoDisableCamera',
273
+ level: 'healthy',
274
+ reason: 'no_remote_peers',
275
+ action: 'cancelCountdown',
276
+ });
277
+ }
278
+ return;
279
+ }
280
+ // Ring-buffer minimal sample for the debug modal. We only keep the
281
+ // numbers we actually evaluate — full client payload is too big.
282
+ try {
283
+ const summary = {
284
+ ts: now(),
285
+ dir: data.transportType,
286
+ rttMs: Number(data?.network?.roundTripTime || 0) * 1000,
287
+ availMbps: Number(data?.network?.availableOutgoingBitrate || 0) / 1e6,
288
+ videoLossPct: Number(data?.video?.packetLossPercentage || 0),
289
+ audioLossPct: Number(data?.audio?.packetLossPercentage || 0),
290
+ qLdCpu: Number(data?.video?.qLdCpu || 0),
291
+ qLdBandwidth: Number(data?.video?.qLdBandwidth || 0),
292
+ fps: Number(data?.video?.framesPerSecond || 0),
293
+ w: Number(data?.video?.frameWidth || 0),
294
+ h: Number(data?.video?.frameHeight || 0),
295
+ connScore: Number(data?.connectionQuality?.finalScore || 0),
296
+ audioMos: Number(data?.audio?.mos || 0),
297
+ videoMos: Number(data?.video?.mos || 0),
298
+ };
299
+ this._lastSampleAt[data.transportType] = summary.ts;
300
+ this._debugSamples.push(summary);
301
+ if (this._debugSamples.length > this._debugMax)
302
+ this._debugSamples.shift();
303
+ } catch (_e) {}
304
+
305
+ if (data.transportType === "send") {
306
+ this._evalOutbound(data);
307
+ this._evalNetwork(data);
308
+ } else if (data.transportType === "recv") {
309
+ this._evalInbound(data);
310
+ }
311
+ this._evalAutoDisableCamera();
312
+ }
313
+
314
+ // Outbound: encoder pressure.
315
+ _evalOutbound(data) {
316
+ const t = now();
317
+ const v = data.video || {};
318
+ // qLdBandwidth / qLdCpu are CUMULATIVE seconds. Compare to previous
319
+ // sample to see if the encoder spent any of the last ~1s constrained.
320
+ const cpu = Number(v.qLdCpu) || 0;
321
+ const bw = Number(v.qLdBandwidth) || 0;
322
+ const dCpu = Math.max(0, cpu - (this._lastQLd.cpu || 0));
323
+ const dBw = Math.max(0, bw - (this._lastQLd.bandwidth || 0));
324
+ // Sample-window ratio: how much of the last sample interval the
325
+ // encoder spent bandwidth-limited. Used by _evalNetwork to detect
326
+ // throttled uplinks (where RTT/loss stay clean but BWE collapsed).
327
+ const sampleIntervalSec = this._lastSendSampleTs
328
+ ? Math.max(0.5, (t - this._lastSendSampleTs) / 1000)
329
+ : 1;
330
+ this._lastSendSampleTs = t;
331
+ this._lastEncoderBwLimitedRatio = Math.min(1, dBw / sampleIntervalSec);
332
+ this._lastQLd = { cpu, bandwidth: bw, other: Number(v.qLdOther) || 0 };
333
+
334
+ // If the encoder spent >70% of the last sample window constrained
335
+ // on CPU or bandwidth, that sample counts as "bad". A lower
336
+ // threshold trips on routine BWE probes / momentary backoffs that
337
+ // are part of normal simulcast operation on healthy networks.
338
+ const constrainedNow = dCpu + dBw > 0.7;
339
+ if (constrainedNow) {
340
+ if (!this._encoderBadSince) this._encoderBadSince = t;
341
+ } else {
342
+ this._encoderBadSince = null;
343
+ }
344
+ const sustainedMs = this._encoderBadSince ? t - this._encoderBadSince : 0;
345
+ const severity = constrainedNow
346
+ ? sustainedMs >= this.thresholds.encoderSevereConstrainedMs
347
+ ? 2
348
+ : sustainedMs >= this.thresholds.encoderConstrainedMs
349
+ ? 1
350
+ : 0
351
+ : 0;
352
+ const transitioned = this.outbound.observe(severity, sustainedMs);
353
+ if (!transitioned) return;
354
+
355
+ if (transitioned === "warning") {
356
+ // Silent cap to mid-layer. No toast — Meet/Zoom do this invisibly.
357
+ this._capOutbound("mid", "encoder pressure (sustained)");
358
+ this._emit({
359
+ kind: "outbound",
360
+ level: "warning",
361
+ reason: dCpu > dBw ? "cpu" : "bandwidth",
362
+ action: "silentCapMid",
363
+ });
364
+ } else if (transitioned === "critical") {
365
+ this._capOutbound("low", "encoder severely constrained");
366
+ if (this.outbound.shouldToast("critical")) {
367
+ this._emit({
368
+ kind: "outbound",
369
+ level: "critical",
370
+ reason: dCpu > dBw ? "cpu" : "bandwidth",
371
+ action: "capLowAndToast",
372
+ message:
373
+ dCpu > dBw
374
+ ? "Your device is struggling. We have reduced your video quality."
375
+ : "Your upload bandwidth is low. We have reduced your video quality.",
376
+ });
377
+ }
378
+ } else if (transitioned === "healthy") {
379
+ this._uncapOutbound("encoder recovered");
380
+ this._emit({
381
+ kind: "outbound",
382
+ level: "healthy",
383
+ reason: "recovered",
384
+ action: "uncap",
385
+ });
386
+ }
387
+ }
388
+
389
+ // Network: our path-to-SFU health. RTT + send/recv loss.
390
+ _evalNetwork(data) {
391
+ const t = now();
392
+ const rttMs = (Number(data?.network?.roundTripTime) || 0) * 1000;
393
+ const sendLoss = (Number(data?.audio?.packetLossPercentage) || 0) / 100;
394
+ const videoLoss = (Number(data?.video?.packetLossPercentage) || 0) / 100;
395
+ const recvAudioLoss =
396
+ (Number(data?.recvAvg?.audio?.packetLostPercentage) || 0) / 100;
397
+ const recvVideoLoss =
398
+ (Number(data?.recvAvg?.video?.packetLostPercentage) || 0) / 100;
399
+ const maxLoss = Math.max(sendLoss, videoLoss, recvAudioLoss, recvVideoLoss);
400
+ // Bandwidth-pressure inputs. A throttled uplink usually shows up here
401
+ // before it shows up as RTT/loss: availableOutgoingBitrate collapses
402
+ // and the encoder reports sustained qualityLimitationReason=bandwidth.
403
+ const availMbps =
404
+ (Number(data?.network?.availableOutgoingBitrate) || 0) / 1e6;
405
+ const bwLimitedRatio = this._lastEncoderBwLimitedRatio || 0;
406
+
407
+ const rttLossSeverity =
408
+ rttMs >= this.thresholds.rttCritMs ||
409
+ maxLoss >= this.thresholds.lossCritRatio
410
+ ? 2
411
+ : rttMs >= this.thresholds.rttWarnMs ||
412
+ maxLoss >= this.thresholds.lossWarnRatio
413
+ ? 1
414
+ : 0;
415
+ // Only count availMbps once we've seen a real reading (>0); some
416
+ // browsers report 0 transiently at session start.
417
+ const bandwidthSeverity =
418
+ availMbps > 0 && availMbps <= this.thresholds.availMbpsCrit
419
+ ? 2
420
+ : bwLimitedRatio >= this.thresholds.encoderBwLimitedCritRatio
421
+ ? 2
422
+ : availMbps > 0 && availMbps <= this.thresholds.availMbpsWarn
423
+ ? 1
424
+ : bwLimitedRatio >= this.thresholds.encoderBwLimitedWarnRatio
425
+ ? 1
426
+ : 0;
427
+ const severity = Math.max(rttLossSeverity, bandwidthSeverity);
428
+
429
+ if (severity > 0) {
430
+ if (!this._netBadSince) this._netBadSince = t;
431
+ } else {
432
+ this._netBadSince = null;
433
+ }
434
+ const sustainedMs = this._netBadSince ? t - this._netBadSince : 0;
435
+ const transitioned = this.network.observe(severity, sustainedMs);
436
+ if (!transitioned) return;
437
+
438
+ const reasonFor = (level) => {
439
+ if (level === 2 || rttLossSeverity >= 1) {
440
+ if (rttMs >= this.thresholds.rttWarnMs) return "rtt";
441
+ if (maxLoss >= this.thresholds.lossWarnRatio) return "loss";
442
+ }
443
+ return "bandwidth";
444
+ };
445
+
446
+ if (transitioned === "warning") {
447
+ if (this.network.shouldToast("warning")) {
448
+ this._emit({
449
+ kind: "network",
450
+ level: "warning",
451
+ reason: reasonFor(severity),
452
+ action: "toast",
453
+ message: "Your network is unstable.",
454
+ detail: {
455
+ rttMs,
456
+ maxLossPct: +(maxLoss * 100).toFixed(2),
457
+ availMbps: +availMbps.toFixed(2),
458
+ bwLimitedRatio: +bwLimitedRatio.toFixed(2),
459
+ },
460
+ });
461
+ }
462
+ } else if (transitioned === "critical") {
463
+ // Path-driven cap. Independent of encoder cap; whichever floor is
464
+ // lower wins.
465
+ this._capOutbound("low", "network critical");
466
+ if (this.network.shouldToast("critical")) {
467
+ this._emit({
468
+ kind: "network",
469
+ level: "critical",
470
+ reason: reasonFor(severity),
471
+ action: "capLowAndToast",
472
+ message: "Poor connection — reducing video quality.",
473
+ detail: {
474
+ rttMs,
475
+ maxLossPct: +(maxLoss * 100).toFixed(2),
476
+ availMbps: +availMbps.toFixed(2),
477
+ bwLimitedRatio: +bwLimitedRatio.toFixed(2),
478
+ },
479
+ });
480
+ }
481
+ } else if (transitioned === "healthy") {
482
+ this._uncapOutbound("network recovered");
483
+ this._emit({
484
+ kind: "network",
485
+ level: "healthy",
486
+ reason: "recovered",
487
+ action: "uncap",
488
+ });
489
+ }
490
+ }
491
+
492
+ // Inbound: per-peer freezes on what we receive. The recv stats payload
493
+ // shape from StatsCollector.js puts per-participant video stats under
494
+ // `video[participantId]`. We track freeze duration deltas per peer.
495
+ _evalInbound(data) {
496
+ const t = now();
497
+ const perPeerVideo = data?.video || {};
498
+ for (const [peerId, vs] of Object.entries(perPeerVideo)) {
499
+ if (!vs || typeof vs !== "object") continue;
500
+ const freezeMs = (Number(vs.totalFreezesDuration) || 0) * 1000;
501
+ const baseline = this._freezeBaselineByPeer.get(peerId);
502
+ // First observation establishes a baseline; we measure window-rate
503
+ // afterwards.
504
+ if (!baseline) {
505
+ this._freezeBaselineByPeer.set(peerId, { freezeMs, ts: t });
506
+ continue;
507
+ }
508
+ const windowMs = t - baseline.ts;
509
+ if (windowMs < 5000) continue; // need a window
510
+ const dFreeze = freezeMs - baseline.freezeMs;
511
+ // Reset baseline so we measure the *next* window, not cumulative
512
+ this._freezeBaselineByPeer.set(peerId, { freezeMs, ts: t });
513
+
514
+ const severity =
515
+ dFreeze >= this.thresholds.freezeDurationSevereMs
516
+ ? 2
517
+ : dFreeze >= this.thresholds.freezeDurationDegradedMs
518
+ ? 1
519
+ : 0;
520
+
521
+ let state = this.inboundByPeer.get(peerId);
522
+ if (!state) {
523
+ state = new SignalState(`inbound:${peerId}`, this.thresholds);
524
+ this.inboundByPeer.set(peerId, state);
525
+ }
526
+ // For inbound we treat any "bad" sample as sustained immediately —
527
+ // freezes ARE sustained badness by their nature (deltas already are
528
+ // accumulator deltas over the window).
529
+ const sustainedMs = severity > 0 ? this.thresholds.critSustainedMs : 0;
530
+ const transitioned = state.observe(severity, sustainedMs);
531
+ if (!transitioned) continue;
532
+
533
+ if (transitioned === "warning") {
534
+ // Tile badge only; no toast.
535
+ this._emit({
536
+ kind: "inbound",
537
+ peerId,
538
+ level: "warning",
539
+ reason: "freezes",
540
+ action: "badgePeerTile",
541
+ detail: { freezeMsInWindow: dFreeze },
542
+ });
543
+ } else if (transitioned === "critical") {
544
+ // Ask server to give us a lower layer of THIS peer. Avoids
545
+ // affecting our other consumers.
546
+ this._capInbound(peerId, "low");
547
+ this._emit({
548
+ kind: "inbound",
549
+ peerId,
550
+ level: "critical",
551
+ reason: "freezes",
552
+ action: "requestLowerLayer",
553
+ detail: { freezeMsInWindow: dFreeze },
554
+ });
555
+ } else if (transitioned === "healthy") {
556
+ this._uncapInbound(peerId);
557
+ this._emit({
558
+ kind: "inbound",
559
+ peerId,
560
+ level: "healthy",
561
+ reason: "recovered",
562
+ action: "uncap",
563
+ });
564
+ }
565
+ }
566
+ }
567
+
568
+ // Auto-disable camera: triggers when ALL three signals have been at
569
+ // critical for autoDisableCameraMs. Severe sustained problem.
570
+ //
571
+ // Behavior: we never auto-disable the camera. We surface a banner
572
+ // suggestion with a "Disable camera" button (host wires this via
573
+ // suggestDisableCamera()) and a "Keep on" dismiss. The banner
574
+ // auto-dismisses after autoDisableGraceMs if the user ignores it,
575
+ // and re-shows after toastCooldownMs if conditions stay critical.
576
+ // This matches Meet/Zoom UX where the system *recommends* — it never
577
+ // surprises the user by killing their video stream.
578
+ _evalAutoDisableCamera() {
579
+ // Trigger when BOTH outbound and network are critical. Requiring
580
+ // two independent signals to agree is what makes this banner
581
+ // trustworthy: outbound alone fires on routine encoder backoffs,
582
+ // and network alone fires on transient RTT spikes. The user only
583
+ // wants to see "disable your camera" when their connection is
584
+ // genuinely impaired. Network evaluation now includes bandwidth
585
+ // pressure (availMbps + encoder-bw-limited ratio), so throttled
586
+ // uplinks correctly drive network → critical alongside outbound.
587
+ const triggerCritical =
588
+ this.outbound.level === "critical" &&
589
+ this.network.level === "critical";
590
+ const t = now();
591
+ if (triggerCritical) {
592
+ if (!this._allCriticalSince) this._allCriticalSince = t;
593
+ } else {
594
+ this._allCriticalSince = null;
595
+ if (this._autoDisableCountdownTimer) {
596
+ clearTimeout(this._autoDisableCountdownTimer);
597
+ this._autoDisableCountdownTimer = null;
598
+ this._emit({
599
+ kind: "autoDisableCamera",
600
+ level: "healthy",
601
+ reason: "recovered",
602
+ action: "cancelCountdown",
603
+ });
604
+ }
605
+ return;
606
+ }
607
+ // Cooldown after a prior banner dismissal (timeout OR user "Keep
608
+ // on"). Without this, the moment dismissed_timeout fires we'd
609
+ // immediately re-trip the trigger because _allCriticalSince is
610
+ // still set, producing a relentless show/dismiss loop while
611
+ // conditions stay bad.
612
+ const cooldownLeft = this._autoDisableLastDismissedAt
613
+ ? this.thresholds.toastCooldownMs -
614
+ (t - this._autoDisableLastDismissedAt)
615
+ : 0;
616
+ if (
617
+ this._allCriticalSince &&
618
+ t - this._allCriticalSince >= this.thresholds.autoDisableCameraMs &&
619
+ !this._autoDisableCountdownTimer &&
620
+ cooldownLeft <= 0
621
+ ) {
622
+ // action: 'countdown' tells the host to show the suggestion
623
+ // banner. graceMs is how long the banner stays open before
624
+ // auto-dismissing if the user does nothing.
625
+ this._emit({
626
+ kind: "autoDisableCamera",
627
+ level: "critical",
628
+ reason: "sustained_severe",
629
+ action: "countdown",
630
+ graceMs: this.thresholds.autoDisableGraceMs,
631
+ message:
632
+ "Your connection is poor. Disabling your camera may improve audio quality.",
633
+ });
634
+ this._autoDisableCountdownTimer = setTimeout(() => {
635
+ this._autoDisableCountdownTimer = null;
636
+ // Mark the dismissal time so the cooldown gate above prevents
637
+ // an immediate re-show on the next sample tick.
638
+ this._autoDisableLastDismissedAt = now();
639
+ // Banner times out — emit a dismiss so the host can hide it.
640
+ // We do NOT emit action='disable' here anymore; the camera
641
+ // stays on unless the user actively clicks the button.
642
+ this._emit({
643
+ kind: "autoDisableCamera",
644
+ level: "healthy",
645
+ reason: "dismissed_timeout",
646
+ action: "cancelCountdown",
647
+ });
648
+ }, this.thresholds.autoDisableGraceMs);
649
+ }
650
+ }
651
+
652
+ // User clicked "Keep camera on" in the toast.
653
+ cancelAutoDisableCamera() {
654
+ if (this._autoDisableCountdownTimer) {
655
+ clearTimeout(this._autoDisableCountdownTimer);
656
+ this._autoDisableCountdownTimer = null;
657
+ }
658
+ // Reset the all-critical timer so we don't immediately re-trigger.
659
+ this._allCriticalSince = null;
660
+ // User explicitly dismissed — apply the same cooldown the timeout
661
+ // path uses so a stuck-bad connection doesn't immediately re-show
662
+ // the banner the moment the all-critical timer rearms.
663
+ this._autoDisableLastDismissedAt = now();
664
+ }
665
+
666
+ // ---------- actions ----------
667
+
668
+ // Cap the outgoing simulcast to a target top-layer ('mid' | 'low').
669
+ // Mediasoup encodings carry rids 'l', 'm', 'h'; we disable layers above
670
+ // the target by setting their active=false via setParameters. The
671
+ // encoder stops producing them; bandwidth and CPU drop.
672
+ async _capOutbound(target, reason) {
673
+ const videoProducer = this._findVideoProducer();
674
+ if (!videoProducer) return;
675
+ const sender = videoProducer.rtpSender;
676
+ if (!sender || typeof sender.getParameters !== "function") return;
677
+ try {
678
+ const params = sender.getParameters();
679
+ if (!params.encodings || params.encodings.length < 2) return;
680
+ // Keep layers up to and including the target rid; disable above.
681
+ const keep = target === "mid" ? ["l", "m"] : ["l"];
682
+ let changed = false;
683
+ for (const enc of params.encodings) {
684
+ const wantActive = keep.includes(enc.rid);
685
+ if (enc.active !== wantActive) {
686
+ enc.active = wantActive;
687
+ changed = true;
688
+ }
689
+ }
690
+ if (!changed) return;
691
+ await sender.setParameters(params);
692
+ this._capState.outboundCapped = target;
693
+ this._log(`outbound cap → ${target} :: ${reason}`);
694
+ } catch (err) {
695
+ this._log(`outbound cap failed :: ${err?.message || err}`);
696
+ }
697
+ }
698
+
699
+ async _uncapOutbound(reason) {
700
+ if (!this._capState.outboundCapped) return;
701
+ const videoProducer = this._findVideoProducer();
702
+ if (!videoProducer) return;
703
+ const sender = videoProducer.rtpSender;
704
+ if (!sender || typeof sender.getParameters !== "function") return;
705
+ try {
706
+ const params = sender.getParameters();
707
+ let changed = false;
708
+ for (const enc of params.encodings || []) {
709
+ if (!enc.active) {
710
+ enc.active = true;
711
+ changed = true;
712
+ }
713
+ }
714
+ if (changed) await sender.setParameters(params);
715
+ this._capState.outboundCapped = false;
716
+ this._log(`outbound uncap :: ${reason}`);
717
+ } catch (err) {
718
+ this._log(`outbound uncap failed :: ${err?.message || err}`);
719
+ }
720
+ }
721
+
722
+ async _capInbound(peerId, target) {
723
+ // Asks the server to prefer a lower simulcast layer for this peer's
724
+ // producer when forwarding to us. Mediasoup consumer API: spatial
725
+ // layer 0 is lowest. Delegated to MediasoupManager which owns the
726
+ // consumer map and the signaling client.
727
+ if (this._capState.byPeerLow.has(peerId)) return;
728
+ try {
729
+ const ok = await this._sendSetPreferredLayers(peerId, 0);
730
+ if (ok) this._capState.byPeerLow.add(peerId);
731
+ this._log(`inbound cap peer ${peerId} → low :: ${ok}`);
732
+ } catch (err) {
733
+ this._log(`inbound cap failed :: ${err?.message || err}`);
734
+ }
735
+ }
736
+
737
+ async _uncapInbound(peerId) {
738
+ if (!this._capState.byPeerLow.has(peerId)) return;
739
+ try {
740
+ await this._sendSetPreferredLayers(peerId, 2);
741
+ this._capState.byPeerLow.delete(peerId);
742
+ this._log(`inbound uncap peer ${peerId}`);
743
+ } catch (err) {
744
+ this._log(`inbound uncap failed :: ${err?.message || err}`);
745
+ }
746
+ }
747
+
748
+ async _sendSetPreferredLayers(peerId, spatialLayer) {
749
+ if (!this.mediasoupManager) return false;
750
+ // The MediasoupManager already speaks media.consumer.setPreferredLayers
751
+ // to the server for layer switching today; we surface a thin wrapper.
752
+ if (typeof this.mediasoupManager.setPeerPreferredLayer === "function") {
753
+ return this.mediasoupManager.setPeerPreferredLayer(peerId, spatialLayer);
754
+ }
755
+ return false;
756
+ }
757
+
758
+ _findVideoProducer() {
759
+ if (!this.mediasoupManager) return null;
760
+ if (typeof this.mediasoupManager.getVideoProducer === "function") {
761
+ return this.mediasoupManager.getVideoProducer();
762
+ }
763
+ return null;
764
+ }
765
+
766
+ _emit(evt) {
767
+ // Always log into the debug ring, even if onQualityEvent throws.
768
+ try {
769
+ this._debugEvents.push({ ts: now(), ...evt });
770
+ if (this._debugEvents.length > 50) this._debugEvents.shift();
771
+ } catch (_e) {}
772
+ try {
773
+ this.onQualityEvent(evt);
774
+ } catch (e) {
775
+ this._log(`onQualityEvent threw :: ${e?.message || e}`);
776
+ }
777
+ }
778
+
779
+ /**
780
+ * Public: snapshot for the in-app debug modal. Returns a plain object
781
+ * safe to JSON-stringify and copy to clipboard. No PII; just monitor
782
+ * state, recent samples, and emitted events.
783
+ */
784
+ getDebugSnapshot() {
785
+ return {
786
+ generatedAt: new Date().toISOString(),
787
+ thresholds: this.thresholds,
788
+ state: {
789
+ outbound: {
790
+ level: this.outbound.level,
791
+ levelSinceMs: now() - this.outbound.levelSince,
792
+ recoveryStartedAt: this.outbound.recoveryStartedAt,
793
+ },
794
+ network: {
795
+ level: this.network.level,
796
+ levelSinceMs: now() - this.network.levelSince,
797
+ recoveryStartedAt: this.network.recoveryStartedAt,
798
+ },
799
+ inboundPeers: [...this.inboundByPeer.entries()].map(([pid, st]) => ({
800
+ peerId: pid,
801
+ level: st.level,
802
+ levelSinceMs: now() - st.levelSince,
803
+ })),
804
+ encoderBadSinceMs: this._encoderBadSince
805
+ ? now() - this._encoderBadSince
806
+ : 0,
807
+ netBadSinceMs: this._netBadSince ? now() - this._netBadSince : 0,
808
+ allCriticalSinceMs: this._allCriticalSince
809
+ ? now() - this._allCriticalSince
810
+ : 0,
811
+ autoDisableCameraScheduled: !!this._autoDisableCountdownTimer,
812
+ // Solo gate input. If this is 0, _onSample short-circuits and
813
+ // no evaluation runs — a non-zero value while you believe you
814
+ // are alone in the meeting means the gate is reading a phantom
815
+ // consumer (e.g. our own producer counted as a consumer, or a
816
+ // stale entry from a previous session).
817
+ remotePeerCount: (() => {
818
+ try { return this.getRemotePeerCount(); } catch { return null; }
819
+ })(),
820
+ outboundCapped: this._capState.outboundCapped,
821
+ inboundCappedPeers: [...this._capState.byPeerLow],
822
+ lastSendSampleAgeMs: this._lastSampleAt.send
823
+ ? now() - this._lastSampleAt.send
824
+ : null,
825
+ lastRecvSampleAgeMs: this._lastSampleAt.recv
826
+ ? now() - this._lastSampleAt.recv
827
+ : null,
828
+ },
829
+ // Last 10 samples per direction (chronological)
830
+ recentSendSamples: this._debugSamples
831
+ .filter((s) => s.dir === "send")
832
+ .slice(-10),
833
+ recentRecvSamples: this._debugSamples
834
+ .filter((s) => s.dir === "recv")
835
+ .slice(-10),
836
+ recentEvents: this._debugEvents.slice(-25),
837
+ };
838
+ }
839
+
840
+ _log(msg) {
841
+ if (typeof console !== "undefined") {
842
+ console.log(`QualityMonitor :: ${msg}`);
843
+ }
844
+ }
845
+ }