@observertc/observer-js 1.1.0 → 1.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -31,6 +31,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var src_exports = {};
32
32
  __export(src_exports, {
33
33
  ActiveIssuesRegistry: () => ActiveIssuesRegistry,
34
+ AudioEchoDetector: () => AudioEchoDetector,
35
+ AudioEchoTypes: () => AudioEchoTypes,
34
36
  CODEC_MISMATCH_ISSUE: () => CODEC_MISMATCH_ISSUE,
35
37
  CallConcurrentIssueDetector: () => CallConcurrentIssueDetector,
36
38
  CallConcurrentIssueTypes: () => CallConcurrentIssueTypes,
@@ -40,9 +42,9 @@ __export(src_exports, {
40
42
  ClientMetaTypes: () => ClientMetaTypes,
41
43
  ClientPopulationIssueDetector: () => ClientPopulationIssueDetector,
42
44
  ClientPopulationIssueTypes: () => ClientPopulationIssueTypes,
43
- ClientSampleSink: () => ClientSampleSink,
44
45
  CodecConsistencyValidator: () => CodecConsistencyValidator,
45
46
  Detectors: () => Detectors,
47
+ ExtensionStatsRegistry: () => ExtensionStatsRegistry,
46
48
  GEOHASH_CELL_SIZES: () => GEOHASH_CELL_SIZES,
47
49
  InMemorySink: () => InMemorySink,
48
50
  IssueFanOutDetector: () => IssueFanOutDetector,
@@ -78,10 +80,15 @@ __export(src_exports, {
78
80
  PublisherFaultCorroborationDetector: () => PublisherFaultCorroborationDetector,
79
81
  PublisherFaultTypes: () => PublisherFaultTypes,
80
82
  RESOLVED_ISSUE_SUFFIX: () => RESOLVED_ISSUE_SUFFIX,
83
+ RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS: () => RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS,
84
+ RTT_HISTOGRAM_NUMBER_OF_BUCKETS: () => RTT_HISTOGRAM_NUMBER_OF_BUCKETS,
81
85
  RemoteTrackResolver: () => RemoteTrackResolver,
82
86
  RemoteTrackResolverValidator: () => RemoteTrackResolverValidator,
87
+ SCORE_HISTOGRAM_BUCKET_WIDTH: () => SCORE_HISTOGRAM_BUCKET_WIDTH,
88
+ SCORE_HISTOGRAM_NUMBER_OF_BUCKETS: () => SCORE_HISTOGRAM_NUMBER_OF_BUCKETS,
83
89
  SfuCongestionDetector: () => SfuCongestionDetector,
84
90
  SimulcastReceiverValidator: () => SimulcastReceiverValidator,
91
+ SinkRegistry: () => SinkRegistry,
85
92
  SlidingWindow: () => SlidingWindow,
86
93
  TrackDeliveryMismatchDetector: () => TrackDeliveryMismatchDetector,
87
94
  TrackDeliveryMismatchTypes: () => TrackDeliveryMismatchTypes,
@@ -102,7 +109,6 @@ __export(src_exports, {
102
109
  createDefaultMediasoupRemoteTrackResolverFactory: () => createDefaultMediasoupRemoteTrackResolverFactory,
103
110
  createInMemorySink: () => createInMemorySink,
104
111
  createJsonlFileSink: () => createJsonlFileSink,
105
- createJsonlFileSinkFactory: () => createJsonlFileSinkFactory,
106
112
  createLogger: () => createLogger,
107
113
  createP2pRemoteTrackResolverFactory: () => createP2pRemoteTrackResolverFactory,
108
114
  defaultCallSummaryConfig: () => defaultCallSummaryConfig,
@@ -117,6 +123,7 @@ __export(src_exports, {
117
123
  medianAbsoluteDeviation: () => medianAbsoluteDeviation,
118
124
  pageHinkley: () => pageHinkley,
119
125
  percentile: () => percentile,
126
+ percentileOfHistogram: () => percentileOfHistogram,
120
127
  percentileOfSorted: () => percentileOfSorted,
121
128
  robustZScore: () => robustZScore,
122
129
  schemaVersion: () => schemaVersion,
@@ -164,18 +171,189 @@ function createLogger(moduleName) {
164
171
  }
165
172
  }();
166
173
  }
167
- function setObserverLogger(logger11) {
168
- mainLogger = logger11;
174
+ function setObserverLogger(logger13) {
175
+ mainLogger = logger13;
169
176
  }
170
177
 
171
178
  // src/ObservedCall.ts
172
- var import_events3 = require("events");
179
+ var import_node_events4 = require("events");
180
+
181
+ // src/sinks/SinkRegistry.ts
182
+ var import_uuid = require("uuid");
183
+ var logger = createLogger("SinkRegistry");
184
+ var SinkRegistry = class {
185
+ constructor(parent) {
186
+ this.parent = parent;
187
+ }
188
+ parent;
189
+ // This registry's sinks and its descendants'.
190
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
191
+ _sinks = /* @__PURE__ */ new Map();
192
+ /** Number of open sinks in this registry and its descendants. */
193
+ get size() {
194
+ return this._sinks.size;
195
+ }
196
+ keys() {
197
+ return this._sinks.keys();
198
+ }
199
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
200
+ values() {
201
+ return this._sinks.values();
202
+ }
203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
+ get(id) {
205
+ return this._sinks.get(id);
206
+ }
207
+ /**
208
+ * Construct `new SinkClass(settings)` and register it. The id (`settings.id`, generated when
209
+ * omitted) is checked first, so nothing is constructed — no file is opened — when it is taken:
210
+ * `undefined` is returned and a warning logged.
211
+ */
212
+ create(SinkClass, settings) {
213
+ const lineage = this._lineage();
214
+ const id = settings.id ?? (0, import_uuid.v4)();
215
+ if (lineage[lineage.length - 1]._sinks.has(id)) {
216
+ logger.warn("Sink id %s is already in use; sink ids must be unique per observer. No sink was created", id);
217
+ return void 0;
218
+ }
219
+ const sink = new SinkClass({ ...settings, id });
220
+ const onError = (err) => logger.warn("Sink %s failed: %o", id, err);
221
+ sink.on("error", onError);
222
+ sink.once("close", () => sink.off("error", onError));
223
+ sink.once("ended", () => {
224
+ for (const registry of lineage) {
225
+ if (registry._sinks.get(id) === sink) registry._sinks.delete(id);
226
+ }
227
+ });
228
+ for (const registry of lineage) registry._sinks.set(id, sink);
229
+ return sink;
230
+ }
231
+ /** Write `record` to the sink `id`. `false` when there is no such sink, or on backpressure. */
232
+ write(id, record) {
233
+ const sink = this._sinks.get(id);
234
+ if (!sink) {
235
+ logger.debug("No sink %s; record dropped", id);
236
+ return false;
237
+ }
238
+ return sink.write(record);
239
+ }
240
+ /** End every sink of this registry and of its descendants. */
241
+ clear() {
242
+ for (const sink of [...this._sinks.values()]) {
243
+ try {
244
+ sink.end();
245
+ } catch (err) {
246
+ logger.warn("Sink %s failed to end: %o", sink.id, err);
247
+ }
248
+ }
249
+ }
250
+ /** This registry and its ancestors, up to the root. */
251
+ _lineage() {
252
+ return [this, ...this.parent?._lineage() ?? []];
253
+ }
254
+ };
255
+
256
+ // src/sinks/ExtensionStatsRegistry.ts
257
+ var logger2 = createLogger("ExtensionStats");
258
+ var ExtensionStatsRegistry = class {
259
+ constructor(id, _sinks) {
260
+ this.id = id;
261
+ this._sinks = _sinks;
262
+ }
263
+ id;
264
+ _sinks;
265
+ /** The last data accepted for each extension stats id. */
266
+ values = /* @__PURE__ */ new Map();
267
+ closed = false;
268
+ /**
269
+ * Refuse further `create` / `accept`, and end the extension stats' sinks (the sinks whose id is
270
+ * an extension stats id). Other sinks of the owner, e.g. client sample sinks, are left to the
271
+ * owner's `sinks.clear()`. Called by the owner when it closes.
272
+ */
273
+ close() {
274
+ if (this.closed) return;
275
+ this.closed = true;
276
+ for (const id of this.values.keys()) {
277
+ this._sinks.get(id)?.end();
278
+ }
279
+ }
280
+ create(settings) {
281
+ if (this.closed) {
282
+ logger2.warn("Attempted to create extension stats %s on closed %s", settings.id, this.id);
283
+ return void 0;
284
+ }
285
+ if (this.values.has(settings.id)) {
286
+ logger2.warn("Extension stats with id %s already exists on %s", settings.id, this.id);
287
+ return void 0;
288
+ }
289
+ this.values.set(settings.id, void 0);
290
+ if (!settings.createSink) return void 0;
291
+ const [SinkClass, sinkSettings] = settings.createSink;
292
+ this._sinks.get(settings.id)?.end();
293
+ return this._sinks.create(SinkClass, { ...sinkSettings, id: settings.id });
294
+ }
295
+ accept(id, data) {
296
+ if (this.closed) {
297
+ logger2.debug("Extension stats %s dropped: %s is closed", id, this.id);
298
+ return;
299
+ }
300
+ this.values.set(id, data);
301
+ try {
302
+ this._sinks.get(id)?.write(data);
303
+ } catch (err) {
304
+ logger2.warn("Failed to write extension stats %s to sink: %o", id, err);
305
+ }
306
+ }
307
+ get(id) {
308
+ return this.values.get(id);
309
+ }
310
+ };
311
+
312
+ // src/sinks/JsonlFileSink.ts
313
+ var fs = __toESM(require("fs"));
314
+ var import_node_events = require("events");
315
+ var JsonlFileSink = class extends import_node_events.EventEmitter {
316
+ id;
317
+ /** The file this sink writes to. Read it (e.g. in a `close` handler) to upload/move the file. */
318
+ path;
319
+ _stream;
320
+ _serializeSample;
321
+ _ended = false;
322
+ constructor(options) {
323
+ super();
324
+ this.id = options.id ?? options.path;
325
+ this.path = options.path;
326
+ this._stream = fs.createWriteStream(this.path, { flags: "a" });
327
+ this._stream.on("close", () => this.emit("close"));
328
+ this._stream.on("finish", () => this.emit("finish"));
329
+ this._stream.on("drain", () => this.emit("drain"));
330
+ this._stream.on("error", (err) => this.emit("error", err));
331
+ this._serializeSample = options.serializeSample ?? ((record) => JSON.stringify(record));
332
+ }
333
+ get ended() {
334
+ return this._ended;
335
+ }
336
+ write(record) {
337
+ if (this._ended) return false;
338
+ return this._stream.write(`${this._serializeSample(record)}
339
+ `);
340
+ }
341
+ end() {
342
+ if (this._ended) return;
343
+ this._ended = true;
344
+ this.emit("ended");
345
+ this._stream.end();
346
+ }
347
+ };
348
+ function createJsonlFileSink(options) {
349
+ return new JsonlFileSink(options);
350
+ }
173
351
 
174
352
  // src/ObservedClient.ts
175
- var import_events2 = require("events");
353
+ var import_node_events3 = require("events");
176
354
 
177
355
  // src/ObservedPeerConnection.ts
178
- var import_events = require("events");
356
+ var import_node_events2 = require("events");
179
357
 
180
358
  // src/utils/stats.ts
181
359
  function percentile(values, p) {
@@ -230,6 +408,10 @@ function counterDelta(previous, current) {
230
408
  if (current < previous) return 0;
231
409
  return current - previous;
232
410
  }
411
+ function rmsAudioLevel(deltaTotalAudioEnergy, deltaTotalSamplesDuration) {
412
+ if (!(0 < deltaTotalSamplesDuration)) return void 0;
413
+ return Math.sqrt(Math.max(0, deltaTotalAudioEnergy) / deltaTotalSamplesDuration);
414
+ }
233
415
  function correlation(xs, ys) {
234
416
  const n = Math.min(xs.length, ys.length);
235
417
  if (n < 2) return 0;
@@ -262,8 +444,8 @@ function pageHinkley(values, delta = 0, lambda = 50) {
262
444
  let runningMinimum = 0;
263
445
  for (let i = 0; i < values.length; i++) {
264
446
  sum += values[i];
265
- const mean = sum / (i + 1);
266
- cumulative += values[i] - mean - delta;
447
+ const mean2 = sum / (i + 1);
448
+ cumulative += values[i] - mean2 - delta;
267
449
  if (cumulative < runningMinimum) runningMinimum = cumulative;
268
450
  const ph = cumulative - runningMinimum;
269
451
  statistic.push(ph);
@@ -315,6 +497,25 @@ function erf(x) {
315
497
  const y = 1 - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-(ax * ax));
316
498
  return sign * y;
317
499
  }
500
+ function percentileOfHistogram(histogram, bucketWidth, p, options = {}) {
501
+ let total = 0;
502
+ for (const count of histogram) total += count;
503
+ if (total < 1) return void 0;
504
+ const rank = Math.min(Math.max(p, 0), 1) * total;
505
+ let cumulative = 0;
506
+ for (let index = 0; index < histogram.length; ++index) {
507
+ const count = histogram[index];
508
+ if (count < 1 || cumulative + count < rank) {
509
+ cumulative += count;
510
+ continue;
511
+ }
512
+ if (options.openEnded && index === histogram.length - 1 && options.max !== void 0) return options.max;
513
+ const lower = Math.max(index * bucketWidth, options.min ?? -Infinity);
514
+ const upper = Math.min((index + 1) * bucketWidth, options.max ?? Infinity);
515
+ return lower + (rank - cumulative) / count * Math.max(0, upper - lower);
516
+ }
517
+ return options.max;
518
+ }
318
519
 
319
520
  // src/ObservedInboundRtp.ts
320
521
  var ObservedInboundRtp = class {
@@ -425,6 +626,15 @@ var ObservedInboundRtp = class {
425
626
  deltaFecPacketsReceived = 0;
426
627
  deltaFecPacketsDiscarded = 0;
427
628
  deltaPausesDuration = 0;
629
+ deltaTotalAudioEnergy = 0;
630
+ deltaTotalSamplesDuration = 0;
631
+ /**
632
+ * RMS level (0..1) of the audio played out from this stream over the tick, from the two deltas
633
+ * above. This is the far end's outgoing level **as this receiver heard it**, which is the only
634
+ * send-side level available for publishers on browsers that do not report `media-source` audio
635
+ * levels (Firefox). `undefined` for video and for a tick with no audio accounted for.
636
+ */
637
+ rmsAudioLevel;
428
638
  /**
429
639
  * Mean jitter-buffer delay for the frames/samples emitted in this tick (seconds), derived from
430
640
  * the cumulative `jitterBufferDelay` / `jitterBufferEmittedCount` pair — the only correct way to
@@ -510,6 +720,9 @@ var ObservedInboundRtp = class {
510
720
  this.jitterBufferDelayInMs = void 0;
511
721
  this.concealmentRatio = void 0;
512
722
  this.framesDroppedRatio = void 0;
723
+ this.deltaTotalAudioEnergy = 0;
724
+ this.deltaTotalSamplesDuration = 0;
725
+ this.rmsAudioLevel = void 0;
513
726
  const elapsedTimeInMs = stats.timestamp - this.timestamp;
514
727
  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;
515
728
  if (0 < elapsedTimeInMs && !this.counterResetBoundary) {
@@ -539,6 +752,9 @@ var ObservedInboundRtp = class {
539
752
  this.deltaRetransmittedPacketsReceived = counterDelta(this.retransmittedPacketsReceived, stats.retransmittedPacketsReceived);
540
753
  this.deltaFecPacketsReceived = counterDelta(this.fecPacketsReceived, stats.fecPacketsReceived);
541
754
  this.deltaFecPacketsDiscarded = counterDelta(this.fecPacketsDiscarded, stats.fecPacketsDiscarded);
755
+ this.deltaTotalAudioEnergy = counterDelta(this.totalAudioEnergy, stats.totalAudioEnergy);
756
+ this.deltaTotalSamplesDuration = counterDelta(this.totalSamplesDuration, stats.totalSamplesDuration);
757
+ this.rmsAudioLevel = rmsAudioLevel(this.deltaTotalAudioEnergy, this.deltaTotalSamplesDuration);
542
758
  this.bitrate = this.deltaBytesReceived * 8 / elapsedInSeconds;
543
759
  const pixels = (stats.frameWidth ?? 0) * (stats.frameHeight ?? 0);
544
760
  if (0 < pixels && 0 < this.deltaFramesReceived) {
@@ -596,6 +812,9 @@ var ObservedInboundRtp = class {
596
812
  this.jitterBufferEmittedCount = stats.jitterBufferEmittedCount;
597
813
  this.jitterBufferMinimumDelay = stats.jitterBufferMinimumDelay;
598
814
  this.totalSamplesReceived = stats.totalSamplesReceived;
815
+ this.audioLevel = stats.audioLevel;
816
+ this.totalAudioEnergy = stats.totalAudioEnergy;
817
+ this.totalSamplesDuration = stats.totalSamplesDuration;
599
818
  this.concealedSamples = stats.concealedSamples;
600
819
  this.silentConcealedSamples = stats.silentConcealedSamples;
601
820
  this.concealmentEvents = stats.concealmentEvents;
@@ -1250,6 +1469,16 @@ var ObservedMediaSource = class {
1250
1469
  frames;
1251
1470
  framesPerSecond;
1252
1471
  attachments;
1472
+ /** Increase of `totalAudioEnergy` in this tick (audio only). */
1473
+ deltaTotalAudioEnergy = 0;
1474
+ /** Increase of `totalSamplesDuration` in this tick, in seconds (audio only). */
1475
+ deltaTotalSamplesDuration = 0;
1476
+ /**
1477
+ * RMS level (0..1) of what the capture device delivered over the tick, after the browser's own
1478
+ * processing (AEC, NS, AGC). Chrome and Safari report the inputs; Firefox does not, and there the
1479
+ * publisher's level has to come from its receivers — see `ObservedOutboundTrack.rmsAudioLevel`.
1480
+ */
1481
+ rmsAudioLevel;
1253
1482
  get visited() {
1254
1483
  const visited = this._visited;
1255
1484
  this._visited = false;
@@ -1263,6 +1492,14 @@ var ObservedMediaSource = class {
1263
1492
  }
1264
1493
  update(stats) {
1265
1494
  this._visited = true;
1495
+ this.deltaTotalAudioEnergy = 0;
1496
+ this.deltaTotalSamplesDuration = 0;
1497
+ this.rmsAudioLevel = void 0;
1498
+ if (this.timestamp < stats.timestamp) {
1499
+ this.deltaTotalAudioEnergy = counterDelta(this.totalAudioEnergy, stats.totalAudioEnergy);
1500
+ this.deltaTotalSamplesDuration = counterDelta(this.totalSamplesDuration, stats.totalSamplesDuration);
1501
+ this.rmsAudioLevel = rmsAudioLevel(this.deltaTotalAudioEnergy, this.deltaTotalSamplesDuration);
1502
+ }
1266
1503
  this.timestamp = stats.timestamp;
1267
1504
  this.trackIdentifier = stats.trackIdentifier;
1268
1505
  this.audioLevel = stats.audioLevel;
@@ -1661,6 +1898,20 @@ var ObservedOutboundTrack = class {
1661
1898
  remoteFractionLost;
1662
1899
  remoteRttInMs;
1663
1900
  qualityLimitationReason;
1901
+ /**
1902
+ * This audio track's RMS level (0..1) over the latest tick — how loud the publisher was sending.
1903
+ * Taken from the publisher's own `media-source` when it reports one (Chrome, Safari), otherwise
1904
+ * the median of what its receivers heard ({@link receivedRmsAudioLevel}), which is what makes it
1905
+ * available for Firefox publishers too. `undefined` for video, or when neither is known.
1906
+ * {@link rmsAudioLevelSource} says which one it is.
1907
+ */
1908
+ rmsAudioLevel;
1909
+ rmsAudioLevelSource;
1910
+ /**
1911
+ * Median RMS level (0..1) of this track across the receivers that played it out, each over its own
1912
+ * latest tick. Needs a `RemoteTrackResolver`: without links there are no receivers to ask.
1913
+ */
1914
+ receivedRmsAudioLevel;
1664
1915
  get score() {
1665
1916
  return this.calculatedScore.value;
1666
1917
  }
@@ -1681,6 +1932,10 @@ var ObservedOutboundTrack = class {
1681
1932
  getMediaSource() {
1682
1933
  return this._mediaSource;
1683
1934
  }
1935
+ /** The capture device behind this track, when the client sent `AUDIO_INPUT_DEVICE` metadata for it. */
1936
+ get audioInputDevice() {
1937
+ return this._peerConnection.client.audioInputDevices.get(this.id);
1938
+ }
1684
1939
  update(stats) {
1685
1940
  this._visited = true;
1686
1941
  this.timestamp = stats.timestamp;
@@ -1712,8 +1967,30 @@ var ObservedOutboundTrack = class {
1712
1967
  if (this.muted !== true && this.deltaPacketsSent === 0) reasons.push("no-packets-sent");
1713
1968
  this.degradedReasons = reasons.length === 0 ? void 0 : reasons;
1714
1969
  this.receivingDistribution = this.createReceivingDistribution();
1970
+ this._updateAudioLevel();
1715
1971
  this.detectors.update();
1716
1972
  }
1973
+ _updateAudioLevel() {
1974
+ this.rmsAudioLevel = void 0;
1975
+ this.rmsAudioLevelSource = void 0;
1976
+ this.receivedRmsAudioLevel = void 0;
1977
+ if (this.kind !== "audio") return;
1978
+ if (0 < this.remoteInboundTracks.size) {
1979
+ const levels = [];
1980
+ for (const inboundTrack of this.remoteInboundTracks) {
1981
+ pushFinite(levels, inboundTrack.getInboundRtp()?.rmsAudioLevel);
1982
+ }
1983
+ this.receivedRmsAudioLevel = median(levels);
1984
+ }
1985
+ const sourceLevel = this._mediaSource?.rmsAudioLevel;
1986
+ if (sourceLevel !== void 0) {
1987
+ this.rmsAudioLevel = sourceLevel;
1988
+ this.rmsAudioLevelSource = "media-source";
1989
+ } else if (this.receivedRmsAudioLevel !== void 0) {
1990
+ this.rmsAudioLevel = this.receivedRmsAudioLevel;
1991
+ this.rmsAudioLevelSource = "receivers";
1992
+ }
1993
+ }
1717
1994
  createReceivingDistribution() {
1718
1995
  if (this.remoteInboundTracks.size === 0) return void 0;
1719
1996
  let freezeAffected = 0;
@@ -1769,14 +2046,14 @@ var ObservedOutboundTrack = class {
1769
2046
 
1770
2047
  // src/ObservedPeerConnection.ts
1771
2048
  var ascending2 = (a, b) => a - b;
1772
- var logger = createLogger("ObservedPeerConnection");
2049
+ var logger3 = createLogger("ObservedPeerConnection");
1773
2050
  function publicAddressOfLocalCandidate(candidate) {
1774
2051
  if (!candidate) return void 0;
1775
2052
  if (candidate.candidateType !== "relay" && isPublicIpAddress(candidate.address)) return candidate.address;
1776
2053
  if (isPublicIpAddress(candidate.relatedAddress)) return candidate.relatedAddress;
1777
2054
  return void 0;
1778
2055
  }
1779
- var ObservedPeerConnection = class extends import_events.EventEmitter {
2056
+ var ObservedPeerConnection = class extends import_node_events2.EventEmitter {
1780
2057
  constructor(peerConnectionId, client) {
1781
2058
  super();
1782
2059
  this.peerConnectionId = peerConnectionId;
@@ -2358,7 +2635,7 @@ var ObservedPeerConnection = class extends import_events.EventEmitter {
2358
2635
  let observedCertificate = this.observedCertificates.get(stats.id);
2359
2636
  if (!observedCertificate) {
2360
2637
  if (!stats.timestamp || !stats.id || !stats.fingerprint) {
2361
- return logger.warn(
2638
+ return logger3.warn(
2362
2639
  `ObservedPeerConnection received an invalid CertificateStats (missing timestamp OR id OR fingerprint field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2363
2640
  stats
2364
2641
  );
@@ -2377,7 +2654,7 @@ var ObservedPeerConnection = class extends import_events.EventEmitter {
2377
2654
  let observedCodec = this.observedCodecs.get(stats.id);
2378
2655
  if (!observedCodec) {
2379
2656
  if (!stats.timestamp || !stats.id || !stats.mimeType) {
2380
- return logger.warn(
2657
+ return logger3.warn(
2381
2658
  `ObservedPeerConnection received an invalid CodecStats (missing timestamp OR id OR mimeType field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2382
2659
  stats
2383
2660
  );
@@ -2396,7 +2673,7 @@ var ObservedPeerConnection = class extends import_events.EventEmitter {
2396
2673
  let observedDataChannel = this.observedDataChannels.get(stats.id);
2397
2674
  if (!observedDataChannel) {
2398
2675
  if (!stats.timestamp || !stats.id) {
2399
- return logger.warn(
2676
+ return logger3.warn(
2400
2677
  `ObservedPeerConnection received an invalid DataChannelStats (missing timestamp OR id field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2401
2678
  stats
2402
2679
  );
@@ -2415,7 +2692,7 @@ var ObservedPeerConnection = class extends import_events.EventEmitter {
2415
2692
  let observedIceCandidate = this.observedIceCandidates.get(stats.id);
2416
2693
  if (!observedIceCandidate) {
2417
2694
  if (!stats.timestamp || !stats.id) {
2418
- return logger.warn(
2695
+ return logger3.warn(
2419
2696
  `ObservedPeerConnection received an invalid IceCandidateStats (missing timestamp OR id field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2420
2697
  stats
2421
2698
  );
@@ -2434,7 +2711,7 @@ var ObservedPeerConnection = class extends import_events.EventEmitter {
2434
2711
  let observedIceCandidatePair = this.observedIceCandidatesPair.get(stats.id);
2435
2712
  if (!observedIceCandidatePair) {
2436
2713
  if (!stats.timestamp || !stats.id) {
2437
- return logger.warn(
2714
+ return logger3.warn(
2438
2715
  `ObservedPeerConnection received an invalid IceCandidateStats (missing timestamp OR id field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2439
2716
  stats
2440
2717
  );
@@ -2453,7 +2730,7 @@ var ObservedPeerConnection = class extends import_events.EventEmitter {
2453
2730
  let observedIceTransport = this.observedIceTransports.get(stats.id);
2454
2731
  if (!observedIceTransport) {
2455
2732
  if (!stats.timestamp || !stats.id) {
2456
- return logger.warn(
2733
+ return logger3.warn(
2457
2734
  `ObservedPeerConnection received an invalid IceCandidateStats (missing timestamp OR id field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2458
2735
  stats
2459
2736
  );
@@ -2472,7 +2749,7 @@ var ObservedPeerConnection = class extends import_events.EventEmitter {
2472
2749
  let observedInboundRtp = this.observedInboundRtps.get(stats.ssrc);
2473
2750
  if (!observedInboundRtp) {
2474
2751
  if (!stats.timestamp || !stats.id || !stats.ssrc || !stats.kind || !stats.trackIdentifier) {
2475
- return logger.warn(
2752
+ return logger3.warn(
2476
2753
  `ObservedPeerConnection received an invalid InboundRtpStats (missing timestamp OR id OR ssrc OR kind OR trackIdentifier field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2477
2754
  stats
2478
2755
  );
@@ -2498,7 +2775,7 @@ var ObservedPeerConnection = class extends import_events.EventEmitter {
2498
2775
  let observedInboundTrack = this.observedInboundTracks.get(stats.id);
2499
2776
  if (!observedInboundTrack) {
2500
2777
  if (!stats.timestamp || !stats.id || !stats.kind) {
2501
- return logger.warn(
2778
+ return logger3.warn(
2502
2779
  `ObservedPeerConnection received an invalid InboundTrackSample (missing timestamp OR id OR kind field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2503
2780
  stats
2504
2781
  );
@@ -2526,7 +2803,7 @@ var ObservedPeerConnection = class extends import_events.EventEmitter {
2526
2803
  let observedMediaPlayout = this.observedMediaPlayouts.get(stats.id);
2527
2804
  if (!observedMediaPlayout) {
2528
2805
  if (!stats.timestamp || !stats.id || !stats.kind) {
2529
- return logger.warn(
2806
+ return logger3.warn(
2530
2807
  `ObservedPeerConnection received an invalid InboundRtpStats (missing timestamp OR id OR kind OR trackIdentifier field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2531
2808
  stats
2532
2809
  );
@@ -2550,7 +2827,7 @@ var ObservedPeerConnection = class extends import_events.EventEmitter {
2550
2827
  let observedMediaSource = this.observedMediaSources.get(stats.id);
2551
2828
  if (!observedMediaSource) {
2552
2829
  if (!stats.timestamp || !stats.id || !stats.kind) {
2553
- return logger.warn(
2830
+ return logger3.warn(
2554
2831
  `ObservedPeerConnection received an invalid InboundRtpStats (missing timestamp OR id OR kind field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2555
2832
  stats
2556
2833
  );
@@ -2574,7 +2851,7 @@ var ObservedPeerConnection = class extends import_events.EventEmitter {
2574
2851
  let observedOutboundRtp = this.observedOutboundRtps.get(stats.ssrc);
2575
2852
  if (!observedOutboundRtp) {
2576
2853
  if (!stats.timestamp || !stats.id || !stats.ssrc || !stats.kind) {
2577
- return logger.warn(
2854
+ return logger3.warn(
2578
2855
  `ObservedPeerConnection received an invalid OutboundRtpStats (missing timestamp OR id OR ssrc OR kind field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2579
2856
  stats
2580
2857
  );
@@ -2599,7 +2876,7 @@ var ObservedPeerConnection = class extends import_events.EventEmitter {
2599
2876
  let observedOutboundTrack = this.observedOutboundTracks.get(stats.id);
2600
2877
  if (!observedOutboundTrack) {
2601
2878
  if (!stats.timestamp || !stats.id || !stats.kind) {
2602
- return logger.warn(
2879
+ return logger3.warn(
2603
2880
  `ObservedPeerConnection received an invalid OutboundTrackSample (missing timestamp OR id OR kind field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2604
2881
  stats
2605
2882
  );
@@ -2627,7 +2904,7 @@ var ObservedPeerConnection = class extends import_events.EventEmitter {
2627
2904
  let observedPeerConnectionTransport = this.observedPeerConnectionTransports.get(stats.id);
2628
2905
  if (!observedPeerConnectionTransport) {
2629
2906
  if (!stats.timestamp || !stats.id) {
2630
- return logger.warn(
2907
+ return logger3.warn(
2631
2908
  `ObservedPeerConnection received an invalid PeerConnectionTransportStats (missing timestamp OR id field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2632
2909
  stats
2633
2910
  );
@@ -2646,7 +2923,7 @@ var ObservedPeerConnection = class extends import_events.EventEmitter {
2646
2923
  let observedRemoteInboundRtp = this.observedRemoteInboundRtps.get(stats.ssrc);
2647
2924
  if (!observedRemoteInboundRtp) {
2648
2925
  if (!stats.timestamp || !stats.id || !stats.ssrc || !stats.kind) {
2649
- return logger.warn(
2926
+ return logger3.warn(
2650
2927
  `ObservedPeerConnection received an invalid RemoteInboundRtpStats (missing timestamp OR id OR ssrc OR kind field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2651
2928
  stats
2652
2929
  );
@@ -2671,7 +2948,7 @@ var ObservedPeerConnection = class extends import_events.EventEmitter {
2671
2948
  let observedRemoteOutboundRtp = this.observedRemoteOutboundRtps.get(stats.ssrc);
2672
2949
  if (!observedRemoteOutboundRtp) {
2673
2950
  if (!stats.timestamp || !stats.id || !stats.ssrc || !stats.kind) {
2674
- return logger.warn(
2951
+ return logger3.warn(
2675
2952
  `ObservedPeerConnection received an invalid RemoteOutboundRtpStats (missing timestamp OR id OR ssrc OR kind field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2676
2953
  stats
2677
2954
  );
@@ -2753,6 +3030,7 @@ var ClientMetaTypes = /* @__PURE__ */ ((ClientMetaTypes2) => {
2753
3030
  ClientMetaTypes2["MEDIA_DEVICES_SUPPORTED_CONSTRAINTS"] = "MEDIA_DEVICES_SUPPORTED_CONSTRAINTS";
2754
3031
  ClientMetaTypes2["USER_MEDIA_ERROR"] = "USER_MEDIA_ERROR";
2755
3032
  ClientMetaTypes2["LOCAL_SDP"] = "LOCAL_SDP";
3033
+ ClientMetaTypes2["AUDIO_INPUT_DEVICE"] = "AUDIO_INPUT_DEVICE";
2756
3034
  ClientMetaTypes2["OPERATION_SYSTEM"] = "OPERATION_SYSTEM";
2757
3035
  ClientMetaTypes2["ENGINE"] = "ENGINE";
2758
3036
  ClientMetaTypes2["PLATFORM"] = "PLATFORM";
@@ -2770,31 +3048,27 @@ function baseIssueType(type) {
2770
3048
  }
2771
3049
 
2772
3050
  // src/ObservedClient.ts
2773
- var logger2 = createLogger("ObservedClient");
2774
- var ObservedClient = class extends import_events2.EventEmitter {
3051
+ var logger4 = createLogger("ObservedClient");
3052
+ var RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS = 20;
3053
+ var RTT_HISTOGRAM_NUMBER_OF_BUCKETS = 50;
3054
+ var SCORE_HISTOGRAM_BUCKET_WIDTH = 1;
3055
+ var SCORE_HISTOGRAM_NUMBER_OF_BUCKETS = 5;
3056
+ var ObservedClient = class extends import_node_events3.EventEmitter {
2775
3057
  constructor(settings, call, activeIssues) {
2776
3058
  super();
2777
3059
  this.call = call;
2778
3060
  this.setMaxListeners(Infinity);
2779
3061
  this.eventScope = { observer: call.observer, observedCall: call, observedClient: this };
2780
- this.sink = call.observer.config.createClientSink?.({ clientId: settings.clientId, observedCall: call });
2781
3062
  this.clientId = settings.clientId;
2782
3063
  this.appData = settings.appData ?? {};
2783
3064
  this.settings = {
2784
3065
  closeClientIfIdleForMs: settings.closeClientIfIdleForMs
2785
3066
  };
2786
- if (this.sink) {
2787
- const onError = (err) => logger2.warn("ClientSampleSink error for client %s: %o", this.clientId, err);
2788
- this.sink.once("close", () => this.sink?.off("error", onError));
2789
- this.sink.on("error", onError);
2790
- }
2791
3067
  this.activeIssues = activeIssues;
2792
3068
  }
2793
3069
  call;
2794
3070
  clientId;
2795
3071
  observedPeerConnections = /* @__PURE__ */ new Map();
2796
- /** Per-client sink (from `ObserverConfig.createClientSink`), if any. */
2797
- sink;
2798
3072
  /** Ancestry base shared by all Observer-bus events originating at this client. */
2799
3073
  eventScope;
2800
3074
  calculatedScore = {
@@ -2835,10 +3109,6 @@ var ObservedClient = class extends import_events2.EventEmitter {
2835
3109
  deltaOutboundPacketsSent = 0;
2836
3110
  deltaTransportSentBytes = 0;
2837
3111
  deltaTransportReceivedBytes = 0;
2838
- deltaRttLt50Measurements = 0;
2839
- deltaRttLt150Measurements = 0;
2840
- deltaRttLt300Measurements = 0;
2841
- deltaRttGtOrEq300Measurements = 0;
2842
3112
  currentMaxRttInMs;
2843
3113
  currentMinRttInMs;
2844
3114
  currentAvgRttInMs;
@@ -2854,8 +3124,73 @@ var ObservedClient = class extends import_events2.EventEmitter {
2854
3124
  deltaNumberOfIssues = 0;
2855
3125
  totalScoreSum = 0;
2856
3126
  numberOfScoreMeasurements = 0;
2857
- // public totalNumberOfIssues = 0;
3127
+ minScore;
3128
+ maxScore;
3129
+ /**
3130
+ * Score measurements (one per accepted sample with a score) counted into
3131
+ * `SCORE_HISTOGRAM_NUMBER_OF_BUCKETS` buckets of `SCORE_HISTOGRAM_BUCKET_WIDTH`. Histograms of
3132
+ * several clients can be added bucket by bucket to get the distribution of a call.
3133
+ */
3134
+ scoreHistogram = new Array(SCORE_HISTOGRAM_NUMBER_OF_BUCKETS).fill(0);
3135
+ /** Issues raised over the session, counted as in `issueHistogram`. */
3136
+ totalNumberOfIssues = 0;
3137
+ /**
3138
+ * Number of times each issue type was raised over the session, keyed by the base type (without
3139
+ * the `-resolved` suffix): `client.issueHistogram['loopback-audio-input']`. A resolution entry is
3140
+ * not counted, and neither is a re-raise of a keyed issue that is still open, so a stateful issue
3141
+ * reported on every tick counts once per open interval. A type never raised is `undefined`.
3142
+ */
3143
+ issueHistogram = /* @__PURE__ */ Object.create(null);
3144
+ // Session totals. Unlike the `delta*` and `current*` fields they are never reset, and unlike the
3145
+ // `total*` fields of `ObservedPeerConnection` they survive a peer connection being closed.
3146
+ // Every RTT measurement is one peer connection's `currentRttInMs` in one accepted sample.
3147
+ totalRttSumInMs = 0;
3148
+ numberOfRttMeasurements = 0;
3149
+ maxRttInMs;
3150
+ minRttInMs;
3151
+ /**
3152
+ * RTT measurements counted into fixed buckets of `RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS`; the last bucket
3153
+ * holds every measurement of 980 ms and above. Constant memory, so percentiles can be estimated
3154
+ * over the whole session (see `rttSummary`) without keeping every measurement. Histograms
3155
+ * of several clients can be added bucket by bucket to get the distribution of a call.
3156
+ */
3157
+ rttHistogram = new Array(RTT_HISTOGRAM_NUMBER_OF_BUCKETS).fill(0);
3158
+ _rttSummary;
3159
+ _scoreSummary;
3160
+ totalInboundPacketsLost = 0;
3161
+ totalInboundPacketsReceived = 0;
3162
+ totalOutboundPacketsSent = 0;
3163
+ totalReceivedAudioBytes = 0;
3164
+ totalReceivedVideoBytes = 0;
3165
+ totalSentAudioBytes = 0;
3166
+ totalSentVideoBytes = 0;
3167
+ totalTransportReceivedBytes = 0;
3168
+ totalTransportSentBytes = 0;
3169
+ totalDataChannelBytesReceived = 0;
3170
+ totalDataChannelBytesSent = 0;
3171
+ totalDataChannelMessagesReceived = 0;
3172
+ totalDataChannelMessagesSent = 0;
3173
+ /** Highest `sendingAudioBitrate + sendingVideoBitrate` of any sample, in bits per second. */
3174
+ peakSendingBitrate = 0;
3175
+ /** Highest `receivingAudioBitrate + receivingVideoBitrate` of any sample, in bits per second. */
3176
+ peakReceivingBitrate = 0;
3177
+ // Samples by the transport path of the selected ICE candidate pairs. A sample whose peer
3178
+ // connections use different paths is counted under each of them, so more than one non-zero
3179
+ // counter means the path changed or was mixed. A sample with no selected pair counts as unknown.
3180
+ numberOfSamplesOverUdp = 0;
3181
+ numberOfSamplesOverTcp = 0;
3182
+ numberOfSamplesOverTurnUdp = 0;
3183
+ numberOfSamplesOverTurnTcp = 0;
3184
+ numberOfSamplesWithUnknownPath = 0;
3185
+ numberOfPublicAddressChanges = 0;
2858
3186
  mediaDevices = [];
3187
+ /**
3188
+ * The capture device behind each outbound audio track, keyed by track id, from the client's
3189
+ * `AUDIO_INPUT_DEVICE` metadata. Kept for the client's lifetime (a device switch is a new track
3190
+ * id), so a finding raised after a track ended can still name its device. Read it per track via
3191
+ * `ObservedOutboundTrack.audioInputDevice`.
3192
+ */
3193
+ audioInputDevices = /* @__PURE__ */ new Map();
2859
3194
  /**
2860
3195
  * The client's currently **open** stateful issues, keyed by `ClientIssue.key` — the server-side
2861
3196
  * mirror of the client monitor's own active-issue map (client-monitor-js >= 4.6.0).
@@ -2868,12 +3203,17 @@ var ObservedClient = class extends import_events2.EventEmitter {
2868
3203
  activeIssues;
2869
3204
  _pendingInjections = {};
2870
3205
  _activeSample;
2871
- /** Peer connections the client reported closed (`PEER_CONNECTION_CLOSED`), closed once the current sample is applied. */
2872
3206
  _peerConnectionsToClose = /* @__PURE__ */ new Set();
2873
- /** Ids of peer connections closed by the client; late stats for them are ignored rather than resurrecting them. */
2874
3207
  _closedPeerConnectionIds = /* @__PURE__ */ new Set();
2875
3208
  closeTimer;
2876
3209
  /** The distinct `publicAddress`es currently known by this client's open peer connections. */
3210
+ /**
3211
+ * The sink this client's samples are written to: whatever is registered under its client id in
3212
+ * `call.sinks` (the `createClientJsonlSink` file, or a sink the application added), if any.
3213
+ */
3214
+ get sink() {
3215
+ return this.call.sinks.get(this.clientId);
3216
+ }
2877
3217
  get publicAddresses() {
2878
3218
  const result = /* @__PURE__ */ new Set();
2879
3219
  for (const observedPeerConnection of this.observedPeerConnections.values()) {
@@ -2887,6 +3227,61 @@ var ObservedClient = class extends import_events2.EventEmitter {
2887
3227
  get score() {
2888
3228
  return this.calculatedScore.value;
2889
3229
  }
3230
+ /**
3231
+ * Percentiles (error at most one 20 ms bucket; a percentile in the overflow bucket is reported as
3232
+ * `maxRttInMs`) and mean of every RTT measurement of the session. Cached until the next measurement.
3233
+ */
3234
+ get rttSummary() {
3235
+ if (!this._rttSummary) {
3236
+ const bounds = { min: this.minRttInMs, max: this.maxRttInMs, openEnded: true };
3237
+ const percentile2 = (p) => percentileOfHistogram(this.rttHistogram, RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS, p, bounds);
3238
+ this._rttSummary = Object.freeze({
3239
+ p50: percentile2(0.5),
3240
+ p75: percentile2(0.75),
3241
+ p95: percentile2(0.95),
3242
+ p99: percentile2(0.99),
3243
+ avg: 0 < this.numberOfRttMeasurements ? this.totalRttSumInMs / this.numberOfRttMeasurements : void 0
3244
+ });
3245
+ }
3246
+ return this._rttSummary;
3247
+ }
3248
+ /**
3249
+ * Percentiles (error at most one score point) and mean of every score of the session. Cached until
3250
+ * the next score.
3251
+ */
3252
+ get scoreSummary() {
3253
+ if (!this._scoreSummary) {
3254
+ const bounds = { min: this.minScore, max: this.maxScore };
3255
+ const percentile2 = (p) => percentileOfHistogram(this.scoreHistogram, SCORE_HISTOGRAM_BUCKET_WIDTH, p, bounds);
3256
+ this._scoreSummary = Object.freeze({
3257
+ p5: percentile2(0.05),
3258
+ p25: percentile2(0.25),
3259
+ p50: percentile2(0.5),
3260
+ p75: percentile2(0.75),
3261
+ p95: percentile2(0.95),
3262
+ avg: 0 < this.numberOfScoreMeasurements ? this.totalScoreSum / this.numberOfScoreMeasurements : void 0
3263
+ });
3264
+ }
3265
+ return this._scoreSummary;
3266
+ }
3267
+ _addScoreMeasurement(score) {
3268
+ this._scoreSummary = void 0;
3269
+ this.totalScoreSum += score;
3270
+ ++this.numberOfScoreMeasurements;
3271
+ if (this.maxScore === void 0 || this.maxScore < score) this.maxScore = score;
3272
+ if (this.minScore === void 0 || score < this.minScore) this.minScore = score;
3273
+ const bucket = Math.min(Math.max(Math.floor(score / SCORE_HISTOGRAM_BUCKET_WIDTH), 0), SCORE_HISTOGRAM_NUMBER_OF_BUCKETS - 1);
3274
+ ++this.scoreHistogram[bucket];
3275
+ }
3276
+ _addRttMeasurement(rttInMs) {
3277
+ this._rttSummary = void 0;
3278
+ this.totalRttSumInMs += rttInMs;
3279
+ ++this.numberOfRttMeasurements;
3280
+ if (this.maxRttInMs === void 0 || this.maxRttInMs < rttInMs) this.maxRttInMs = rttInMs;
3281
+ if (this.minRttInMs === void 0 || rttInMs < this.minRttInMs) this.minRttInMs = rttInMs;
3282
+ const bucket = Math.min(Math.max(Math.floor(rttInMs / RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS), 0), RTT_HISTOGRAM_NUMBER_OF_BUCKETS - 1);
3283
+ ++this.rttHistogram[bucket];
3284
+ }
2890
3285
  close() {
2891
3286
  if (this.closed) return;
2892
3287
  if (this.closeTimer) {
@@ -2913,12 +3308,12 @@ var ObservedClient = class extends import_events2.EventEmitter {
2913
3308
  try {
2914
3309
  this.sink?.end();
2915
3310
  } catch (err) {
2916
- logger2.warn("ClientSampleSink.end failed for client %s: %o", this.clientId, err);
3311
+ logger4.warn("Sink end failed for client %s: %o", this.clientId, err);
2917
3312
  }
2918
3313
  }
2919
3314
  accept(sample, context) {
2920
3315
  if (this.closed) {
2921
- logger2.warn("Attempted to accept a sample on a closed client %s", this.clientId);
3316
+ logger4.warn("Attempted to accept a sample on a closed client %s", this.clientId);
2922
3317
  return;
2923
3318
  }
2924
3319
  if (this.closeTimer) {
@@ -2930,6 +3325,10 @@ var ObservedClient = class extends import_events2.EventEmitter {
2930
3325
  const elapsedInSeconds = elapsedInMs / 1e3;
2931
3326
  let sumOfRtts = 0;
2932
3327
  let numberOfRttMeasurements = 0;
3328
+ let overUdp = false;
3329
+ let overTcp = false;
3330
+ let overTurnUdp = false;
3331
+ let overTurnTcp = false;
2933
3332
  ++this.acceptedSamples;
2934
3333
  this.availableIncomingBitrate = 0;
2935
3334
  this.availableOutgoingBitrate = 0;
@@ -2946,10 +3345,6 @@ var ObservedClient = class extends import_events2.EventEmitter {
2946
3345
  this.deltaSentVideoBytes = 0;
2947
3346
  this.deltaTransportReceivedBytes = 0;
2948
3347
  this.deltaTransportSentBytes = 0;
2949
- this.deltaRttLt50Measurements = 0;
2950
- this.deltaRttLt150Measurements = 0;
2951
- this.deltaRttLt300Measurements = 0;
2952
- this.deltaRttGtOrEq300Measurements = 0;
2953
3348
  this.deltaNumberOfIssues = 0;
2954
3349
  this.numberOfDataChannels = 0;
2955
3350
  this.numberOfInboundRtpStreams = 0;
@@ -3021,6 +3416,18 @@ var ObservedClient = class extends import_events2.EventEmitter {
3021
3416
  if (observedPeerConnection.usingTCP) {
3022
3417
  this.usingTCP = true;
3023
3418
  }
3419
+ for (const selectedPair of observedPeerConnection.selectedIceCandidatePairs) {
3420
+ const localCandidate = selectedPair.getLocalCandidate();
3421
+ if (!localCandidate) continue;
3422
+ if (localCandidate.candidateType === "relay") {
3423
+ if (localCandidate.relayProtocol === "tcp" || localCandidate.relayProtocol === "tls") overTurnTcp = true;
3424
+ else overTurnUdp = true;
3425
+ } else if (localCandidate.protocol === "tcp") {
3426
+ overTcp = true;
3427
+ } else {
3428
+ overUdp = true;
3429
+ }
3430
+ }
3024
3431
  if (observedPeerConnection.currentRttInMs) {
3025
3432
  if (this.currentMinRttInMs === void 0 || observedPeerConnection.currentRttInMs < this.currentMinRttInMs) {
3026
3433
  this.currentMinRttInMs = observedPeerConnection.currentRttInMs;
@@ -3028,17 +3435,9 @@ var ObservedClient = class extends import_events2.EventEmitter {
3028
3435
  if (this.currentMaxRttInMs === void 0 || observedPeerConnection.currentRttInMs > this.currentMaxRttInMs) {
3029
3436
  this.currentMaxRttInMs = observedPeerConnection.currentRttInMs;
3030
3437
  }
3031
- if (observedPeerConnection.currentRttInMs < 50) {
3032
- this.deltaRttLt50Measurements += 1;
3033
- } else if (observedPeerConnection.currentRttInMs < 150) {
3034
- this.deltaRttLt150Measurements += 1;
3035
- } else if (observedPeerConnection.currentRttInMs < 300) {
3036
- this.deltaRttLt300Measurements += 1;
3037
- } else if (300 <= observedPeerConnection.currentRttInMs) {
3038
- this.deltaRttGtOrEq300Measurements += 1;
3039
- }
3040
3438
  sumOfRtts += observedPeerConnection.currentRttInMs;
3041
3439
  ++numberOfRttMeasurements;
3440
+ this._addRttMeasurement(observedPeerConnection.currentRttInMs);
3042
3441
  }
3043
3442
  }
3044
3443
  for (const clientEvent of clientEventsPostBuffer) {
@@ -3048,6 +3447,7 @@ var ObservedClient = class extends import_events2.EventEmitter {
3048
3447
  if (latestPublicAddress !== void 0 && latestPublicAddress !== this.publicAddress) {
3049
3448
  const previousPublicAddress = this.publicAddress;
3050
3449
  this.publicAddress = latestPublicAddress;
3450
+ if (previousPublicAddress !== void 0) ++this.numberOfPublicAddressChanges;
3051
3451
  this._notify("client-public-address-changed", { ...this.eventScope, publicAddress: latestPublicAddress, previousPublicAddress });
3052
3452
  }
3053
3453
  this.attachments = sample.attachments;
@@ -3063,15 +3463,34 @@ var ObservedClient = class extends import_events2.EventEmitter {
3063
3463
  this.sendingVideoBitrate = 0;
3064
3464
  }
3065
3465
  this.currentAvgRttInMs = 0 < numberOfRttMeasurements ? sumOfRtts / numberOfRttMeasurements : void 0;
3466
+ this.peakSendingBitrate = Math.max(this.peakSendingBitrate, this.sendingAudioBitrate + this.sendingVideoBitrate);
3467
+ this.peakReceivingBitrate = Math.max(this.peakReceivingBitrate, this.receivingAudioBitrate + this.receivingVideoBitrate);
3468
+ this.totalInboundPacketsLost += this.deltaInboundPacketsLost;
3469
+ this.totalInboundPacketsReceived += this.deltaInboundPacketsReceived;
3470
+ this.totalOutboundPacketsSent += this.deltaOutboundPacketsSent;
3471
+ this.totalReceivedAudioBytes += this.deltaReceivedAudioBytes;
3472
+ this.totalReceivedVideoBytes += this.deltaReceivedVideoBytes;
3473
+ this.totalSentAudioBytes += this.deltaSentAudioBytes;
3474
+ this.totalSentVideoBytes += this.deltaSentVideoBytes;
3475
+ this.totalTransportReceivedBytes += this.deltaTransportReceivedBytes;
3476
+ this.totalTransportSentBytes += this.deltaTransportSentBytes;
3477
+ this.totalDataChannelBytesReceived += this.deltaDataChannelBytesReceived;
3478
+ this.totalDataChannelBytesSent += this.deltaDataChannelBytesSent;
3479
+ this.totalDataChannelMessagesReceived += this.deltaDataChannelMessagesReceived;
3480
+ this.totalDataChannelMessagesSent += this.deltaDataChannelMessagesSent;
3481
+ if (overUdp) ++this.numberOfSamplesOverUdp;
3482
+ if (overTcp) ++this.numberOfSamplesOverTcp;
3483
+ if (overTurnUdp) ++this.numberOfSamplesOverTurnUdp;
3484
+ if (overTurnTcp) ++this.numberOfSamplesOverTurnTcp;
3485
+ if (!overUdp && !overTcp && !overTurnUdp && !overTurnTcp) ++this.numberOfSamplesWithUnknownPath;
3066
3486
  this.calculatedScore.value = sample.score;
3067
3487
  this.lastSampleTimestamp = sample.timestamp;
3068
3488
  const elapsedTimeInMs = now - this.updated;
3069
3489
  this.emit("update", sample, elapsedTimeInMs, context);
3070
3490
  this._notify("client-updated", { ...this.eventScope, sample, elapsedTimeInMs, context });
3071
3491
  this.updated = now;
3072
- if (this.calculatedScore.value) {
3073
- this.totalScoreSum += this.calculatedScore.value;
3074
- ++this.numberOfScoreMeasurements;
3492
+ if (typeof this.calculatedScore.value === "number" && Number.isFinite(this.calculatedScore.value)) {
3493
+ this._addScoreMeasurement(this.calculatedScore.value);
3075
3494
  }
3076
3495
  if (this.settings.closeClientIfIdleForMs !== void 0) {
3077
3496
  this.closeTimer = setTimeout(() => {
@@ -3084,7 +3503,7 @@ var ObservedClient = class extends import_events2.EventEmitter {
3084
3503
  try {
3085
3504
  this.sink?.write(sample);
3086
3505
  } catch (err) {
3087
- logger2.warn("ClientSampleSink.write failed for client %s: %o", this.clientId, err);
3506
+ logger4.warn("Sink write failed for client %s: %o", this.clientId, err);
3088
3507
  }
3089
3508
  }
3090
3509
  injectMetaData(metaData) {
@@ -3162,6 +3581,13 @@ var ObservedClient = class extends import_events2.EventEmitter {
3162
3581
  this.operationSystem = parseJsonAs(metadata.payload);
3163
3582
  break;
3164
3583
  }
3584
+ case "AUDIO_INPUT_DEVICE" /* AUDIO_INPUT_DEVICE */: {
3585
+ const device = parseJsonAs(metadata.payload);
3586
+ if (typeof device?.trackId === "string" && typeof device.label === "string") {
3587
+ this.audioInputDevices.set(device.trackId, device);
3588
+ }
3589
+ break;
3590
+ }
3165
3591
  }
3166
3592
  this._notify("client-metadata", { ...this.eventScope, metaData: metadata });
3167
3593
  }
@@ -3182,6 +3608,11 @@ var ObservedClient = class extends import_events2.EventEmitter {
3182
3608
  if (isClientIssueResolutionEntry(issue)) {
3183
3609
  return this._resolveIssue(issue);
3184
3610
  }
3611
+ if (!issue.key || !this.activeIssues.get(issue.key)) {
3612
+ const type = baseIssueType(issue.type);
3613
+ this.issueHistogram[type] = (this.issueHistogram[type] ?? 0) + 1;
3614
+ ++this.totalNumberOfIssues;
3615
+ }
3185
3616
  const now = Date.now();
3186
3617
  if (issue.key) {
3187
3618
  const payload = parseJsonObject(issue.payload);
@@ -3255,10 +3686,10 @@ var ObservedClient = class extends import_events2.EventEmitter {
3255
3686
  this._notify("client-rejoined", { ...this.eventScope, timestamp: event.timestamp });
3256
3687
  } else {
3257
3688
  this.joinedAt = event.timestamp;
3258
- logger2.warn(`Client ${this.clientId} joinedAt timestamp was updated to ${event.timestamp}. the joined event will not be emitted.`);
3689
+ logger4.warn(`Client ${this.clientId} joinedAt timestamp was updated to ${event.timestamp}. the joined event will not be emitted.`);
3259
3690
  }
3260
3691
  }
3261
- logger2.debug("Client %s joined at %o", this.clientId, event);
3692
+ logger4.debug("Client %s joined at %o", this.clientId, event);
3262
3693
  break;
3263
3694
  }
3264
3695
  case "CLIENT_LEFT" /* CLIENT_LEFT */: {
@@ -3268,10 +3699,10 @@ var ObservedClient = class extends import_events2.EventEmitter {
3268
3699
  this.emit("left");
3269
3700
  this._notify("client-left", { ...this.eventScope });
3270
3701
  } else {
3271
- logger2.warn(`Client ${this.clientId} leftAt timestamp was already set`);
3702
+ logger4.warn(`Client ${this.clientId} leftAt timestamp was already set`);
3272
3703
  }
3273
3704
  }
3274
- logger2.debug("Client %s left at %o", this.clientId, event);
3705
+ logger4.debug("Client %s left at %o", this.clientId, event);
3275
3706
  break;
3276
3707
  }
3277
3708
  case "PEER_CONNECTION_OPENED" /* PEER_CONNECTION_OPENED */: {
@@ -3283,7 +3714,7 @@ var ObservedClient = class extends import_events2.EventEmitter {
3283
3714
  } else if (postBuffer) {
3284
3715
  postBuffer.push(event);
3285
3716
  } else {
3286
- logger2.warn("Received PEER_CONNECTION_OPENED event without a corresponding observedPeerConnection: %o", event);
3717
+ logger4.warn("Received PEER_CONNECTION_OPENED event without a corresponding observedPeerConnection: %o", event);
3287
3718
  }
3288
3719
  }
3289
3720
  break;
@@ -3300,7 +3731,7 @@ var ObservedClient = class extends import_events2.EventEmitter {
3300
3731
  } else if (postBuffer) {
3301
3732
  postBuffer.push(event);
3302
3733
  } else {
3303
- logger2.warn("Received PEER_CONNECTION_CLOSED event without a corresponding observedPeerConnection: %o", event);
3734
+ logger4.warn("Received PEER_CONNECTION_CLOSED event without a corresponding observedPeerConnection: %o", event);
3304
3735
  }
3305
3736
  }
3306
3737
  break;
@@ -3315,7 +3746,7 @@ var ObservedClient = class extends import_events2.EventEmitter {
3315
3746
  } else if (postBuffer) {
3316
3747
  postBuffer.push(event);
3317
3748
  } else {
3318
- logger2.warn("Received MEDIA_TRACK_ADDED event without a corresponding observedPeerConnection or observedTrack: %o", event);
3749
+ logger4.warn("Received MEDIA_TRACK_ADDED event without a corresponding observedPeerConnection or observedTrack: %o", event);
3319
3750
  }
3320
3751
  }
3321
3752
  break;
@@ -3330,7 +3761,7 @@ var ObservedClient = class extends import_events2.EventEmitter {
3330
3761
  } else if (postBuffer) {
3331
3762
  postBuffer.push(event);
3332
3763
  } else {
3333
- logger2.warn("Received MEDIA_TRACK_REMOVED event without a corresponding observedPeerConnection or observedTrack: %o", event);
3764
+ logger4.warn("Received MEDIA_TRACK_REMOVED event without a corresponding observedPeerConnection or observedTrack: %o", event);
3334
3765
  }
3335
3766
  }
3336
3767
  break;
@@ -3345,7 +3776,7 @@ var ObservedClient = class extends import_events2.EventEmitter {
3345
3776
  } else if (postBuffer) {
3346
3777
  postBuffer.push(event);
3347
3778
  } else {
3348
- logger2.warn("Received DATA_CHANNEL_OPENED event without a corresponding observedPeerConnection or observedDataChannel: %o", event);
3779
+ logger4.warn("Received DATA_CHANNEL_OPENED event without a corresponding observedPeerConnection or observedDataChannel: %o", event);
3349
3780
  }
3350
3781
  }
3351
3782
  break;
@@ -3360,7 +3791,7 @@ var ObservedClient = class extends import_events2.EventEmitter {
3360
3791
  } else if (postBuffer) {
3361
3792
  postBuffer.push(event);
3362
3793
  } else {
3363
- logger2.warn("Received DATA_CHANNEL_CLOSE event without a corresponding observedPeerConnection or observedDataChannel: %o", event);
3794
+ logger4.warn("Received DATA_CHANNEL_CLOSE event without a corresponding observedPeerConnection or observedDataChannel: %o", event);
3364
3795
  }
3365
3796
  }
3366
3797
  break;
@@ -3380,7 +3811,7 @@ var ObservedClient = class extends import_events2.EventEmitter {
3380
3811
  } else if (postBuffer) {
3381
3812
  postBuffer.push(event);
3382
3813
  } else {
3383
- logger2.warn("Received MEDIA_TRACK_MUTED event without a corresponding observedPeerConnection or observedTrack: %o", event);
3814
+ logger4.warn("Received MEDIA_TRACK_MUTED event without a corresponding observedPeerConnection or observedTrack: %o", event);
3384
3815
  }
3385
3816
  }
3386
3817
  break;
@@ -3400,7 +3831,7 @@ var ObservedClient = class extends import_events2.EventEmitter {
3400
3831
  } else if (postBuffer) {
3401
3832
  postBuffer.push(event);
3402
3833
  } else {
3403
- logger2.warn("Received MEDIA_TRACK_UNMUTED event without a corresponding observedPeerConnection or observedTrack: %o", event);
3834
+ logger4.warn("Received MEDIA_TRACK_UNMUTED event without a corresponding observedPeerConnection or observedTrack: %o", event);
3404
3835
  }
3405
3836
  }
3406
3837
  break;
@@ -3415,7 +3846,7 @@ var ObservedClient = class extends import_events2.EventEmitter {
3415
3846
  } else if (postBuffer) {
3416
3847
  postBuffer.push(event);
3417
3848
  } else {
3418
- logger2.warn("Received ICE_CONNECTION_STATE_CHANGED event without a corresponding observedPeerConnection: %o", event);
3849
+ logger4.warn("Received ICE_CONNECTION_STATE_CHANGED event without a corresponding observedPeerConnection: %o", event);
3419
3850
  }
3420
3851
  }
3421
3852
  break;
@@ -3430,7 +3861,7 @@ var ObservedClient = class extends import_events2.EventEmitter {
3430
3861
  } else if (postBuffer) {
3431
3862
  postBuffer.push(event);
3432
3863
  } else {
3433
- logger2.warn("Received ICE_GATHERING_STATE_CHANGED event without a corresponding observedPeerConnection: %o", event);
3864
+ logger4.warn("Received ICE_GATHERING_STATE_CHANGED event without a corresponding observedPeerConnection: %o", event);
3434
3865
  }
3435
3866
  }
3436
3867
  break;
@@ -3445,7 +3876,7 @@ var ObservedClient = class extends import_events2.EventEmitter {
3445
3876
  } else if (postBuffer) {
3446
3877
  postBuffer.push(event);
3447
3878
  } else {
3448
- logger2.warn("Received PEER_CONNECTION_STATE_CHANGED event without a corresponding observedPeerConnection: %o", event);
3879
+ logger4.warn("Received PEER_CONNECTION_STATE_CHANGED event without a corresponding observedPeerConnection: %o", event);
3449
3880
  }
3450
3881
  }
3451
3882
  break;
@@ -3464,11 +3895,11 @@ var ObservedClient = class extends import_events2.EventEmitter {
3464
3895
  let observedPeerConnection = this.observedPeerConnections.get(sample.peerConnectionId);
3465
3896
  if (!observedPeerConnection) {
3466
3897
  if (this._closedPeerConnectionIds.has(sample.peerConnectionId)) {
3467
- logger2.debug("Ignoring stats of peer connection %s, already closed by client %s", sample.peerConnectionId, this.clientId);
3898
+ logger4.debug("Ignoring stats of peer connection %s, already closed by client %s", sample.peerConnectionId, this.clientId);
3468
3899
  return void 0;
3469
3900
  }
3470
3901
  if (!sample.peerConnectionId) {
3471
- return logger2.warn(
3902
+ return logger4.warn(
3472
3903
  `ObservedClient received an invalid PeerConnectionSample (missing peerConnectionId field). ClientId: ${this.clientId}, CallId: ${this.call.callId}`,
3473
3904
  sample
3474
3905
  ), void 0;
@@ -3528,7 +3959,7 @@ var ObservedClient = class extends import_events2.EventEmitter {
3528
3959
  try {
3529
3960
  this.sink?.write(finalSample);
3530
3961
  } catch (err) {
3531
- logger2.warn("ClientSampleSink.write failed during injection flush for client %s: %o", this.clientId, err);
3962
+ logger4.warn("Sink write failed during injection flush for client %s: %o", this.clientId, err);
3532
3963
  }
3533
3964
  }
3534
3965
  /** Emit an Observer-bus event scoped to this client (or a peer connection under it). */
@@ -3556,7 +3987,7 @@ var DefaultCallScoreCalculator = class {
3556
3987
  };
3557
3988
 
3558
3989
  // src/detectors/Detectors.ts
3559
- var logger3 = createLogger("Detectors");
3990
+ var logger5 = createLogger("Detectors");
3560
3991
  var Detectors = class {
3561
3992
  _detectors;
3562
3993
  constructor(...detectors) {
@@ -3637,7 +4068,7 @@ var Detectors = class {
3637
4068
  try {
3638
4069
  detector.update();
3639
4070
  } catch (err) {
3640
- logger3.warn(`Error updating detector ${detector?.name}`, err);
4071
+ logger5.warn(`Error updating detector ${detector?.name}`, err);
3641
4072
  }
3642
4073
  }
3643
4074
  }
@@ -3650,7 +4081,7 @@ var Detectors = class {
3650
4081
  try {
3651
4082
  detector.close?.();
3652
4083
  } catch (err) {
3653
- logger3.warn(`Error closing detector ${detector?.name}`, err);
4084
+ logger5.warn(`Error closing detector ${detector?.name}`, err);
3654
4085
  }
3655
4086
  }
3656
4087
  };
@@ -4453,9 +4884,172 @@ var PublisherFaultCorroborationDetector = class _PublisherFaultCorroborationDete
4453
4884
  }
4454
4885
  };
4455
4886
 
4887
+ // src/detectors/AudioEchoDetector.ts
4888
+ var AudioEchoTypes = {
4889
+ /**
4890
+ * One participant's outgoing audio follows another participant's voice: it rises when they speak,
4891
+ * in proportion to how loud they are. The echoing participant is sending the far end's playout
4892
+ * back into the call — speakers into an open mic, or a loopback capture device.
4893
+ */
4894
+ audioEchoSuspected: "AUDIO_ECHO_SUSPECTED"
4895
+ };
4896
+ var DIRECTION_MARGIN = 0.1;
4897
+ var mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length;
4898
+ var LEVEL_FLOOR = 1e-5;
4899
+ var toDb = (level) => 20 * Math.log10(Math.max(level, LEVEL_FLOOR));
4900
+ var AudioEchoDetector = class _AudioEchoDetector {
4901
+ constructor(call, config = {}) {
4902
+ this.call = call;
4903
+ this._config = {
4904
+ bucketMs: 5e3,
4905
+ windowBuckets: 24,
4906
+ activeRmsLevel: 0.01,
4907
+ minSourceActiveBuckets: 8,
4908
+ minFollowRatio: 0.8,
4909
+ minCorrelation: 0.6,
4910
+ cooldownMs: 12e4,
4911
+ loopbackAudioInputIssueType: "loopback-audio-input",
4912
+ ...config
4913
+ };
4914
+ }
4915
+ call;
4916
+ static NAME = "audio-echo-detector";
4917
+ name = _AudioEchoDetector.NAME;
4918
+ _config;
4919
+ _series = /* @__PURE__ */ new Map();
4920
+ _lastRaisedAt = /* @__PURE__ */ new Map();
4921
+ _lastEvaluatedBucket;
4922
+ close() {
4923
+ this._series.clear();
4924
+ this._lastRaisedAt.clear();
4925
+ }
4926
+ update() {
4927
+ const now = Date.now();
4928
+ const bucket = Math.floor(now / this._config.bucketMs);
4929
+ if (this._lastEvaluatedBucket === bucket) return;
4930
+ const closedBucket = bucket - 1;
4931
+ this._lastEvaluatedBucket = bucket;
4932
+ this._collect(now, closedBucket);
4933
+ this._evaluate(now, bucket);
4934
+ }
4935
+ _collect(now, bucket) {
4936
+ for (const client of this.call.observedClients.values()) {
4937
+ for (const peerConnection of client.observedPeerConnections.values()) {
4938
+ for (const track of peerConnection.observedOutboundTracks.values()) {
4939
+ if (track.kind !== "audio") continue;
4940
+ let series = this._series.get(track.id);
4941
+ if (!series) {
4942
+ series = { track, clientId: client.clientId, lastTimestamp: -1, samples: [], lastSeenAt: now };
4943
+ this._series.set(track.id, series);
4944
+ }
4945
+ series.lastSeenAt = now;
4946
+ if (series.lastTimestamp === track.timestamp) continue;
4947
+ series.lastTimestamp = track.timestamp;
4948
+ const level = track.rmsAudioLevel;
4949
+ if (level === void 0) continue;
4950
+ series.samples.push({ bucket, level });
4951
+ const oldest = bucket - this._config.windowBuckets;
4952
+ while (series.samples.length && series.samples[0].bucket <= oldest) series.samples.shift();
4953
+ }
4954
+ }
4955
+ }
4956
+ for (const [trackId, series] of this._series) {
4957
+ if (series.lastSeenAt !== now) this._series.delete(trackId);
4958
+ }
4959
+ }
4960
+ _evaluate(now, currentBucket) {
4961
+ if (this._series.size < 2) return;
4962
+ const tops = /* @__PURE__ */ new Map();
4963
+ for (const [trackId, series] of this._series) {
4964
+ for (const { bucket, level } of series.samples) {
4965
+ if (currentBucket <= bucket) continue;
4966
+ const top = tops.get(bucket);
4967
+ const entry = { id: trackId, level };
4968
+ if (!top) tops.set(bucket, [entry, void 0]);
4969
+ else if (top[0].level < level) tops.set(bucket, [entry, top[0]]);
4970
+ else if (!top[1] || top[1].level < level) top[1] = entry;
4971
+ }
4972
+ }
4973
+ const candidates = /* @__PURE__ */ new Map();
4974
+ for (const [trackId, series] of this._series) {
4975
+ const suspectDb = [];
4976
+ const sourceDb = [];
4977
+ const sourceCounts = /* @__PURE__ */ new Map();
4978
+ let followed = 0;
4979
+ for (const { bucket, level } of series.samples) {
4980
+ const top = tops.get(bucket);
4981
+ if (!top) continue;
4982
+ const source = top[0].id === trackId ? top[1] : top[0];
4983
+ if (!source || source.level < this._config.activeRmsLevel) continue;
4984
+ if (this._config.activeRmsLevel <= level) followed += 1;
4985
+ suspectDb.push(toDb(level));
4986
+ sourceDb.push(toDb(source.level));
4987
+ sourceCounts.set(source.id, (sourceCounts.get(source.id) ?? 0) + 1);
4988
+ }
4989
+ const sourceActiveBuckets = sourceDb.length;
4990
+ if (sourceActiveBuckets < this._config.minSourceActiveBuckets) continue;
4991
+ const followRatio = followed / sourceActiveBuckets;
4992
+ if (followRatio < this._config.minFollowRatio) continue;
4993
+ const levelCorrelation = correlation(suspectDb, sourceDb);
4994
+ if (levelCorrelation < this._config.minCorrelation) continue;
4995
+ const [sourceTrackId] = [...sourceCounts].reduce((a, b) => a[1] < b[1] ? b : a);
4996
+ const client = this.call.observedClients.get(series.clientId);
4997
+ candidates.set(trackId, {
4998
+ trackId,
4999
+ series,
5000
+ sourceTrackId,
5001
+ followRatio,
5002
+ levelCorrelation,
5003
+ sourceActiveBuckets,
5004
+ // How much quieter than its source the suspect is, in dB. An echo is at most as loud as
5005
+ // what it echoes, give or take AGC.
5006
+ relativeLevelDb: mean(suspectDb) - mean(sourceDb),
5007
+ loopbackAudioInput: client?.activeIssues.get(`${this._config.loopbackAudioInputIssueType}-track-${trackId}`) !== void 0
5008
+ });
5009
+ }
5010
+ for (const candidate of candidates.values()) {
5011
+ const reverse = candidates.get(candidate.sourceTrackId);
5012
+ let ambiguousDirection = false;
5013
+ if (reverse && reverse.sourceTrackId === candidate.trackId) {
5014
+ if (candidate.loopbackAudioInput !== reverse.loopbackAudioInput) {
5015
+ if (!candidate.loopbackAudioInput) continue;
5016
+ } else if (DIRECTION_MARGIN < Math.abs(candidate.followRatio - reverse.followRatio)) {
5017
+ if (candidate.followRatio < reverse.followRatio) continue;
5018
+ } else {
5019
+ ambiguousDirection = true;
5020
+ if (reverse.relativeLevelDb < candidate.relativeLevelDb) continue;
5021
+ if (reverse.relativeLevelDb === candidate.relativeLevelDb && reverse.trackId < candidate.trackId) continue;
5022
+ }
5023
+ }
5024
+ if (now - (this._lastRaisedAt.get(candidate.trackId) ?? -Infinity) < this._config.cooldownMs) continue;
5025
+ this._lastRaisedAt.set(candidate.trackId, now);
5026
+ const { series } = candidate;
5027
+ this.call.addIssue({
5028
+ type: AudioEchoTypes.audioEchoSuspected,
5029
+ timestamp: now,
5030
+ payload: {
5031
+ trackId: candidate.trackId,
5032
+ clientId: series.clientId,
5033
+ sourceTrackId: candidate.sourceTrackId,
5034
+ sourceClientId: this._series.get(candidate.sourceTrackId)?.clientId,
5035
+ followRatio: candidate.followRatio,
5036
+ levelCorrelation: candidate.levelCorrelation,
5037
+ relativeLevelDb: candidate.relativeLevelDb,
5038
+ sourceActiveBuckets: candidate.sourceActiveBuckets,
5039
+ bucketMs: this._config.bucketMs,
5040
+ ambiguousDirection,
5041
+ levelSource: series.track.rmsAudioLevelSource,
5042
+ audioInputLabel: series.track.audioInputDevice?.label,
5043
+ loopbackAudioInput: candidate.loopbackAudioInput
5044
+ }
5045
+ });
5046
+ }
5047
+ }
5048
+ };
5049
+
4456
5050
  // src/ObservedCall.ts
4457
- var logger4 = createLogger("ObservedCall");
4458
- var ObservedCall = class extends import_events3.EventEmitter {
5051
+ var logger6 = createLogger("ObservedCall");
5052
+ var ObservedCall = class extends import_node_events4.EventEmitter {
4459
5053
  constructor(settings, observer, activeIssuesRegistry) {
4460
5054
  super();
4461
5055
  this.observer = observer;
@@ -4463,6 +5057,8 @@ var ObservedCall = class extends import_events3.EventEmitter {
4463
5057
  this.setMaxListeners(Infinity);
4464
5058
  this.eventScope = { observer: this.observer, observedCall: this };
4465
5059
  this.callId = settings.callId;
5060
+ this.sinks = new SinkRegistry(this.observer.sinks);
5061
+ this.extensionStats = new ExtensionStatsRegistry(this.callId, this.sinks);
4466
5062
  this.appData = settings.appData ?? {};
4467
5063
  this.scoreCalculator = new DefaultCallScoreCalculator(this);
4468
5064
  this.detectors = new Detectors();
@@ -4511,10 +5107,6 @@ var ObservedCall = class extends import_events3.EventEmitter {
4511
5107
  numberOfDataChannels = 0;
4512
5108
  maxNumberOfClients = 0;
4513
5109
  deltaNumberOfIssues = 0;
4514
- // public deltaRttLt50Measurements = 0;
4515
- // public deltaRttLt150Measurements = 0;
4516
- // public deltaRttLt300Measurements = 0;
4517
- // public deltaRttGtOrEq300Measurements = 0;
4518
5110
  appData;
4519
5111
  closed = false;
4520
5112
  startedAt;
@@ -4523,6 +5115,8 @@ var ObservedCall = class extends import_events3.EventEmitter {
4523
5115
  settings;
4524
5116
  /** Ancestry base shared by all Observer-bus events originating at this call. */
4525
5117
  eventScope;
5118
+ sinks;
5119
+ extensionStats;
4526
5120
  closeTimer;
4527
5121
  get numberOfClients() {
4528
5122
  return this.observedClients.size;
@@ -4560,8 +5154,12 @@ var ObservedCall = class extends import_events3.EventEmitter {
4560
5154
  detector = new PublisherFaultCorroborationDetector(this, config);
4561
5155
  break;
4562
5156
  }
5157
+ case AudioEchoDetector.NAME: {
5158
+ detector = new AudioEchoDetector(this, config);
5159
+ break;
5160
+ }
4563
5161
  default: {
4564
- logger4.warn("Unknown detector name %s; skipping", name);
5162
+ logger6.warn("Unknown detector name %s; skipping", name);
4565
5163
  return this;
4566
5164
  }
4567
5165
  }
@@ -4631,6 +5229,8 @@ var ObservedCall = class extends import_events3.EventEmitter {
4631
5229
  if (client.joinedAt) minSampleTimestamps = Math.min(minSampleTimestamps ?? client.joinedAt, client.joinedAt);
4632
5230
  if (client.leftAt) maxSampleTimestamps = Math.max(maxSampleTimestamps ?? client.leftAt, client.leftAt);
4633
5231
  }
5232
+ this.extensionStats.close();
5233
+ this.sinks.clear();
4634
5234
  if (this.startedAt === void 0) this.startedAt = minSampleTimestamps;
4635
5235
  if (this.endedAt === void 0) this.endedAt = maxSampleTimestamps;
4636
5236
  this.closedAt = Date.now();
@@ -4643,17 +5243,26 @@ var ObservedCall = class extends import_events3.EventEmitter {
4643
5243
  this.emit("close");
4644
5244
  this._notify("call-closed", { ...this.eventScope });
4645
5245
  }
5246
+ createExtensionStats(settings) {
5247
+ return this.extensionStats.create(settings);
5248
+ }
5249
+ acceptExtensionStats(id, data) {
5250
+ this.extensionStats.accept(id, data);
5251
+ }
5252
+ getExtensionStats(id) {
5253
+ return this.extensionStats.get(id);
5254
+ }
4646
5255
  getObservedClient(clientId) {
4647
5256
  if (this.closed || !this.observedClients.has(clientId)) return;
4648
5257
  return this.observedClients.get(clientId);
4649
5258
  }
4650
5259
  createObservedClient(settings, acceptCtx) {
4651
5260
  if (this.closed) {
4652
- logger4.warn("Attempted to create a client (clientId: %s) on a closed call %s", settings.clientId, this.callId);
5261
+ logger6.warn("Attempted to create a client (clientId: %s) on a closed call %s", settings.clientId, this.callId);
4653
5262
  return void 0;
4654
5263
  }
4655
5264
  if (this.observedClients.has(settings.clientId)) {
4656
- logger4.warn("Client with id %s already exists in call %s; returning the existing instance", settings.clientId, this.callId);
5265
+ logger6.warn("Client with id %s already exists in call %s; returning the existing instance", settings.clientId, this.callId);
4657
5266
  return this.observedClients.get(settings.clientId);
4658
5267
  }
4659
5268
  const clientSettings = {
@@ -4703,10 +5312,17 @@ var ObservedCall = class extends import_events3.EventEmitter {
4703
5312
  clearTimeout(this.closeTimer);
4704
5313
  this.closeTimer = void 0;
4705
5314
  }
5315
+ const jsonlPath = this.observer.config.createClientJsonlSink?.({ observedCall: this, clientId: settings.clientId });
5316
+ let jsonlSink;
5317
+ if (jsonlPath !== void 0 && this.observer.sinks.get(settings.clientId)) {
5318
+ logger6.warn("No sample file for client %s in call %s: sink id %s is already in use", settings.clientId, this.callId, settings.clientId);
5319
+ } else if (jsonlPath !== void 0) {
5320
+ jsonlSink = this.sinks.create(JsonlFileSink, { id: settings.clientId, path: jsonlPath });
5321
+ }
4706
5322
  this.emit("newclient", result);
4707
5323
  this._notify("client-added", { ...this.eventScope, observedClient: result });
4708
- if (result.sink) {
4709
- this._notify("client-sink-created", { ...this.eventScope, observedClient: result, sink: result.sink });
5324
+ if (jsonlSink) {
5325
+ this._notify("client-sink-created", { ...this.eventScope, observedClient: result, sink: jsonlSink });
4710
5326
  }
4711
5327
  if (wasEmpty) {
4712
5328
  this.emit("not-empty");
@@ -4759,10 +5375,10 @@ var ObservedCall = class extends import_events3.EventEmitter {
4759
5375
  };
4760
5376
 
4761
5377
  // src/Observer.ts
4762
- var import_events6 = require("events");
5378
+ var import_node_events7 = require("events");
4763
5379
 
4764
5380
  // src/ObservedTURN.ts
4765
- var import_events4 = require("events");
5381
+ var import_node_events5 = require("events");
4766
5382
 
4767
5383
  // src/ObservedTurnServer.ts
4768
5384
  var ObservedTurnServer = class {
@@ -4822,8 +5438,8 @@ var ObservedTurnServer = class {
4822
5438
  };
4823
5439
 
4824
5440
  // src/ObservedTURN.ts
4825
- var logger5 = createLogger("ObservedTURN");
4826
- var ObservedTURN = class extends import_events4.EventEmitter {
5441
+ var logger7 = createLogger("ObservedTURN");
5442
+ var ObservedTURN = class extends import_node_events5.EventEmitter {
4827
5443
  totalBytesSent = 0;
4828
5444
  totalBytesReceived = 0;
4829
5445
  totalPacketsSent = 0;
@@ -4852,12 +5468,12 @@ var ObservedTURN = class extends import_events4.EventEmitter {
4852
5468
  addPeerConnection(peerConnection) {
4853
5469
  const turnPairs = peerConnection.selectedIceCandiadtePairForTurn;
4854
5470
  if (turnPairs.length !== 1) {
4855
- return logger5.warn(`Expected exactly one TURN pair, but found for peerconnection ${peerConnection.peerConnectionId}`, turnPairs.length), void 0;
5471
+ return logger7.warn(`Expected exactly one TURN pair, but found for peerconnection ${peerConnection.peerConnectionId}`, turnPairs.length), void 0;
4856
5472
  }
4857
5473
  const candidatePair = turnPairs[0];
4858
5474
  const rawUrl = candidatePair.getLocalCandidate()?.url;
4859
5475
  if (!rawUrl) {
4860
- return logger5.warn(`No local candidate URL found for peerconnection ${peerConnection.peerConnectionId}`), void 0;
5476
+ return logger7.warn(`No local candidate URL found for peerconnection ${peerConnection.peerConnectionId}`), void 0;
4861
5477
  }
4862
5478
  const turnServerUrl = rawUrl.split("?")[0];
4863
5479
  let turnServer = this.servers.get(turnServerUrl);
@@ -4933,14 +5549,16 @@ var MiddlewareProcessor = class {
4933
5549
  };
4934
5550
 
4935
5551
  // src/ObservedMediasoupRouter.ts
4936
- var import_events5 = require("events");
4937
- var logger6 = createLogger("ObservedMediasoupRouter");
4938
- var ObservedMediasoupRouter = class extends import_events5.EventEmitter {
5552
+ var import_node_events6 = require("events");
5553
+ var logger8 = createLogger("ObservedMediasoupRouter");
5554
+ var ObservedMediasoupRouter = class extends import_node_events6.EventEmitter {
4939
5555
  router;
4940
5556
  appData;
4941
5557
  sample;
4942
5558
  webrtcTransportIds = /* @__PURE__ */ new Set();
4943
5559
  closed = false;
5560
+ sinks;
5561
+ extensionStats;
4944
5562
  // Id -> sample indexes over the arrays in `this.sample`, so an entity can be reached in O(1)
4945
5563
  // instead of scanning (`sample.producers.find(...)`). They hold the *same* objects as the arrays.
4946
5564
  _transportSamples = /* @__PURE__ */ new Map();
@@ -4949,11 +5567,17 @@ var ObservedMediasoupRouter = class extends import_events5.EventEmitter {
4949
5567
  _dataProducerSamples = /* @__PURE__ */ new Map();
4950
5568
  _dataConsumerSamples = /* @__PURE__ */ new Map();
4951
5569
  _enrich;
4952
- constructor(settings) {
5570
+ /**
5571
+ * @param parentSinks the parent of `sinks`: `observer.sinks`, passed by
5572
+ * `Observer.createObservedMediasoupRouter`. Internal; a router constructed directly has none.
5573
+ */
5574
+ constructor(settings, parentSinks) {
4953
5575
  super();
4954
5576
  this.router = settings.router;
4955
5577
  this.appData = settings.appData ?? {};
4956
5578
  this._enrich = settings.enrich;
5579
+ this.sinks = new SinkRegistry(parentSinks);
5580
+ this.extensionStats = new ExtensionStatsRegistry(this.router.id, this.sinks);
4957
5581
  this.sample = {
4958
5582
  routerId: this.router.id,
4959
5583
  attachments: settings.attachments ?? {},
@@ -5022,8 +5646,19 @@ var ObservedMediasoupRouter = class extends import_events5.EventEmitter {
5022
5646
  if (this.closed) return;
5023
5647
  this.closed = true;
5024
5648
  if (this.sample.closedAt === void 0) this.sample.closedAt = Date.now();
5649
+ this.extensionStats.close();
5650
+ this.sinks.clear();
5025
5651
  this.emit("close");
5026
5652
  }
5653
+ createExtensionStats(settings) {
5654
+ return this.extensionStats.create(settings);
5655
+ }
5656
+ acceptExtensionStats(id, data) {
5657
+ this.extensionStats.accept(id, data);
5658
+ }
5659
+ getExtensionStats(id) {
5660
+ return this.extensionStats.get(id);
5661
+ }
5027
5662
  addTransport = (transport) => {
5028
5663
  let transportType = transport.type;
5029
5664
  if (!transportType) {
@@ -5032,7 +5667,7 @@ var ObservedMediasoupRouter = class extends import_events5.EventEmitter {
5032
5667
  else if (transport.constructor.name === "PipeTransport") transportType = "pipe";
5033
5668
  else if (transport.constructor.name === "DirectTransport") transportType = "direct";
5034
5669
  }
5035
- if (!transportType) return logger6.warn(`Unknown transport type for transport ${transport.id}: ${transport.constructor.name}`);
5670
+ if (!transportType) return logger8.warn(`Unknown transport type for transport ${transport.id}: ${transport.constructor.name}`);
5036
5671
  switch (transportType) {
5037
5672
  case "webrtc":
5038
5673
  return this.addWebRtcTransport(transport);
@@ -5043,7 +5678,7 @@ var ObservedMediasoupRouter = class extends import_events5.EventEmitter {
5043
5678
  case "direct":
5044
5679
  return this.addDirectTransport(transport);
5045
5680
  default:
5046
- return logger6.warn(`Unsupported transport type for transport ${transport.id}: ${transportType}`);
5681
+ return logger8.warn(`Unsupported transport type for transport ${transport.id}: ${transportType}`);
5047
5682
  }
5048
5683
  };
5049
5684
  addWebRtcTransport(transport) {
@@ -5285,7 +5920,7 @@ var ObservedMediasoupRouter = class extends import_events5.EventEmitter {
5285
5920
  const attachments = enrich();
5286
5921
  if (attachments) sample.attachments = { ...sample.attachments, ...attachments };
5287
5922
  } catch (err) {
5288
- logger6.warn("Mediasoup sample enricher threw for router %s: %o", this.id, err);
5923
+ logger8.warn("Mediasoup sample enricher threw for router %s: %o", this.id, err);
5289
5924
  }
5290
5925
  }
5291
5926
  _attachTransportObserverListeners(transport, transportSample, onClose) {
@@ -5310,7 +5945,7 @@ var ObservedMediasoupRouter = class extends import_events5.EventEmitter {
5310
5945
  };
5311
5946
 
5312
5947
  // src/issues/ActiveIssuesRegistry.ts
5313
- var logger7 = createLogger("ActiveIssuesRegistry");
5948
+ var logger9 = createLogger("ActiveIssuesRegistry");
5314
5949
  var ActiveIssuesRegistry = class {
5315
5950
  constructor(parent) {
5316
5951
  this.parent = parent;
@@ -5396,7 +6031,7 @@ var ActiveIssuesRegistry = class {
5396
6031
  try {
5397
6032
  apply(target);
5398
6033
  } catch (err) {
5399
- logger7.warn("An issue tracker threw while being notified; continuing with the rest. %o", err);
6034
+ logger9.warn("An issue tracker threw while being notified; continuing with the rest. %o", err);
5400
6035
  }
5401
6036
  }
5402
6037
  };
@@ -5425,7 +6060,7 @@ function createCallSummary(callId, config) {
5425
6060
  }
5426
6061
 
5427
6062
  // src/summaries/CallSummaryCollector.ts
5428
- var logger8 = createLogger("CallSummaryCollector");
6063
+ var logger10 = createLogger("CallSummaryCollector");
5429
6064
  var CallSummaryCollector = class {
5430
6065
  constructor(_observer, _config) {
5431
6066
  this._observer = _observer;
@@ -5500,7 +6135,7 @@ var CallSummaryCollector = class {
5500
6135
  try {
5501
6136
  handler(summary, scratch, ...args);
5502
6137
  } catch (err) {
5503
- logger8.warn("A call-summary handler for %s threw; continuing. %o", event, err);
6138
+ logger10.warn("A call-summary handler for %s threw; continuing. %o", event, err);
5504
6139
  }
5505
6140
  };
5506
6141
  this._observer.on(event, listener);
@@ -5925,7 +6560,7 @@ function geohash(location, precision = 3) {
5925
6560
  }
5926
6561
 
5927
6562
  // src/detectors/ClientPopulationIssueDetector.ts
5928
- var logger9 = createLogger("ClientPopulationIssueDetector");
6563
+ var logger11 = createLogger("ClientPopulationIssueDetector");
5929
6564
  var ClientPopulationIssueTypes = {
5930
6565
  /** One issue type is concentrated on one client population while the rest of the fleet is fine. */
5931
6566
  clientPopulationIssue: "CLIENT_POPULATION_ISSUE"
@@ -5947,7 +6582,7 @@ var ClientPopulationIssueDetector = class _ClientPopulationIssueDetector {
5947
6582
  ...config
5948
6583
  };
5949
6584
  if (this._config.groupBy === "location" && !this._config.resolveClientLocation) {
5950
- logger9.warn(
6585
+ logger11.warn(
5951
6586
  "%s is configured with groupBy: 'location' but no resolveClientLocation, so no client can be placed and nothing will ever be detected",
5952
6587
  this.name
5953
6588
  );
@@ -6781,11 +7416,13 @@ function mediaKindOf(mimeType) {
6781
7416
  }
6782
7417
 
6783
7418
  // src/Observer.ts
6784
- var logger10 = createLogger("Observer");
6785
- var Observer = class extends import_events6.EventEmitter {
7419
+ var logger12 = createLogger("Observer");
7420
+ var Observer = class extends import_node_events7.EventEmitter {
6786
7421
  observedTURN = new ObservedTURN();
6787
7422
  observedCalls = /* @__PURE__ */ new Map();
6788
7423
  observedMediasoupRouters = /* @__PURE__ */ new Map();
7424
+ sinks = new SinkRegistry();
7425
+ extensionStats = new ExtensionStatsRegistry("observer", this.sinks);
6789
7426
  /** Ancestry base shared by all Observer-bus events originating at the observer. */
6790
7427
  eventScope = { observer: this };
6791
7428
  config;
@@ -6900,7 +7537,7 @@ var Observer = class extends import_events6.EventEmitter {
6900
7537
  break;
6901
7538
  }
6902
7539
  default: {
6903
- logger10.warn("Unknown detector name %s; skipping", name);
7540
+ logger12.warn("Unknown detector name %s; skipping", name);
6904
7541
  return this;
6905
7542
  }
6906
7543
  }
@@ -6985,7 +7622,7 @@ var Observer = class extends import_events6.EventEmitter {
6985
7622
  break;
6986
7623
  }
6987
7624
  if (!validator) {
6988
- logger10.warn("Unknown validator name %s; skipping", name);
7625
+ logger12.warn("Unknown validator name %s; skipping", name);
6989
7626
  return this;
6990
7627
  }
6991
7628
  this.validators.add(validator);
@@ -7016,7 +7653,7 @@ var Observer = class extends import_events6.EventEmitter {
7016
7653
  try {
7017
7654
  validator.cancel(reason);
7018
7655
  } catch (err) {
7019
- logger10.warn("Error cancelling validator %s: %o", validator.name, err);
7656
+ logger12.warn("Error cancelling validator %s: %o", validator.name, err);
7020
7657
  }
7021
7658
  }
7022
7659
  return matching.length;
@@ -7032,11 +7669,11 @@ var Observer = class extends import_events6.EventEmitter {
7032
7669
  */
7033
7670
  createObservedCall(settings, acceptCtx) {
7034
7671
  if (this.closed) {
7035
- logger10.warn("Attempted to create a call (callId: %s) on a closed observer", settings.callId);
7672
+ logger12.warn("Attempted to create a call (callId: %s) on a closed observer", settings.callId);
7036
7673
  return void 0;
7037
7674
  }
7038
7675
  if (this.observedCalls.has(settings.callId)) {
7039
- logger10.warn("Observed Call with id %s already exists; returning the existing instance", settings.callId);
7676
+ logger12.warn("Observed Call with id %s already exists; returning the existing instance", settings.callId);
7040
7677
  return this.observedCalls.get(settings.callId);
7041
7678
  }
7042
7679
  const callSettings = {
@@ -7074,14 +7711,14 @@ var Observer = class extends import_events6.EventEmitter {
7074
7711
  }
7075
7712
  createObservedMediasoupRouter(settings) {
7076
7713
  if (this.closed) {
7077
- logger10.warn("Attempted to create mediasoup router (id: %d) on a closed observer", settings.router.id);
7714
+ logger12.warn("Attempted to create mediasoup router (id: %d) on a closed observer", settings.router.id);
7078
7715
  return void 0;
7079
7716
  }
7080
7717
  if (this.observedMediasoupRouters.has(settings.router.id)) {
7081
- logger10.warn("Observed Mediasoup Router (id %s) already exists; returning the existing instance", settings.router.id);
7718
+ logger12.warn("Observed Mediasoup Router (id %s) already exists; returning the existing instance", settings.router.id);
7082
7719
  return this.observedMediasoupRouters.get(settings.router.id);
7083
7720
  }
7084
- const observedMediasoupRouter = new ObservedMediasoupRouter(settings);
7721
+ const observedMediasoupRouter = new ObservedMediasoupRouter(settings, this.sinks);
7085
7722
  const observedMediasoupRouterScope = {
7086
7723
  observedMediasoupRouter,
7087
7724
  observer: this
@@ -7109,7 +7746,7 @@ var Observer = class extends import_events6.EventEmitter {
7109
7746
  }
7110
7747
  close() {
7111
7748
  if (this.closed) {
7112
- return logger10.debug("Attempted to close twice");
7749
+ return logger12.debug("Attempted to close twice");
7113
7750
  }
7114
7751
  this.closed = true;
7115
7752
  for (const call of [...this.observedCalls.values()]) call.close();
@@ -7118,8 +7755,19 @@ var Observer = class extends import_events6.EventEmitter {
7118
7755
  this.validators.clear();
7119
7756
  this.callSummaryCollector?.close();
7120
7757
  this.activeIssuesRegistry.clear();
7758
+ this.extensionStats.close();
7759
+ this.sinks.clear();
7121
7760
  this._notify("observer-closed", { ...this.eventScope });
7122
7761
  }
7762
+ createExtensionStats(settings) {
7763
+ return this.extensionStats.create(settings);
7764
+ }
7765
+ acceptExtensionStats(id, data) {
7766
+ this.extensionStats.accept(id, data);
7767
+ }
7768
+ getExtensionStats(id) {
7769
+ return this.extensionStats.get(id);
7770
+ }
7123
7771
  accept(sample, context) {
7124
7772
  if (this.closed) {
7125
7773
  return this._notify("sample-rejected", { ...this.eventScope, reason: "observer-closed", sample });
@@ -7127,7 +7775,7 @@ var Observer = class extends import_events6.EventEmitter {
7127
7775
  try {
7128
7776
  this.acceptMiddlewares.process({ sample, context });
7129
7777
  } catch (err) {
7130
- logger10.warn("An accept middleware threw; dropping the sample. %o", err);
7778
+ logger12.warn("An accept middleware threw; dropping the sample. %o", err);
7131
7779
  }
7132
7780
  if (!sample.callId) {
7133
7781
  this._notify("sample-rejected", { ...this.eventScope, reason: "missing-callId", sample });
@@ -7167,7 +7815,7 @@ var Observer = class extends import_events6.EventEmitter {
7167
7815
  try {
7168
7816
  validator.update();
7169
7817
  } catch (err) {
7170
- logger10.warn("Error running validator %s: %o", validator.name, err);
7818
+ logger12.warn("Error running validator %s: %o", validator.name, err);
7171
7819
  }
7172
7820
  }
7173
7821
  this._notify("observer-updated", { ...this.eventScope });
@@ -7403,67 +8051,34 @@ var TrendTester = class {
7403
8051
  }
7404
8052
  };
7405
8053
 
7406
- // src/sinks/ClientSampleSink.ts
7407
- var import_events7 = require("events");
7408
- var ClientSampleSink = class extends import_events7.EventEmitter {
7409
- };
7410
-
7411
- // src/sinks/JsonlFileSink.ts
7412
- var fs = __toESM(require("fs"));
7413
- var path = __toESM(require("path"));
7414
- var JsonlFileSink = class extends ClientSampleSink {
7415
- /** The file this sink writes to. Read it (e.g. in a `close` handler) to upload/move the file. */
7416
- path;
7417
- _stream;
7418
- _serializeSample;
7419
- constructor(options) {
7420
- super();
7421
- this.path = options.path;
7422
- this._stream = fs.createWriteStream(this.path, { flags: "a" });
7423
- this._stream.on("close", () => this.emit("close"));
7424
- this._stream.on("finish", () => this.emit("finish"));
7425
- this._stream.on("drain", () => this.emit("drain"));
7426
- this._stream.on("error", (err) => this.emit("error", err));
7427
- this._serializeSample = options.serializeSample ?? ((sample) => JSON.stringify(sample));
7428
- }
7429
- write(sample) {
7430
- return this._stream.write(`${this._serializeSample(sample)}
7431
- `);
7432
- }
7433
- end() {
7434
- this._stream.end();
7435
- }
7436
- };
7437
- function createJsonlFileSink(options) {
7438
- return new JsonlFileSink(options);
7439
- }
7440
- function createJsonlFileSinkFactory(options) {
7441
- return ({ clientId, observedCall }) => {
7442
- const filename = options.getFileName?.({ callId: observedCall.callId, clientId }) ?? `${observedCall.callId}__${clientId}.jsonl`;
7443
- return createJsonlFileSink({
7444
- path: path.join(options.directory, filename),
7445
- serializeSample: options.serializeSample
7446
- });
7447
- };
7448
- }
7449
-
7450
8054
  // src/sinks/InMemorySink.ts
7451
- var InMemorySink = class extends ClientSampleSink {
7452
- constructor(samples = []) {
8055
+ var import_node_events8 = require("events");
8056
+ var InMemorySink = class extends import_node_events8.EventEmitter {
8057
+ id;
8058
+ samples;
8059
+ _ended = false;
8060
+ constructor(settings = {}) {
7453
8061
  super();
7454
- this.samples = samples;
8062
+ this.id = settings.id ?? "in-memory";
8063
+ this.samples = settings.samples ?? [];
8064
+ }
8065
+ get ended() {
8066
+ return this._ended;
7455
8067
  }
7456
- samples;
7457
8068
  write(sample) {
8069
+ if (this._ended) return false;
7458
8070
  this.samples.push(sample);
7459
8071
  return true;
7460
8072
  }
7461
8073
  end() {
8074
+ if (this._ended) return;
8075
+ this._ended = true;
8076
+ this.emit("ended");
7462
8077
  this.emit("close");
7463
8078
  }
7464
8079
  };
7465
8080
  function createInMemorySink(samples) {
7466
- return new InMemorySink(samples);
8081
+ return new InMemorySink({ samples });
7467
8082
  }
7468
8083
 
7469
8084
  // src/resolvers/RemoteTrackResolver.ts
@@ -7641,6 +8256,8 @@ function createP2pRemoteTrackResolverFactory() {
7641
8256
  // Annotate the CommonJS export names for ESM import in node:
7642
8257
  0 && (module.exports = {
7643
8258
  ActiveIssuesRegistry,
8259
+ AudioEchoDetector,
8260
+ AudioEchoTypes,
7644
8261
  CODEC_MISMATCH_ISSUE,
7645
8262
  CallConcurrentIssueDetector,
7646
8263
  CallConcurrentIssueTypes,
@@ -7650,9 +8267,9 @@ function createP2pRemoteTrackResolverFactory() {
7650
8267
  ClientMetaTypes,
7651
8268
  ClientPopulationIssueDetector,
7652
8269
  ClientPopulationIssueTypes,
7653
- ClientSampleSink,
7654
8270
  CodecConsistencyValidator,
7655
8271
  Detectors,
8272
+ ExtensionStatsRegistry,
7656
8273
  GEOHASH_CELL_SIZES,
7657
8274
  InMemorySink,
7658
8275
  IssueFanOutDetector,
@@ -7688,10 +8305,15 @@ function createP2pRemoteTrackResolverFactory() {
7688
8305
  PublisherFaultCorroborationDetector,
7689
8306
  PublisherFaultTypes,
7690
8307
  RESOLVED_ISSUE_SUFFIX,
8308
+ RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS,
8309
+ RTT_HISTOGRAM_NUMBER_OF_BUCKETS,
7691
8310
  RemoteTrackResolver,
7692
8311
  RemoteTrackResolverValidator,
8312
+ SCORE_HISTOGRAM_BUCKET_WIDTH,
8313
+ SCORE_HISTOGRAM_NUMBER_OF_BUCKETS,
7693
8314
  SfuCongestionDetector,
7694
8315
  SimulcastReceiverValidator,
8316
+ SinkRegistry,
7695
8317
  SlidingWindow,
7696
8318
  TrackDeliveryMismatchDetector,
7697
8319
  TrackDeliveryMismatchTypes,
@@ -7712,7 +8334,6 @@ function createP2pRemoteTrackResolverFactory() {
7712
8334
  createDefaultMediasoupRemoteTrackResolverFactory,
7713
8335
  createInMemorySink,
7714
8336
  createJsonlFileSink,
7715
- createJsonlFileSinkFactory,
7716
8337
  createLogger,
7717
8338
  createP2pRemoteTrackResolverFactory,
7718
8339
  defaultCallSummaryConfig,
@@ -7727,6 +8348,7 @@ function createP2pRemoteTrackResolverFactory() {
7727
8348
  medianAbsoluteDeviation,
7728
8349
  pageHinkley,
7729
8350
  percentile,
8351
+ percentileOfHistogram,
7730
8352
  percentileOfSorted,
7731
8353
  robustZScore,
7732
8354
  schemaVersion,