@observertc/observer-js 1.1.0 → 1.2.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.
package/dist/index.mjs CHANGED
@@ -103,6 +103,10 @@ function counterDelta(previous, current) {
103
103
  if (current < previous) return 0;
104
104
  return current - previous;
105
105
  }
106
+ function rmsAudioLevel(deltaTotalAudioEnergy, deltaTotalSamplesDuration) {
107
+ if (!(0 < deltaTotalSamplesDuration)) return void 0;
108
+ return Math.sqrt(Math.max(0, deltaTotalAudioEnergy) / deltaTotalSamplesDuration);
109
+ }
106
110
  function correlation(xs, ys) {
107
111
  const n = Math.min(xs.length, ys.length);
108
112
  if (n < 2) return 0;
@@ -135,8 +139,8 @@ function pageHinkley(values, delta = 0, lambda = 50) {
135
139
  let runningMinimum = 0;
136
140
  for (let i = 0; i < values.length; i++) {
137
141
  sum += values[i];
138
- const mean = sum / (i + 1);
139
- cumulative += values[i] - mean - delta;
142
+ const mean2 = sum / (i + 1);
143
+ cumulative += values[i] - mean2 - delta;
140
144
  if (cumulative < runningMinimum) runningMinimum = cumulative;
141
145
  const ph = cumulative - runningMinimum;
142
146
  statistic.push(ph);
@@ -188,6 +192,24 @@ function erf(x) {
188
192
  const y = 1 - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-(ax * ax));
189
193
  return sign * y;
190
194
  }
195
+ function percentileOfHistogram(histogram, bucketWidth, p, options = {}) {
196
+ let total = 0;
197
+ for (const count of histogram) total += count;
198
+ if (total < 1) return void 0;
199
+ const rank = Math.min(Math.max(p, 0), 1) * total;
200
+ let cumulative = 0;
201
+ for (let index = 0; index < histogram.length; ++index) {
202
+ const count = histogram[index];
203
+ if (count < 1 || cumulative + count < rank) {
204
+ cumulative += count;
205
+ continue;
206
+ }
207
+ if (options.openEnded && index === histogram.length - 1 && options.max !== void 0) return options.max;
208
+ const estimate = index * bucketWidth + (rank - cumulative) / count * bucketWidth;
209
+ return Math.min(Math.max(estimate, options.min ?? estimate), options.max ?? estimate);
210
+ }
211
+ return options.max;
212
+ }
191
213
 
192
214
  // src/ObservedInboundRtp.ts
193
215
  var ObservedInboundRtp = class {
@@ -298,6 +320,15 @@ var ObservedInboundRtp = class {
298
320
  deltaFecPacketsReceived = 0;
299
321
  deltaFecPacketsDiscarded = 0;
300
322
  deltaPausesDuration = 0;
323
+ deltaTotalAudioEnergy = 0;
324
+ deltaTotalSamplesDuration = 0;
325
+ /**
326
+ * RMS level (0..1) of the audio played out from this stream over the tick, from the two deltas
327
+ * above. This is the far end's outgoing level **as this receiver heard it**, which is the only
328
+ * send-side level available for publishers on browsers that do not report `media-source` audio
329
+ * levels (Firefox). `undefined` for video and for a tick with no audio accounted for.
330
+ */
331
+ rmsAudioLevel;
301
332
  /**
302
333
  * Mean jitter-buffer delay for the frames/samples emitted in this tick (seconds), derived from
303
334
  * the cumulative `jitterBufferDelay` / `jitterBufferEmittedCount` pair — the only correct way to
@@ -383,6 +414,9 @@ var ObservedInboundRtp = class {
383
414
  this.jitterBufferDelayInMs = void 0;
384
415
  this.concealmentRatio = void 0;
385
416
  this.framesDroppedRatio = void 0;
417
+ this.deltaTotalAudioEnergy = 0;
418
+ this.deltaTotalSamplesDuration = 0;
419
+ this.rmsAudioLevel = void 0;
386
420
  const elapsedTimeInMs = stats.timestamp - this.timestamp;
387
421
  this.counterResetBoundary = this.codecId !== void 0 && stats.codecId !== void 0 && this.codecId !== stats.codecId || this.decoderImplementation !== void 0 && stats.decoderImplementation !== void 0 && this.decoderImplementation !== stats.decoderImplementation;
388
422
  if (0 < elapsedTimeInMs && !this.counterResetBoundary) {
@@ -412,6 +446,9 @@ var ObservedInboundRtp = class {
412
446
  this.deltaRetransmittedPacketsReceived = counterDelta(this.retransmittedPacketsReceived, stats.retransmittedPacketsReceived);
413
447
  this.deltaFecPacketsReceived = counterDelta(this.fecPacketsReceived, stats.fecPacketsReceived);
414
448
  this.deltaFecPacketsDiscarded = counterDelta(this.fecPacketsDiscarded, stats.fecPacketsDiscarded);
449
+ this.deltaTotalAudioEnergy = counterDelta(this.totalAudioEnergy, stats.totalAudioEnergy);
450
+ this.deltaTotalSamplesDuration = counterDelta(this.totalSamplesDuration, stats.totalSamplesDuration);
451
+ this.rmsAudioLevel = rmsAudioLevel(this.deltaTotalAudioEnergy, this.deltaTotalSamplesDuration);
415
452
  this.bitrate = this.deltaBytesReceived * 8 / elapsedInSeconds;
416
453
  const pixels = (stats.frameWidth ?? 0) * (stats.frameHeight ?? 0);
417
454
  if (0 < pixels && 0 < this.deltaFramesReceived) {
@@ -469,6 +506,9 @@ var ObservedInboundRtp = class {
469
506
  this.jitterBufferEmittedCount = stats.jitterBufferEmittedCount;
470
507
  this.jitterBufferMinimumDelay = stats.jitterBufferMinimumDelay;
471
508
  this.totalSamplesReceived = stats.totalSamplesReceived;
509
+ this.audioLevel = stats.audioLevel;
510
+ this.totalAudioEnergy = stats.totalAudioEnergy;
511
+ this.totalSamplesDuration = stats.totalSamplesDuration;
472
512
  this.concealedSamples = stats.concealedSamples;
473
513
  this.silentConcealedSamples = stats.silentConcealedSamples;
474
514
  this.concealmentEvents = stats.concealmentEvents;
@@ -1123,6 +1163,16 @@ var ObservedMediaSource = class {
1123
1163
  frames;
1124
1164
  framesPerSecond;
1125
1165
  attachments;
1166
+ /** Increase of `totalAudioEnergy` in this tick (audio only). */
1167
+ deltaTotalAudioEnergy = 0;
1168
+ /** Increase of `totalSamplesDuration` in this tick, in seconds (audio only). */
1169
+ deltaTotalSamplesDuration = 0;
1170
+ /**
1171
+ * RMS level (0..1) of what the capture device delivered over the tick, after the browser's own
1172
+ * processing (AEC, NS, AGC). Chrome and Safari report the inputs; Firefox does not, and there the
1173
+ * publisher's level has to come from its receivers — see `ObservedOutboundTrack.rmsAudioLevel`.
1174
+ */
1175
+ rmsAudioLevel;
1126
1176
  get visited() {
1127
1177
  const visited = this._visited;
1128
1178
  this._visited = false;
@@ -1136,6 +1186,14 @@ var ObservedMediaSource = class {
1136
1186
  }
1137
1187
  update(stats) {
1138
1188
  this._visited = true;
1189
+ this.deltaTotalAudioEnergy = 0;
1190
+ this.deltaTotalSamplesDuration = 0;
1191
+ this.rmsAudioLevel = void 0;
1192
+ if (this.timestamp < stats.timestamp) {
1193
+ this.deltaTotalAudioEnergy = counterDelta(this.totalAudioEnergy, stats.totalAudioEnergy);
1194
+ this.deltaTotalSamplesDuration = counterDelta(this.totalSamplesDuration, stats.totalSamplesDuration);
1195
+ this.rmsAudioLevel = rmsAudioLevel(this.deltaTotalAudioEnergy, this.deltaTotalSamplesDuration);
1196
+ }
1139
1197
  this.timestamp = stats.timestamp;
1140
1198
  this.trackIdentifier = stats.trackIdentifier;
1141
1199
  this.audioLevel = stats.audioLevel;
@@ -1534,6 +1592,20 @@ var ObservedOutboundTrack = class {
1534
1592
  remoteFractionLost;
1535
1593
  remoteRttInMs;
1536
1594
  qualityLimitationReason;
1595
+ /**
1596
+ * This audio track's RMS level (0..1) over the latest tick — how loud the publisher was sending.
1597
+ * Taken from the publisher's own `media-source` when it reports one (Chrome, Safari), otherwise
1598
+ * the median of what its receivers heard ({@link receivedRmsAudioLevel}), which is what makes it
1599
+ * available for Firefox publishers too. `undefined` for video, or when neither is known.
1600
+ * {@link rmsAudioLevelSource} says which one it is.
1601
+ */
1602
+ rmsAudioLevel;
1603
+ rmsAudioLevelSource;
1604
+ /**
1605
+ * Median RMS level (0..1) of this track across the receivers that played it out, each over its own
1606
+ * latest tick. Needs a `RemoteTrackResolver`: without links there are no receivers to ask.
1607
+ */
1608
+ receivedRmsAudioLevel;
1537
1609
  get score() {
1538
1610
  return this.calculatedScore.value;
1539
1611
  }
@@ -1554,6 +1626,10 @@ var ObservedOutboundTrack = class {
1554
1626
  getMediaSource() {
1555
1627
  return this._mediaSource;
1556
1628
  }
1629
+ /** The capture device behind this track, when the client sent `AUDIO_INPUT_DEVICE` metadata for it. */
1630
+ get audioInputDevice() {
1631
+ return this._peerConnection.client.audioInputDevices.get(this.id);
1632
+ }
1557
1633
  update(stats) {
1558
1634
  this._visited = true;
1559
1635
  this.timestamp = stats.timestamp;
@@ -1585,8 +1661,30 @@ var ObservedOutboundTrack = class {
1585
1661
  if (this.muted !== true && this.deltaPacketsSent === 0) reasons.push("no-packets-sent");
1586
1662
  this.degradedReasons = reasons.length === 0 ? void 0 : reasons;
1587
1663
  this.receivingDistribution = this.createReceivingDistribution();
1664
+ this._updateAudioLevel();
1588
1665
  this.detectors.update();
1589
1666
  }
1667
+ _updateAudioLevel() {
1668
+ this.rmsAudioLevel = void 0;
1669
+ this.rmsAudioLevelSource = void 0;
1670
+ this.receivedRmsAudioLevel = void 0;
1671
+ if (this.kind !== "audio") return;
1672
+ if (0 < this.remoteInboundTracks.size) {
1673
+ const levels = [];
1674
+ for (const inboundTrack of this.remoteInboundTracks) {
1675
+ pushFinite(levels, inboundTrack.getInboundRtp()?.rmsAudioLevel);
1676
+ }
1677
+ this.receivedRmsAudioLevel = median(levels);
1678
+ }
1679
+ const sourceLevel = this._mediaSource?.rmsAudioLevel;
1680
+ if (sourceLevel !== void 0) {
1681
+ this.rmsAudioLevel = sourceLevel;
1682
+ this.rmsAudioLevelSource = "media-source";
1683
+ } else if (this.receivedRmsAudioLevel !== void 0) {
1684
+ this.rmsAudioLevel = this.receivedRmsAudioLevel;
1685
+ this.rmsAudioLevelSource = "receivers";
1686
+ }
1687
+ }
1590
1688
  createReceivingDistribution() {
1591
1689
  if (this.remoteInboundTracks.size === 0) return void 0;
1592
1690
  let freezeAffected = 0;
@@ -2626,6 +2724,7 @@ var ClientMetaTypes = /* @__PURE__ */ ((ClientMetaTypes2) => {
2626
2724
  ClientMetaTypes2["MEDIA_DEVICES_SUPPORTED_CONSTRAINTS"] = "MEDIA_DEVICES_SUPPORTED_CONSTRAINTS";
2627
2725
  ClientMetaTypes2["USER_MEDIA_ERROR"] = "USER_MEDIA_ERROR";
2628
2726
  ClientMetaTypes2["LOCAL_SDP"] = "LOCAL_SDP";
2727
+ ClientMetaTypes2["AUDIO_INPUT_DEVICE"] = "AUDIO_INPUT_DEVICE";
2629
2728
  ClientMetaTypes2["OPERATION_SYSTEM"] = "OPERATION_SYSTEM";
2630
2729
  ClientMetaTypes2["ENGINE"] = "ENGINE";
2631
2730
  ClientMetaTypes2["PLATFORM"] = "PLATFORM";
@@ -2644,6 +2743,10 @@ function baseIssueType(type) {
2644
2743
 
2645
2744
  // src/ObservedClient.ts
2646
2745
  var logger2 = createLogger("ObservedClient");
2746
+ var RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS = 20;
2747
+ var RTT_HISTOGRAM_NUMBER_OF_BUCKETS = 50;
2748
+ var SCORE_HISTOGRAM_BUCKET_WIDTH = 1;
2749
+ var SCORE_HISTOGRAM_NUMBER_OF_BUCKETS = 5;
2647
2750
  var ObservedClient = class extends EventEmitter2 {
2648
2751
  constructor(settings, call, activeIssues) {
2649
2752
  super();
@@ -2708,10 +2811,6 @@ var ObservedClient = class extends EventEmitter2 {
2708
2811
  deltaOutboundPacketsSent = 0;
2709
2812
  deltaTransportSentBytes = 0;
2710
2813
  deltaTransportReceivedBytes = 0;
2711
- deltaRttLt50Measurements = 0;
2712
- deltaRttLt150Measurements = 0;
2713
- deltaRttLt300Measurements = 0;
2714
- deltaRttGtOrEq300Measurements = 0;
2715
2814
  currentMaxRttInMs;
2716
2815
  currentMinRttInMs;
2717
2816
  currentAvgRttInMs;
@@ -2727,8 +2826,73 @@ var ObservedClient = class extends EventEmitter2 {
2727
2826
  deltaNumberOfIssues = 0;
2728
2827
  totalScoreSum = 0;
2729
2828
  numberOfScoreMeasurements = 0;
2730
- // public totalNumberOfIssues = 0;
2829
+ minScore;
2830
+ maxScore;
2831
+ /**
2832
+ * Score measurements (one per accepted sample with a score) counted into
2833
+ * `SCORE_HISTOGRAM_NUMBER_OF_BUCKETS` buckets of `SCORE_HISTOGRAM_BUCKET_WIDTH`. Histograms of
2834
+ * several clients can be added bucket by bucket to get the distribution of a call.
2835
+ */
2836
+ scoreHistogram = new Array(SCORE_HISTOGRAM_NUMBER_OF_BUCKETS).fill(0);
2837
+ /** Issues raised over the session, counted as in `issueHistogram`. */
2838
+ totalNumberOfIssues = 0;
2839
+ /**
2840
+ * Number of times each issue type was raised over the session, keyed by the base type (without
2841
+ * the `-resolved` suffix): `client.issueHistogram['loopback-audio-input']`. A resolution entry is
2842
+ * not counted, and neither is a re-raise of a keyed issue that is still open, so a stateful issue
2843
+ * reported on every tick counts once per open interval. A type never raised is `undefined`.
2844
+ */
2845
+ issueHistogram = /* @__PURE__ */ Object.create(null);
2846
+ // Session totals. Unlike the `delta*` and `current*` fields they are never reset, and unlike the
2847
+ // `total*` fields of `ObservedPeerConnection` they survive a peer connection being closed.
2848
+ // Every RTT measurement is one peer connection's `currentRttInMs` in one accepted sample.
2849
+ totalRttSumInMs = 0;
2850
+ numberOfRttMeasurements = 0;
2851
+ maxRttInMs;
2852
+ minRttInMs;
2853
+ /**
2854
+ * RTT measurements counted into fixed buckets of `RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS`; the last bucket
2855
+ * holds every measurement of 980 ms and above. Constant memory, so percentiles can be estimated
2856
+ * over the whole session (see `rttSummary`) without keeping every measurement. Histograms
2857
+ * of several clients can be added bucket by bucket to get the distribution of a call.
2858
+ */
2859
+ rttHistogram = new Array(RTT_HISTOGRAM_NUMBER_OF_BUCKETS).fill(0);
2860
+ _rttSummary;
2861
+ _scoreSummary;
2862
+ totalInboundPacketsLost = 0;
2863
+ totalInboundPacketsReceived = 0;
2864
+ totalOutboundPacketsSent = 0;
2865
+ totalReceivedAudioBytes = 0;
2866
+ totalReceivedVideoBytes = 0;
2867
+ totalSentAudioBytes = 0;
2868
+ totalSentVideoBytes = 0;
2869
+ totalTransportReceivedBytes = 0;
2870
+ totalTransportSentBytes = 0;
2871
+ totalDataChannelBytesReceived = 0;
2872
+ totalDataChannelBytesSent = 0;
2873
+ totalDataChannelMessagesReceived = 0;
2874
+ totalDataChannelMessagesSent = 0;
2875
+ /** Highest `sendingAudioBitrate + sendingVideoBitrate` of any sample, in bits per second. */
2876
+ peakSendingBitrate = 0;
2877
+ /** Highest `receivingAudioBitrate + receivingVideoBitrate` of any sample, in bits per second. */
2878
+ peakReceivingBitrate = 0;
2879
+ // Samples by the transport path of the selected ICE candidate pairs. A sample whose peer
2880
+ // connections use different paths is counted under each of them, so more than one non-zero
2881
+ // counter means the path changed or was mixed. A sample with no selected pair counts as unknown.
2882
+ numberOfSamplesOverUdp = 0;
2883
+ numberOfSamplesOverTcp = 0;
2884
+ numberOfSamplesOverTurnUdp = 0;
2885
+ numberOfSamplesOverTurnTcp = 0;
2886
+ numberOfSamplesWithUnknownPath = 0;
2887
+ numberOfPublicAddressChanges = 0;
2731
2888
  mediaDevices = [];
2889
+ /**
2890
+ * The capture device behind each outbound audio track, keyed by track id, from the client's
2891
+ * `AUDIO_INPUT_DEVICE` metadata. Kept for the client's lifetime (a device switch is a new track
2892
+ * id), so a finding raised after a track ended can still name its device. Read it per track via
2893
+ * `ObservedOutboundTrack.audioInputDevice`.
2894
+ */
2895
+ audioInputDevices = /* @__PURE__ */ new Map();
2732
2896
  /**
2733
2897
  * The client's currently **open** stateful issues, keyed by `ClientIssue.key` — the server-side
2734
2898
  * mirror of the client monitor's own active-issue map (client-monitor-js >= 4.6.0).
@@ -2741,9 +2905,7 @@ var ObservedClient = class extends EventEmitter2 {
2741
2905
  activeIssues;
2742
2906
  _pendingInjections = {};
2743
2907
  _activeSample;
2744
- /** Peer connections the client reported closed (`PEER_CONNECTION_CLOSED`), closed once the current sample is applied. */
2745
2908
  _peerConnectionsToClose = /* @__PURE__ */ new Set();
2746
- /** Ids of peer connections closed by the client; late stats for them are ignored rather than resurrecting them. */
2747
2909
  _closedPeerConnectionIds = /* @__PURE__ */ new Set();
2748
2910
  closeTimer;
2749
2911
  /** The distinct `publicAddress`es currently known by this client's open peer connections. */
@@ -2760,6 +2922,61 @@ var ObservedClient = class extends EventEmitter2 {
2760
2922
  get score() {
2761
2923
  return this.calculatedScore.value;
2762
2924
  }
2925
+ /**
2926
+ * Percentiles (error at most one 20 ms bucket; a percentile in the overflow bucket is reported as
2927
+ * `maxRttInMs`) and mean of every RTT measurement of the session. Cached until the next measurement.
2928
+ */
2929
+ get rttSummary() {
2930
+ if (!this._rttSummary) {
2931
+ const bounds = { min: this.minRttInMs, max: this.maxRttInMs, openEnded: true };
2932
+ const percentile2 = (p) => percentileOfHistogram(this.rttHistogram, RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS, p, bounds);
2933
+ this._rttSummary = Object.freeze({
2934
+ p50: percentile2(0.5),
2935
+ p75: percentile2(0.75),
2936
+ p95: percentile2(0.95),
2937
+ p99: percentile2(0.99),
2938
+ avg: 0 < this.numberOfRttMeasurements ? this.totalRttSumInMs / this.numberOfRttMeasurements : void 0
2939
+ });
2940
+ }
2941
+ return this._rttSummary;
2942
+ }
2943
+ /**
2944
+ * Percentiles (error at most one score point) and mean of every score of the session. Cached until
2945
+ * the next score.
2946
+ */
2947
+ get scoreSummary() {
2948
+ if (!this._scoreSummary) {
2949
+ const bounds = { min: this.minScore, max: this.maxScore };
2950
+ const percentile2 = (p) => percentileOfHistogram(this.scoreHistogram, SCORE_HISTOGRAM_BUCKET_WIDTH, p, bounds);
2951
+ this._scoreSummary = Object.freeze({
2952
+ p5: percentile2(0.05),
2953
+ p25: percentile2(0.25),
2954
+ p50: percentile2(0.5),
2955
+ p75: percentile2(0.75),
2956
+ p95: percentile2(0.95),
2957
+ avg: 0 < this.numberOfScoreMeasurements ? this.totalScoreSum / this.numberOfScoreMeasurements : void 0
2958
+ });
2959
+ }
2960
+ return this._scoreSummary;
2961
+ }
2962
+ _addScoreMeasurement(score) {
2963
+ this._scoreSummary = void 0;
2964
+ this.totalScoreSum += score;
2965
+ ++this.numberOfScoreMeasurements;
2966
+ if (this.maxScore === void 0 || this.maxScore < score) this.maxScore = score;
2967
+ if (this.minScore === void 0 || score < this.minScore) this.minScore = score;
2968
+ const bucket = Math.min(Math.max(Math.floor(score / SCORE_HISTOGRAM_BUCKET_WIDTH), 0), SCORE_HISTOGRAM_NUMBER_OF_BUCKETS - 1);
2969
+ ++this.scoreHistogram[bucket];
2970
+ }
2971
+ _addRttMeasurement(rttInMs) {
2972
+ this._rttSummary = void 0;
2973
+ this.totalRttSumInMs += rttInMs;
2974
+ ++this.numberOfRttMeasurements;
2975
+ if (this.maxRttInMs === void 0 || this.maxRttInMs < rttInMs) this.maxRttInMs = rttInMs;
2976
+ if (this.minRttInMs === void 0 || rttInMs < this.minRttInMs) this.minRttInMs = rttInMs;
2977
+ const bucket = Math.min(Math.max(Math.floor(rttInMs / RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS), 0), RTT_HISTOGRAM_NUMBER_OF_BUCKETS - 1);
2978
+ ++this.rttHistogram[bucket];
2979
+ }
2763
2980
  close() {
2764
2981
  if (this.closed) return;
2765
2982
  if (this.closeTimer) {
@@ -2803,6 +3020,10 @@ var ObservedClient = class extends EventEmitter2 {
2803
3020
  const elapsedInSeconds = elapsedInMs / 1e3;
2804
3021
  let sumOfRtts = 0;
2805
3022
  let numberOfRttMeasurements = 0;
3023
+ let overUdp = false;
3024
+ let overTcp = false;
3025
+ let overTurnUdp = false;
3026
+ let overTurnTcp = false;
2806
3027
  ++this.acceptedSamples;
2807
3028
  this.availableIncomingBitrate = 0;
2808
3029
  this.availableOutgoingBitrate = 0;
@@ -2819,10 +3040,6 @@ var ObservedClient = class extends EventEmitter2 {
2819
3040
  this.deltaSentVideoBytes = 0;
2820
3041
  this.deltaTransportReceivedBytes = 0;
2821
3042
  this.deltaTransportSentBytes = 0;
2822
- this.deltaRttLt50Measurements = 0;
2823
- this.deltaRttLt150Measurements = 0;
2824
- this.deltaRttLt300Measurements = 0;
2825
- this.deltaRttGtOrEq300Measurements = 0;
2826
3043
  this.deltaNumberOfIssues = 0;
2827
3044
  this.numberOfDataChannels = 0;
2828
3045
  this.numberOfInboundRtpStreams = 0;
@@ -2894,6 +3111,18 @@ var ObservedClient = class extends EventEmitter2 {
2894
3111
  if (observedPeerConnection.usingTCP) {
2895
3112
  this.usingTCP = true;
2896
3113
  }
3114
+ for (const selectedPair of observedPeerConnection.selectedIceCandidatePairs) {
3115
+ const localCandidate = selectedPair.getLocalCandidate();
3116
+ if (!localCandidate) continue;
3117
+ if (localCandidate.candidateType === "relay") {
3118
+ if (localCandidate.relayProtocol === "tcp" || localCandidate.relayProtocol === "tls") overTurnTcp = true;
3119
+ else overTurnUdp = true;
3120
+ } else if (localCandidate.protocol === "tcp") {
3121
+ overTcp = true;
3122
+ } else {
3123
+ overUdp = true;
3124
+ }
3125
+ }
2897
3126
  if (observedPeerConnection.currentRttInMs) {
2898
3127
  if (this.currentMinRttInMs === void 0 || observedPeerConnection.currentRttInMs < this.currentMinRttInMs) {
2899
3128
  this.currentMinRttInMs = observedPeerConnection.currentRttInMs;
@@ -2901,17 +3130,9 @@ var ObservedClient = class extends EventEmitter2 {
2901
3130
  if (this.currentMaxRttInMs === void 0 || observedPeerConnection.currentRttInMs > this.currentMaxRttInMs) {
2902
3131
  this.currentMaxRttInMs = observedPeerConnection.currentRttInMs;
2903
3132
  }
2904
- if (observedPeerConnection.currentRttInMs < 50) {
2905
- this.deltaRttLt50Measurements += 1;
2906
- } else if (observedPeerConnection.currentRttInMs < 150) {
2907
- this.deltaRttLt150Measurements += 1;
2908
- } else if (observedPeerConnection.currentRttInMs < 300) {
2909
- this.deltaRttLt300Measurements += 1;
2910
- } else if (300 <= observedPeerConnection.currentRttInMs) {
2911
- this.deltaRttGtOrEq300Measurements += 1;
2912
- }
2913
3133
  sumOfRtts += observedPeerConnection.currentRttInMs;
2914
3134
  ++numberOfRttMeasurements;
3135
+ this._addRttMeasurement(observedPeerConnection.currentRttInMs);
2915
3136
  }
2916
3137
  }
2917
3138
  for (const clientEvent of clientEventsPostBuffer) {
@@ -2921,6 +3142,7 @@ var ObservedClient = class extends EventEmitter2 {
2921
3142
  if (latestPublicAddress !== void 0 && latestPublicAddress !== this.publicAddress) {
2922
3143
  const previousPublicAddress = this.publicAddress;
2923
3144
  this.publicAddress = latestPublicAddress;
3145
+ if (previousPublicAddress !== void 0) ++this.numberOfPublicAddressChanges;
2924
3146
  this._notify("client-public-address-changed", { ...this.eventScope, publicAddress: latestPublicAddress, previousPublicAddress });
2925
3147
  }
2926
3148
  this.attachments = sample.attachments;
@@ -2936,15 +3158,34 @@ var ObservedClient = class extends EventEmitter2 {
2936
3158
  this.sendingVideoBitrate = 0;
2937
3159
  }
2938
3160
  this.currentAvgRttInMs = 0 < numberOfRttMeasurements ? sumOfRtts / numberOfRttMeasurements : void 0;
3161
+ this.peakSendingBitrate = Math.max(this.peakSendingBitrate, this.sendingAudioBitrate + this.sendingVideoBitrate);
3162
+ this.peakReceivingBitrate = Math.max(this.peakReceivingBitrate, this.receivingAudioBitrate + this.receivingVideoBitrate);
3163
+ this.totalInboundPacketsLost += this.deltaInboundPacketsLost;
3164
+ this.totalInboundPacketsReceived += this.deltaInboundPacketsReceived;
3165
+ this.totalOutboundPacketsSent += this.deltaOutboundPacketsSent;
3166
+ this.totalReceivedAudioBytes += this.deltaReceivedAudioBytes;
3167
+ this.totalReceivedVideoBytes += this.deltaReceivedVideoBytes;
3168
+ this.totalSentAudioBytes += this.deltaSentAudioBytes;
3169
+ this.totalSentVideoBytes += this.deltaSentVideoBytes;
3170
+ this.totalTransportReceivedBytes += this.deltaTransportReceivedBytes;
3171
+ this.totalTransportSentBytes += this.deltaTransportSentBytes;
3172
+ this.totalDataChannelBytesReceived += this.deltaDataChannelBytesReceived;
3173
+ this.totalDataChannelBytesSent += this.deltaDataChannelBytesSent;
3174
+ this.totalDataChannelMessagesReceived += this.deltaDataChannelMessagesReceived;
3175
+ this.totalDataChannelMessagesSent += this.deltaDataChannelMessagesSent;
3176
+ if (overUdp) ++this.numberOfSamplesOverUdp;
3177
+ if (overTcp) ++this.numberOfSamplesOverTcp;
3178
+ if (overTurnUdp) ++this.numberOfSamplesOverTurnUdp;
3179
+ if (overTurnTcp) ++this.numberOfSamplesOverTurnTcp;
3180
+ if (!overUdp && !overTcp && !overTurnUdp && !overTurnTcp) ++this.numberOfSamplesWithUnknownPath;
2939
3181
  this.calculatedScore.value = sample.score;
2940
3182
  this.lastSampleTimestamp = sample.timestamp;
2941
3183
  const elapsedTimeInMs = now - this.updated;
2942
3184
  this.emit("update", sample, elapsedTimeInMs, context);
2943
3185
  this._notify("client-updated", { ...this.eventScope, sample, elapsedTimeInMs, context });
2944
3186
  this.updated = now;
2945
- if (this.calculatedScore.value) {
2946
- this.totalScoreSum += this.calculatedScore.value;
2947
- ++this.numberOfScoreMeasurements;
3187
+ if (typeof this.calculatedScore.value === "number" && Number.isFinite(this.calculatedScore.value)) {
3188
+ this._addScoreMeasurement(this.calculatedScore.value);
2948
3189
  }
2949
3190
  if (this.settings.closeClientIfIdleForMs !== void 0) {
2950
3191
  this.closeTimer = setTimeout(() => {
@@ -3035,6 +3276,13 @@ var ObservedClient = class extends EventEmitter2 {
3035
3276
  this.operationSystem = parseJsonAs(metadata.payload);
3036
3277
  break;
3037
3278
  }
3279
+ case "AUDIO_INPUT_DEVICE" /* AUDIO_INPUT_DEVICE */: {
3280
+ const device = parseJsonAs(metadata.payload);
3281
+ if (typeof device?.trackId === "string" && typeof device.label === "string") {
3282
+ this.audioInputDevices.set(device.trackId, device);
3283
+ }
3284
+ break;
3285
+ }
3038
3286
  }
3039
3287
  this._notify("client-metadata", { ...this.eventScope, metaData: metadata });
3040
3288
  }
@@ -3055,6 +3303,11 @@ var ObservedClient = class extends EventEmitter2 {
3055
3303
  if (isClientIssueResolutionEntry(issue)) {
3056
3304
  return this._resolveIssue(issue);
3057
3305
  }
3306
+ if (!issue.key || !this.activeIssues.get(issue.key)) {
3307
+ const type = baseIssueType(issue.type);
3308
+ this.issueHistogram[type] = (this.issueHistogram[type] ?? 0) + 1;
3309
+ ++this.totalNumberOfIssues;
3310
+ }
3058
3311
  const now = Date.now();
3059
3312
  if (issue.key) {
3060
3313
  const payload = parseJsonObject(issue.payload);
@@ -4326,6 +4579,169 @@ var PublisherFaultCorroborationDetector = class _PublisherFaultCorroborationDete
4326
4579
  }
4327
4580
  };
4328
4581
 
4582
+ // src/detectors/AudioEchoDetector.ts
4583
+ var AudioEchoTypes = {
4584
+ /**
4585
+ * One participant's outgoing audio follows another participant's voice: it rises when they speak,
4586
+ * in proportion to how loud they are. The echoing participant is sending the far end's playout
4587
+ * back into the call — speakers into an open mic, or a loopback capture device.
4588
+ */
4589
+ audioEchoSuspected: "AUDIO_ECHO_SUSPECTED"
4590
+ };
4591
+ var DIRECTION_MARGIN = 0.1;
4592
+ var mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length;
4593
+ var LEVEL_FLOOR = 1e-5;
4594
+ var toDb = (level) => 20 * Math.log10(Math.max(level, LEVEL_FLOOR));
4595
+ var AudioEchoDetector = class _AudioEchoDetector {
4596
+ constructor(call, config = {}) {
4597
+ this.call = call;
4598
+ this._config = {
4599
+ bucketMs: 5e3,
4600
+ windowBuckets: 24,
4601
+ activeRmsLevel: 0.01,
4602
+ minSourceActiveBuckets: 8,
4603
+ minFollowRatio: 0.8,
4604
+ minCorrelation: 0.6,
4605
+ cooldownMs: 12e4,
4606
+ loopbackAudioInputIssueType: "loopback-audio-input",
4607
+ ...config
4608
+ };
4609
+ }
4610
+ call;
4611
+ static NAME = "audio-echo-detector";
4612
+ name = _AudioEchoDetector.NAME;
4613
+ _config;
4614
+ _series = /* @__PURE__ */ new Map();
4615
+ _lastRaisedAt = /* @__PURE__ */ new Map();
4616
+ _lastEvaluatedBucket;
4617
+ close() {
4618
+ this._series.clear();
4619
+ this._lastRaisedAt.clear();
4620
+ }
4621
+ update() {
4622
+ const now = Date.now();
4623
+ const bucket = Math.floor(now / this._config.bucketMs);
4624
+ if (this._lastEvaluatedBucket === bucket) return;
4625
+ const closedBucket = bucket - 1;
4626
+ this._lastEvaluatedBucket = bucket;
4627
+ this._collect(now, closedBucket);
4628
+ this._evaluate(now, bucket);
4629
+ }
4630
+ _collect(now, bucket) {
4631
+ for (const client of this.call.observedClients.values()) {
4632
+ for (const peerConnection of client.observedPeerConnections.values()) {
4633
+ for (const track of peerConnection.observedOutboundTracks.values()) {
4634
+ if (track.kind !== "audio") continue;
4635
+ let series = this._series.get(track.id);
4636
+ if (!series) {
4637
+ series = { track, clientId: client.clientId, lastTimestamp: -1, samples: [], lastSeenAt: now };
4638
+ this._series.set(track.id, series);
4639
+ }
4640
+ series.lastSeenAt = now;
4641
+ if (series.lastTimestamp === track.timestamp) continue;
4642
+ series.lastTimestamp = track.timestamp;
4643
+ const level = track.rmsAudioLevel;
4644
+ if (level === void 0) continue;
4645
+ series.samples.push({ bucket, level });
4646
+ const oldest = bucket - this._config.windowBuckets;
4647
+ while (series.samples.length && series.samples[0].bucket <= oldest) series.samples.shift();
4648
+ }
4649
+ }
4650
+ }
4651
+ for (const [trackId, series] of this._series) {
4652
+ if (series.lastSeenAt !== now) this._series.delete(trackId);
4653
+ }
4654
+ }
4655
+ _evaluate(now, currentBucket) {
4656
+ if (this._series.size < 2) return;
4657
+ const tops = /* @__PURE__ */ new Map();
4658
+ for (const [trackId, series] of this._series) {
4659
+ for (const { bucket, level } of series.samples) {
4660
+ if (currentBucket <= bucket) continue;
4661
+ const top = tops.get(bucket);
4662
+ const entry = { id: trackId, level };
4663
+ if (!top) tops.set(bucket, [entry, void 0]);
4664
+ else if (top[0].level < level) tops.set(bucket, [entry, top[0]]);
4665
+ else if (!top[1] || top[1].level < level) top[1] = entry;
4666
+ }
4667
+ }
4668
+ const candidates = /* @__PURE__ */ new Map();
4669
+ for (const [trackId, series] of this._series) {
4670
+ const suspectDb = [];
4671
+ const sourceDb = [];
4672
+ const sourceCounts = /* @__PURE__ */ new Map();
4673
+ let followed = 0;
4674
+ for (const { bucket, level } of series.samples) {
4675
+ const top = tops.get(bucket);
4676
+ if (!top) continue;
4677
+ const source = top[0].id === trackId ? top[1] : top[0];
4678
+ if (!source || source.level < this._config.activeRmsLevel) continue;
4679
+ if (this._config.activeRmsLevel <= level) followed += 1;
4680
+ suspectDb.push(toDb(level));
4681
+ sourceDb.push(toDb(source.level));
4682
+ sourceCounts.set(source.id, (sourceCounts.get(source.id) ?? 0) + 1);
4683
+ }
4684
+ const sourceActiveBuckets = sourceDb.length;
4685
+ if (sourceActiveBuckets < this._config.minSourceActiveBuckets) continue;
4686
+ const followRatio = followed / sourceActiveBuckets;
4687
+ if (followRatio < this._config.minFollowRatio) continue;
4688
+ const levelCorrelation = correlation(suspectDb, sourceDb);
4689
+ if (levelCorrelation < this._config.minCorrelation) continue;
4690
+ const [sourceTrackId] = [...sourceCounts].reduce((a, b) => a[1] < b[1] ? b : a);
4691
+ const client = this.call.observedClients.get(series.clientId);
4692
+ candidates.set(trackId, {
4693
+ trackId,
4694
+ series,
4695
+ sourceTrackId,
4696
+ followRatio,
4697
+ levelCorrelation,
4698
+ sourceActiveBuckets,
4699
+ // How much quieter than its source the suspect is, in dB. An echo is at most as loud as
4700
+ // what it echoes, give or take AGC.
4701
+ relativeLevelDb: mean(suspectDb) - mean(sourceDb),
4702
+ loopbackAudioInput: client?.activeIssues.get(`${this._config.loopbackAudioInputIssueType}-track-${trackId}`) !== void 0
4703
+ });
4704
+ }
4705
+ for (const candidate of candidates.values()) {
4706
+ const reverse = candidates.get(candidate.sourceTrackId);
4707
+ let ambiguousDirection = false;
4708
+ if (reverse && reverse.sourceTrackId === candidate.trackId) {
4709
+ if (candidate.loopbackAudioInput !== reverse.loopbackAudioInput) {
4710
+ if (!candidate.loopbackAudioInput) continue;
4711
+ } else if (DIRECTION_MARGIN < Math.abs(candidate.followRatio - reverse.followRatio)) {
4712
+ if (candidate.followRatio < reverse.followRatio) continue;
4713
+ } else {
4714
+ ambiguousDirection = true;
4715
+ if (reverse.relativeLevelDb < candidate.relativeLevelDb) continue;
4716
+ if (reverse.relativeLevelDb === candidate.relativeLevelDb && reverse.trackId < candidate.trackId) continue;
4717
+ }
4718
+ }
4719
+ if (now - (this._lastRaisedAt.get(candidate.trackId) ?? -Infinity) < this._config.cooldownMs) continue;
4720
+ this._lastRaisedAt.set(candidate.trackId, now);
4721
+ const { series } = candidate;
4722
+ this.call.addIssue({
4723
+ type: AudioEchoTypes.audioEchoSuspected,
4724
+ timestamp: now,
4725
+ payload: {
4726
+ trackId: candidate.trackId,
4727
+ clientId: series.clientId,
4728
+ sourceTrackId: candidate.sourceTrackId,
4729
+ sourceClientId: this._series.get(candidate.sourceTrackId)?.clientId,
4730
+ followRatio: candidate.followRatio,
4731
+ levelCorrelation: candidate.levelCorrelation,
4732
+ relativeLevelDb: candidate.relativeLevelDb,
4733
+ sourceActiveBuckets: candidate.sourceActiveBuckets,
4734
+ bucketMs: this._config.bucketMs,
4735
+ ambiguousDirection,
4736
+ levelSource: series.track.rmsAudioLevelSource,
4737
+ audioInputLabel: series.track.audioInputDevice?.label,
4738
+ loopbackAudioInput: candidate.loopbackAudioInput
4739
+ }
4740
+ });
4741
+ }
4742
+ }
4743
+ };
4744
+
4329
4745
  // src/ObservedCall.ts
4330
4746
  var logger4 = createLogger("ObservedCall");
4331
4747
  var ObservedCall = class extends EventEmitter3 {
@@ -4384,10 +4800,6 @@ var ObservedCall = class extends EventEmitter3 {
4384
4800
  numberOfDataChannels = 0;
4385
4801
  maxNumberOfClients = 0;
4386
4802
  deltaNumberOfIssues = 0;
4387
- // public deltaRttLt50Measurements = 0;
4388
- // public deltaRttLt150Measurements = 0;
4389
- // public deltaRttLt300Measurements = 0;
4390
- // public deltaRttGtOrEq300Measurements = 0;
4391
4803
  appData;
4392
4804
  closed = false;
4393
4805
  startedAt;
@@ -4433,6 +4845,10 @@ var ObservedCall = class extends EventEmitter3 {
4433
4845
  detector = new PublisherFaultCorroborationDetector(this, config);
4434
4846
  break;
4435
4847
  }
4848
+ case AudioEchoDetector.NAME: {
4849
+ detector = new AudioEchoDetector(this, config);
4850
+ break;
4851
+ }
4436
4852
  default: {
4437
4853
  logger4.warn("Unknown detector name %s; skipping", name);
4438
4854
  return this;
@@ -7513,6 +7929,8 @@ function createP2pRemoteTrackResolverFactory() {
7513
7929
  }
7514
7930
  export {
7515
7931
  ActiveIssuesRegistry,
7932
+ AudioEchoDetector,
7933
+ AudioEchoTypes,
7516
7934
  CODEC_MISMATCH_ISSUE,
7517
7935
  CallConcurrentIssueDetector,
7518
7936
  CallConcurrentIssueTypes,
@@ -7560,8 +7978,12 @@ export {
7560
7978
  PublisherFaultCorroborationDetector,
7561
7979
  PublisherFaultTypes,
7562
7980
  RESOLVED_ISSUE_SUFFIX,
7981
+ RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS,
7982
+ RTT_HISTOGRAM_NUMBER_OF_BUCKETS,
7563
7983
  RemoteTrackResolver,
7564
7984
  RemoteTrackResolverValidator,
7985
+ SCORE_HISTOGRAM_BUCKET_WIDTH,
7986
+ SCORE_HISTOGRAM_NUMBER_OF_BUCKETS,
7565
7987
  SfuCongestionDetector,
7566
7988
  SimulcastReceiverValidator,
7567
7989
  SlidingWindow,
@@ -7599,6 +8021,7 @@ export {
7599
8021
  medianAbsoluteDeviation,
7600
8022
  pageHinkley,
7601
8023
  percentile,
8024
+ percentileOfHistogram,
7602
8025
  percentileOfSorted,
7603
8026
  robustZScore,
7604
8027
  schemaVersion,