@observertc/client-monitor-js 4.6.1-e133dc3.0 → 4.7.0

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.
Files changed (101) hide show
  1. package/README.md +843 -391
  2. package/dist/ClientMonitor.d.ts +20 -11
  3. package/dist/ClientMonitor.js +1 -1
  4. package/dist/ClientMonitorConfig.d.ts +30 -12
  5. package/dist/ClientMonitorEvents.d.ts +28 -5
  6. package/dist/ClientMonitorIssues.d.ts +34 -1
  7. package/dist/ClientMonitorIssues.js +1 -1
  8. package/dist/adapters/ChromeStatsAdapter.d.ts +6 -0
  9. package/dist/adapters/ChromeStatsAdapter.js +1 -0
  10. package/dist/adapters/FirefoxStatsAdapter.d.ts +10 -0
  11. package/dist/adapters/FirefoxStatsAdapter.js +1 -0
  12. package/dist/adapters/SafariStatsAdapter.d.ts +6 -0
  13. package/dist/adapters/SafariStatsAdapter.js +1 -0
  14. package/dist/adapters/adapterTools.d.ts +9 -0
  15. package/dist/adapters/adapterTools.js +1 -0
  16. package/dist/detectors/AudioConcealmentDetector.d.ts +1 -5
  17. package/dist/detectors/AudioConcealmentDetector.js +1 -1
  18. package/dist/detectors/AudioDesyncDetector.d.ts +1 -1
  19. package/dist/detectors/AudioDesyncDetector.js +1 -1
  20. package/dist/detectors/BlockedTransportDetector.d.ts +34 -0
  21. package/dist/detectors/BlockedTransportDetector.js +1 -0
  22. package/dist/detectors/CaptureFailureDetector.d.ts +1 -1
  23. package/dist/detectors/CaptureFailureDetector.js +1 -1
  24. package/dist/detectors/CongestionDetector.d.ts +1 -0
  25. package/dist/detectors/CongestionDetector.js +1 -1
  26. package/dist/detectors/CpuPerformanceDetector.d.ts +3 -0
  27. package/dist/detectors/CpuPerformanceDetector.js +1 -1
  28. package/dist/detectors/DecoderPerformanceDetector.d.ts +1 -0
  29. package/dist/detectors/DecoderPerformanceDetector.js +1 -1
  30. package/dist/detectors/Detector.d.ts +1 -0
  31. package/dist/detectors/DryInboundTrackDetector.d.ts +1 -1
  32. package/dist/detectors/DryInboundTrackDetector.js +1 -1
  33. package/dist/detectors/DryOutboundTrackDetector.d.ts +1 -1
  34. package/dist/detectors/DryOutboundTrackDetector.js +1 -1
  35. package/dist/detectors/EncoderPerformanceDetector.d.ts +38 -0
  36. package/dist/detectors/EncoderPerformanceDetector.js +1 -0
  37. package/dist/detectors/FreezedVideoTrackDetector.d.ts +8 -7
  38. package/dist/detectors/FreezedVideoTrackDetector.js +1 -1
  39. package/dist/detectors/IceConnectivityDetector.d.ts +1 -0
  40. package/dist/detectors/IceConnectivityDetector.js +1 -1
  41. package/dist/detectors/InboundFrameSupplyDetector.d.ts +29 -0
  42. package/dist/detectors/InboundFrameSupplyDetector.js +1 -0
  43. package/dist/detectors/JitterBufferStressDetector.d.ts +1 -0
  44. package/dist/detectors/JitterBufferStressDetector.js +1 -1
  45. package/dist/detectors/MediaPipelineDetector.d.ts +37 -0
  46. package/dist/detectors/MediaPipelineDetector.js +1 -0
  47. package/dist/detectors/NoAvailableIceCandidateDetector.d.ts +28 -0
  48. package/dist/detectors/NoAvailableIceCandidateDetector.js +1 -0
  49. package/dist/detectors/OutboundFrameSupplyDetector.d.ts +29 -0
  50. package/dist/detectors/OutboundFrameSupplyDetector.js +1 -0
  51. package/dist/detectors/PlayoutDiscrepancyDetector.d.ts +4 -1
  52. package/dist/detectors/PlayoutDiscrepancyDetector.js +1 -1
  53. package/dist/detectors/SimulcastLayerDetector.js +1 -1
  54. package/dist/detectors/StuckDecoderDetector.d.ts +1 -1
  55. package/dist/detectors/StuckDecoderDetector.js +1 -1
  56. package/dist/detectors/SynthesizedSamplesDetector.js +1 -1
  57. package/dist/index.d.ts +25 -5
  58. package/dist/index.js +1 -1
  59. package/dist/monitors/IceCandidateMonitor.d.ts +1 -0
  60. package/dist/monitors/IceCandidateMonitor.js +1 -1
  61. package/dist/monitors/IceCandidatePairMonitor.js +1 -1
  62. package/dist/monitors/InboundRtpMonitor.d.ts +3 -0
  63. package/dist/monitors/InboundRtpMonitor.js +1 -1
  64. package/dist/monitors/InboundTrackMonitor.d.ts +22 -1
  65. package/dist/monitors/InboundTrackMonitor.js +1 -1
  66. package/dist/monitors/MediaSourceMonitor.js +1 -1
  67. package/dist/monitors/OutboundTrackMonitor.d.ts +8 -0
  68. package/dist/monitors/OutboundTrackMonitor.js +1 -1
  69. package/dist/monitors/PeerConnectionMonitor.d.ts +6 -0
  70. package/dist/monitors/PeerConnectionMonitor.js +1 -1
  71. package/dist/monitors/SelectedIcePath.js +1 -1
  72. package/dist/monitors/TrackMonitor.d.ts +3 -2
  73. package/dist/schema/ClientEventTypes.d.ts +54 -49
  74. package/dist/schema/ClientEventTypes.js +1 -1
  75. package/dist/schema/ClientSample.d.ts +10 -9
  76. package/dist/schema/ClientSample.js +1 -1
  77. package/dist/scores/CalculatedScore.d.ts +6 -66
  78. package/dist/scores/CalculatedScore.js +1 -1
  79. package/dist/scores/DefaultScoreCalculator.d.ts +35 -2
  80. package/dist/scores/DefaultScoreCalculator.js +1 -1
  81. package/dist/scores/ScoreCalculator.d.ts +0 -6
  82. package/dist/scores/utils.d.ts +1 -0
  83. package/dist/scores/utils.js +1 -0
  84. package/dist/sources/ClientEventPayloadProvider.d.ts +5 -3
  85. package/dist/sources/ClientEventPayloadProvider.js +1 -1
  86. package/dist/sources/MediasoupTransportBinding.d.ts +1 -0
  87. package/dist/sources/MediasoupTransportBinding.js +1 -1
  88. package/dist/sources/RtcPeerConnectionBinding.js +1 -1
  89. package/dist/sources/Sources.d.ts +2 -0
  90. package/dist/sources/Sources.js +1 -1
  91. package/dist/sources/watchTabVisibility.d.ts +3 -0
  92. package/dist/sources/watchTabVisibility.js +1 -0
  93. package/dist/utils/common.d.ts +1 -0
  94. package/dist/utils/common.js +1 -1
  95. package/package.json +2 -1
  96. package/dist/adapters/Firefox94StatsAdapter.d.ts +0 -5
  97. package/dist/adapters/Firefox94StatsAdapter.js +0 -1
  98. package/dist/adapters/FirefoxTransportStatsAdapter.d.ts +0 -10
  99. package/dist/adapters/FirefoxTransportStatsAdapter.js +0 -1
  100. package/dist/detectors/SourceEncoderBottleneckDetector.d.ts +0 -48
  101. package/dist/detectors/SourceEncoderBottleneckDetector.js +0 -1
package/README.md CHANGED
@@ -40,6 +40,26 @@ or
40
40
  yarn add @observertc/client-monitor-js
41
41
  ```
42
42
 
43
+ ### Release candidates
44
+
45
+ Every push to `develop` publishes a release candidate as `X.Y.Z-rc.<N>`, where `N` increases with every build. Depend on the **`next` dist-tag** to track them:
46
+
47
+ ```jsonc
48
+ // package.json
49
+ "dependencies": {
50
+ "@observertc/client-monitor-js": "next"
51
+ }
52
+ ```
53
+
54
+ `next` always points at the newest RC across all version lines, so this dependency never has to be edited when the line bumps from `4.7.x` to `4.8.x`. Per-line tags (`develop-470-rc`, `develop-460-rc`, ...) are still maintained if you want to stay on one line.
55
+
56
+ **Do not use a caret range to track RCs** — it cannot work, for two separate reasons rooted in how semver ranges treat prereleases:
57
+
58
+ - `"^4.6.0"` resolves to the stable `4.6.0` and silently excludes every RC. A range with no prerelease in it never matches prerelease versions.
59
+ - `"^4.7.1-rc.5"` does match RCs, but only of `4.7.1` — it will never see `4.8.1-rc.N`, so it stops updating the moment the line bumps.
60
+
61
+ Historically RCs were published as `X.Y.Z-<git-sha>.0`. Semver compares prerelease identifiers as ASCII strings and git SHAs have no chronological order, so the "highest" RC of that scheme was effectively random — a caret range on one of them resolved to an arbitrary older build and never moved. Those versions are still published and untouched, but they are superseded: any `rc.N` sorts above all of them.
62
+
43
63
  ## Quick Start
44
64
 
45
65
  ```javascript
@@ -223,10 +243,11 @@ const monitor = new ClientMonitor({
223
243
 
224
244
  dryInboundTrackDetector: { thresholdInMs: 5000 },
225
245
  dryOutboundTrackDetector: { thresholdInMs: 5000 },
226
- videoFreezesDetector: {},
246
+ videoFreezesDetector: { minConsecutiveTicks: 2 },
227
247
  playoutDiscrepancyDetector: {
228
- lowSkewThreshold: 2,
229
- highSkewThreshold: 5,
248
+ lowSkewRatio: 0.1,
249
+ highSkewRatio: 0.25,
250
+ minFramesReceived: 10,
230
251
  },
231
252
  syntheticSamplesDetector: {
232
253
  minSynthesizedSamplesDuration: 1000,
@@ -243,6 +264,20 @@ const monitor = new ClientMonitor({
243
264
  iceRestartRecommendationCooldownInMs: 15000, // min gap between recommendations
244
265
  createEvent: true,
245
266
  },
267
+ blockedTransportDetector: {
268
+ thresholdInMs: 5000, // how long the STUN-ok-but-media-blocked discrepancy must persist
269
+ minMediaBitrateBps: 10000, // "producer is demonstrably producing" bar
270
+ maxReturnBitrateBps: 2000, // at or below this, the return path is STUN-only
271
+ maxSendShare: 0.1, // transport send below this share of produced => not leaving
272
+ stunFreshnessInMs: 10000, // how recent a STUN response must be to count as verified
273
+ },
274
+ noAvailableIceCandidateDetector: {
275
+ thresholdInMs: 6000, // grace for `new`/`connecting` with zero local candidates
276
+ },
277
+ mediaPipelineDetector: {
278
+ thresholdInMs: 4000, // how long a broken pipeline boundary must persist
279
+ minTransportReceiveBitrateBps: 20000, // above this, incoming transport traffic must demux
280
+ },
246
281
 
247
282
  audioConcealmentDetector: {
248
283
  onThreshold: 0.03, // Webex treats >3% concealment as significant, >5% as severe
@@ -272,21 +307,30 @@ const monitor = new ClientMonitor({
272
307
  stuckDecoderDetector: {
273
308
  thresholdInMs: 4000, // floor; effective wait = max(this, rttMultiplier x RTT)
274
309
  rttMultiplier: 15, // high-RTT paths get more time to recover legitimately
275
- minStuckTicks: 2, // never judge on fewer observations than this
276
- minBitrate: 10000, // bps below which this is a dry track, not a wedge
310
+ minBitrate: 10000, // bps below which this is a dry track, not a wedge
277
311
  minPliCount: 2,
278
312
  },
279
- sourceEncoderBottleneckDetector: {
280
- captureFpsRatioThreshold: 0.5,
281
- minSourceFps: 5,
282
- encodeFpsRatioThreshold: 0.7,
283
- encodeTimeBudgetRatio: 0.8,
284
- cpuLimitationShareThreshold: 0.3,
285
- minConsecutiveTicks: 2,
313
+ // Frame supply: is whatever produces this track's frames delivering what it
314
+ // should? Average over a duration, compare, judge. The config *types* live
315
+ // in the detector files; the defaults are here with every other detector's.
316
+ outboundFrameSupplyDetector: {
317
+ durationInMs: 15000, // average the capture device over this long ...
318
+ captureFpsRatioThreshold: 0.9, // ... then require 90% of the configured fps
319
+ },
320
+ encoderPerformanceDetector: {
321
+ encodeFpsRatioThreshold: 0.7, // encoder below 70% of source fps = behind
322
+ encodeTimeBudgetRatio: 0.8, // encode time per frame vs the frame budget
323
+ cpuLimitationShareThreshold: null, // null = ignore the browser's CPU-limited signal
324
+ minConsecutiveTicks: 2, // two reads agreeing, not a span of time
325
+ },
326
+ inboundFrameSupplyDetector: {
327
+ durationInMs: 15000, // average the decoder over this long ...
328
+ decodeFpsRatioThreshold: 0.9, // ... then require 90% of what arrived
329
+ minReceivedFps: 5, // too thin a stream to judge a decoder on
286
330
  },
287
331
  captureFailureDetector: {
288
- silenceThresholdInMs: 30000, // long on purpose: silence != a broken mic
289
- silenceRmsThreshold: 0.001,
332
+ silenceThresholdInMs: 60000, // long on purpose: silence != a broken mic
333
+ silenceRmsThreshold: 0.0001,
290
334
  createEvent: true,
291
335
  },
292
336
 
@@ -383,454 +427,623 @@ The `ClientMonitor` is the main class that orchestrates WebRTC monitoring, stati
383
427
 
384
428
  ## Detectors
385
429
 
386
- Detectors are specialized components that monitor for specific anomalies and issues in WebRTC connections. Each detector focuses on a particular aspect of the connection quality.
430
+ Detectors turn the collected stats into *verdicts*. Each one watches a specific failure mode and reports through up to three channels: **stateful issues** (raised when the condition starts, resolved when it clears — with the full lifecycle shipped to the server, see [Sample-channel behavior](#sample-channel-behavior)), **monitor events** (realtime, for the application to act on), and **client events** (buffered into samples for server-side correlation).
387
431
 
388
- ### Built-in Detectors
432
+ Configuration follows one convention everywhere: omit a detector's config key to get defaults, pass `null` to not construct it at all, or flip the instance's `disabled` flag at runtime to silence it without removing it (see [Controlling which detectors run](#controlling-which-detectors-run)).
389
433
 
390
- #### AudioDesyncDetector
434
+ ### Detector overview
391
435
 
392
- Detects audio synchronization issues by monitoring sample corrections.
436
+ | Detector | Watches | Reports | Good for |
437
+ |---|---|---|---|
438
+ | [`AudioConcealmentDetector`](#audioconcealmentdetector) | inbound audio | issue `audio-concealment` | How the audio actually *sounded* — catches degradation packet loss numbers miss |
439
+ | [`JitterBufferStressDetector`](#jitterbufferstressdetector) | inbound audio | issue `audio-jitter-buffer-stress` | The jitter buffer adding latency *and* stretching audio — delay the user hears |
440
+ | [`AudioDesyncDetector`](#audiodesyncdetector) | inbound audio | issue `audio-desync` | Playback drifting out of sync through heavy sample correction |
441
+ | [`SynthesizedSamplesDetector`](#synthesizedsamplesdetector) | audio playout | event `synthesized-audio` | The playout device injecting synthesized audio |
442
+ | [`FreezedVideoTrackDetector`](#freezedvideotrackdetector) | inbound video | issues `freezed-video-track`, `keyframe-storm`, `video-recovery-failed` | Frozen pictures and a repair loop that stopped working |
443
+ | [`DecoderPerformanceDetector`](#decoderperformancedetector) | inbound video | issue `video-decoder-overloaded` | Frames arrived but this device cannot decode them in time |
444
+ | [`StuckDecoderDetector`](#stuckdecoderdetector) | inbound video | issue `stuck-decoder` | RTP flowing, nothing decoding — the wedge only recreating the consumer fixes |
445
+ | [`PlayoutDiscrepancyDetector`](#playoutdiscrepancydetector) | inbound video | issue `inbound-video-playout-discrepancy` | Frames received but not rendered — a rendering pipeline backlog |
446
+ | [`DryInboundTrackDetector` / `DryOutboundTrackDetector`](#dryinboundtrackdetector--dryoutboundtrackdetector) | tracks | issues `dry-inbound-track`, `dry-outbound-track` | A track that should be flowing but carries no bytes at all |
447
+ | [`OutboundFrameSupplyDetector`](#outboundframesupplydetector) | outbound video | issue `capture-bottleneck` | The camera is not delivering the frames it was configured for — caught *while it degrades*, not once it has stopped |
448
+ | [`EncoderPerformanceDetector`](#encoderperformancedetector) | outbound video | issue `encoder-bottleneck` | The camera is delivering and the encoder cannot keep up with it |
449
+ | [`InboundFrameSupplyDetector`](#inboundframesupplydetector) | inbound video | issue `decoder-bottleneck` | Frames arrived and the decoder did not turn enough of them into pictures |
450
+ | [`CaptureFailureDetector`](#capturefailuredetector) | outbound tracks | issues `capture-track-ended`, `silent-audio-source` | Vanished devices and microphones producing pure silence |
451
+ | [`CongestionDetector`](#congestiondetector) | peer connection | issue `congestion` | Bandwidth-limited sending corroborated by RTT / loss |
452
+ | [`CpuPerformanceDetector`](#cpuperformancedetector) | whole client | issue `cpulimitation` | The device running out of CPU for encode/decode |
453
+ | [`LongPcConnectionEstablishmentDetector`](#longpcconnectionestablishmentdetector) | peer connection | event `too-long-pc-connection-establishment` | Connection setup taking suspiciously long |
454
+ | [`IceConnectivityDetector`](#iceconnectivitydetector) | ICE transports | issues `ice-disconnected`, `ice-connection-failed`, `ice-transport-stalled`, `unstable-ice-path`; events `ice-restart`, `ice-restart-recommended` | Runtime ICE health and *when* an ICE restart is warranted |
455
+ | [`BlockedTransportDetector`](#blockedtransportdetector) | ICE transports | issue `blocked-transport` | STUN passes but media does not — the firewall / policy-middlebox signature |
456
+ | [`NoAvailableIceCandidateDetector`](#noavailableicecandidatedetector) | peer connection | issue `no-available-ice-candidate` | Zero local ICE candidates while the connection falls over — no usable network at all |
457
+ | [`MediaPipelineDetector`](#mediapipelinedetector) | peer connection | issue `media-pipeline-stalled` | The first broken stage of the media pipeline nothing else covers: encoded frames never leave the sender, or transport traffic never demuxes |
458
+ | [`IceTupleChangeDetector`](#icetuplechangedetector) | ICE transports | event `ice-tuple-changed` | The low-level signal that the selected network tuple changed |
459
+ | [`CodecChangeDetector`](#observation-detectors) | tracks | event `codec-changed` / `CODEC_CHANGED` | Which codec/profile is actually in use, and when it changed |
460
+ | [`VideoResolutionChangeDetector`](#observation-detectors) | video tracks | event `video-resolution-changed` / `VIDEO_RESOLUTION_CHANGED` | The adaptation ladder, with the *reason* attached |
461
+ | [`SimulcastLayerDetector`](#observation-detectors) | outbound video | event `simulcast-layer-changed` / `SIMULCAST_LAYER_CHANGED` | Which simulcast layers are actually being sent |
462
+ | [`StatsGapDetector`](#observation-detectors) | the monitor itself | event `stats-collection-gap` / `STATS_COLLECTION_GAP` | Backgrounded-tab gaps that would otherwise read as network spikes |
463
+
464
+ The last four are **observations**: they emit events and never raise issues, because what they report is not a fault — it is the missing context in most investigations.
465
+
466
+ ### Which issues belong in the sample
467
+
468
+ Every issue-raising detector exposes a runtime flag next to `disabled`:
469
+
470
+ ```ts
471
+ /** like `disabled`, flippable at runtime */
472
+ public includeIssueInSample = true;
473
+ ```
474
+
475
+ When flipped to `false`, the detector keeps working locally — monitor events fire and the issue lifecycle (`activeIssues`, `'issue'` / `'issue-resolved'`) is maintained — but neither the raise entry nor the resolution entry is buffered into the `ClientSample`. (`raiseIssue` / `addIssue` accept the same thing directly via `includeInSample` for custom issues.)
476
+
477
+ In case shrinking down the sample size is something your application wants, the table below is the useful thing to know: it says for every issue whether the server can **derive the same verdict from one component's stats that the sample already carries** (all the load-bearing counters are monotonic totals, so a server holding consecutive samples can recompute every delta). Issues that are derivable are the safe candidates for `includeIssueInSample = false`; issues that are not derivable join stats across components, depend on state that never reaches the sample (`MediaStreamTrack.muted`, `getSettings()`, connection-state transitions), or live in sub-sampling-period timing — switch those off and the information is gone.
478
+
479
+ | Detector | Issue | Derivable from one component's sampled stats? | From what |
480
+ | --- | --- | --- | --- |
481
+ | `FreezedVideoTrackDetector` | `freezed-video-track` | **Yes** | `inbound-rtp` `freezeCount`, `totalFreezesDuration` |
482
+ | `FreezedVideoTrackDetector` | `keyframe-storm` | **Yes** | `inbound-rtp` `pliCount`, `firCount`, `keyFramesDecoded` |
483
+ | `FreezedVideoTrackDetector` | `video-recovery-failed` | No | tick-level sequencing of freeze + PLI + keyframe counters |
484
+ | `AudioDesyncDetector` | `audio-desync` | **Yes** | `inbound-rtp` inserted/removed sample totals |
485
+ | `AudioConcealmentDetector` | `audio-concealment` | **Yes** | `inbound-rtp` `concealedSamples`, `silentConcealedSamples` |
486
+ | `JitterBufferStressDetector` | `audio-jitter-buffer-stress` | **Yes** (approx.) | `inbound-rtp` jitter-buffer totals; the consecutive-tick nuance is lost |
487
+ | `SynthesizedSamplesDetector` | event only | **Yes** | `media-playout` synthesized-sample totals |
488
+ | `PlayoutDiscrepancyDetector` | `inbound-video-playout-discrepancy` | **Yes** | `inbound-rtp` `framesReceived` vs `framesRendered` |
489
+ | `DecoderPerformanceDetector` | `video-decoder-overloaded` | Partially | `inbound-rtp` decode/drop totals; frame-budget + quiet-loss guards are coarser at the sampling period |
490
+ | `StuckDecoderDetector` | `stuck-decoder` | No | tick-level bytes-up/frames-flat/PLI-up fingerprint; drives consumer recreation |
491
+ | `DryInboundTrackDetector` | `dry-inbound-track` | No | guards read `MediaStreamTrack.muted`/`readyState` + remote pause state — not in the sample |
492
+ | `DryOutboundTrackDetector` | `dry-outbound-track` | No | same non-sampled track-state guards |
493
+ | `CaptureFailureDetector` | `capture-track-ended` | No | `MediaStreamTrack` `ended` event — no stats representation |
494
+ | `CaptureFailureDetector` | `silent-audio-source` | No | energy totals are sampled, but the live/enabled/unmuted guards are not |
495
+ | `OutboundFrameSupplyDetector` | `capture-bottleneck` | No | the frame rate is a counter differenced against measured elapsed time, and the guards read `track.getSettings()`, pause state, screen-share content type and live track state — none of it reconstructable from a sample |
496
+ | `EncoderPerformanceDetector` | `encoder-bottleneck` | No | joins the media source's frame rate with the highest active layer's encode time and CPU-limitation shares per collecting tick, and chains off whether `capture-bottleneck` is active |
497
+ | `InboundFrameSupplyDetector` | `decoder-bottleneck` | No | differences `framesDecoded` against `framesReceived` per collecting tick, behind pause and live-track guards that are not sampled |
498
+ | `CongestionDetector` | `congestion` | Mostly | `candidate-pair` available bitrates + `outbound-rtp` `qualityLimitationReason` — two components, but both sampled |
499
+ | `CpuPerformanceDetector` | `cpulimitation` | No | joins send-side and receive-side evidence plus `durationOfCollectingStatsInMs`, which is not sampled |
500
+ | `IceConnectivityDetector` | `ice-disconnected`, `ice-connection-failed`, `ice-transport-stalled`, `unstable-ice-path` | No | state transitions and episode timing happen *between* samples |
501
+ | `BlockedTransportDetector` | `blocked-transport` | No | joins candidate-pair STUN counters + transport bytes + outbound-rtp bitrate per collecting tick |
502
+ | `NoAvailableIceCandidateDetector` | `no-available-ice-candidate` | No | connection-state jumps + gathering state; with no network the next sample may never leave the device |
503
+ | `MediaPipelineDetector` | `media-pipeline-stalled` | No | cross-references outbound-rtp vs its own packet counters and transport bytes vs inbound-rtp bytes per collecting tick |
504
+
505
+
506
+ ---
507
+
508
+ ### Audio detectors
393
509
 
394
- **Triggers on:**
510
+ #### AudioConcealmentDetector
395
511
 
396
- - Audio acceleration/deceleration corrections exceed thresholds
397
- - Indicates audio-video sync problems
512
+ Reports how the audio actually *sounded*. Opus + NetEQ conceal a lot of loss inaudibly, and audio also degrades without dramatic loss — so the **audible** concealment share (silent concealment subtracted) is both more sensitive and more specific than packet loss. Judged over a sliding window, since concealment is bursty.
398
513
 
399
- **Configuration:**
514
+ **Use the result:** show a "poor audio from X" indicator on the affected participant's tile. Server-side, the issue lifecycle gives you exact audible-degradation windows per participant.
400
515
 
401
516
  ```javascript
402
- audioDesyncDetector: {
403
- fractionalCorrectionAlertOnThreshold: 0.1, // 10% correction rate triggers alert
404
- fractionalCorrectionAlertOffThreshold: 0.05, // 5% correction rate clears alert
517
+ audioConcealmentDetector: {
518
+ onThreshold: 0.03, // audible concealment share that raises (Webex: >3% = significant)
519
+ offThreshold: 0.01, // share below which it resolves (hysteresis)
520
+ windowInMs: 15000, // sliding window; spans several ticks even at 5s collection
521
+ minSamplesInWindow: 24000, // don't judge on less than ~0.5s of 48kHz audio
405
522
  }
406
523
  ```
407
524
 
408
- #### CongestionDetector
525
+ ```typescript
526
+ monitor.on('audio-concealment', ({ trackMonitor, concealmentRate }) => {
527
+ // the user is HEARING this — mark the participant's tile
528
+ ui.setAudioQualityWarning(trackMonitor.track.id, { rate: concealmentRate });
529
+ });
530
+ monitor.on('issue-resolved', (issue) => {
531
+ if (issue.type === 'audio-concealment') ui.clearAudioQualityWarning(/* by key */);
532
+ });
533
+ ```
409
534
 
410
- Monitors network congestion by analyzing available bandwidth vs. usage.
535
+ **Sources:** [Voice quality monitoring (Webex)](https://help.webex.com/article/kqh7le/Voice-quality-monitoring) · [How WebRTC's NetEQ jitter buffer provides smooth audio (webrtcHacks)](https://webrtchacks.com/how-webrtcs-neteq-jitter-buffer-provides-smooth-audio/) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
411
536
 
412
- **Triggers on:**
537
+ #### JitterBufferStressDetector
413
538
 
414
- - Available bandwidth falls below sending/receiving bitrates
415
- - Network congestion conditions
539
+ Fires only when the jitter buffer's target delay has grown **and** NetEQ is time-stretching audio. Either alone is the system working (buying latency to hide jitter is success); together they are added delay the user actually hears.
416
540
 
417
- **Configuration:**
541
+ **Use the result:** for latency-sensitive products, surface a "your connection is adding delay" hint; there is nothing to fix client-side, so the main value is attribution — this participant's audio lag is *their network jitter*, not your platform.
418
542
 
419
543
  ```javascript
420
- congestionDetector: {
421
- sensitivity: 'medium', // 'low', 'medium', 'high'
544
+ jitterBufferStressDetector: {
545
+ targetDelayThresholdInMs: 200, // >200ms added delay is noticeable degradation
546
+ timeStretchThreshold: 0.02, // share of samples stretched/compressed
547
+ minConsecutiveTicks: 2, // sustained, not a one-tick blip
422
548
  }
423
549
  ```
424
550
 
425
- #### CpuPerformanceDetector
426
-
427
- Detects CPU performance issues affecting media processing.
551
+ ```typescript
552
+ monitor.on('audio-jitter-buffer-stress', ({ trackMonitor, targetDelayInMs }) => {
553
+ log.info(`audio delayed ~${Math.round(targetDelayInMs)}ms by jitter buffering`, trackMonitor.track.id);
554
+ });
555
+ ```
428
556
 
429
- **Triggers on:**
557
+ **Sources:** [How WebRTC's NetEQ jitter buffer provides smooth audio (webrtcHacks)](https://webrtchacks.com/how-webrtcs-neteq-jitter-buffer-provides-smooth-audio/) · [NetEQ (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/neteq/) · [RTCRtpReceiver.jitterBufferTarget (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpReceiver/jitterBufferTarget)
430
558
 
431
- - Outbound RTP quality limitation reason is `'cpu'`
432
- - Inbound decoded/received frames ratio drops below threshold (the decoder cannot keep up with received frames — a sign of decode-side CPU limitation)
433
- - Stats collection takes too long (indicating CPU stress)
559
+ #### AudioDesyncDetector
434
560
 
435
- > **Why not FPS volatility?** Earlier versions inferred decode CPU pressure from frame-rate volatility. That false-triggered on content such as screen share, whose fps legitimately swings (e.g. 15 → 1 fps when the shared content goes static). The decoded/received ratio is robust to this: when fps drops legitimately, received and decoded frames drop together so the ratio stays near 1.0. An alert only fires when frames are received but not decoded.
561
+ Detects heavy sample correction (acceleration/deceleration) on an inbound audio track — the signature of playback drifting and being yanked back, which the user perceives as warbly or out-of-sync audio.
436
562
 
437
- **Configuration:**
563
+ **Use the result:** correlate with lip-sync complaints; persistent desync on one track is usually the far end's capture clock, so route the report to *that* participant's diagnostics rather than the listener's.
438
564
 
439
565
  ```javascript
440
- cpuPerformanceDetector: {
441
- incomingDecodedFramesRatioThresholds: {
442
- alertOn: 0.7, // alert ON when <70% of received frames are decoded
443
- alertOff: 0.85, // alert OFF once >=85% are decoded again (hysteresis)
444
- minReceivedFrames: 10, // skip intervals with fewer received frames (low-fps noise guard)
445
- },
446
- durationOfCollectingStatsThreshold: {
447
- lowWatermark: 5000,
448
- highWatermark: 10000,
449
- },
566
+ audioDesyncDetector: {
567
+ fractionalCorrectionAlertOnThreshold: 0.1, // >10% of samples corrected raises
568
+ fractionalCorrectionAlertOffThreshold: 0.05, // <5% resolves
450
569
  }
451
570
  ```
452
571
 
453
- #### DryInboundTrackDetector
572
+ ```typescript
573
+ monitor.on('audio-desync-track', ({ trackMonitor }) => {
574
+ diagnostics.flag('audio-desync', trackMonitor.track.id);
575
+ });
576
+ ```
454
577
 
455
- Detects inbound tracks that stop receiving data.
578
+ **Sources:** [How WebRTC's NetEQ jitter buffer provides smooth audio (webrtcHacks)](https://webrtchacks.com/how-webrtcs-neteq-jitter-buffer-provides-smooth-audio/) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
456
579
 
457
- **Triggers on:**
580
+ #### SynthesizedSamplesDetector
458
581
 
459
- - Inbound track receives no data for specified duration
460
- - Track stalling or connection issues
582
+ Watches `media-playout` for synthesized (concealment/generated) samples injected at the playout device level, and emits `'synthesized-audio'` plus the `EXCESSIVE_SYNTHESIZED_AUDIO` client event when the duration in one interval exceeds the configured minimum.
461
583
 
462
- **Configuration:**
584
+ **Use the result:** sustained synthesized playout with otherwise healthy inbound stats points at the *output* path — suggest the user switch audio output device.
463
585
 
464
586
  ```javascript
465
- dryInboundTrackDetector: {
466
- thresholdInMs: 5000,
587
+ syntheticSamplesDetector: {
588
+ minSynthesizedSamplesDuration: 0, // ms of synthesized audio per interval before reporting
589
+ createEvent: true, // also buffer EXCESSIVE_SYNTHESIZED_AUDIO into samples
467
590
  }
468
591
  ```
469
592
 
470
- #### DryOutboundTrackDetector
593
+ **Sources:** [How WebRTC's NetEQ jitter buffer provides smooth audio (webrtcHacks)](https://webrtchacks.com/how-webrtcs-neteq-jitter-buffer-provides-smooth-audio/) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
594
+
595
+ ---
471
596
 
472
- Detects outbound tracks that stop sending data.
597
+ ### Video detectors
598
+
599
+ #### FreezedVideoTrackDetector
473
600
 
474
- **Triggers on:**
601
+ Owns the whole freeze/repair domain of an inbound video track. It derives the freeze state (a freeze persists until frames actually render again) and watches the repair loop — PLI/FIR out, keyframes back in:
475
602
 
476
- - Outbound track sends no data for specified duration
477
- - Local media issues or encoding problems
603
+ - `freezed-video-track` — the picture is frozen (config: `videoFreezesDetector`).
604
+ - `keyframe-storm` — sustained PLI rate; self-reinforcing congestion, since keyframes are large (config: `videoRecoveryDetector`).
605
+ - `video-recovery-failed` — PLIs going out, picture still frozen, keyframes not advancing: the repair request left the client and nothing came back, which points at SFU forwarding (config: `videoRecoveryDetector`).
478
606
 
479
- **Configuration:**
607
+ **Use the result:** on `freezed-video-track`, overlay a spinner/last-frame treatment on the tile. `video-recovery-failed` is your escalation signal — pair it with [`stuck-decoder`](#stuckdecoderdetector): if both fire, recreate the consumer; if only recovery fails (no bytes checked here), the producer or SFU forwarding needs the look.
480
608
 
481
609
  ```javascript
482
- dryOutboundTrackDetector: {
483
- thresholdInMs: 5000,
610
+ videoFreezesDetector: { minConsecutiveTicks: 2 }, // consecutive frozen intervals before an issue (null = off)
611
+ videoRecoveryDetector: {
612
+ windowInMs: 30000, // window for PLI/keyframe rates
613
+ pliRateAlertOn: 0.5, // real-world storms run ~0.5-0.7 PLI/s sustained
614
+ pliRateAlertOff: 0.15,
615
+ recoveryFailedThresholdInMs: 5000, // frozen + PLIs out + no keyframe for this long
616
+ recoveryFailedMinPliCount: 2, // proof we actually asked for repair
484
617
  }
485
618
  ```
486
619
 
487
- #### FreezedVideoTrackDetector
620
+ ```typescript
621
+ monitor.on('freezed-video-track', ({ trackMonitor }) => ui.showFreezeOverlay(trackMonitor.track.id));
622
+ monitor.on('video-recovery-failed', ({ trackMonitor, pliCountSinceStalled }) => {
623
+ // we asked for a keyframe repeatedly and nothing came back — not a local problem
624
+ reportToServer('recovery-failed', trackMonitor.track.id, { pliCountSinceStalled });
625
+ });
626
+ ```
488
627
 
489
- Detects frozen video tracks.
628
+ **Sources:** [PLI: Picture Loss Indication (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/pli/) · [RFC 4585: RTP/AVPF (PLI/FIR)](https://datatracker.ietf.org/doc/html/rfc4585) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
490
629
 
491
- **Triggers on:**
630
+ #### DecoderPerformanceDetector
492
631
 
493
- - Video frames stop updating
494
- - Video freeze conditions
632
+ Blames this device only when frames demonstrably *arrived* — healthy receive rate, quiet loss — but decode time overran a budget derived from the stream's own frame rate, or frames were dropped after arrival. This is the detector that separates "the network dropped it" from "the client could not decode it": same chart, opposite fixes.
495
633
 
496
- **Configuration:**
634
+ **Use the result:** reduce decode load — subscribe to lower simulcast layers, cap the number of rendered videos, or pause off-screen tiles. The payload's `decoderImplementation` / `powerEfficientDecoder` tell you whether a software decoder is doing work the hardware could.
497
635
 
498
636
  ```javascript
499
- videoFreezesDetector: {
637
+ decoderPerformanceDetector: {
638
+ decodeTimeBudgetRatio: 0.8, // share of the per-frame budget (1000/fps) decode may use
639
+ dropRatioThreshold: 0.1, // frames dropped after arriving
640
+ minFramesReceived: 10, // don't judge starved intervals (e.g. static screen share)
641
+ quietLossThreshold: 0.02, // above this, the network is the better explanation
642
+ minConsecutiveTicks: 2,
500
643
  }
501
644
  ```
502
645
 
503
- #### PlayoutDiscrepancyDetector
646
+ ```typescript
647
+ monitor.on('video-decoder-overloaded', ({ trackMonitor, decodeTimePerFrameInMs, frameBudgetInMs }) => {
648
+ // frames are arriving; this device can't keep up — lower the decode load
649
+ sfuClient.preferLayer(trackMonitor.track.id, 'low');
650
+ });
651
+ ```
504
652
 
505
- Detects discrepancies between received and rendered frames.
653
+ **Sources:** [Power-up getStats for client monitoring (webrtcHacks)](https://webrtchacks.com/power-up-getstats-for-client-monitoring/) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
506
654
 
507
- **Triggers on:**
655
+ #### InboundFrameSupplyDetector
508
656
 
509
- - Frame skew exceeds thresholds
510
- - Video playout buffer issues
657
+ The receive-side counterpart of `capture-bottleneck`: frames arrived and the decoder did not turn enough of them into pictures. Raises `decoder-bottleneck`.
511
658
 
512
- **Configuration:**
659
+ **The rule, in full:** add up the frames that arrived and the frames that were decoded; once `durationInMs` has accumulated, compare them. Decoded below `decodeFpsRatioThreshold` of arrived → raise. At or above → resolve. Start a new window.
513
660
 
514
661
  ```javascript
515
- playoutDiscrepancyDetector: {
516
- lowSkewThreshold: 2,
517
- highSkewThreshold: 5,
662
+ inboundFrameSupplyDetector: {
663
+ durationInMs: 15000, // average the decoder over this long ...
664
+ decodeFpsRatioThreshold: 0.9, // ... then require 90% of what arrived
665
+ minReceivedFps: 5, // too thin a stream to judge a decoder on
518
666
  }
519
667
  ```
520
668
 
521
- #### SynthesizedSamplesDetector
669
+ **The bar is the arrival rate, never the sender's.** Frames that never arrived are the network's story — `FreezedVideoTrackDetector` and the peer connection's loss reasons tell it — so a stream throttled to 5fps that decodes cleanly is silent. That is also what separates it from [`DecoderPerformanceDetector`](#decoderperformancedetector), which asks whether decoding *cost* too much over consecutive ticks: that one is about the price of decoding, this one about frames going missing. Both firing at once is the honest answer when both are true.
522
670
 
523
- Detects when audio playout synthesizes samples due to missing data.
671
+ **Use the result:** the client cannot decode what it was handed — drop to a lower simulcast layer, or ask the SFU for one.
524
672
 
525
- **Triggers on:**
673
+ ```typescript
674
+ monitor.on('decoder-bottleneck', ({ trackMonitor, decodedFps, receivedFps }) => {
675
+ sfu.requestLowerLayer(trackMonitor.track.id, { decodedFps, receivedFps });
676
+ });
677
+ ```
526
678
 
527
- - Synthesized audio samples exceed duration threshold
528
- - Audio gaps requiring interpolation
679
+ **What it refuses to judge**, because a low decode rate there is legitimate: a backgrounded tab, a paused consumer, a paused remote sender, a track that is not live and unmuted, and a stream thinner than `minReceivedFps`. The window restarts after a collection gap.
529
680
 
530
- **Configuration:**
681
+ #### StuckDecoderDetector
682
+
683
+ Catches the per-consumer decode wedge: RTP bytes keep arriving while nothing decodes and PLIs fire continuously — a corrupted/incomplete frame broke the decode chain and it never recovers on its own. The wait is adaptive (`max(thresholdInMs, rttMultiplier × RTT)` plus a minimum number of stuck ticks), and the `minBitrate` floor separates it from a merely starved track.
684
+
685
+ **Use the result:** **recreate the consumer** — that is the known mitigation, and this detector fires exactly and only when it applies (delivery confirmed, output zero). The payload's `variant` separates an `assembly` wedge (no frame ever reassembled) from a `decode` wedge, and `deadBytesReceived` quantifies the waste for the report.
531
686
 
532
687
  ```javascript
533
- syntheticSamplesDetector: {
534
- minSynthesizedSamplesDuration: 1000,
688
+ stuckDecoderDetector: {
689
+ thresholdInMs: 4000, // floor; effective wait = max(this, rttMultiplier × RTT)
690
+ rttMultiplier: 15, // high-RTT paths get more time to recover legitimately
691
+ minBitrate: 10000, // bps below which this is a dry track, not a wedge
692
+ minPliCount: 2, // the browser must be asking for repair
535
693
  }
536
694
  ```
537
695
 
538
- #### LongPcConnectionEstablishmentDetector
696
+ ```typescript
697
+ monitor.on('stuck-decoder', async ({ trackMonitor, variant, deadBytesReceived }) => {
698
+ // the stream is delivered but nothing decodes — recreate the consumer
699
+ await sfuClient.recreateConsumerFor(trackMonitor.track.id);
700
+ reportToServer('stuck-decoder', { variant, deadBytesReceived });
701
+ });
702
+ ```
539
703
 
540
- Detects slow peer connection establishment.
704
+ **Sources:** [PLI: Picture Loss Indication (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/pli/) · [RFC 4585: RTP/AVPF (PLI/FIR)](https://datatracker.ietf.org/doc/html/rfc4585) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
541
705
 
542
- **Triggers on:**
706
+ #### PlayoutDiscrepancyDetector
543
707
 
544
- - Peer connection takes too long to establish
545
- - Connection setup issues
708
+ Detects a growing skew between frames *received* and frames *rendered* on an inbound video track — decode succeeded, but the rendering pipeline is falling behind.
546
709
 
547
- **Configuration:**
710
+ **Use the result:** re-attach the media element or recreate the `<video>` sink; this is a local rendering problem, not a network one.
548
711
 
549
712
  ```javascript
550
- longPcConnectionEstablishmentDetector: {
551
- thresholdInMs: 5000,
713
+ playoutDiscrepancyDetector: {
714
+ lowSkewRatio: 0.1, // share of received frames at which the issue resolves
715
+ highSkewRatio: 0.25, // share of received frames at which it raises
716
+ minFramesReceived: 10, // below this the interval carries too few frames to judge
552
717
  }
553
718
  ```
554
719
 
555
- #### IceConnectivityDetector
720
+ ```typescript
721
+ monitor.on('inbound-video-playout-discrepancy', ({ trackMonitor }) => {
722
+ videoSinks.reattach(trackMonitor.track.id);
723
+ });
724
+ ```
725
+
726
+ **Sources:** [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
727
+
728
+ ---
729
+
730
+ ### Track activity
556
731
 
557
- Runtime ICE and transport health, per ICE transport. Peer-connection setup latency
558
- is **not** in scope — `LongPcConnectionEstablishmentDetector` covers that.
732
+ #### DryInboundTrackDetector / DryOutboundTrackDetector
559
733
 
560
- **Raises:**
734
+ Raise `dry-inbound-track` / `dry-outbound-track` when a track that should be flowing carries no bytes at all past a threshold. This is *starvation* — contrast with [`stuck-decoder`](#stuckdecoderdetector), where bytes flow and nothing decodes.
561
735
 
562
- - `ice-disconnected` — the transport stayed `disconnected` past
563
- `disconnectedThresholdInMs`. Transient blips, which ICE usually heals on its
564
- own, never raise an issue.
565
- - `ice-connection-failed` — ICE reached `failed`, which is terminal for that
566
- generation.
567
- - `ice-transport-stalled` — deliberately narrow: raised only while this endpoint
568
- is still **sending** on a succeeded pair of a connected transport but receives
569
- nothing, and only after inbound traffic had previously been observed. "No
570
- traffic in either direction" is *not* reported, because at peer-connection
571
- level it cannot be told apart from a legitimately idle or paused connection.
572
- - `unstable-ice-path` — the selected path switched `pathSwitchThreshold` times
573
- within `pathSwitchWindowInMs`.
736
+ **Use the result:** inbound dry → verify the producer is not paused, then resubscribe/reconsume; outbound dry → check the local track (`muted`, `enabled`, `readyState`) and the transport before blaming the network.
574
737
 
575
- **Emits:**
738
+ ```javascript
739
+ dryInboundTrackDetector: { thresholdInMs: 5000 },
740
+ dryOutboundTrackDetector: { thresholdInMs: 5000 },
741
+ ```
576
742
 
577
- - `'ice-restart-recommended'` — see below.
578
- - `'ice-restart'` — a new ICE generation was inferred from a changed ICE
579
- username fragment, with `outcome` of `'detected'`, `'recovered'` or
580
- `'failed'`. A `connected → checking` transition alone is never treated as a
581
- restart.
743
+ ```typescript
744
+ monitor.on('dry-inbound-track', async ({ trackMonitor }) => {
745
+ if (!(await sfuClient.isProducerPaused(trackMonitor.track.id))) {
746
+ await sfuClient.resubscribe(trackMonitor.track.id);
747
+ }
748
+ });
749
+ ```
582
750
 
583
- ##### Recommending an ICE restart
751
+ **Sources:** [Power-up getStats for client monitoring (webrtcHacks)](https://webrtchacks.com/power-up-getstats-for-client-monitoring/) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
584
752
 
585
- The library detects **when** a restart is warranted; performing it stays with the
586
- application. Only the application knows whether renegotiation is safe right now,
587
- whether signalling is up, and whether it would rather tear the call down — so the
588
- detector names the moment and gets out of the way.
753
+ ---
589
754
 
590
- A recommendation fires when ICE reaches `failed` (immediately — it never
591
- self-heals), or when `disconnected`, an inbound stall, or an unfinished
592
- establishment outlasts `iceRestartRecommendationThresholdInMs`. The `reason`
593
- tells you which:
755
+ ### Send side
594
756
 
595
- | `reason` | Meaning |
596
- |---|---|
597
- | `ice-failed` | ICE gave up on this generation. |
598
- | `ice-disconnected` | `disconnected` outlasted the window in which ICE usually self-heals. |
599
- | `transport-stalled` | ICE still reports connected, but the selected path stopped delivering. |
600
- | `never-established` | The peer connection never finished connecting. Tracked from `connectionState`, which covers the DTLS handshake too — a connection can sit in `connecting` while every ICE transport reports `connected`. |
601
-
602
- `LongPcConnectionEstablishmentDetector` reports that setup is *slow* at its own
603
- (shorter) threshold; the `never-established` recommendation says it is not going
604
- to happen on its own. The two thresholds form an escalation, not a duplicate
605
- report. While a restart the application already started is in flight, the
606
- detector stays quiet. Repeat recommendations are spaced
607
- by `iceRestartRecommendationCooldownInMs`, and each carries a
608
- `recommendationCount` and the current `iceGeneration` so you can back off after
609
- repeated failed attempts.
757
+ #### OutboundFrameSupplyDetector
610
758
 
611
- ```typescript
612
- monitor.on('ice-restart-recommended', ({ peerConnectionMonitor, reason, recommendationCount }) => {
613
- if (3 <= recommendationCount) return rejoinTheCall(); // restarts are not helping
759
+ Is the capture device delivering the frames the track was configured to capture? The send-side mirror of [`InboundFrameSupplyDetector`](#inboundframesupplydetector), which asks the same of the decoder. Raises `capture-bottleneck`.
614
760
 
615
- // your application decides — the library never restarts ICE itself
616
- myRtcPeerConnection.restartIce();
617
- // or, with mediasoup: ask the server for new ICE parameters and
618
- // transport.restartIce({ iceParameters })
619
- console.warn(`ICE restart recommended (${reason})`, peerConnectionMonitor.peerConnectionId);
761
+ **The rule, in full:** add up the frames the source delivered and the time it had to deliver them; once `durationInMs` has accumulated, compare the average against `getSettings().frameRate`. Below `captureFpsRatioThreshold` of it → raise. At or above → resolve. Start again. Two running totals, no history buffer.
762
+
763
+ ```javascript
764
+ outboundFrameSupplyDetector: {
765
+ durationInMs: 15000, // average the capture device over this long ...
766
+ captureFpsRatioThreshold: 0.9, // ... then require 90% of the configured fps
767
+ }
768
+ ```
769
+
770
+ **Why average rather than threshold each tick.** A camera that is failing rather than merely busy dips and recovers: 150 frames per 5s tick becomes 132, back to 150, then 97. Tick by tick most of it looks fine. The average over 15s does not — 26.3fps against a configured 30 — so it raises while the camera is still delivering, about half a minute before this one stopped entirely. Averaging also weights *how far* the source fell short rather than merely how often.
771
+
772
+ **Why a duration and not a tick count.** What matters here is that the device stayed short for a stretch of time that means something. A tick count would mean six seconds at a 2s collecting period and thirty at a 10s one. ([`EncoderPerformanceDetector`](#encoderperformancedetector) is the other way round, and says why.)
773
+
774
+ **The rate is always the counter, never `mediaSource.framesPerSecond`.** `sourceFps` is the frame counter differenced against *measured* elapsed time. The browser's own figure is coarse and smooths this exact stutter away — it can read `30` across an interval that actually delivered 132 frames in five seconds. When `sourceFps` is undefined the counter restarted, and a restart is not a measurement.
775
+
776
+ **No baseline, no judgement.** If the browser does not report `getSettings().frameRate`, nothing is substituted for it: there is no rate for the measurement to fall short *of*, so the check stays quiet.
777
+
778
+ **What it refuses to judge**, because a low frame rate there is legitimate: a backgrounded tab (`ClientMonitor.activeTab === false`), a paused or stopped sender, and screen shares, whose frame rate is content-driven (a still document delivers nothing). If you capture a moving surface that should be watched, declare it with `monitor.setOutboundTrackContext(trackId, { contentType: 'camera' })`. The totals also restart after a settings change or a collection gap — that threshold is derived from `collectingPeriodInMs` rather than configured.
779
+
780
+ ```typescript
781
+ monitor.on('capture-bottleneck', ({ trackMonitor, sourceFps, expectedFps }) => {
782
+ ui.hintCameraTrouble(trackMonitor.track.id, { sourceFps, expectedFps });
620
783
  });
621
784
  ```
622
785
 
623
- #### AudioConcealmentDetector
786
+ **Threshold caveat.** `0.9` over 15s came from two captured sessions — one failure, one control, one camera model. They catch that failure and stay silent on that control, and are otherwise unvalidated: treat `capture-bottleneck` as observation-grade until a corpus sets the numbers.
624
787
 
625
- Reports how the audio actually *sounded*, which packet loss does not. Opus and
626
- NetEQ conceal a great deal of loss inaudibly, and conversely audio degrades
627
- without dramatic loss when the jitter buffer misbehaves — so concealment is both
628
- the more sensitive and the more specific signal.
788
+ **Sources:** [Power-up getStats for client monitoring (webrtcHacks)](https://webrtchacks.com/power-up-getstats-for-client-monitoring/) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
629
789
 
630
- The rate is **audible** concealment only: `concealedSamples` also rises during
631
- ordinary silence, so `silentConcealedSamples` is subtracted before the detector
632
- sees the number. Without that subtraction this would flag every quiet moment in
633
- every call.
790
+ #### EncoderPerformanceDetector
634
791
 
635
- **Raises:** `audio-concealment`, with `concealmentRate`, `concealmentEventRate`
636
- and a `burstiness` of `'bursty'` (many short clicks) or `'continuous'` (fewer,
637
- longer dropouts) — they sound different and have different causes.
792
+ Given a capture source that *is* delivering, is the encoder keeping up with it? The send-side mirror of [`DecoderPerformanceDetector`](#decoderperformancedetector). Raises `encoder-bottleneck`.
638
793
 
639
- It stays silent while the remote track is paused, and while too few samples
640
- arrived in the window to judge.
794
+ Any one of three signals is enough: the highest active layer encodes below `encodeFpsRatioThreshold` of what the source delivered, encoding one frame costs more than `encodeTimeBudgetRatio` of the per-frame budget (`1000 / sourceFps`), or — only if you configure it — the browser reported itself CPU-limited for more than `cpuLimitationShareThreshold` of the interval.
641
795
 
642
796
  ```javascript
643
- audioConcealmentDetector: {
644
- onThreshold: 0.03,
645
- offThreshold: 0.01,
646
- windowInMs: 5000,
647
- minSamplesInWindow: 24000,
797
+ encoderPerformanceDetector: {
798
+ encodeFpsRatioThreshold: 0.7, // encoder below 70% of source fps = behind
799
+ encodeTimeBudgetRatio: 0.8, // encode time per frame vs the frame budget
800
+ cpuLimitationShareThreshold: null, // null = ignore the browser's CPU-limited signal
801
+ minConsecutiveTicks: 2, // two reads agreeing, not a span of time
648
802
  }
649
803
  ```
650
804
 
651
- #### JitterBufferStressDetector
805
+ **Everything is measured against what the source actually delivered**, never against what the track was configured to capture at. An encoder handed 3fps and emitting 3fps is doing its job perfectly; comparing it to a configured 30 would call that a catastrophic failure. Whether the source itself is short is the *other* detector's question — and while its `capture-bottleneck` is active, this one says nothing at all. The frames were never there to encode. The two issues are mutually exclusive by construction.
806
+
807
+ That chain is read from the issue rather than shared through a field: this detector checks `ClientMonitor.isIssueActive('capture-bottleneck-track-<id>')`. `OutboundTrackMonitor` registers the capture detector first and `Detectors.update()` preserves registration order, so the verdict is same-tick.
808
+
809
+ **Why `minConsecutiveTicks` here and a duration on the capture side.** They answer different questions. A tick count is a *confidence* floor — every signal above is a per-interval ratio that a single stats read can get wrong, so what is wanted is two independent reads agreeing, which is two samples whatever the collecting period happens to be. A duration is a *persistence* bar — the capture case, where the device has to stay short long enough to matter. `DecoderPerformanceDetector` and `JitterBufferStressDetector` use ticks for the same reason this one does.
810
+
811
+ **Why `cpuLimitationShareThreshold` defaults to `null`.** `CpuPerformanceDetector` already reports CPU limitation as its own `cpulimitation` issue, and the useful thing to do with the two is correlate them — `encoder-bottleneck` and `cpulimitation` firing together is evidence the encoder is CPU-bound. That inference is only worth something while `encoder-bottleneck` is derived *without* reading the same signal; wire the CPU share in here too and the correlation becomes tautological. Set a number (`0.3` is a reasonable one) if you would rather have the extra sensitivity than the independent evidence.
812
+
813
+ **Use the result:** reduce encode load — drop the top simulcast layer, lower resolution or frame rate, disable background effects. The payload carries `encoderImplementation`, `cpuLimitationShare` and the fps pair for the report.
814
+
815
+ ```typescript
816
+ monitor.on('encoder-bottleneck', () => sender.dropTopSimulcastLayer());
817
+ ```
652
818
 
653
- The complement to `AudioConcealmentDetector`: it separates "network jitter
654
- absorbed cleanly" from "the jitter buffer ballooned, adding latency and
655
- stretching audio to cope".
819
+ #### CaptureFailureDetector
656
820
 
657
- Both conditions are required, deliberately. A high target delay on its own means
658
- NetEQ is *succeeding* — buying latency to hide jitter, with the user hearing
659
- nothing wrong. Time stretching on its own is ordinary clock-drift correction. It
660
- is the two together that mean the buffer is fighting the network and losing.
821
+ Watches the source end of outbound tracks: the device is gone (`capture-track-ended`), the OS or another app took it (`capture-track-muted` event), or a live, unmuted microphone has produced nothing but silence for a long stretch (`silent-audio-source` — the threshold is deliberately long, because only duration separates a dead mic from a quiet person).
661
822
 
662
- **Raises:** `audio-jitter-buffer-stress`.
823
+ **Use the result:** `capture-track-ended` → open the device picker. `silent-audio-source` → the classic "are you speaking? we can't hear you" banner, with a shortcut to switch microphone.
663
824
 
664
825
  ```javascript
665
- jitterBufferStressDetector: {
666
- targetDelayThresholdInMs: 200,
667
- timeStretchThreshold: 0.02,
668
- minConsecutiveTicks: 2,
826
+ captureFailureDetector: {
827
+ silenceThresholdInMs: 60000, // long on purpose: silence ≠ broken until it persists
828
+ silenceRmsThreshold: 0.0001, // interval-integrated RMS, not the flickery audioLevel
829
+ createEvent: true, // also buffer CAPTURE_TRACK_ENDED / _MUTED into samples
669
830
  }
670
831
  ```
671
832
 
672
- #### DecoderPerformanceDetector
833
+ ```typescript
834
+ monitor.on('silent-audio-source', ({ trackMonitor, silentForInMs }) => {
835
+ ui.showBanner("We can't hear you — check your microphone", { switchDeviceAction: true });
836
+ });
837
+ monitor.on('capture-track-ended', () => ui.openDevicePicker('audioinput'));
838
+ ```
673
839
 
674
- The receive-side sibling of CPU limitation, and the piece that makes
675
- network-versus-client attribution possible. Frames dropped because they never
676
- arrived and frames dropped because the client could not decode them look
677
- identical in a frame-rate chart, and the fixes are opposite — so this detector
678
- fires only when the frames demonstrably *did* arrive: enough frames received,
679
- loss below `quietLossThreshold`, and either decode time past the per-frame
680
- budget or frames dropped after arrival.
840
+ **Sources:** [MediaStreamTrack mute event (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/mute_event) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
681
841
 
682
- The budget is derived from the stream's own frame rate (33 ms at 30 fps, 66 ms
683
- at 15 fps), so a static screen share dropping to 1 fps does not trip it.
842
+ ---
684
843
 
685
- **Raises:** `video-decoder-overloaded`, carrying `decoderImplementation` and
686
- `powerEfficientDecoder` — a software decoder on a codec the device can do in
687
- hardware is the most actionable finding here.
844
+ ### Connection & client health
845
+
846
+ #### CongestionDetector
847
+
848
+ Detects network congestion on a peer connection: bandwidth-limited outbound streams, corroborated (depending on `sensitivity`) by an RTT jump over its own EWMA baseline or by outbound loss. The RTT it reads never mixes RTCP and ICE measurements.
849
+
850
+ **Use the result:** reduce what you send — lower simulcast layers or cap the bitrate — and show a network-quality indicator. The event payload carries the available bitrates plus the maxima seen before congestion, which sizes *how much* to back off.
688
851
 
689
852
  ```javascript
690
- decoderPerformanceDetector: {
691
- decodeTimeBudgetRatio: 0.8,
692
- dropRatioThreshold: 0.1,
693
- minFramesReceived: 10,
694
- quietLossThreshold: 0.02,
695
- minConsecutiveTicks: 2,
853
+ congestionDetector: {
854
+ sensitivity: 'medium', // 'high': any bw-limitation | 'medium': + RTT rise | 'low': + >5% loss
696
855
  }
697
856
  ```
698
857
 
699
- #### Video recovery (part of `FreezedVideoTrackDetector`)
858
+ ```typescript
859
+ monitor.on('congestion', ({ availableOutgoingBitrate, maxSendingBitrate }) => {
860
+ sender.capBitrate(Math.min(availableOutgoingBitrate, maxSendingBitrate * 0.8));
861
+ ui.setNetworkIndicator('poor');
862
+ });
863
+ ```
700
864
 
701
- `FreezedVideoTrackDetector` owns the whole freeze / repair domain: it derives
702
- the track's freeze state (a freeze persists until frames render again, not just
703
- until the next tick) and watches the repair loop — PLI/FIR out, keyframes back
704
- in. The `videoRecoveryDetector` config gates the two repair-loop issues:
865
+ **Sources:** [Power-up getStats for client monitoring (webrtcHacks)](https://webrtchacks.com/power-up-getstats-for-client-monitoring/) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
705
866
 
706
- - `keyframe-storm` — a sustained PLI rate. Worth its own issue because it is
707
- self-reinforcing: keyframes are several times the size of delta frames, so a
708
- burst of them worsens exactly the congestion that provoked the PLIs.
709
- - `video-recovery-failed` — PLIs going out repeatedly, the picture still
710
- frozen, and `keyFramesDecoded` *not* advancing. This is the valuable one for
711
- debugging an SFU: it says the repair request left the client and nothing came
712
- back, which points at forwarding rather than at the first-hop network.
867
+ #### CpuPerformanceDetector
868
+
869
+ Client-wide CPU pressure: outbound streams explicitly CPU-limited (instantaneous label *and* sustained duration shares), encode time per frame over budget, inbound decode falling behind receive, or stats collection itself slowing down.
870
+
871
+ **Use the result:** shed load in order of user impact — disable background blur/effects first, then reduce rendered remote videos, then lower capture resolution. Resolve restores them.
713
872
 
714
873
  ```javascript
715
- videoRecoveryDetector: {
716
- windowInMs: 30000,
717
- pliRateAlertOn: 0.5,
718
- pliRateAlertOff: 0.15,
719
- recoveryFailedThresholdInMs: 5000,
720
- recoveryFailedMinPliCount: 2,
874
+ cpuPerformanceDetector: {
875
+ incomingDecodedFramesRatioThresholds: { alertOn: 0.7, alertOff: 0.85, minReceivedFrames: 10 },
876
+ durationOfCollectingStatsThreshold: { lowWatermark: 5000, highWatermark: 10000 },
877
+ encoderCpuLimitationShareThreshold: 0.3, // share of interval spent CPU-limited
878
+ encodeTimeBudgetRatio: 0.8, // encode ms per frame vs 1000/fps budget
721
879
  }
722
880
  ```
723
881
 
724
- #### StuckDecoderDetector
882
+ ```typescript
883
+ monitor.on('cpulimitation', () => effects.disableBackgroundBlur());
884
+ monitor.on('issue-resolved', (issue) => {
885
+ if (issue.type === 'cpulimitation') effects.restore();
886
+ });
887
+ ```
725
888
 
726
- Detects the per-consumer decode wedge: RTP bytes keep arriving but no frame
727
- ever decodes again — a corrupt or incomplete frame broke the decode chain, PLIs
728
- go out continuously, keyframes may even be generated upstream, and this
729
- consumer never assembles a usable frame until it is recreated.
889
+ **Sources:** [Power-up getStats for client monitoring (webrtcHacks)](https://webrtchacks.com/power-up-getstats-for-client-monitoring/) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
730
890
 
731
- The fingerprint is `bytesReceived` rising + `framesReceived` flat + `pliCount`
732
- rising + `keyFramesDecoded` flat. The "bytes still flowing" condition is what
733
- separates it from everything nearby: a dry track has no bytes, and
734
- `video-recovery-failed` reports an unanswered repair request without saying
735
- whether the pipe is dead or the decoder is. It reads only RTP deltas, so it
736
- works regardless of browser freeze statistics.
891
+ #### LongPcConnectionEstablishmentDetector
737
892
 
738
- A wedge never self-heals, so the wait only needs to outlast a *legitimate*
739
- PLI → keyframe recovery — and that cost scales with the connection, not with a
740
- fixed number of seconds. The effective wait is
741
- `max(thresholdInMs, rttMultiplier × RTT)`, at least `minStuckTicks`
742
- collections, with `minBitrate` (a rate, so it means the same thing at every
743
- collecting period) confirming the stream is actually being delivered.
893
+ Emits `'too-long-pc-connection-establishment'` (and the `LONG_PC_CONNECTION_ESTABLISHMENT` client event) when a peer connection stays in `connecting` past the threshold. Re-arms on any exit from `connecting`, so slow *retries* are reported too.
744
894
 
745
- **Raises:** `stuck-decoder`, with a `variant` (`'assembly'`: no frame ever
746
- reassembled; `'decode'`: frames assemble but never decode), the accumulated
747
- dead bytes, and the PLI count since the wedge began.
895
+ **Use the result:** show "connecting is taking longer than usual"; if it repeats, retry with `iceTransportPolicy: 'relay'` to test whether direct connectivity is the blocker. The [`never-established`](#iceconnectivitydetector) restart recommendation is this detector's escalation.
748
896
 
749
- **Mitigation hook:** recreating the consumer is the known workaround — listen
750
- for the `stuck-decoder` monitor event:
897
+ ```javascript
898
+ longPcConnectionEstablishmentDetector: {
899
+ thresholdInMs: 5000,
900
+ createEvent: true,
901
+ }
902
+ ```
903
+
904
+ **Sources:** [RTCPeerConnection.connectionState (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionState) · [ICE (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/ice/)
905
+
906
+ #### IceConnectivityDetector
907
+
908
+ Runtime ICE and transport health, per ICE transport (a peer connection without BUNDLE has several, and they fail independently). Raises `ice-disconnected` (only after the threshold — transient blips self-heal), `ice-connection-failed` (immediately — `failed` is terminal for the generation), `ice-transport-stalled` (still sending, receiving nothing, after inbound had been seen) and `unstable-ice-path` (selected path flapping).
909
+
910
+ **Use the result — the restart loop:** the library names *when* an ICE restart is warranted; performing it is the application's job. `'ice-restart-recommended'` carries a `reason` and a `recommendationCount` so you can escalate to a full rejoin when restarts stop helping; `'ice-restart'` then reports whether the restart you performed `recovered` or `failed`.
911
+
912
+ | `reason` | Meaning |
913
+ |---|---|
914
+ | `ice-failed` | ICE gave up on this generation — restart immediately. |
915
+ | `ice-disconnected` | `disconnected` outlasted the self-heal window. |
916
+ | `transport-stalled` | ICE says connected, but the selected path stopped delivering. |
917
+ | `never-established` | The connection never finished connecting (covers stuck DTLS). |
918
+
919
+ ```javascript
920
+ iceConnectivityDetector: {
921
+ disconnectedThresholdInMs: 5000, // how long `disconnected` may self-heal
922
+ transportStallThresholdInMs: 5000, // sending-but-not-receiving tolerance
923
+ pathSwitchWindowInMs: 30000, // window for counting selected-path switches
924
+ pathSwitchThreshold: 3, // switches in the window => unstable path
925
+ iceRestartRecommendationThresholdInMs: 10000,
926
+ iceRestartRecommendationCooldownInMs: 15000,
927
+ createEvent: true, // buffer ICE_RESTART / _RECOMMENDED into samples
928
+ }
929
+ ```
751
930
 
752
931
  ```typescript
753
- monitor.on('stuck-decoder', ({ trackMonitor, variant, deadBytesReceived }) => {
754
- // the stream is being delivered but nothing decodes — recreate the consumer
755
- recreateConsumerFor(trackMonitor.track.id);
932
+ monitor.on('ice-restart-recommended', ({ peerConnectionMonitor, reason, recommendationCount }) => {
933
+ if (recommendationCount >= 3) return session.rejoin(); // restarts are not helping
934
+ rtcPeerConnection.restartIce(); // or mediasoup transport.restartIce()
756
935
  });
936
+ monitor.on('ice-restart', ({ outcome }) => metrics.count(`ice-restart.${outcome}`));
757
937
  ```
758
938
 
939
+ **Sources:** [ICE restart: recovering connectivity (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/ice-restart/) · [RTCPeerConnection.restartIce (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/restartIce) · [RFC 8445: ICE](https://datatracker.ietf.org/doc/html/rfc8445) · [RFC 7675: STUN consent freshness](https://datatracker.ietf.org/doc/html/rfc7675)
940
+
941
+ #### BlockedTransportDetector
942
+
943
+ The firewall signature: a middlebox that lets ICE/STUN through but blocks the media itself. Every connectivity signal looks healthy — the candidate pair is `succeeded`, consent checks keep passing, `iceConnectionState` is `connected` — yet the call carries nothing. The existing detectors structurally miss this case: STUN consent responses count into the pair's `bytesReceived`, so the pair never looks dry and the inbound-stall check never fires, while the dry-track detectors see outbound-rtp counters advancing and stay silent.
944
+
945
+ Raises `blocked-transport` (per ICE transport) when, sustained for `thresholdInMs`, all three hold: STUN demonstrably alive (`responsesReceived` advanced within `stunFreshnessInMs`), the application demonstrably producing (outbound RTP on the transport ≥ `minMediaBitrateBps`), and the media demonstrably not traversing. The payload's `evidence` field says which discrepancy was observed:
946
+
947
+ | `evidence` | Meaning |
948
+ |---|---|
949
+ | `media-not-leaving-transport` | RTP senders produce bytes but the transport's own send counter barely moves — host firewall, blocked socket, dead route. |
950
+ | `no-return-traffic` | Media leaves at full rate, STUN answers, but nothing except STUN comes back — not even RTCP. Classic DPI / UDP-throttling firewall. |
951
+
952
+ The detector judges the *sending* side, where the client holds both halves of the proof. A firewall blocking only the receive direction shows up on the remote peer's sending side, or as a dry inbound track here.
953
+
759
954
  ```javascript
760
- stuckDecoderDetector: {
761
- thresholdInMs: 4000,
762
- rttMultiplier: 15,
763
- minStuckTicks: 2,
764
- minBitrate: 10000,
765
- minPliCount: 2,
955
+ blockedTransportDetector: {
956
+ thresholdInMs: 5000, // discrepancy persistence before raising
957
+ minMediaBitrateBps: 10000, // below this the transport is legitimately quiet
958
+ maxReturnBitrateBps: 2000, // at/below this the return path counts as STUN-only
959
+ maxSendShare: 0.1, // transport send under this share of produced => blocked on send
960
+ stunFreshnessInMs: 10000, // consent checks run ~5s; must comfortably exceed one interval
766
961
  }
767
962
  ```
768
963
 
769
- #### SourceEncoderBottleneckDetector
964
+ **Use the result:** tell the user their network blocks media (a TURN/TLS fallback or a network change is the fix, an ICE restart on the same path is not), and correlate server-side: many `blocked-transport` clients on one corporate network is a firewall policy, not N user problems.
965
+
966
+ **Sources:** [RFC 7675: STUN consent freshness](https://datatracker.ietf.org/doc/html/rfc7675) · [RTCIceCandidatePairStats (W3C webrtc-stats)](https://www.w3.org/TR/webrtc-stats/#candidatepair-dict*) · [WebRTC and firewalls (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/firewall/)
770
967
 
771
- Splits one symptom — "we are sending fewer frames than we should" — into its two
772
- causes, which from RTP alone are indistinguishable:
968
+ #### NoAvailableIceCandidateDetector
773
969
 
774
- - `capture-bottleneck` — the *source* never produced the frames. A camera
775
- throttling in low light, an OS capture stall, a device the browser is quietly
776
- downgrading. Nothing the encoder or the network can do about it.
777
- - `encoder-bottleneck` — the source produced frames and the encoder could not
778
- keep up. Carries `encodeTimePerFrameInMs`, `cpuLimitationShare`,
779
- `encoderImplementation` and `powerEfficientEncoder`.
970
+ The other end of the connectivity spectrum: the client cannot even *begin* to connect because ICE gathering produced **zero local candidates**. A healthy establishment gathers a host candidate within milliseconds — even without internet, any up interface yields one. Zero candidates while the connection state jumps from `new`/`connecting` straight to `disconnected`/`failed` means there was nothing to connect *with*: no interface, airplane mode, a VPN that tore down every route. This is a different diagnosis from every other ICE issue — those describe a path that existed and stopped working; this one says no path was ever possible.
780
971
 
781
- The discriminator is `MediaSourceMonitor.sourceFps` against what the highest
782
- active layer actually encoded.
972
+ Raises `no-available-ice-candidate` (per peer connection) immediately on `disconnected`/`failed` with zero local candidates on a never-connected PC, and after `thresholdInMs` when the PC just sits in `new`/`connecting` with nothing gathered. Resolves when a local candidate appears or the connection reaches `connected`. Never fires on a connection that once connected — mid-call network loss belongs to `IceConnectivityDetector`.
783
973
 
784
974
  ```javascript
785
- sourceEncoderBottleneckDetector: {
786
- captureFpsRatioThreshold: 0.5,
787
- minSourceFps: 5,
788
- encodeFpsRatioThreshold: 0.7,
789
- encodeTimeBudgetRatio: 0.8,
790
- cpuLimitationShareThreshold: 0.3,
791
- minConsecutiveTicks: 2,
975
+ noAvailableIceCandidateDetector: {
976
+ thresholdInMs: 6000, // grace for `new`/`connecting` before the sustained variant raises
792
977
  }
793
978
  ```
794
979
 
795
- #### CaptureFailureDetector
980
+ **Use the result:** skip the ICE-restart dance entirely — recommend the user check their connection; on the server, treat the client as offline-at-join rather than call-quality-degraded.
796
981
 
797
- Watches the source end of an outbound track, where several very common
798
- user-visible failures originate and none of them show up in RTP.
982
+ **Sources:** [RTCPeerConnection.connectionState (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionState) · [RTCPeerConnection.iceGatheringState (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/iceGatheringState) · [RFC 8445: ICE](https://datatracker.ietf.org/doc/html/rfc8445)
799
983
 
800
- **Raises:** `capture-track-ended` (the device is gone), `silent-audio-source`
801
- (the microphone is live and producing nothing).
984
+ #### MediaPipelineDetector
802
985
 
803
- **Emits:** `'capture-track-ended'`, `'capture-track-muted'` (the OS or another
804
- application took the device), plus the matching client events.
986
+ The pipeline stage classifier: media moves through a fixed chain (capture → encoder → RTP sender → transport → wire, mirrored on receive), every stage has a monotonic counter proving progress, and a disruption is locatable as the *first* boundary where the upstream counter advances and the downstream one does not. Most boundaries are owned by specialist detectors; this one raises `media-pipeline-stalled` for the two nothing else covers:
805
987
 
806
- The silence threshold is 30 seconds by default, and long on purpose: a
807
- microphone capturing digital silence and a person not talking are the same
808
- measurement, and only duration separates them. The check requires the track to
809
- be live, enabled and unmuted — a muted microphone is silent deliberately and is
810
- reported as a mute, not a failure. The level comes from
811
- `MediaSourceMonitor.rmsAudioLevel`, which integrates `totalAudioEnergy` over the
812
- interval, rather than the instantaneous `audioLevel` that reads zero between
813
- words.
988
+ | `stage` | Direction | Meaning |
989
+ |---|---|---|
990
+ | `rtp-sender` | send | `deltaFramesEncoded > 0` while `deltaPacketsSent === 0` — an encoded frame always packetizes, so a sustained violation is a wedged sender/pacer (seen after `replaceTrack` races and simulcast reconfigurations). Guarded by track live + unmuted + layer active. |
991
+ | `transport-demux` | receive | The ICE transport receives ≥ `minTransportReceiveBitrateBps` — well above what RTCP + STUN can explain — while every inbound RTP of the transport stays flat: traffic arrives that never demuxes (SSRC mismatch after renegotiation, a consumer against a dead producer). Requires at least one inbound RTP to exist. |
992
+
993
+ The payload carries `suspectedIssueTypes` — the specialist issues active on this peer connection at raise time — so one entry both localizes the stage and links the detailed evidence. Registered last among the peer-connection detectors for exactly that reason.
814
994
 
815
995
  ```javascript
816
- captureFailureDetector: {
817
- silenceThresholdInMs: 30000,
818
- silenceRmsThreshold: 0.001,
819
- createEvent: true,
996
+ mediaPipelineDetector: {
997
+ thresholdInMs: 4000, // how long a broken boundary must persist
998
+ minTransportReceiveBitrateBps: 20000, // above this, incoming traffic must demux
820
999
  }
821
1000
  ```
822
1001
 
823
- #### Observation detectors
1002
+ **Use the result:** `rtp-sender` → renegotiate or replace the sender (the encoder is fine, the pipe after it is wedged); `transport-demux` → recreate the consumers / re-signal SSRCs (the network is fine, the demux is not).
824
1003
 
825
- These four emit events and **never raise issues** — they describe things that are
826
- not faults but are the missing context in most investigations.
1004
+ #### IceTupleChangeDetector
827
1005
 
828
- | Detector | Monitor event | Client event | What it answers |
829
- |---|---|---|---|
830
- | `CodecChangeDetector` | `codec-changed` | `CODEC_CHANGED` | "Why do all the bad calls use H264?" Compares `sdpFmtpLine` too, so a profile switch within one mime type is caught. |
831
- | `VideoResolutionChangeDetector` | `video-resolution-changed` | `VIDEO_RESOLUTION_CHANGED` | The adaptation ladder. On outbound tracks it carries `qualityLimitationReason`, which is what separates encoder adaptation from the application changing its constraints. Classified as `upgrade`, `downgrade` or `reshape` (an orientation flip). |
832
- | `SimulcastLayerDetector` | `simulcast-layer-changed` | `SIMULCAST_LAYER_CHANGED` | Which layers are actually being sent. A layer counts as active only if it sent bytes — `active: true` with no bytes is the common shape of a layer the encoder quietly gave up on. |
833
- | `StatsGapDetector` | `stats-collection-gap` | `STATS_COLLECTION_GAP` | Protects the monitor from itself: a backgrounded tab or sleeping device makes the next tick attribute a large accumulation to a short window. The gap is reported rather than corrected, because the counters cannot say when within it the traffic happened. |
1006
+ The low-level primitive under the path detectors: emits `'ice-tuple-changed'` whenever the set of selected `local:remote` network tuples changes. Always registered; `SelectedIcePath` classifies *what kind of* change it was, and only `IceConnectivityDetector` raises issues.
1007
+
1008
+ **Use the result:** debugging and logging — a tuple change with no `ice-path-changed` classification usually means a port change on the same interface.
1009
+
1010
+ **Sources:** [RFC 8445: ICE](https://datatracker.ietf.org/doc/html/rfc8445) · [TURN server: when you need it and what it costs (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/turn/) · [RTCIceCandidateStats.relayProtocol (MDN)](https://developer.mozilla.org/docs/Web/API/RTCIceCandidateStats/relayProtocol) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
1011
+
1012
+ ---
1013
+
1014
+ ### Observation detectors
1015
+
1016
+ These four emit events and **never raise issues** — they record context that is not a fault but is the missing column in most investigations. Each has a single config option, `createEvent` (default `true`), which buffers the matching client event into samples for server-side use.
1017
+
1018
+ | Detector | Monitor event / client event | Use the result for |
1019
+ |---|---|---|
1020
+ | `CodecChangeDetector` | `codec-changed` / `CODEC_CHANGED` | Answering "why do all the bad calls use H264" — compares `sdpFmtpLine` too, so an H264 profile switch is caught. Fires once or twice per call. |
1021
+ | `VideoResolutionChangeDetector` | `video-resolution-changed` / `VIDEO_RESOLUTION_CHANGED` | Following the adaptation ladder. On outbound tracks the event carries `qualityLimitationReason` — the field that separates encoder adaptation from your own constraint changes. Classified `upgrade` / `downgrade` / `reshape` (orientation flip). |
1022
+ | `SimulcastLayerDetector` | `simulcast-layer-changed` / `SIMULCAST_LAYER_CHANGED` | Debugging "why is this participant blurry": a layer counts as active only if it *sent bytes*, so a layer the encoder quietly gave up on becomes visible. |
1023
+ | `StatsGapDetector` | `stats-collection-gap` / `STATS_COLLECTION_GAP` | Discounting the metrics right after a backgrounded-tab / sleep gap instead of reading them as a network spike. |
1024
+
1025
+ ```javascript
1026
+ codecChangeDetector: { createEvent: true },
1027
+ videoResolutionChangeDetector: { createEvent: true },
1028
+ simulcastLayerDetector: { createEvent: true },
1029
+ statsGapDetector: {
1030
+ gapRatioThreshold: 2, // multiple of collectingPeriodInMs that counts as a gap
1031
+ minGapInMs: 5000, // a single missed short tick is jitter, not a gap
1032
+ createEvent: true,
1033
+ },
1034
+ ```
1035
+
1036
+ ```typescript
1037
+ monitor.on('video-resolution-changed', ({ trackMonitor, direction, to, qualityLimitationReason }) => {
1038
+ if (trackMonitor.direction === 'outbound' && direction === 'downgrade' && qualityLimitationReason === 'cpu') {
1039
+ // the encoder is shrinking the picture because of CPU, not bandwidth
1040
+ effects.disableBackgroundBlur();
1041
+ }
1042
+ });
1043
+ monitor.on('stats-collection-gap', ({ gapInMs }) => metrics.markUnreliableWindow(gapInMs));
1044
+ ```
1045
+
1046
+ **Sources:** [Simulcast (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/simulcast/) · [Page Visibility API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
834
1047
 
835
1048
  ### Custom Detectors
836
1049
 
@@ -881,21 +1094,66 @@ monitor.detectors.remove(detector);
881
1094
 
882
1095
  See [Controlling which detectors run](#controlling-which-detectors-run) for the full set of registry helpers.
883
1096
 
1097
+ ---
1098
+
1099
+ ### Replaying a captured session
1100
+
1101
+ Detector thresholds are only as good as the sessions they were checked against,
1102
+ so the library ships a replay harness: a captured session is fed back through a
1103
+ real `ClientMonitor` on a virtual clock, producing the same monitors, derived
1104
+ fields, issues, events and samples the live run produced — against current or
1105
+ experimental thresholds.
1106
+
1107
+ The input is JSONL, one captured collection tick per line, described by the
1108
+ `ReplayEntry` type in `tests/helpers/StatsReplayer.ts`. Producing the lines is
1109
+ not this library's business: a server-side capture, an app-side listener on
1110
+ `'stats-collected'`, or a script synthesizing a scenario all work.
1111
+
1112
+ **From the command line:**
1113
+
1114
+ ```bash
1115
+ npm run replay -- tests/fixtures/degrading-camera.jsonl
1116
+ npm run replay -- session.jsonl --only capture-bottleneck,encoder-bottleneck
1117
+ npm run replay -- session.jsonl --config '{"outboundFrameSupplyDetector":{ ... }}'
1118
+ cat session.jsonl | npm run replay -- - --pretty
1119
+ ```
1120
+
1121
+ Everything on stdout is NDJSON — one `{"record":"issue",...}` object per
1122
+ detector fire, carrying the issue's own timestamp plus the `tick` and
1123
+ `tickTimestamp` that locate it in the input, then a closing `summary` record
1124
+ with per-type counts. Progress and warnings go to stderr, so the output pipes
1125
+ straight into `jq`, a notebook, or a corpus runner sweeping thresholds across
1126
+ many sessions. Detectors that are opt-in in production are **enabled** by
1127
+ default under replay: the point of a replay is to see what they would have said.
1128
+ `--help` lists every flag.
1129
+
1130
+ **From a spec** — drop the file in `tests/fixtures/` and use `replayFixture`:
1131
+
1132
+ ```typescript
1133
+ import { replayFixture } from './helpers/replayFixture';
1134
+
1135
+ const run = await replayFixture('degrading-camera');
1136
+
1137
+ expect(run.issueTypes.has('capture-bottleneck')).toBe(true);
1138
+ run.close();
1139
+ ```
1140
+
1141
+ Its second argument is config overrides, so the same capture can be replayed
1142
+ against different thresholds to find where a detector flips. For full control
1143
+ — multiple monitors, tick-by-tick assertions, real-time replay — use
1144
+ `StatsReplayer` directly; `tests/fixtures/README.md` has the details.
1145
+
884
1146
  ## Score Calculation
885
1147
 
886
1148
  The scoring system provides quantitative quality assessment ranging from 0.0 (worst) to 5.0 (best). The library includes a `DefaultScoreCalculator` implementation and allows custom score calculators via the `ScoreCalculator` interface.
887
1149
 
1150
+ > **Full reference:** every reason key, threshold, ramp and formula is documented in [docs/SCORE_CALCULATIONS.md](./docs/SCORE_CALCULATIONS.md). This section is the overview.
1151
+
888
1152
  ### ScoreCalculator Interface
889
1153
 
890
1154
  ```typescript
891
1155
  interface ScoreCalculator {
892
1156
  update(): void;
893
- encodeClientScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
894
- encodePeerConnectionScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
895
- encodeInboundAudioScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
896
- encodeInboundVideoScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
897
- encodeOutboundAudioScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
898
- encodeOutboundVideoScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
899
1157
  }
900
1158
  ```
901
1159
 
@@ -920,69 +1178,200 @@ Where PC_Score = Track_Score_Avg × PC_Stability_Score
920
1178
 
921
1179
  #### Peer Connection Stability Score
922
1180
 
923
- Based on Round Trip Time (RTT) and packet loss:
1181
+ Based on Round Trip Time (RTT), jitter and packet loss. RTT and jitter are penalized **separately** — a long path and a jittery path are different problems with different fixes, and the score reasons say which one it is:
1182
+
1183
+ **RTT Penalties (`high-rtt`)** — one reason key, two magnitudes, like jitter and loss:
1184
+
1185
+ - 150-300ms average RTT: -1.0 point
1186
+ - \>300ms average RTT: -2.0 points
924
1187
 
925
- **RTT Penalties:**
1188
+ **Jitter Penalties (`high-jitter`)** — measured jitter averaged over the streams that reported one:
926
1189
 
927
- - High RTT (150-300ms): -1.0 point
928
- - Very High RTT (>300ms): -2.0 points
1190
+ - 30-100ms average jitter: -1.0 point
1191
+ - \>100ms average jitter: -2.0 points
929
1192
 
930
- **Packet Loss Penalties:**
1193
+ **Packet Loss Penalties (`high-packetloss`)** — the per-interval `deltaFractionLost`, **averaged** across streams (a raw sum would read ten streams at 1% each as 10%):
931
1194
 
932
1195
  - 1-5% loss: -1.0 point
933
1196
  - 5-20% loss: -2.0 points
934
1197
  - > 20% loss: -5.0 points
935
1198
 
1199
+ **Loss and jitter are attributed here and nowhere else.** They are properties of the *path*, shared by every stream on the transport, so track scores do not subtract for them — see [Track Score Calculations](#track-score-calculations).
1200
+
1201
+ **Only streams carrying media measure the path.** Both averages skip any stream that shows no evidence of carrying media in the interval: under `MIN_PATH_SAMPLE_BITRATE` (8 kbps) *and* under `MIN_PATH_SAMPLE_PACKETS` (25 packets) *and* delivering no frames. An SFU's bandwidth-probation stream — mediasoup sends one on `mid: "probator"` — is a handful of deliberately discardable packets with no frames, and its loss and jitter figures are not measurements of anything: observed at ~2 kbps with ~50% "loss" and ~490 ms "jitter" while the real streams beside it ran at 0% loss and 2 ms jitter. Averaged in with equal weight it used to pin the connection at the minimum score for an entire session.
1202
+
1203
+ #### Normalized penalty ramps
1204
+
1205
+ Most metric-driven penalties are **normalized to `0..1`**: nothing is subtracted while the metric stays at or below an *activation threshold*, then the penalty ramps up linearly and saturates at `1.0` at a *saturation point*:
1206
+
1207
+ ```
1208
+ penalty(value) = clamp((value − activation) / (saturation − activation), 0, 1)
1209
+ ```
1210
+
1211
+ The activation/saturation constants are `public static readonly` on `DefaultScoreCalculator`. Penalties that are effectively binary (a frozen picture, a CPU-limited encoder) stay stepped. The tables in [docs/SCORE_CALCULATIONS.md](./docs/SCORE_CALCULATIONS.md) list every ramp.
1212
+
936
1213
  #### Track Score Calculations
937
1214
 
1215
+ **Track scores measure what the user perceived, not what the network did.** Freezes, low and volatile fps, dropped frames, pixelation, concealment, time-stretch and jitter-buffer delay are all measurements of damage. Packet loss and jitter are *causes*, they are properties of the path rather than of any one track, and they are attributed once on the peer connection — so no track penalty subtracts for them. A server attributing a degradation joins a track's symptoms to its peer connection's path reasons, which arrive in the same sample.
1216
+
938
1217
  **Inbound Audio Track Score:**
939
1218
 
940
- - Based on normalized bitrate and packet loss
941
- - Uses logarithmic bitrate normalization
942
- - Exponential decay for packet loss impact
1219
+ - Based on normalized bitrate. **Packet loss is not subtracted here** — it belongs to the peer connection; what the loss *did* to the audio is measured directly as concealment and time-stretch below
1220
+ - When the audio detectors run, their windowed, hysteresis-guarded verdicts **gate** additional penalties, and the current per-tick metric **scales** them as a normalized `0..1` ramp starting at the detector's own configured threshold:
1221
+ - `audio-concealment` issue active → scaled by `concealmentRate` (detector `onThreshold` → 0.10)
1222
+ - `audio-jitter-buffer-stress` issue active → `high-jitter-buffer-delay`, scaled by `jitterBufferTargetDelayInMs` (detector `targetDelayThresholdInMs` → 500 ms)
1223
+ - `audio-desync` issue active → `audio-time-stretch`, scaled by `timeStretchRate` (detector `fractionalCorrectionAlertOnThreshold` → 0.3)
1224
+ - A tick where the metric dipped back under the threshold contributes no penalty even while hysteresis keeps the issue open
1225
+ - Without the detectors the score falls back to the pure loss decay
943
1226
 
944
1227
  ```javascript
945
1228
  normalizedBitrate = log10(max(bitrate, MIN_AUDIO_BITRATE) / MIN_AUDIO_BITRATE) / NORMALIZATION_FACTOR;
946
- lossPenalty = exp(-packetLoss / 2);
947
- score = min(MAX_SCORE, 5 * normalizedBitrate * lossPenalty);
1229
+ score = min(MAX_SCORE, 5 * normalizedBitrate) - issuePenalties;
948
1230
  ```
949
1231
 
950
1232
  **Inbound Video Track Score:**
951
1233
 
952
- - FPS volatility penalties
953
- - Dropped frames penalties
954
- - Frame corruption penalties
1234
+ - FPS volatility (`volatile-fps`, normalized 0–1): activation 0.1, saturation 0.2 — *skipped for screen share*
1235
+ - Sustained low fps while frames are flowing (`low-fps`, ewma fps < 10): -1.0 — *skipped for screen share*
1236
+ - Dropped frames (`dropped-video-frames`, normalized 0–1): activation 10%, saturation 20% of frames dropped instead of rendered
1237
+ - Frame corruptions (`video-frame-corruptions`, normalized 0–1): per-interval corruption probability, activation 0.05, saturation 0.5
1238
+ - Frozen picture (`frozen-video`, from the freeze state the detector derives): -2.0
1239
+ - Pixelation (`pixelated-video`): ramps 0→1 from the codec's activation QP to its saturation QP (`VIDEO_QP_THRESHOLDS`), from the mean quantizer of the frames actually decoded (`avgQpPerFrame`, from the inbound `qpSum`) — then multiplied by a weight chosen by **how big the picture is shown**, so the reason ranges 0–3.0.
1240
+
1241
+ **A large pixelated video is charged harder, deliberately.** The same QP is punishing at full screen and nearly invisible in a grid thumbnail, because what the eye resolves is the coded block's size on screen. So the weight is not symmetric — the big video is the one the viewer is complaining about:
1242
+
1243
+ ```
1244
+ magnification = sqrt((presentedW * presentedH) / (decodedW * decodedH))
1245
+ >= 1.5 -> weight 3.0 | 0.75..1.5 -> weight 2.0 | < 0.75 -> weight 0.5
1246
+ ```
1247
+
1248
+ For vp8 standard motion (band 40 → 80), the same stream at QP 60 costs **0.25** in a thumbnail, **1.0** in a grid tile and **1.5** in speaker view; at saturation, **3.0**. No clamp is needed — the tiers are flat. **No presented resolution, no adjustment** (the ordinary 2.0 applies).
1249
+
1250
+ ```typescript
1251
+ monitor.setInboundTrackContext(trackId, { presentedResolution: { width: 1280, height: 720 } }); // device pixels
1252
+ monitor.setInboundTrackContext(trackId, { videoTag }); // or hand over the element — re-measured every tick
1253
+ ```
1254
+
1255
+ The `videoTag` route measures the element's layout box (`clientWidth`/`clientHeight` × `devicePixelRatio`) with the frame's aspect ratio fitted into it as `object-fit: contain` does; an application using `object-fit: cover` should declare the resolution itself. [Full table](docs/SCORE_CALCULATIONS.md#a-large-pixelated-video-is-charged-harder-deliberately).
1256
+
1257
+ ```typescript
1258
+ monitor.setInboundTrackContext(trackId, { presentedResolution: { width: 1280, height: 720 } }); // device pixels
1259
+ monitor.setInboundTrackContext(trackId, { videoTag }); // or hand over the element — re-measured every tick
1260
+ ```
1261
+
1262
+ The `videoTag` route measures the element's layout box (`clientWidth`/`clientHeight` × `devicePixelRatio`) with the frame's aspect ratio fitted into it, as `object-fit: contain` does; an application using `object-fit: cover` should declare the resolution itself. **No presented resolution, no shift** — the shipped band is used as written, and nothing is substituted for the missing number.
1263
+
1264
+ QP is the encoder stating how coarsely it had to quantize, so it measures the blockiness and detail loss the viewer is looking at. Bitrate cannot: the same 500 kbps is generous for a static talking head and starvation for a fast pan, and nothing observable separates those two from bits alone. **Where the browser does not report `qpSum` for the codec in use, the reason is simply absent** — no judgement is better than one inferred from bitrate.
1265
+
1266
+ QP scales are codec-specific and *not* comparable as fractions of their ranges — H.264 runs 0–51, VP8 0–127, VP9 0–255 — so each codec carries its own pair, and an unrecognised codec yields no judgement. The shipped values are literature starting points, not measurements of any deployment; calibrate against your own corpus:
1267
+
1268
+ **Motion class.** The same quantizer is not equally visible on all content: fast movement masks compression artifacts, while a slide or a still face shows every blocked edge. `VIDEO_QP_THRESHOLDS` is therefore indexed `[codec][motionType]` — note the bands run the *opposite* way to bitrate, since high-motion content needs more bits to reach a given QP yet tolerates a higher one once there. Nothing in the stats reveals motion, so the application declares it; undeclared, screen share is judged as `lowmotion` (blocked text is a hard failure) and everything else as `standard`:
1269
+
1270
+ ```typescript
1271
+ monitor.setInboundTrackContext(trackId, { motionType: 'highmotion' }); // by id, works before the track exists
1272
+ monitor.getInboundTrackMonitor(track.id)?.setContext({ motionType: 'lowmotion' });
1273
+ ```
1274
+
1275
+ ```typescript
1276
+ import { VIDEO_QP_THRESHOLDS } from '@observertc/client-monitor-js';
1277
+
1278
+ VIDEO_QP_THRESHOLDS.vp8!.standard = { activation: 45, saturation: 90 };
1279
+ ```
1280
+
1281
+ Every penalty ramp on `DefaultScoreCalculator` is a mutable static and can be retuned the same way.
1282
+
1283
+ Whether an inbound video track is a screen share is decided by `InboundTrackMonitor.contentType` — same mechanism as the outbound side (see below), except a received track exposes no `displaySurface` to auto-detect from, so the application declares it:
1284
+
1285
+ ```typescript
1286
+ monitor.getInboundTrackMonitor(track.id)?.setContext({ contentType: 'screenshare' });
1287
+ ```
955
1288
 
956
1289
  **Outbound Audio Track Score:**
957
1290
 
958
1291
  - Similar to inbound, using sending bitrate
959
1292
  - Remote packet loss consideration
960
1293
 
961
- **Outbound Video Track Score:**
1294
+ **Outbound Video Track Score (camera):**
1295
+
1296
+ - Bitrate deviation from target (`high-deviation-from-target-bitrate`, normalized 0–1): activation 5%, saturation 15% under target, gated on the absolute shortfall also exceeding `max(20 kbps, 5% of target)`
1297
+ - Quality-limitation penalties from the **interval duration shares** (the instantaneous `qualityLimitationReason` flickers): cpu share ≥30% → -2.0 (`cpu-limitation`), bandwidth share ≥50% → -1.0 (`bandwidth-limitation`, milder — BWE adaptation is the system working); instantaneous reason used as fallback when shares are unavailable
1298
+ - Bitrate volatility (`high-volatile-bitrate`, normalized 0–1): activation 0.1, saturation 0.2
1299
+
1300
+ **Outbound Video Track Score (screen share):**
1301
+
1302
+ Decided by `OutboundTrackMonitor.contentType`, **never** by `track.contentHint` (applications set `'detail'`/`'text'` on camera tracks too, so the hint is not a reliable screen-share signal). The flag is auto-detected only from `track.getSettings().displaySurface` — present exclusively on display capture — and otherwise declared by the application:
1303
+
1304
+ ```typescript
1305
+ monitor.getOutboundTrackMonitor(track.id)?.setContext({ contentType: 'screenshare' });
1306
+ ```
1307
+
1308
+ `getInbound/OutboundTrackMonitor(id)?.setContext(...)` requires the track's monitor to already exist, which only happens on the first stats tick after the track appears on a peer connection. When the application knows earlier — signaling announces a guest's upcoming screen-share track before any media arrives — declare it by track id on the client monitor instead; it is applied immediately if the monitor exists, and otherwise held pending and picked up the moment the track manifests on any peer connection:
1309
+
1310
+ ```typescript
1311
+ monitor.setOutboundTrackContext(trackId, { contentType: 'screenshare' });
1312
+ monitor.setInboundTrackContext(trackId, { contentType: 'screenshare', motionType: 'lowmotion' });
1313
+ ```
1314
+
1315
+ Both **merge**: fields omitted from the argument keep whatever was declared before, in the pending state as well as on a live monitor, so a content type declared from signaling survives a later call that only attaches the video element. Passing a field as an explicit `undefined` means "not declared here" rather than "reset"; assign the monitor's field directly to clear it.
1316
+
1317
+ For screen-share tracks, sharpness is the quality: fps and bitrate volatility are meaningless on mostly-static content (VBR drops to ~zero between changes), so deviation/volatility penalties are skipped entirely. Instead:
962
1318
 
963
- - Bitrate deviation from target penalties
964
- - CPU limitation penalties
965
- - Bitrate volatility penalties
966
- - If `track.contentHint === 'screen'`, bitrate deviation and volatility penalties are skipped to better fit screen-share traffic patterns
1319
+ - Quality-limitation duration share penalties (same as camera)
1320
+ - Encoded resolution downscaled vs. the captured surface (`downscaled-screenshare`): encoded area < ½ of source area → -1.0, < ¼ → -2.0 — the point where shared text stops being readable
967
1321
 
968
1322
  ### Score Reasons
969
1323
 
970
- Each score calculation includes detailed reasons for penalties:
1324
+ Every penalty the `DefaultScoreCalculator` applies is captured as a **reason**: a map from a reason key to the points it subtracted (`Record<string, number>`). The reasons are the explanation of the score — a `3.0` alone says something is wrong; `{ "frozen-video": 2.0 }` says *what*. They are produced by default, attributed to the entity that caused them, and readable in three places:
971
1325
 
972
- ```javascript
973
- monitor.on("score", (event) => {
974
- console.log("Client Score:", event.clientScore);
975
- console.log("Score Reasons:", event.scoreReasons);
976
- // Example reasons:
1326
+ **1. The realtime `'score'` event** — carries the client-level aggregate of the current tick's reasons across every peer connection and track (as `currentReasons`):
1327
+
1328
+ ```typescript
1329
+ monitor.on("score", ({ clientScore, currentReasons }) => {
1330
+ console.log("Client Score:", clientScore);
1331
+ console.log("Score Reasons:", currentReasons);
1332
+ // Example (normalized penalties carry fractional magnitudes):
977
1333
  // {
978
- // "high-rtt": 1.0,
979
- // "high-packetloss": 2.0,
980
- // "cpu-limitation": 2.0,
981
- // "dropped-video-frames": 1.0
1334
+ // "high-rtt": 1.0, // pc: raw RTT above 150ms
1335
+ // "high-jitter": 0.25, // inbound video: jitter 40ms, ramp 20->100ms
1336
+ // "high-packetloss": 2.0, // pc: avg delta fraction lost 5-20%
1337
+ // "cpu-limitation": 2.0, // outbound video: cpu-limited >=30% of the interval
1338
+ // "bandwidth-limitation": 1.0, // outbound video: bandwidth-limited >=50%
1339
+ // "frozen-video": 2.0, // inbound video: picture currently frozen
1340
+ // "audio-concealment": 0.5, // inbound audio: issue active, rate midway to saturation
1341
+ // "downscaled-screenshare": 2.0, // screenshare sent below 1/4 of source area
1342
+ // "dropped-video-frames": 0.4 // inbound video: 14% frames dropped, ramp 10->20%
982
1343
  // }
983
1344
  });
984
1345
  ```
985
1346
 
1347
+ **2. On the monitors** — each entity holds only its *own* reasons, so a low track score is explained on the track, not on the peer connection:
1348
+
1349
+ ```typescript
1350
+ pcMonitor.scoreReasons; // rtt / jitter / packetloss only
1351
+ monitor.getInboundTrackMonitor(id)?.scoreReasons; // e.g. frozen-video, audio-concealment
1352
+ monitor.getOutboundTrackMonitor(id)?.scoreReasons; // e.g. cpu-limitation, downscaled-screenshare
1353
+ ```
1354
+
1355
+ `ClientMonitor.scoreReasons` follows the same rule: it holds the client's **own** reasons, and there are none today — the client score subtracts nothing directly, being a weighted aggregate of the peer-connection and track scores. So it stays undefined.
1356
+
1357
+ The aggregated view lives on the `'score'` **event** instead, as `currentReasons` — every component's reasons summed by key. Score and reasons are kept separate on purpose: the event gives an application the whole picture to react to, while each monitor's `scoreReasons` stays scoped to what that entity itself caused.
1358
+
1359
+ ```typescript
1360
+ monitor.on('score', ({ clientScore, currentReasons }) => {
1361
+ // currentReasons: { 'high-rtt': 1.0, 'pixelated-video': 0.27 } — the aggregate
1362
+ });
1363
+
1364
+ monitor.scoreReasons; // the client's OWN reasons — undefined today
1365
+ ```
1366
+
1367
+ **3. In the samples — every entity ships only its own reasons.** The peer-connection and track sample entries carry `scoreReasons` as a **record of reason key → subtracted points** (`Record<string, number>`), so a degraded score explains itself on the wire, magnitudes included. The field is omitted when there is nothing to explain.
1368
+
1369
+ The **client sample entry carries no reasons**, because the client score subtracts nothing of its own. Shipping the aggregate there would put every reason on the wire a second time in the same sample, and would read as though the client itself were pixelating or losing packets when the cause was one inbound track. A server reconstructs the client-level view in post-analysis by re-aggregating the components of the same sample — the information is not lost, only sent once. If a client-level penalty is ever added it lands on `ClientMonitor.scoreReasons` like any other component's, and ships automatically.
1370
+
1371
+ Set `sendScoreReasonsToServer: false` in the config to drop the reasons from the wire entirely — the scores themselves and the realtime event are unaffected.
1372
+
1373
+ The full key set — with every threshold, ramp and what each reason means for the user experience — is documented in [docs/SCORE_CALCULATIONS.md](./docs/SCORE_CALCULATIONS.md); the type union is exported as `DefaultScoreCalculatorSubtractionReason`.
1374
+
986
1375
  ### Custom Score Calculator
987
1376
 
988
1377
  Implement your own scoring logic by implementing the `ScoreCalculator` interface:
@@ -1253,66 +1642,76 @@ if (sample) {
1253
1642
 
1254
1643
  ### Sample Compression
1255
1644
 
1256
- For efficient data transmission and storage, ObserveRTC provides dedicated compression packages for `ClientSample` objects:
1645
+ `ClientSample` objects compress well because consecutive samples are nearly identical — the same tracks, the same peer connections, counters that moved a little. Two codec packages exploit that by encoding **each sample as the delta from the previous one**; pick one by the wire format you want:
1646
+
1647
+ | Package | Wire format | Use it when |
1648
+ | --- | --- | --- |
1649
+ | `@observertc/samples-protobuf-codec` | Protobuf binary | You want the smallest payload and already speak protobuf on the server. |
1650
+ | `@observertc/samples-json-codec` | JSON | You want zero dependencies (~2 KB gzipped) and a payload you can read in a log. |
1257
1651
 
1258
- **@observertc/samples-encoder** - Compresses ClientSample objects for transmission:
1652
+ Both expose the same shape: a `ClientSampleEncoder`, a `ClientSampleDecoder`, and a `createClientSampleCodec()` factory that returns a matched pair.
1653
+
1654
+ **Encoding on the client:**
1259
1655
 
1260
1656
  ```javascript
1261
- import { SamplesEncoder } from "@observertc/samples-encoder";
1657
+ import { ClientSampleEncoder } from "@observertc/samples-protobuf-codec";
1262
1658
 
1263
- const encoder = new SamplesEncoder();
1264
- const sample = monitor.createSample();
1659
+ const encoder = new ClientSampleEncoder();
1265
1660
 
1266
- // Encode the sample for efficient transmission
1267
- const encodedSample = encoder.encode(sample);
1661
+ monitor.on("sample-created", ({ sample }) => {
1662
+ const encoded = encoder.encode(sample);
1268
1663
 
1269
- // Send compressed data over the network
1270
- fetch("/api/samples", {
1271
- method: "POST",
1272
- headers: {
1273
- "Content-Type": "application/octet-stream",
1274
- },
1275
- body: encodedSample,
1664
+ fetch("/api/samples", {
1665
+ method: "POST",
1666
+ headers: { "Content-Type": "application/octet-stream" },
1667
+ body: encoded,
1668
+ });
1276
1669
  });
1277
1670
  ```
1278
1671
 
1279
- **@observertc/samples-decoder** - Decompresses received ClientSample objects:
1672
+ **Decoding on the server:**
1280
1673
 
1281
1674
  ```javascript
1282
- import { SamplesDecoder } from "@observertc/samples-decoder";
1675
+ import { ClientSampleDecoder } from "@observertc/samples-protobuf-codec";
1283
1676
 
1284
- const decoder = new SamplesDecoder();
1677
+ // one decoder per client connection — see "Delta encoding is stateful" below
1678
+ const decoder = new ClientSampleDecoder();
1285
1679
 
1286
- // Receive compressed sample data
1287
- const compressedData = await response.arrayBuffer();
1680
+ const sample = decoder.decode(new Uint8Array(await request.arrayBuffer()));
1681
+ ```
1682
+
1683
+ **Delta encoding is stateful, and that has three consequences:**
1684
+
1685
+ 1. **One encoder per client, one decoder per client.** Each holds the previous sample as its baseline. Sharing an encoder across clients, or decoding two clients' streams through one decoder, produces garbage rather than an error.
1686
+ 2. **Samples must be decoded in the order they were encoded.** A dropped or reordered payload desynchronises the pair. Use `tryDecode()` where the transport can lose messages — it returns `undefined` instead of throwing, so you can drop the sample and wait for the next resync rather than tearing down the connection.
1687
+ 3. **Call `reset()` on both sides when a client reconnects.** The encoder starts a fresh baseline; the decoder must be told to expect one.
1288
1688
 
1289
- // Decode back to ClientSample object
1290
- const decodedSample = decoder.decode(compressedData);
1689
+ **Transport-specific helpers.** Where the payload has to survive a text channel, each codec carries its own:
1690
+
1691
+ ```javascript
1692
+ // protobuf: base64 for text transports, or the raw protobuf message
1693
+ encoder.encodeToBase64(sample); decoder.decodeBase64(text);
1694
+ encoder.encodeToMessage(sample); decoder.decodeFromMessage(message);
1291
1695
 
1292
- // Process the restored sample
1293
- console.log("Decoded sample:", decodedSample);
1696
+ // json: a plain JSON-serialisable delta
1697
+ encoder.encodeToJson(sample); decoder.decodeJson(json);
1698
+ decoder.tryDecodeJson(json);
1294
1699
  ```
1295
1700
 
1296
- **Benefits of Using Compression:**
1701
+ **Errors** are `ProtobufCodecError` / `JsonCodecError`, each carrying a `code` and a `context` describing what failed — a schema mismatch and a desynchronised baseline report differently, which is what you want in a log.
1297
1702
 
1298
- - **Reduced Bandwidth**: Compressed samples require significantly less network bandwidth
1299
- - **Faster Transmission**: Smaller payloads improve upload/download times
1300
- - **Storage Efficiency**: Compressed samples consume less storage space
1301
- - **Schema Consistency**: Ensures proper serialization/deserialization of all ClientSample fields
1703
+ **Schema compatibility.** Both codecs export a `schemaVersion` describing the `ClientSample` shape they were built against. This monitor ships schema **3.6.0** (`ClientMonitor.samplingSchemaVersion`). Check the two agree before deploying — a codec built against an older schema silently drops fields the monitor now sends.
1302
1704
 
1303
1705
  **Installation:**
1304
1706
 
1305
1707
  ```bash
1306
- # For encoding (client-side)
1307
- npm install @observertc/samples-encoder
1308
-
1309
- # For decoding (server-side)
1310
- npm install @observertc/samples-decoder
1311
-
1312
- # Both packages (if needed)
1313
- npm install @observertc/samples-encoder @observertc/samples-decoder
1708
+ # choose one
1709
+ npm install @observertc/samples-protobuf-codec
1710
+ npm install @observertc/samples-json-codec
1314
1711
  ```
1315
1712
 
1713
+ Both packages ship CommonJS and ESM builds with type definitions.
1714
+
1316
1715
  **Integration with ObserveRTC Stack:**
1317
1716
  These compression packages are part of the broader ObserveRTC ecosystem and are designed to work seamlessly with:
1318
1717
 
@@ -1385,7 +1784,7 @@ type ResolvedClientIssue<T = ClientIssuePayload> = RaisedClientIssue<T> & {
1385
1784
 
1386
1785
  Narrow between the two by checking for `'key' in issue` — that's the discriminant.
1387
1786
 
1388
- > **Wire format**: `ClientSample.clientIssues[]` ships a stripped shape: `{ type, payload?: string (JSON-stringified), timestamp }`. The richer in-memory `id`-less, key-bearing object is a runtime concern; the server schema is unchanged.
1787
+ > **Wire format** (schema 3.5.0): `ClientSample.clientIssues[]` ships a stripped shape: `{ type, key?, payload?: Record<string, boolean | string | number>, timestamp }`. Payloads are flat records of primitives on the wire — never pre-serialised JSON strings — so nothing is stringified per issue or per event, and the server reads payload fields directly.
1389
1788
 
1390
1789
  ### Lifecycle: the events you can listen to
1391
1790
 
@@ -1436,7 +1835,7 @@ Most built-in detectors raise their own stateful issue with a typed payload, emi
1436
1835
  | `dry-inbound-track` | Inbound bytes stay flat for `thresholdInMs` | Bytes start flowing again | `'dry-inbound-track'` | `DryInboundTrackIssuePayload` |
1437
1836
  | `dry-outbound-track` | Outbound bytes stay flat for `thresholdInMs` | Bytes start flowing again | `'dry-outbound-track'` | `DryOutboundTrackIssuePayload` |
1438
1837
  | `freezed-video-track` | `freezeCount` increases | No new freezes for one tick | `'freezed-video-track'` | `FreezedVideoTrackIssuePayload` |
1439
- | `inbound-video-playout-discrepancy` | `framesReceived - framesRendered > highSkewThreshold` | Skew drops below `lowSkewThreshold` | `'inbound-video-playout-discrepancy'` | `PlayoutDiscrepancyIssuePayload` |
1838
+ | `inbound-video-playout-discrepancy` | `(framesReceived - framesRendered) / framesReceived > highSkewRatio` | Ratio drops below `lowSkewRatio` | `'inbound-video-playout-discrepancy'` | `PlayoutDiscrepancyIssuePayload` |
1440
1839
  | `ice-disconnected` | An ICE transport stayed `disconnected` past `disconnectedThresholdInMs` | ICE reconnects, or the transport goes away | — | `IceDisconnectedIssuePayload` |
1441
1840
  | `ice-connection-failed` | An ICE transport reached `failed` | ICE reconnects (typically after a restart) | — | `IceConnectionFailedIssuePayload` |
1442
1841
  | `ice-transport-stalled` | Still sending on a succeeded pair of a connected transport, but receiving nothing for `transportStallThresholdInMs` | Inbound traffic resumes | — | `IceTransportStalledIssuePayload` |
@@ -1446,8 +1845,9 @@ Most built-in detectors raise their own stateful issue with a typed payload, emi
1446
1845
  | `video-decoder-overloaded` | Frames arrived and loss was quiet, but decode time overran the frame budget or frames were dropped after arrival | The decoder keeps up again | `'video-decoder-overloaded'` | `DecoderPerformanceIssuePayload` |
1447
1846
  | `keyframe-storm` | Sustained PLI rate above `pliRateAlertOn` | Rate falls below `pliRateAlertOff` | `'keyframe-storm'` | `KeyframeStormIssuePayload` |
1448
1847
  | `video-recovery-failed` | PLIs sent, picture frozen, `keyFramesDecoded` not advancing for `recoveryFailedThresholdInMs` | A keyframe arrives or the freeze ends | `'video-recovery-failed'` | `VideoRecoveryFailedIssuePayload` |
1449
- | `capture-bottleneck` | The capture source produced far fewer frames than configured | The source recovers | `'capture-bottleneck'` | `CaptureBottleneckIssuePayload` |
1450
- | `encoder-bottleneck` | A healthy source outran the encoder, or the encoder was CPU-limited | The encoder keeps up again | `'encoder-bottleneck'` | `EncoderBottleneckIssuePayload` |
1848
+ | `capture-bottleneck` | the capture device averaged under `captureFpsRatioThreshold` of the configured frame rate over `durationInMs` | the next average comes back at or above it | `'capture-bottleneck'` | `CaptureBottleneckIssuePayload` |
1849
+ | `decoder-bottleneck` | the decoder averaged under `decodeFpsRatioThreshold` of the frames that arrived over `durationInMs` | the next average comes back at or above it | `'decoder-bottleneck'` | `DecoderBottleneckIssuePayload` |
1850
+ | `encoder-bottleneck` | A delivering source outran the encoder for `durationInMs` continuously | The encoder keeps up again | `'encoder-bottleneck'` | `EncoderBottleneckIssuePayload` |
1451
1851
  | `capture-track-ended` | The outbound track's device reached `ended` | — (terminal) | `'capture-track-ended'` | `CaptureTrackEndedIssuePayload` |
1452
1852
  | `silent-audio-source` | A live, enabled, unmuted microphone produced silence for `silenceThresholdInMs` | Audio appears, or the track stops capturing | `'silent-audio-source'` | `SilentAudioSourceIssuePayload` |
1453
1853
  | `stuck-decoder` | RTP bytes flowing, nothing decoding, PLIs firing, for `thresholdInMs` | Frames decode again | `'stuck-decoder'` | `StuckDecoderIssuePayload` |
@@ -1684,7 +2084,7 @@ new ClientMonitor({
1684
2084
  });
1685
2085
  ```
1686
2086
 
1687
- Already running and want to flip a detector on/off without restarting the monitor? Every built-in detector exposes a `public disabled = false` field, and every layer's `detectors` registry exposes ergonomic helpers for finding and toggling them.
2087
+ Already running and want to flip a detector on/off without restarting the monitor? Every built-in detector exposes a `public disabled = false` field, and every layer's `detectors` registry exposes ergonomic helpers for finding and toggling them. Issue-raising detectors additionally expose `public includeIssueInSample = true` — flip it to `false` to keep a detector running locally (events, `activeIssues`) while excluding its issues from the samples shipped to the server; see [Which issues belong in the sample](#which-issues-belong-in-the-sample).
1688
2088
 
1689
2089
  `Detectors` (the registry attached as `monitor.detectors`, `peerConnectionMonitor.detectors`, `inboundTrackMonitor.detectors`, `outboundTrackMonitor.detectors`, `mediaPlayoutMonitor.detectors`) offers:
1690
2090
 
@@ -1740,7 +2140,16 @@ If you want a detector outright gone (not just silenced), call `detectors.remove
1740
2140
 
1741
2141
  ### Sample-channel behavior
1742
2142
 
1743
- Every `addIssue` and every `raiseIssue` adds an entry to the next `ClientSample.clientIssues[]`. **Re-raises do not add a new entry** — they emit `'issue-updated'` to live listeners but the sample buffer is unchanged. Resolutions are not currently included in the sample buffer (only in the realtime `'issue-resolved'` event). If you reconstruct state server-side from samples only, treat each `clientIssues` entry as "the issue started here" and apply your own correlation logic.
2143
+ Every `addIssue` and every `raiseIssue` adds an entry to the next `ClientSample.clientIssues[]` — unless the issue was raised with `includeInSample: false` (what a detector's `includeIssueInSample = false` compiles down to), in which case neither the raise nor its resolution reaches the sample. **Re-raises do not add a new entry** — they emit `'issue-updated'` to live listeners but the sample buffer is unchanged.
2144
+
2145
+ **The issue lifecycle reaches the sample too** (`sendResolvedIssuesToServer`, default `true`). The purpose: the server keeps an on-the-fly mirror of each client's currently *active* issues and can correlate across clients or act immediately (recreate a consumer, recommend a rejoin) instead of only ever learning that issues started. On the wire, both entries of a stateful issue carry the schema-level `key` — the identity the server opens and closes on:
2146
+
2147
+ ```
2148
+ raise: { type: 'stuck-decoder', key, payload, timestamp: raisedAt }
2149
+ resolution: { type: 'stuck-decoder-resolved', key, payload: { raisedAt, comment, ...resolution }, timestamp: resolvedAt }
2150
+ ```
2151
+
2152
+ The resolution's payload carries only what was **explicitly passed** to `resolveIssue`, flattened — the built-in detectors pass their final payload, so fields like `durationInMs` appear here, while a bare resolve carries just `raisedAt` and `comment`. The raise-time payload is not repeated; the server already has it from the raise entry. `raisedAt` equals the raise entry's `timestamp` — a secondary join for consumers that do not store keys. Issues still active at `close()` are auto-resolved and reach the final sample. Servers switching on issue `type` should ignore or handle the `-resolved` suffix; one-shot `addIssue` entries have no lifecycle and no `key`. Pass `sendResolvedIssuesToServer: false` to restore the previous wire format exactly (raise entries only, no `key`); the realtime `'issue-resolved'` event is emitted either way.
1744
2153
 
1745
2154
  ### Event listeners cheat-sheet
1746
2155
 
@@ -1768,6 +2177,7 @@ monitor.on('keyframe-storm', (e) => { /* PLIs feeding the c
1768
2177
  monitor.on('video-recovery-failed', (e) => { /* we asked for a keyframe; nothing came back */ });
1769
2178
  monitor.on('stuck-decoder', (e) => { /* RTP flowing, nothing decodes — recreate the consumer */ });
1770
2179
  monitor.on('capture-bottleneck', (e) => { /* the camera never produced the frames */ });
2180
+ monitor.on('decoder-bottleneck', (e) => { /* frames arrived; the decoder could not decode them */ });
1771
2181
  monitor.on('encoder-bottleneck', (e) => { /* the source did; the encoder could not keep up */ });
1772
2182
  monitor.on('capture-track-ended', (e) => { /* the device is gone */ });
1773
2183
  monitor.on('capture-track-muted', (e) => { /* the OS or another app took it */ });
@@ -2034,25 +2444,66 @@ Stats adapters provide a powerful mechanism to customize how WebRTC statistics a
2034
2444
 
2035
2445
  ### Built-in Adapters
2036
2446
 
2037
- The library includes several built-in adapters that are automatically applied based on browser detection:
2447
+ Every engine deviates from the [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/) specification — legacy aliases, spec-removed members, missing dictionaries, renamed fields. The library ships one normalizing adapter per browser family, applied automatically based on the detected browser, so the monitors (and everything downstream — detectors, samples, your own code) always see stats as close to the standard shape as possible. Each fix feature-detects from the report itself rather than parsing browser versions, so an adapter applied to an already-conformant report is a no-op.
2038
2448
 
2039
- #### Firefox Adapters
2449
+ Adapters do exactly three things: **fold** a value into the standard field it provably belongs to (a renamed member, a legacy report carrying the same measurement), **infer references** — the `*Id` fields that wire one report to another — and **map** legacy enum spellings onto the values the monitors accept.
2040
2450
 
2041
- - **Firefox94StatsAdapter**: Normalizes `mediaType` to `kind` field for RTP stats
2042
- - **FirefoxTransportStatsAdapter**: Creates transport stats from ICE candidate pairs when native transport stats are missing
2451
+ Inferring a reference is safe where computing a measurement is not. A reference is a structural link, and the report graph either determines it or it doesn't; when it doesn't, the field is left unset rather than guessed. Measured values are never invented: a number a browser omits stays omitted, because an approximation is indistinguishable downstream from a measurement and a detector cannot tell that it is judging a guess.
2043
2452
 
2044
- #### Browser-Specific Adaptations
2453
+ Nothing is thrown away except a value that survives elsewhere — a member folded into its standard name, or a legacy report whose contents were relocated. Members the spec dropped but a browser still fills (a candidate pair's `priority`, Chromium's `contentType`, Firefox's `selected`) are left on the stat: the browser measured them, monitors copy through whatever they receive, and removing them would only destroy information.
2045
2454
 
2046
- Stats adapters are automatically added based on detected browser:
2455
+ #### ChromeStatsAdapter (Chrome, Edge, Opera)
2047
2456
 
2048
- ```javascript
2049
- // Automatically applied for Firefox
2050
- if (browser.name === "firefox") {
2051
- pcMonitor.statsAdapters.add(new Firefox94StatsAdapter());
2052
- pcMonitor.statsAdapters.add(new FirefoxTransportStatsAdapter());
2457
+ Folds: `mediaType` → `kind` (the legacy alias, still emitted on every RTP report); `ip` → `address` on ICE candidate reports (Chromium emits both spellings with identical values); the deprecated `track`/`stream` reports and their `trackId` reference (Chrome ≤ M111) → the matching `inbound-rtp` fields.
2458
+
2459
+ Infers: `mediaSourceId`, `transportId`, the `remoteId`/`localId` cross-references and `codecId` when absent — normally a no-op on Chromium, kept as a safety net for older versions and for stats arriving through a relay that dropped them.
2460
+
2461
+ #### SafariStatsAdapter
2462
+
2463
+ Folds: the deprecated `track` reports (Safari ≤ 16.x) → `inbound-rtp` — most importantly `trackIdentifier`, absent on `inbound-rtp` before Safari 16.4, without which a stream cannot be bound to its `MediaStreamTrack` at all, plus freeze/pause counters, frame geometry and audio levels; `mediaType` → `kind`; `data-channel.datachannelid` → `dataChannelIdentifier` (Safari ≤ 17.6).
2464
+
2465
+ Maps: legacy `candidate-pair.state` spellings → the spec enum (`inprogress` → `in-progress`, `cancelled` → `failed`).
2466
+
2467
+ Infers: `codec.transportId`, spec-required but unfilled through Safari 17.3; `inbound-rtp.remoteId`, which WebKit dropped in Safari 16.4 through 16.6, severing an inbound stream from the sender's clock and RTCP round trip; plus `mediaSourceId` and `codecId` where older WebKit omits them.
2468
+
2469
+ #### FirefoxStatsAdapter
2470
+
2471
+ Folds: `mediaType` → `kind`; the non-standard `discardedPackets` alias → `packetsDiscarded`. Maps `candidate-pair.state: 'cancelled'` → `'failed'`. Brace-wrapped `{uuid}` track identifiers are intentionally left alone — Firefox wraps `MediaStreamTrack.id` the same way, so they match the application's track ids exactly as emitted.
2472
+
2473
+ Reconstructs the whole `transport` report, which Firefox ships none of before Firefox 153, from the `candidate-pair` marked `selected` — accumulating that pair's measured packet and byte counters, and carrying the totals across a selected-pair change rather than jumping back to the new pair's own counters, so ICE-level monitoring behaves the same across browsers. Every number comes from the pair the browser reported. A no-op as soon as a native transport report is present.
2474
+
2475
+ Reference inference matters most here, since Firefox omits the most: `outbound-rtp.mediaSourceId`, never emitted, and the link through which a sent stream reaches its source and its `MediaStreamTrack` — resolved by kind when a single source of that kind exists (so simulcast encodings all resolve to it), left unset when a camera and a screen share make it ambiguous. Also `transportId` on RTP, codec and ICE reports, absent before Firefox 153 — resolved to the sole transport, native or reconstructed — plus the `remoteId`/`localId` cross-references and `codecId`, which respects the `encode`/`decode` direction Firefox tags on codec entries.
2476
+
2477
+ This is the one stateful adapter, since the reconstructed transport accumulates across ticks: one instance per peer connection, and it should see every tick. Re-adapting the same tick is harmless — the accumulation is keyed on the collection timestamp.
2478
+
2479
+ #### Deviations the adapters do not correct
2480
+
2481
+ These fields are absent because the browser does not measure them, and nothing in the report can stand in without guessing:
2482
+
2483
+ - `inbound-rtp.framesRendered` — no engine emits it.
2484
+ - `remote-inbound-rtp.packetsReceived` — Chromium and WebKit never emit it.
2485
+ - Firefox: `qualityLimitationReason`/`Durations`, `totalPacketSendDelay`, `targetBitrate`, `media-source` audio levels, `media-playout` reports, `remote-outbound-rtp` round-trip time.
2486
+ - Safari: `media-playout` reports (so `playoutId` and audio-playout metrics are unavailable) and the `address` on host/peer-reflexive ICE candidates, which WebKit nulls.
2487
+ - Chromium: `candidate-pair.requestsSent` counts only STUN checks sent before the first response — every later check lands in `consentRequestsSent`, so the sum of the two is the real total. `inbound-rtp.packetsDiscarded` is audio-only.
2488
+
2489
+ #### Adding a version-scoped adapter
2490
+
2491
+ There is deliberately one adapter per browser family, not one per browser version. Every fix guards on the data — fold `mediaType` if it is there, fill `transportId` if it is missing, rebuild the `transport` report if none is present — which is why a single Firefox adapter covers Firefox 96 through 155 without knowing which it is talking to. Most spec deviations are of the form "this field only exists from version N", and a presence check handles those for free, with no version matrix to maintain across the boundaries each engine has (Firefox at 96, 104, 106, 135, 142, 153, 154; Safari at 16.4, 17.0, 17.4, 18.0).
2492
+
2493
+ A version gate is warranted only when the report cannot answer the question: the same field, present in every version, *meaning* something different in a range — a unit change, a counter switching from monotonic to per-interval, or a value that is actively wrong in known builds. None of the deviations handled today are of that kind. Prefer the data whenever it can answer, because a reported user-agent version is the less trustworthy signal: Edge, Opera and Brave lag Chromium and do not report its version, WebViews version themselves oddly, and UA reduction freezes minor versions, so a version gate can be wrong about the engine in a way a presence check cannot.
2494
+
2495
+ When one is genuinely needed, register it alongside the browser adapter rather than gating inside shared code — `Sources.addStatsAdapters` already has the browser name and version, and `StatsAdapters` composes adapters in registration order:
2496
+
2497
+ ```typescript
2498
+ case "firefox": {
2499
+ pcMonitor.statsAdapters.add(new FirefoxStatsAdapter());
2500
+ if (majorVersion < 142) pcMonitor.statsAdapters.add(new FirefoxJitterUnitsAdapter());
2501
+ break;
2053
2502
  }
2054
2503
  ```
2055
2504
 
2505
+ Name it after the deviation it corrects, not the version that introduced it. `FirefoxJitterUnitsAdapter` still says what it does after the next boundary moves; `Firefox94StatsAdapter` — this library's former adapter, which despite its name ran on every Firefox version — said nothing at all.
2506
+
2056
2507
  ### Custom Stats Adapters
2057
2508
 
2058
2509
  Create custom adapters by implementing the `StatsAdapter` interface:
@@ -2739,8 +3190,9 @@ class MonitoringDashboard {
2739
3190
  }
2740
3191
 
2741
3192
  setupEventListeners() {
2742
- this.monitor.on("score", ({ clientScore, scoreReasons }) => {
2743
- this.updateScoreDisplay(clientScore, scoreReasons);
3193
+ this.monitor.on("score", ({ clientScore, currentReasons }) => {
3194
+ // currentReasons is the AGGREGATE: every component's reasons summed
3195
+ this.updateScoreDisplay(clientScore, currentReasons);
2744
3196
  });
2745
3197
 
2746
3198
  this.monitor.on("congestion", ({ availableIncomingBitrate, availableOutgoingBitrate }) => {
@@ -2931,7 +3383,7 @@ interface ClientMonitorEvents {
2931
3383
  durationOfCollectingStatsInMs: number;
2932
3384
  collectedStats: [string, RTCStats[]][];
2933
3385
  }) => void;
2934
- score: (data: { clientScore: number; scoreReasons?: Record<string, number> }) => void;
3386
+ score: (data: { clientScore: number; currentReasons: Record<string, number> }) => void;
2935
3387
  issue: (issue: ClientIssue) => void;
2936
3388
  congestion: (data: CongestionEvent) => void;
2937
3389
  close: () => void;
@@ -2957,7 +3409,7 @@ interface ClientMonitorEvents {
2957
3409
  **A**:
2958
3410
 
2959
3411
  1. Increase sampling period
2960
- 2. Use sample compression (@observertc/samples-encoder)
3412
+ 2. Use a delta codec (@observertc/samples-protobuf-codec or @observertc/samples-json-codec)
2961
3413
  3. Filter samples before sending
2962
3414
  4. Disable unnecessary detectors
2963
3415