@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.
- package/VideoMeetingClient.js +1644 -1488
- package/managers/ConnectionHealthMonitor.js +278 -0
- package/managers/ConnectionManager.js +17 -4
- package/managers/MediasoupManager.js +1061 -859
- package/managers/QualityMonitor.js +845 -0
- package/managers/RemoteMediaManager.js +17 -15
- package/managers/StatsCollector.js +35 -4
- package/package.json +1 -1
|
@@ -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
|
+
}
|