@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.mjs CHANGED
@@ -37,18 +37,189 @@ function createLogger(moduleName) {
37
37
  }
38
38
  }();
39
39
  }
40
- function setObserverLogger(logger11) {
41
- mainLogger = logger11;
40
+ function setObserverLogger(logger13) {
41
+ mainLogger = logger13;
42
42
  }
43
43
 
44
44
  // src/ObservedCall.ts
45
- import { EventEmitter as EventEmitter3 } from "events";
45
+ import { EventEmitter as EventEmitter4 } from "events";
46
+
47
+ // src/sinks/SinkRegistry.ts
48
+ import { v4 as uuidv4 } from "uuid";
49
+ var logger = createLogger("SinkRegistry");
50
+ var SinkRegistry = class {
51
+ constructor(parent) {
52
+ this.parent = parent;
53
+ }
54
+ parent;
55
+ // This registry's sinks and its descendants'.
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ _sinks = /* @__PURE__ */ new Map();
58
+ /** Number of open sinks in this registry and its descendants. */
59
+ get size() {
60
+ return this._sinks.size;
61
+ }
62
+ keys() {
63
+ return this._sinks.keys();
64
+ }
65
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
66
+ values() {
67
+ return this._sinks.values();
68
+ }
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
+ get(id) {
71
+ return this._sinks.get(id);
72
+ }
73
+ /**
74
+ * Construct `new SinkClass(settings)` and register it. The id (`settings.id`, generated when
75
+ * omitted) is checked first, so nothing is constructed — no file is opened — when it is taken:
76
+ * `undefined` is returned and a warning logged.
77
+ */
78
+ create(SinkClass, settings) {
79
+ const lineage = this._lineage();
80
+ const id = settings.id ?? uuidv4();
81
+ if (lineage[lineage.length - 1]._sinks.has(id)) {
82
+ logger.warn("Sink id %s is already in use; sink ids must be unique per observer. No sink was created", id);
83
+ return void 0;
84
+ }
85
+ const sink = new SinkClass({ ...settings, id });
86
+ const onError = (err) => logger.warn("Sink %s failed: %o", id, err);
87
+ sink.on("error", onError);
88
+ sink.once("close", () => sink.off("error", onError));
89
+ sink.once("ended", () => {
90
+ for (const registry of lineage) {
91
+ if (registry._sinks.get(id) === sink) registry._sinks.delete(id);
92
+ }
93
+ });
94
+ for (const registry of lineage) registry._sinks.set(id, sink);
95
+ return sink;
96
+ }
97
+ /** Write `record` to the sink `id`. `false` when there is no such sink, or on backpressure. */
98
+ write(id, record) {
99
+ const sink = this._sinks.get(id);
100
+ if (!sink) {
101
+ logger.debug("No sink %s; record dropped", id);
102
+ return false;
103
+ }
104
+ return sink.write(record);
105
+ }
106
+ /** End every sink of this registry and of its descendants. */
107
+ clear() {
108
+ for (const sink of [...this._sinks.values()]) {
109
+ try {
110
+ sink.end();
111
+ } catch (err) {
112
+ logger.warn("Sink %s failed to end: %o", sink.id, err);
113
+ }
114
+ }
115
+ }
116
+ /** This registry and its ancestors, up to the root. */
117
+ _lineage() {
118
+ return [this, ...this.parent?._lineage() ?? []];
119
+ }
120
+ };
121
+
122
+ // src/sinks/ExtensionStatsRegistry.ts
123
+ var logger2 = createLogger("ExtensionStats");
124
+ var ExtensionStatsRegistry = class {
125
+ constructor(id, _sinks) {
126
+ this.id = id;
127
+ this._sinks = _sinks;
128
+ }
129
+ id;
130
+ _sinks;
131
+ /** The last data accepted for each extension stats id. */
132
+ values = /* @__PURE__ */ new Map();
133
+ closed = false;
134
+ /**
135
+ * Refuse further `create` / `accept`, and end the extension stats' sinks (the sinks whose id is
136
+ * an extension stats id). Other sinks of the owner, e.g. client sample sinks, are left to the
137
+ * owner's `sinks.clear()`. Called by the owner when it closes.
138
+ */
139
+ close() {
140
+ if (this.closed) return;
141
+ this.closed = true;
142
+ for (const id of this.values.keys()) {
143
+ this._sinks.get(id)?.end();
144
+ }
145
+ }
146
+ create(settings) {
147
+ if (this.closed) {
148
+ logger2.warn("Attempted to create extension stats %s on closed %s", settings.id, this.id);
149
+ return void 0;
150
+ }
151
+ if (this.values.has(settings.id)) {
152
+ logger2.warn("Extension stats with id %s already exists on %s", settings.id, this.id);
153
+ return void 0;
154
+ }
155
+ this.values.set(settings.id, void 0);
156
+ if (!settings.createSink) return void 0;
157
+ const [SinkClass, sinkSettings] = settings.createSink;
158
+ this._sinks.get(settings.id)?.end();
159
+ return this._sinks.create(SinkClass, { ...sinkSettings, id: settings.id });
160
+ }
161
+ accept(id, data) {
162
+ if (this.closed) {
163
+ logger2.debug("Extension stats %s dropped: %s is closed", id, this.id);
164
+ return;
165
+ }
166
+ this.values.set(id, data);
167
+ try {
168
+ this._sinks.get(id)?.write(data);
169
+ } catch (err) {
170
+ logger2.warn("Failed to write extension stats %s to sink: %o", id, err);
171
+ }
172
+ }
173
+ get(id) {
174
+ return this.values.get(id);
175
+ }
176
+ };
177
+
178
+ // src/sinks/JsonlFileSink.ts
179
+ import * as fs from "fs";
180
+ import { EventEmitter } from "events";
181
+ var JsonlFileSink = class extends EventEmitter {
182
+ id;
183
+ /** The file this sink writes to. Read it (e.g. in a `close` handler) to upload/move the file. */
184
+ path;
185
+ _stream;
186
+ _serializeSample;
187
+ _ended = false;
188
+ constructor(options) {
189
+ super();
190
+ this.id = options.id ?? options.path;
191
+ this.path = options.path;
192
+ this._stream = fs.createWriteStream(this.path, { flags: "a" });
193
+ this._stream.on("close", () => this.emit("close"));
194
+ this._stream.on("finish", () => this.emit("finish"));
195
+ this._stream.on("drain", () => this.emit("drain"));
196
+ this._stream.on("error", (err) => this.emit("error", err));
197
+ this._serializeSample = options.serializeSample ?? ((record) => JSON.stringify(record));
198
+ }
199
+ get ended() {
200
+ return this._ended;
201
+ }
202
+ write(record) {
203
+ if (this._ended) return false;
204
+ return this._stream.write(`${this._serializeSample(record)}
205
+ `);
206
+ }
207
+ end() {
208
+ if (this._ended) return;
209
+ this._ended = true;
210
+ this.emit("ended");
211
+ this._stream.end();
212
+ }
213
+ };
214
+ function createJsonlFileSink(options) {
215
+ return new JsonlFileSink(options);
216
+ }
46
217
 
47
218
  // src/ObservedClient.ts
48
- import { EventEmitter as EventEmitter2 } from "events";
219
+ import { EventEmitter as EventEmitter3 } from "events";
49
220
 
50
221
  // src/ObservedPeerConnection.ts
51
- import { EventEmitter } from "events";
222
+ import { EventEmitter as EventEmitter2 } from "events";
52
223
 
53
224
  // src/utils/stats.ts
54
225
  function percentile(values, p) {
@@ -103,6 +274,10 @@ function counterDelta(previous, current) {
103
274
  if (current < previous) return 0;
104
275
  return current - previous;
105
276
  }
277
+ function rmsAudioLevel(deltaTotalAudioEnergy, deltaTotalSamplesDuration) {
278
+ if (!(0 < deltaTotalSamplesDuration)) return void 0;
279
+ return Math.sqrt(Math.max(0, deltaTotalAudioEnergy) / deltaTotalSamplesDuration);
280
+ }
106
281
  function correlation(xs, ys) {
107
282
  const n = Math.min(xs.length, ys.length);
108
283
  if (n < 2) return 0;
@@ -135,8 +310,8 @@ function pageHinkley(values, delta = 0, lambda = 50) {
135
310
  let runningMinimum = 0;
136
311
  for (let i = 0; i < values.length; i++) {
137
312
  sum += values[i];
138
- const mean = sum / (i + 1);
139
- cumulative += values[i] - mean - delta;
313
+ const mean2 = sum / (i + 1);
314
+ cumulative += values[i] - mean2 - delta;
140
315
  if (cumulative < runningMinimum) runningMinimum = cumulative;
141
316
  const ph = cumulative - runningMinimum;
142
317
  statistic.push(ph);
@@ -188,6 +363,25 @@ function erf(x) {
188
363
  const y = 1 - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-(ax * ax));
189
364
  return sign * y;
190
365
  }
366
+ function percentileOfHistogram(histogram, bucketWidth, p, options = {}) {
367
+ let total = 0;
368
+ for (const count of histogram) total += count;
369
+ if (total < 1) return void 0;
370
+ const rank = Math.min(Math.max(p, 0), 1) * total;
371
+ let cumulative = 0;
372
+ for (let index = 0; index < histogram.length; ++index) {
373
+ const count = histogram[index];
374
+ if (count < 1 || cumulative + count < rank) {
375
+ cumulative += count;
376
+ continue;
377
+ }
378
+ if (options.openEnded && index === histogram.length - 1 && options.max !== void 0) return options.max;
379
+ const lower = Math.max(index * bucketWidth, options.min ?? -Infinity);
380
+ const upper = Math.min((index + 1) * bucketWidth, options.max ?? Infinity);
381
+ return lower + (rank - cumulative) / count * Math.max(0, upper - lower);
382
+ }
383
+ return options.max;
384
+ }
191
385
 
192
386
  // src/ObservedInboundRtp.ts
193
387
  var ObservedInboundRtp = class {
@@ -298,6 +492,15 @@ var ObservedInboundRtp = class {
298
492
  deltaFecPacketsReceived = 0;
299
493
  deltaFecPacketsDiscarded = 0;
300
494
  deltaPausesDuration = 0;
495
+ deltaTotalAudioEnergy = 0;
496
+ deltaTotalSamplesDuration = 0;
497
+ /**
498
+ * RMS level (0..1) of the audio played out from this stream over the tick, from the two deltas
499
+ * above. This is the far end's outgoing level **as this receiver heard it**, which is the only
500
+ * send-side level available for publishers on browsers that do not report `media-source` audio
501
+ * levels (Firefox). `undefined` for video and for a tick with no audio accounted for.
502
+ */
503
+ rmsAudioLevel;
301
504
  /**
302
505
  * Mean jitter-buffer delay for the frames/samples emitted in this tick (seconds), derived from
303
506
  * the cumulative `jitterBufferDelay` / `jitterBufferEmittedCount` pair — the only correct way to
@@ -383,6 +586,9 @@ var ObservedInboundRtp = class {
383
586
  this.jitterBufferDelayInMs = void 0;
384
587
  this.concealmentRatio = void 0;
385
588
  this.framesDroppedRatio = void 0;
589
+ this.deltaTotalAudioEnergy = 0;
590
+ this.deltaTotalSamplesDuration = 0;
591
+ this.rmsAudioLevel = void 0;
386
592
  const elapsedTimeInMs = stats.timestamp - this.timestamp;
387
593
  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
594
  if (0 < elapsedTimeInMs && !this.counterResetBoundary) {
@@ -412,6 +618,9 @@ var ObservedInboundRtp = class {
412
618
  this.deltaRetransmittedPacketsReceived = counterDelta(this.retransmittedPacketsReceived, stats.retransmittedPacketsReceived);
413
619
  this.deltaFecPacketsReceived = counterDelta(this.fecPacketsReceived, stats.fecPacketsReceived);
414
620
  this.deltaFecPacketsDiscarded = counterDelta(this.fecPacketsDiscarded, stats.fecPacketsDiscarded);
621
+ this.deltaTotalAudioEnergy = counterDelta(this.totalAudioEnergy, stats.totalAudioEnergy);
622
+ this.deltaTotalSamplesDuration = counterDelta(this.totalSamplesDuration, stats.totalSamplesDuration);
623
+ this.rmsAudioLevel = rmsAudioLevel(this.deltaTotalAudioEnergy, this.deltaTotalSamplesDuration);
415
624
  this.bitrate = this.deltaBytesReceived * 8 / elapsedInSeconds;
416
625
  const pixels = (stats.frameWidth ?? 0) * (stats.frameHeight ?? 0);
417
626
  if (0 < pixels && 0 < this.deltaFramesReceived) {
@@ -469,6 +678,9 @@ var ObservedInboundRtp = class {
469
678
  this.jitterBufferEmittedCount = stats.jitterBufferEmittedCount;
470
679
  this.jitterBufferMinimumDelay = stats.jitterBufferMinimumDelay;
471
680
  this.totalSamplesReceived = stats.totalSamplesReceived;
681
+ this.audioLevel = stats.audioLevel;
682
+ this.totalAudioEnergy = stats.totalAudioEnergy;
683
+ this.totalSamplesDuration = stats.totalSamplesDuration;
472
684
  this.concealedSamples = stats.concealedSamples;
473
685
  this.silentConcealedSamples = stats.silentConcealedSamples;
474
686
  this.concealmentEvents = stats.concealmentEvents;
@@ -1123,6 +1335,16 @@ var ObservedMediaSource = class {
1123
1335
  frames;
1124
1336
  framesPerSecond;
1125
1337
  attachments;
1338
+ /** Increase of `totalAudioEnergy` in this tick (audio only). */
1339
+ deltaTotalAudioEnergy = 0;
1340
+ /** Increase of `totalSamplesDuration` in this tick, in seconds (audio only). */
1341
+ deltaTotalSamplesDuration = 0;
1342
+ /**
1343
+ * RMS level (0..1) of what the capture device delivered over the tick, after the browser's own
1344
+ * processing (AEC, NS, AGC). Chrome and Safari report the inputs; Firefox does not, and there the
1345
+ * publisher's level has to come from its receivers — see `ObservedOutboundTrack.rmsAudioLevel`.
1346
+ */
1347
+ rmsAudioLevel;
1126
1348
  get visited() {
1127
1349
  const visited = this._visited;
1128
1350
  this._visited = false;
@@ -1136,6 +1358,14 @@ var ObservedMediaSource = class {
1136
1358
  }
1137
1359
  update(stats) {
1138
1360
  this._visited = true;
1361
+ this.deltaTotalAudioEnergy = 0;
1362
+ this.deltaTotalSamplesDuration = 0;
1363
+ this.rmsAudioLevel = void 0;
1364
+ if (this.timestamp < stats.timestamp) {
1365
+ this.deltaTotalAudioEnergy = counterDelta(this.totalAudioEnergy, stats.totalAudioEnergy);
1366
+ this.deltaTotalSamplesDuration = counterDelta(this.totalSamplesDuration, stats.totalSamplesDuration);
1367
+ this.rmsAudioLevel = rmsAudioLevel(this.deltaTotalAudioEnergy, this.deltaTotalSamplesDuration);
1368
+ }
1139
1369
  this.timestamp = stats.timestamp;
1140
1370
  this.trackIdentifier = stats.trackIdentifier;
1141
1371
  this.audioLevel = stats.audioLevel;
@@ -1534,6 +1764,20 @@ var ObservedOutboundTrack = class {
1534
1764
  remoteFractionLost;
1535
1765
  remoteRttInMs;
1536
1766
  qualityLimitationReason;
1767
+ /**
1768
+ * This audio track's RMS level (0..1) over the latest tick — how loud the publisher was sending.
1769
+ * Taken from the publisher's own `media-source` when it reports one (Chrome, Safari), otherwise
1770
+ * the median of what its receivers heard ({@link receivedRmsAudioLevel}), which is what makes it
1771
+ * available for Firefox publishers too. `undefined` for video, or when neither is known.
1772
+ * {@link rmsAudioLevelSource} says which one it is.
1773
+ */
1774
+ rmsAudioLevel;
1775
+ rmsAudioLevelSource;
1776
+ /**
1777
+ * Median RMS level (0..1) of this track across the receivers that played it out, each over its own
1778
+ * latest tick. Needs a `RemoteTrackResolver`: without links there are no receivers to ask.
1779
+ */
1780
+ receivedRmsAudioLevel;
1537
1781
  get score() {
1538
1782
  return this.calculatedScore.value;
1539
1783
  }
@@ -1554,6 +1798,10 @@ var ObservedOutboundTrack = class {
1554
1798
  getMediaSource() {
1555
1799
  return this._mediaSource;
1556
1800
  }
1801
+ /** The capture device behind this track, when the client sent `AUDIO_INPUT_DEVICE` metadata for it. */
1802
+ get audioInputDevice() {
1803
+ return this._peerConnection.client.audioInputDevices.get(this.id);
1804
+ }
1557
1805
  update(stats) {
1558
1806
  this._visited = true;
1559
1807
  this.timestamp = stats.timestamp;
@@ -1585,8 +1833,30 @@ var ObservedOutboundTrack = class {
1585
1833
  if (this.muted !== true && this.deltaPacketsSent === 0) reasons.push("no-packets-sent");
1586
1834
  this.degradedReasons = reasons.length === 0 ? void 0 : reasons;
1587
1835
  this.receivingDistribution = this.createReceivingDistribution();
1836
+ this._updateAudioLevel();
1588
1837
  this.detectors.update();
1589
1838
  }
1839
+ _updateAudioLevel() {
1840
+ this.rmsAudioLevel = void 0;
1841
+ this.rmsAudioLevelSource = void 0;
1842
+ this.receivedRmsAudioLevel = void 0;
1843
+ if (this.kind !== "audio") return;
1844
+ if (0 < this.remoteInboundTracks.size) {
1845
+ const levels = [];
1846
+ for (const inboundTrack of this.remoteInboundTracks) {
1847
+ pushFinite(levels, inboundTrack.getInboundRtp()?.rmsAudioLevel);
1848
+ }
1849
+ this.receivedRmsAudioLevel = median(levels);
1850
+ }
1851
+ const sourceLevel = this._mediaSource?.rmsAudioLevel;
1852
+ if (sourceLevel !== void 0) {
1853
+ this.rmsAudioLevel = sourceLevel;
1854
+ this.rmsAudioLevelSource = "media-source";
1855
+ } else if (this.receivedRmsAudioLevel !== void 0) {
1856
+ this.rmsAudioLevel = this.receivedRmsAudioLevel;
1857
+ this.rmsAudioLevelSource = "receivers";
1858
+ }
1859
+ }
1590
1860
  createReceivingDistribution() {
1591
1861
  if (this.remoteInboundTracks.size === 0) return void 0;
1592
1862
  let freezeAffected = 0;
@@ -1642,14 +1912,14 @@ var ObservedOutboundTrack = class {
1642
1912
 
1643
1913
  // src/ObservedPeerConnection.ts
1644
1914
  var ascending2 = (a, b) => a - b;
1645
- var logger = createLogger("ObservedPeerConnection");
1915
+ var logger3 = createLogger("ObservedPeerConnection");
1646
1916
  function publicAddressOfLocalCandidate(candidate) {
1647
1917
  if (!candidate) return void 0;
1648
1918
  if (candidate.candidateType !== "relay" && isPublicIpAddress(candidate.address)) return candidate.address;
1649
1919
  if (isPublicIpAddress(candidate.relatedAddress)) return candidate.relatedAddress;
1650
1920
  return void 0;
1651
1921
  }
1652
- var ObservedPeerConnection = class extends EventEmitter {
1922
+ var ObservedPeerConnection = class extends EventEmitter2 {
1653
1923
  constructor(peerConnectionId, client) {
1654
1924
  super();
1655
1925
  this.peerConnectionId = peerConnectionId;
@@ -2231,7 +2501,7 @@ var ObservedPeerConnection = class extends EventEmitter {
2231
2501
  let observedCertificate = this.observedCertificates.get(stats.id);
2232
2502
  if (!observedCertificate) {
2233
2503
  if (!stats.timestamp || !stats.id || !stats.fingerprint) {
2234
- return logger.warn(
2504
+ return logger3.warn(
2235
2505
  `ObservedPeerConnection received an invalid CertificateStats (missing timestamp OR id OR fingerprint field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2236
2506
  stats
2237
2507
  );
@@ -2250,7 +2520,7 @@ var ObservedPeerConnection = class extends EventEmitter {
2250
2520
  let observedCodec = this.observedCodecs.get(stats.id);
2251
2521
  if (!observedCodec) {
2252
2522
  if (!stats.timestamp || !stats.id || !stats.mimeType) {
2253
- return logger.warn(
2523
+ return logger3.warn(
2254
2524
  `ObservedPeerConnection received an invalid CodecStats (missing timestamp OR id OR mimeType field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2255
2525
  stats
2256
2526
  );
@@ -2269,7 +2539,7 @@ var ObservedPeerConnection = class extends EventEmitter {
2269
2539
  let observedDataChannel = this.observedDataChannels.get(stats.id);
2270
2540
  if (!observedDataChannel) {
2271
2541
  if (!stats.timestamp || !stats.id) {
2272
- return logger.warn(
2542
+ return logger3.warn(
2273
2543
  `ObservedPeerConnection received an invalid DataChannelStats (missing timestamp OR id field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2274
2544
  stats
2275
2545
  );
@@ -2288,7 +2558,7 @@ var ObservedPeerConnection = class extends EventEmitter {
2288
2558
  let observedIceCandidate = this.observedIceCandidates.get(stats.id);
2289
2559
  if (!observedIceCandidate) {
2290
2560
  if (!stats.timestamp || !stats.id) {
2291
- return logger.warn(
2561
+ return logger3.warn(
2292
2562
  `ObservedPeerConnection received an invalid IceCandidateStats (missing timestamp OR id field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2293
2563
  stats
2294
2564
  );
@@ -2307,7 +2577,7 @@ var ObservedPeerConnection = class extends EventEmitter {
2307
2577
  let observedIceCandidatePair = this.observedIceCandidatesPair.get(stats.id);
2308
2578
  if (!observedIceCandidatePair) {
2309
2579
  if (!stats.timestamp || !stats.id) {
2310
- return logger.warn(
2580
+ return logger3.warn(
2311
2581
  `ObservedPeerConnection received an invalid IceCandidateStats (missing timestamp OR id field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2312
2582
  stats
2313
2583
  );
@@ -2326,7 +2596,7 @@ var ObservedPeerConnection = class extends EventEmitter {
2326
2596
  let observedIceTransport = this.observedIceTransports.get(stats.id);
2327
2597
  if (!observedIceTransport) {
2328
2598
  if (!stats.timestamp || !stats.id) {
2329
- return logger.warn(
2599
+ return logger3.warn(
2330
2600
  `ObservedPeerConnection received an invalid IceCandidateStats (missing timestamp OR id field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2331
2601
  stats
2332
2602
  );
@@ -2345,7 +2615,7 @@ var ObservedPeerConnection = class extends EventEmitter {
2345
2615
  let observedInboundRtp = this.observedInboundRtps.get(stats.ssrc);
2346
2616
  if (!observedInboundRtp) {
2347
2617
  if (!stats.timestamp || !stats.id || !stats.ssrc || !stats.kind || !stats.trackIdentifier) {
2348
- return logger.warn(
2618
+ return logger3.warn(
2349
2619
  `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}`,
2350
2620
  stats
2351
2621
  );
@@ -2371,7 +2641,7 @@ var ObservedPeerConnection = class extends EventEmitter {
2371
2641
  let observedInboundTrack = this.observedInboundTracks.get(stats.id);
2372
2642
  if (!observedInboundTrack) {
2373
2643
  if (!stats.timestamp || !stats.id || !stats.kind) {
2374
- return logger.warn(
2644
+ return logger3.warn(
2375
2645
  `ObservedPeerConnection received an invalid InboundTrackSample (missing timestamp OR id OR kind field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2376
2646
  stats
2377
2647
  );
@@ -2399,7 +2669,7 @@ var ObservedPeerConnection = class extends EventEmitter {
2399
2669
  let observedMediaPlayout = this.observedMediaPlayouts.get(stats.id);
2400
2670
  if (!observedMediaPlayout) {
2401
2671
  if (!stats.timestamp || !stats.id || !stats.kind) {
2402
- return logger.warn(
2672
+ return logger3.warn(
2403
2673
  `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}`,
2404
2674
  stats
2405
2675
  );
@@ -2423,7 +2693,7 @@ var ObservedPeerConnection = class extends EventEmitter {
2423
2693
  let observedMediaSource = this.observedMediaSources.get(stats.id);
2424
2694
  if (!observedMediaSource) {
2425
2695
  if (!stats.timestamp || !stats.id || !stats.kind) {
2426
- return logger.warn(
2696
+ return logger3.warn(
2427
2697
  `ObservedPeerConnection received an invalid InboundRtpStats (missing timestamp OR id OR kind field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2428
2698
  stats
2429
2699
  );
@@ -2447,7 +2717,7 @@ var ObservedPeerConnection = class extends EventEmitter {
2447
2717
  let observedOutboundRtp = this.observedOutboundRtps.get(stats.ssrc);
2448
2718
  if (!observedOutboundRtp) {
2449
2719
  if (!stats.timestamp || !stats.id || !stats.ssrc || !stats.kind) {
2450
- return logger.warn(
2720
+ return logger3.warn(
2451
2721
  `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}`,
2452
2722
  stats
2453
2723
  );
@@ -2472,7 +2742,7 @@ var ObservedPeerConnection = class extends EventEmitter {
2472
2742
  let observedOutboundTrack = this.observedOutboundTracks.get(stats.id);
2473
2743
  if (!observedOutboundTrack) {
2474
2744
  if (!stats.timestamp || !stats.id || !stats.kind) {
2475
- return logger.warn(
2745
+ return logger3.warn(
2476
2746
  `ObservedPeerConnection received an invalid OutboundTrackSample (missing timestamp OR id OR kind field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2477
2747
  stats
2478
2748
  );
@@ -2500,7 +2770,7 @@ var ObservedPeerConnection = class extends EventEmitter {
2500
2770
  let observedPeerConnectionTransport = this.observedPeerConnectionTransports.get(stats.id);
2501
2771
  if (!observedPeerConnectionTransport) {
2502
2772
  if (!stats.timestamp || !stats.id) {
2503
- return logger.warn(
2773
+ return logger3.warn(
2504
2774
  `ObservedPeerConnection received an invalid PeerConnectionTransportStats (missing timestamp OR id field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`,
2505
2775
  stats
2506
2776
  );
@@ -2519,7 +2789,7 @@ var ObservedPeerConnection = class extends EventEmitter {
2519
2789
  let observedRemoteInboundRtp = this.observedRemoteInboundRtps.get(stats.ssrc);
2520
2790
  if (!observedRemoteInboundRtp) {
2521
2791
  if (!stats.timestamp || !stats.id || !stats.ssrc || !stats.kind) {
2522
- return logger.warn(
2792
+ return logger3.warn(
2523
2793
  `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}`,
2524
2794
  stats
2525
2795
  );
@@ -2544,7 +2814,7 @@ var ObservedPeerConnection = class extends EventEmitter {
2544
2814
  let observedRemoteOutboundRtp = this.observedRemoteOutboundRtps.get(stats.ssrc);
2545
2815
  if (!observedRemoteOutboundRtp) {
2546
2816
  if (!stats.timestamp || !stats.id || !stats.ssrc || !stats.kind) {
2547
- return logger.warn(
2817
+ return logger3.warn(
2548
2818
  `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}`,
2549
2819
  stats
2550
2820
  );
@@ -2626,6 +2896,7 @@ var ClientMetaTypes = /* @__PURE__ */ ((ClientMetaTypes2) => {
2626
2896
  ClientMetaTypes2["MEDIA_DEVICES_SUPPORTED_CONSTRAINTS"] = "MEDIA_DEVICES_SUPPORTED_CONSTRAINTS";
2627
2897
  ClientMetaTypes2["USER_MEDIA_ERROR"] = "USER_MEDIA_ERROR";
2628
2898
  ClientMetaTypes2["LOCAL_SDP"] = "LOCAL_SDP";
2899
+ ClientMetaTypes2["AUDIO_INPUT_DEVICE"] = "AUDIO_INPUT_DEVICE";
2629
2900
  ClientMetaTypes2["OPERATION_SYSTEM"] = "OPERATION_SYSTEM";
2630
2901
  ClientMetaTypes2["ENGINE"] = "ENGINE";
2631
2902
  ClientMetaTypes2["PLATFORM"] = "PLATFORM";
@@ -2643,31 +2914,27 @@ function baseIssueType(type) {
2643
2914
  }
2644
2915
 
2645
2916
  // src/ObservedClient.ts
2646
- var logger2 = createLogger("ObservedClient");
2647
- var ObservedClient = class extends EventEmitter2 {
2917
+ var logger4 = createLogger("ObservedClient");
2918
+ var RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS = 20;
2919
+ var RTT_HISTOGRAM_NUMBER_OF_BUCKETS = 50;
2920
+ var SCORE_HISTOGRAM_BUCKET_WIDTH = 1;
2921
+ var SCORE_HISTOGRAM_NUMBER_OF_BUCKETS = 5;
2922
+ var ObservedClient = class extends EventEmitter3 {
2648
2923
  constructor(settings, call, activeIssues) {
2649
2924
  super();
2650
2925
  this.call = call;
2651
2926
  this.setMaxListeners(Infinity);
2652
2927
  this.eventScope = { observer: call.observer, observedCall: call, observedClient: this };
2653
- this.sink = call.observer.config.createClientSink?.({ clientId: settings.clientId, observedCall: call });
2654
2928
  this.clientId = settings.clientId;
2655
2929
  this.appData = settings.appData ?? {};
2656
2930
  this.settings = {
2657
2931
  closeClientIfIdleForMs: settings.closeClientIfIdleForMs
2658
2932
  };
2659
- if (this.sink) {
2660
- const onError = (err) => logger2.warn("ClientSampleSink error for client %s: %o", this.clientId, err);
2661
- this.sink.once("close", () => this.sink?.off("error", onError));
2662
- this.sink.on("error", onError);
2663
- }
2664
2933
  this.activeIssues = activeIssues;
2665
2934
  }
2666
2935
  call;
2667
2936
  clientId;
2668
2937
  observedPeerConnections = /* @__PURE__ */ new Map();
2669
- /** Per-client sink (from `ObserverConfig.createClientSink`), if any. */
2670
- sink;
2671
2938
  /** Ancestry base shared by all Observer-bus events originating at this client. */
2672
2939
  eventScope;
2673
2940
  calculatedScore = {
@@ -2708,10 +2975,6 @@ var ObservedClient = class extends EventEmitter2 {
2708
2975
  deltaOutboundPacketsSent = 0;
2709
2976
  deltaTransportSentBytes = 0;
2710
2977
  deltaTransportReceivedBytes = 0;
2711
- deltaRttLt50Measurements = 0;
2712
- deltaRttLt150Measurements = 0;
2713
- deltaRttLt300Measurements = 0;
2714
- deltaRttGtOrEq300Measurements = 0;
2715
2978
  currentMaxRttInMs;
2716
2979
  currentMinRttInMs;
2717
2980
  currentAvgRttInMs;
@@ -2727,8 +2990,73 @@ var ObservedClient = class extends EventEmitter2 {
2727
2990
  deltaNumberOfIssues = 0;
2728
2991
  totalScoreSum = 0;
2729
2992
  numberOfScoreMeasurements = 0;
2730
- // public totalNumberOfIssues = 0;
2993
+ minScore;
2994
+ maxScore;
2995
+ /**
2996
+ * Score measurements (one per accepted sample with a score) counted into
2997
+ * `SCORE_HISTOGRAM_NUMBER_OF_BUCKETS` buckets of `SCORE_HISTOGRAM_BUCKET_WIDTH`. Histograms of
2998
+ * several clients can be added bucket by bucket to get the distribution of a call.
2999
+ */
3000
+ scoreHistogram = new Array(SCORE_HISTOGRAM_NUMBER_OF_BUCKETS).fill(0);
3001
+ /** Issues raised over the session, counted as in `issueHistogram`. */
3002
+ totalNumberOfIssues = 0;
3003
+ /**
3004
+ * Number of times each issue type was raised over the session, keyed by the base type (without
3005
+ * the `-resolved` suffix): `client.issueHistogram['loopback-audio-input']`. A resolution entry is
3006
+ * not counted, and neither is a re-raise of a keyed issue that is still open, so a stateful issue
3007
+ * reported on every tick counts once per open interval. A type never raised is `undefined`.
3008
+ */
3009
+ issueHistogram = /* @__PURE__ */ Object.create(null);
3010
+ // Session totals. Unlike the `delta*` and `current*` fields they are never reset, and unlike the
3011
+ // `total*` fields of `ObservedPeerConnection` they survive a peer connection being closed.
3012
+ // Every RTT measurement is one peer connection's `currentRttInMs` in one accepted sample.
3013
+ totalRttSumInMs = 0;
3014
+ numberOfRttMeasurements = 0;
3015
+ maxRttInMs;
3016
+ minRttInMs;
3017
+ /**
3018
+ * RTT measurements counted into fixed buckets of `RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS`; the last bucket
3019
+ * holds every measurement of 980 ms and above. Constant memory, so percentiles can be estimated
3020
+ * over the whole session (see `rttSummary`) without keeping every measurement. Histograms
3021
+ * of several clients can be added bucket by bucket to get the distribution of a call.
3022
+ */
3023
+ rttHistogram = new Array(RTT_HISTOGRAM_NUMBER_OF_BUCKETS).fill(0);
3024
+ _rttSummary;
3025
+ _scoreSummary;
3026
+ totalInboundPacketsLost = 0;
3027
+ totalInboundPacketsReceived = 0;
3028
+ totalOutboundPacketsSent = 0;
3029
+ totalReceivedAudioBytes = 0;
3030
+ totalReceivedVideoBytes = 0;
3031
+ totalSentAudioBytes = 0;
3032
+ totalSentVideoBytes = 0;
3033
+ totalTransportReceivedBytes = 0;
3034
+ totalTransportSentBytes = 0;
3035
+ totalDataChannelBytesReceived = 0;
3036
+ totalDataChannelBytesSent = 0;
3037
+ totalDataChannelMessagesReceived = 0;
3038
+ totalDataChannelMessagesSent = 0;
3039
+ /** Highest `sendingAudioBitrate + sendingVideoBitrate` of any sample, in bits per second. */
3040
+ peakSendingBitrate = 0;
3041
+ /** Highest `receivingAudioBitrate + receivingVideoBitrate` of any sample, in bits per second. */
3042
+ peakReceivingBitrate = 0;
3043
+ // Samples by the transport path of the selected ICE candidate pairs. A sample whose peer
3044
+ // connections use different paths is counted under each of them, so more than one non-zero
3045
+ // counter means the path changed or was mixed. A sample with no selected pair counts as unknown.
3046
+ numberOfSamplesOverUdp = 0;
3047
+ numberOfSamplesOverTcp = 0;
3048
+ numberOfSamplesOverTurnUdp = 0;
3049
+ numberOfSamplesOverTurnTcp = 0;
3050
+ numberOfSamplesWithUnknownPath = 0;
3051
+ numberOfPublicAddressChanges = 0;
2731
3052
  mediaDevices = [];
3053
+ /**
3054
+ * The capture device behind each outbound audio track, keyed by track id, from the client's
3055
+ * `AUDIO_INPUT_DEVICE` metadata. Kept for the client's lifetime (a device switch is a new track
3056
+ * id), so a finding raised after a track ended can still name its device. Read it per track via
3057
+ * `ObservedOutboundTrack.audioInputDevice`.
3058
+ */
3059
+ audioInputDevices = /* @__PURE__ */ new Map();
2732
3060
  /**
2733
3061
  * The client's currently **open** stateful issues, keyed by `ClientIssue.key` — the server-side
2734
3062
  * mirror of the client monitor's own active-issue map (client-monitor-js >= 4.6.0).
@@ -2741,12 +3069,17 @@ var ObservedClient = class extends EventEmitter2 {
2741
3069
  activeIssues;
2742
3070
  _pendingInjections = {};
2743
3071
  _activeSample;
2744
- /** Peer connections the client reported closed (`PEER_CONNECTION_CLOSED`), closed once the current sample is applied. */
2745
3072
  _peerConnectionsToClose = /* @__PURE__ */ new Set();
2746
- /** Ids of peer connections closed by the client; late stats for them are ignored rather than resurrecting them. */
2747
3073
  _closedPeerConnectionIds = /* @__PURE__ */ new Set();
2748
3074
  closeTimer;
2749
3075
  /** The distinct `publicAddress`es currently known by this client's open peer connections. */
3076
+ /**
3077
+ * The sink this client's samples are written to: whatever is registered under its client id in
3078
+ * `call.sinks` (the `createClientJsonlSink` file, or a sink the application added), if any.
3079
+ */
3080
+ get sink() {
3081
+ return this.call.sinks.get(this.clientId);
3082
+ }
2750
3083
  get publicAddresses() {
2751
3084
  const result = /* @__PURE__ */ new Set();
2752
3085
  for (const observedPeerConnection of this.observedPeerConnections.values()) {
@@ -2760,6 +3093,61 @@ var ObservedClient = class extends EventEmitter2 {
2760
3093
  get score() {
2761
3094
  return this.calculatedScore.value;
2762
3095
  }
3096
+ /**
3097
+ * Percentiles (error at most one 20 ms bucket; a percentile in the overflow bucket is reported as
3098
+ * `maxRttInMs`) and mean of every RTT measurement of the session. Cached until the next measurement.
3099
+ */
3100
+ get rttSummary() {
3101
+ if (!this._rttSummary) {
3102
+ const bounds = { min: this.minRttInMs, max: this.maxRttInMs, openEnded: true };
3103
+ const percentile2 = (p) => percentileOfHistogram(this.rttHistogram, RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS, p, bounds);
3104
+ this._rttSummary = Object.freeze({
3105
+ p50: percentile2(0.5),
3106
+ p75: percentile2(0.75),
3107
+ p95: percentile2(0.95),
3108
+ p99: percentile2(0.99),
3109
+ avg: 0 < this.numberOfRttMeasurements ? this.totalRttSumInMs / this.numberOfRttMeasurements : void 0
3110
+ });
3111
+ }
3112
+ return this._rttSummary;
3113
+ }
3114
+ /**
3115
+ * Percentiles (error at most one score point) and mean of every score of the session. Cached until
3116
+ * the next score.
3117
+ */
3118
+ get scoreSummary() {
3119
+ if (!this._scoreSummary) {
3120
+ const bounds = { min: this.minScore, max: this.maxScore };
3121
+ const percentile2 = (p) => percentileOfHistogram(this.scoreHistogram, SCORE_HISTOGRAM_BUCKET_WIDTH, p, bounds);
3122
+ this._scoreSummary = Object.freeze({
3123
+ p5: percentile2(0.05),
3124
+ p25: percentile2(0.25),
3125
+ p50: percentile2(0.5),
3126
+ p75: percentile2(0.75),
3127
+ p95: percentile2(0.95),
3128
+ avg: 0 < this.numberOfScoreMeasurements ? this.totalScoreSum / this.numberOfScoreMeasurements : void 0
3129
+ });
3130
+ }
3131
+ return this._scoreSummary;
3132
+ }
3133
+ _addScoreMeasurement(score) {
3134
+ this._scoreSummary = void 0;
3135
+ this.totalScoreSum += score;
3136
+ ++this.numberOfScoreMeasurements;
3137
+ if (this.maxScore === void 0 || this.maxScore < score) this.maxScore = score;
3138
+ if (this.minScore === void 0 || score < this.minScore) this.minScore = score;
3139
+ const bucket = Math.min(Math.max(Math.floor(score / SCORE_HISTOGRAM_BUCKET_WIDTH), 0), SCORE_HISTOGRAM_NUMBER_OF_BUCKETS - 1);
3140
+ ++this.scoreHistogram[bucket];
3141
+ }
3142
+ _addRttMeasurement(rttInMs) {
3143
+ this._rttSummary = void 0;
3144
+ this.totalRttSumInMs += rttInMs;
3145
+ ++this.numberOfRttMeasurements;
3146
+ if (this.maxRttInMs === void 0 || this.maxRttInMs < rttInMs) this.maxRttInMs = rttInMs;
3147
+ if (this.minRttInMs === void 0 || rttInMs < this.minRttInMs) this.minRttInMs = rttInMs;
3148
+ const bucket = Math.min(Math.max(Math.floor(rttInMs / RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS), 0), RTT_HISTOGRAM_NUMBER_OF_BUCKETS - 1);
3149
+ ++this.rttHistogram[bucket];
3150
+ }
2763
3151
  close() {
2764
3152
  if (this.closed) return;
2765
3153
  if (this.closeTimer) {
@@ -2786,12 +3174,12 @@ var ObservedClient = class extends EventEmitter2 {
2786
3174
  try {
2787
3175
  this.sink?.end();
2788
3176
  } catch (err) {
2789
- logger2.warn("ClientSampleSink.end failed for client %s: %o", this.clientId, err);
3177
+ logger4.warn("Sink end failed for client %s: %o", this.clientId, err);
2790
3178
  }
2791
3179
  }
2792
3180
  accept(sample, context) {
2793
3181
  if (this.closed) {
2794
- logger2.warn("Attempted to accept a sample on a closed client %s", this.clientId);
3182
+ logger4.warn("Attempted to accept a sample on a closed client %s", this.clientId);
2795
3183
  return;
2796
3184
  }
2797
3185
  if (this.closeTimer) {
@@ -2803,6 +3191,10 @@ var ObservedClient = class extends EventEmitter2 {
2803
3191
  const elapsedInSeconds = elapsedInMs / 1e3;
2804
3192
  let sumOfRtts = 0;
2805
3193
  let numberOfRttMeasurements = 0;
3194
+ let overUdp = false;
3195
+ let overTcp = false;
3196
+ let overTurnUdp = false;
3197
+ let overTurnTcp = false;
2806
3198
  ++this.acceptedSamples;
2807
3199
  this.availableIncomingBitrate = 0;
2808
3200
  this.availableOutgoingBitrate = 0;
@@ -2819,10 +3211,6 @@ var ObservedClient = class extends EventEmitter2 {
2819
3211
  this.deltaSentVideoBytes = 0;
2820
3212
  this.deltaTransportReceivedBytes = 0;
2821
3213
  this.deltaTransportSentBytes = 0;
2822
- this.deltaRttLt50Measurements = 0;
2823
- this.deltaRttLt150Measurements = 0;
2824
- this.deltaRttLt300Measurements = 0;
2825
- this.deltaRttGtOrEq300Measurements = 0;
2826
3214
  this.deltaNumberOfIssues = 0;
2827
3215
  this.numberOfDataChannels = 0;
2828
3216
  this.numberOfInboundRtpStreams = 0;
@@ -2894,6 +3282,18 @@ var ObservedClient = class extends EventEmitter2 {
2894
3282
  if (observedPeerConnection.usingTCP) {
2895
3283
  this.usingTCP = true;
2896
3284
  }
3285
+ for (const selectedPair of observedPeerConnection.selectedIceCandidatePairs) {
3286
+ const localCandidate = selectedPair.getLocalCandidate();
3287
+ if (!localCandidate) continue;
3288
+ if (localCandidate.candidateType === "relay") {
3289
+ if (localCandidate.relayProtocol === "tcp" || localCandidate.relayProtocol === "tls") overTurnTcp = true;
3290
+ else overTurnUdp = true;
3291
+ } else if (localCandidate.protocol === "tcp") {
3292
+ overTcp = true;
3293
+ } else {
3294
+ overUdp = true;
3295
+ }
3296
+ }
2897
3297
  if (observedPeerConnection.currentRttInMs) {
2898
3298
  if (this.currentMinRttInMs === void 0 || observedPeerConnection.currentRttInMs < this.currentMinRttInMs) {
2899
3299
  this.currentMinRttInMs = observedPeerConnection.currentRttInMs;
@@ -2901,17 +3301,9 @@ var ObservedClient = class extends EventEmitter2 {
2901
3301
  if (this.currentMaxRttInMs === void 0 || observedPeerConnection.currentRttInMs > this.currentMaxRttInMs) {
2902
3302
  this.currentMaxRttInMs = observedPeerConnection.currentRttInMs;
2903
3303
  }
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
3304
  sumOfRtts += observedPeerConnection.currentRttInMs;
2914
3305
  ++numberOfRttMeasurements;
3306
+ this._addRttMeasurement(observedPeerConnection.currentRttInMs);
2915
3307
  }
2916
3308
  }
2917
3309
  for (const clientEvent of clientEventsPostBuffer) {
@@ -2921,6 +3313,7 @@ var ObservedClient = class extends EventEmitter2 {
2921
3313
  if (latestPublicAddress !== void 0 && latestPublicAddress !== this.publicAddress) {
2922
3314
  const previousPublicAddress = this.publicAddress;
2923
3315
  this.publicAddress = latestPublicAddress;
3316
+ if (previousPublicAddress !== void 0) ++this.numberOfPublicAddressChanges;
2924
3317
  this._notify("client-public-address-changed", { ...this.eventScope, publicAddress: latestPublicAddress, previousPublicAddress });
2925
3318
  }
2926
3319
  this.attachments = sample.attachments;
@@ -2936,15 +3329,34 @@ var ObservedClient = class extends EventEmitter2 {
2936
3329
  this.sendingVideoBitrate = 0;
2937
3330
  }
2938
3331
  this.currentAvgRttInMs = 0 < numberOfRttMeasurements ? sumOfRtts / numberOfRttMeasurements : void 0;
3332
+ this.peakSendingBitrate = Math.max(this.peakSendingBitrate, this.sendingAudioBitrate + this.sendingVideoBitrate);
3333
+ this.peakReceivingBitrate = Math.max(this.peakReceivingBitrate, this.receivingAudioBitrate + this.receivingVideoBitrate);
3334
+ this.totalInboundPacketsLost += this.deltaInboundPacketsLost;
3335
+ this.totalInboundPacketsReceived += this.deltaInboundPacketsReceived;
3336
+ this.totalOutboundPacketsSent += this.deltaOutboundPacketsSent;
3337
+ this.totalReceivedAudioBytes += this.deltaReceivedAudioBytes;
3338
+ this.totalReceivedVideoBytes += this.deltaReceivedVideoBytes;
3339
+ this.totalSentAudioBytes += this.deltaSentAudioBytes;
3340
+ this.totalSentVideoBytes += this.deltaSentVideoBytes;
3341
+ this.totalTransportReceivedBytes += this.deltaTransportReceivedBytes;
3342
+ this.totalTransportSentBytes += this.deltaTransportSentBytes;
3343
+ this.totalDataChannelBytesReceived += this.deltaDataChannelBytesReceived;
3344
+ this.totalDataChannelBytesSent += this.deltaDataChannelBytesSent;
3345
+ this.totalDataChannelMessagesReceived += this.deltaDataChannelMessagesReceived;
3346
+ this.totalDataChannelMessagesSent += this.deltaDataChannelMessagesSent;
3347
+ if (overUdp) ++this.numberOfSamplesOverUdp;
3348
+ if (overTcp) ++this.numberOfSamplesOverTcp;
3349
+ if (overTurnUdp) ++this.numberOfSamplesOverTurnUdp;
3350
+ if (overTurnTcp) ++this.numberOfSamplesOverTurnTcp;
3351
+ if (!overUdp && !overTcp && !overTurnUdp && !overTurnTcp) ++this.numberOfSamplesWithUnknownPath;
2939
3352
  this.calculatedScore.value = sample.score;
2940
3353
  this.lastSampleTimestamp = sample.timestamp;
2941
3354
  const elapsedTimeInMs = now - this.updated;
2942
3355
  this.emit("update", sample, elapsedTimeInMs, context);
2943
3356
  this._notify("client-updated", { ...this.eventScope, sample, elapsedTimeInMs, context });
2944
3357
  this.updated = now;
2945
- if (this.calculatedScore.value) {
2946
- this.totalScoreSum += this.calculatedScore.value;
2947
- ++this.numberOfScoreMeasurements;
3358
+ if (typeof this.calculatedScore.value === "number" && Number.isFinite(this.calculatedScore.value)) {
3359
+ this._addScoreMeasurement(this.calculatedScore.value);
2948
3360
  }
2949
3361
  if (this.settings.closeClientIfIdleForMs !== void 0) {
2950
3362
  this.closeTimer = setTimeout(() => {
@@ -2957,7 +3369,7 @@ var ObservedClient = class extends EventEmitter2 {
2957
3369
  try {
2958
3370
  this.sink?.write(sample);
2959
3371
  } catch (err) {
2960
- logger2.warn("ClientSampleSink.write failed for client %s: %o", this.clientId, err);
3372
+ logger4.warn("Sink write failed for client %s: %o", this.clientId, err);
2961
3373
  }
2962
3374
  }
2963
3375
  injectMetaData(metaData) {
@@ -3035,6 +3447,13 @@ var ObservedClient = class extends EventEmitter2 {
3035
3447
  this.operationSystem = parseJsonAs(metadata.payload);
3036
3448
  break;
3037
3449
  }
3450
+ case "AUDIO_INPUT_DEVICE" /* AUDIO_INPUT_DEVICE */: {
3451
+ const device = parseJsonAs(metadata.payload);
3452
+ if (typeof device?.trackId === "string" && typeof device.label === "string") {
3453
+ this.audioInputDevices.set(device.trackId, device);
3454
+ }
3455
+ break;
3456
+ }
3038
3457
  }
3039
3458
  this._notify("client-metadata", { ...this.eventScope, metaData: metadata });
3040
3459
  }
@@ -3055,6 +3474,11 @@ var ObservedClient = class extends EventEmitter2 {
3055
3474
  if (isClientIssueResolutionEntry(issue)) {
3056
3475
  return this._resolveIssue(issue);
3057
3476
  }
3477
+ if (!issue.key || !this.activeIssues.get(issue.key)) {
3478
+ const type = baseIssueType(issue.type);
3479
+ this.issueHistogram[type] = (this.issueHistogram[type] ?? 0) + 1;
3480
+ ++this.totalNumberOfIssues;
3481
+ }
3058
3482
  const now = Date.now();
3059
3483
  if (issue.key) {
3060
3484
  const payload = parseJsonObject(issue.payload);
@@ -3128,10 +3552,10 @@ var ObservedClient = class extends EventEmitter2 {
3128
3552
  this._notify("client-rejoined", { ...this.eventScope, timestamp: event.timestamp });
3129
3553
  } else {
3130
3554
  this.joinedAt = event.timestamp;
3131
- logger2.warn(`Client ${this.clientId} joinedAt timestamp was updated to ${event.timestamp}. the joined event will not be emitted.`);
3555
+ logger4.warn(`Client ${this.clientId} joinedAt timestamp was updated to ${event.timestamp}. the joined event will not be emitted.`);
3132
3556
  }
3133
3557
  }
3134
- logger2.debug("Client %s joined at %o", this.clientId, event);
3558
+ logger4.debug("Client %s joined at %o", this.clientId, event);
3135
3559
  break;
3136
3560
  }
3137
3561
  case "CLIENT_LEFT" /* CLIENT_LEFT */: {
@@ -3141,10 +3565,10 @@ var ObservedClient = class extends EventEmitter2 {
3141
3565
  this.emit("left");
3142
3566
  this._notify("client-left", { ...this.eventScope });
3143
3567
  } else {
3144
- logger2.warn(`Client ${this.clientId} leftAt timestamp was already set`);
3568
+ logger4.warn(`Client ${this.clientId} leftAt timestamp was already set`);
3145
3569
  }
3146
3570
  }
3147
- logger2.debug("Client %s left at %o", this.clientId, event);
3571
+ logger4.debug("Client %s left at %o", this.clientId, event);
3148
3572
  break;
3149
3573
  }
3150
3574
  case "PEER_CONNECTION_OPENED" /* PEER_CONNECTION_OPENED */: {
@@ -3156,7 +3580,7 @@ var ObservedClient = class extends EventEmitter2 {
3156
3580
  } else if (postBuffer) {
3157
3581
  postBuffer.push(event);
3158
3582
  } else {
3159
- logger2.warn("Received PEER_CONNECTION_OPENED event without a corresponding observedPeerConnection: %o", event);
3583
+ logger4.warn("Received PEER_CONNECTION_OPENED event without a corresponding observedPeerConnection: %o", event);
3160
3584
  }
3161
3585
  }
3162
3586
  break;
@@ -3173,7 +3597,7 @@ var ObservedClient = class extends EventEmitter2 {
3173
3597
  } else if (postBuffer) {
3174
3598
  postBuffer.push(event);
3175
3599
  } else {
3176
- logger2.warn("Received PEER_CONNECTION_CLOSED event without a corresponding observedPeerConnection: %o", event);
3600
+ logger4.warn("Received PEER_CONNECTION_CLOSED event without a corresponding observedPeerConnection: %o", event);
3177
3601
  }
3178
3602
  }
3179
3603
  break;
@@ -3188,7 +3612,7 @@ var ObservedClient = class extends EventEmitter2 {
3188
3612
  } else if (postBuffer) {
3189
3613
  postBuffer.push(event);
3190
3614
  } else {
3191
- logger2.warn("Received MEDIA_TRACK_ADDED event without a corresponding observedPeerConnection or observedTrack: %o", event);
3615
+ logger4.warn("Received MEDIA_TRACK_ADDED event without a corresponding observedPeerConnection or observedTrack: %o", event);
3192
3616
  }
3193
3617
  }
3194
3618
  break;
@@ -3203,7 +3627,7 @@ var ObservedClient = class extends EventEmitter2 {
3203
3627
  } else if (postBuffer) {
3204
3628
  postBuffer.push(event);
3205
3629
  } else {
3206
- logger2.warn("Received MEDIA_TRACK_REMOVED event without a corresponding observedPeerConnection or observedTrack: %o", event);
3630
+ logger4.warn("Received MEDIA_TRACK_REMOVED event without a corresponding observedPeerConnection or observedTrack: %o", event);
3207
3631
  }
3208
3632
  }
3209
3633
  break;
@@ -3218,7 +3642,7 @@ var ObservedClient = class extends EventEmitter2 {
3218
3642
  } else if (postBuffer) {
3219
3643
  postBuffer.push(event);
3220
3644
  } else {
3221
- logger2.warn("Received DATA_CHANNEL_OPENED event without a corresponding observedPeerConnection or observedDataChannel: %o", event);
3645
+ logger4.warn("Received DATA_CHANNEL_OPENED event without a corresponding observedPeerConnection or observedDataChannel: %o", event);
3222
3646
  }
3223
3647
  }
3224
3648
  break;
@@ -3233,7 +3657,7 @@ var ObservedClient = class extends EventEmitter2 {
3233
3657
  } else if (postBuffer) {
3234
3658
  postBuffer.push(event);
3235
3659
  } else {
3236
- logger2.warn("Received DATA_CHANNEL_CLOSE event without a corresponding observedPeerConnection or observedDataChannel: %o", event);
3660
+ logger4.warn("Received DATA_CHANNEL_CLOSE event without a corresponding observedPeerConnection or observedDataChannel: %o", event);
3237
3661
  }
3238
3662
  }
3239
3663
  break;
@@ -3253,7 +3677,7 @@ var ObservedClient = class extends EventEmitter2 {
3253
3677
  } else if (postBuffer) {
3254
3678
  postBuffer.push(event);
3255
3679
  } else {
3256
- logger2.warn("Received MEDIA_TRACK_MUTED event without a corresponding observedPeerConnection or observedTrack: %o", event);
3680
+ logger4.warn("Received MEDIA_TRACK_MUTED event without a corresponding observedPeerConnection or observedTrack: %o", event);
3257
3681
  }
3258
3682
  }
3259
3683
  break;
@@ -3273,7 +3697,7 @@ var ObservedClient = class extends EventEmitter2 {
3273
3697
  } else if (postBuffer) {
3274
3698
  postBuffer.push(event);
3275
3699
  } else {
3276
- logger2.warn("Received MEDIA_TRACK_UNMUTED event without a corresponding observedPeerConnection or observedTrack: %o", event);
3700
+ logger4.warn("Received MEDIA_TRACK_UNMUTED event without a corresponding observedPeerConnection or observedTrack: %o", event);
3277
3701
  }
3278
3702
  }
3279
3703
  break;
@@ -3288,7 +3712,7 @@ var ObservedClient = class extends EventEmitter2 {
3288
3712
  } else if (postBuffer) {
3289
3713
  postBuffer.push(event);
3290
3714
  } else {
3291
- logger2.warn("Received ICE_CONNECTION_STATE_CHANGED event without a corresponding observedPeerConnection: %o", event);
3715
+ logger4.warn("Received ICE_CONNECTION_STATE_CHANGED event without a corresponding observedPeerConnection: %o", event);
3292
3716
  }
3293
3717
  }
3294
3718
  break;
@@ -3303,7 +3727,7 @@ var ObservedClient = class extends EventEmitter2 {
3303
3727
  } else if (postBuffer) {
3304
3728
  postBuffer.push(event);
3305
3729
  } else {
3306
- logger2.warn("Received ICE_GATHERING_STATE_CHANGED event without a corresponding observedPeerConnection: %o", event);
3730
+ logger4.warn("Received ICE_GATHERING_STATE_CHANGED event without a corresponding observedPeerConnection: %o", event);
3307
3731
  }
3308
3732
  }
3309
3733
  break;
@@ -3318,7 +3742,7 @@ var ObservedClient = class extends EventEmitter2 {
3318
3742
  } else if (postBuffer) {
3319
3743
  postBuffer.push(event);
3320
3744
  } else {
3321
- logger2.warn("Received PEER_CONNECTION_STATE_CHANGED event without a corresponding observedPeerConnection: %o", event);
3745
+ logger4.warn("Received PEER_CONNECTION_STATE_CHANGED event without a corresponding observedPeerConnection: %o", event);
3322
3746
  }
3323
3747
  }
3324
3748
  break;
@@ -3337,11 +3761,11 @@ var ObservedClient = class extends EventEmitter2 {
3337
3761
  let observedPeerConnection = this.observedPeerConnections.get(sample.peerConnectionId);
3338
3762
  if (!observedPeerConnection) {
3339
3763
  if (this._closedPeerConnectionIds.has(sample.peerConnectionId)) {
3340
- logger2.debug("Ignoring stats of peer connection %s, already closed by client %s", sample.peerConnectionId, this.clientId);
3764
+ logger4.debug("Ignoring stats of peer connection %s, already closed by client %s", sample.peerConnectionId, this.clientId);
3341
3765
  return void 0;
3342
3766
  }
3343
3767
  if (!sample.peerConnectionId) {
3344
- return logger2.warn(
3768
+ return logger4.warn(
3345
3769
  `ObservedClient received an invalid PeerConnectionSample (missing peerConnectionId field). ClientId: ${this.clientId}, CallId: ${this.call.callId}`,
3346
3770
  sample
3347
3771
  ), void 0;
@@ -3401,7 +3825,7 @@ var ObservedClient = class extends EventEmitter2 {
3401
3825
  try {
3402
3826
  this.sink?.write(finalSample);
3403
3827
  } catch (err) {
3404
- logger2.warn("ClientSampleSink.write failed during injection flush for client %s: %o", this.clientId, err);
3828
+ logger4.warn("Sink write failed during injection flush for client %s: %o", this.clientId, err);
3405
3829
  }
3406
3830
  }
3407
3831
  /** Emit an Observer-bus event scoped to this client (or a peer connection under it). */
@@ -3429,7 +3853,7 @@ var DefaultCallScoreCalculator = class {
3429
3853
  };
3430
3854
 
3431
3855
  // src/detectors/Detectors.ts
3432
- var logger3 = createLogger("Detectors");
3856
+ var logger5 = createLogger("Detectors");
3433
3857
  var Detectors = class {
3434
3858
  _detectors;
3435
3859
  constructor(...detectors) {
@@ -3510,7 +3934,7 @@ var Detectors = class {
3510
3934
  try {
3511
3935
  detector.update();
3512
3936
  } catch (err) {
3513
- logger3.warn(`Error updating detector ${detector?.name}`, err);
3937
+ logger5.warn(`Error updating detector ${detector?.name}`, err);
3514
3938
  }
3515
3939
  }
3516
3940
  }
@@ -3523,7 +3947,7 @@ var Detectors = class {
3523
3947
  try {
3524
3948
  detector.close?.();
3525
3949
  } catch (err) {
3526
- logger3.warn(`Error closing detector ${detector?.name}`, err);
3950
+ logger5.warn(`Error closing detector ${detector?.name}`, err);
3527
3951
  }
3528
3952
  }
3529
3953
  };
@@ -4326,9 +4750,172 @@ var PublisherFaultCorroborationDetector = class _PublisherFaultCorroborationDete
4326
4750
  }
4327
4751
  };
4328
4752
 
4753
+ // src/detectors/AudioEchoDetector.ts
4754
+ var AudioEchoTypes = {
4755
+ /**
4756
+ * One participant's outgoing audio follows another participant's voice: it rises when they speak,
4757
+ * in proportion to how loud they are. The echoing participant is sending the far end's playout
4758
+ * back into the call — speakers into an open mic, or a loopback capture device.
4759
+ */
4760
+ audioEchoSuspected: "AUDIO_ECHO_SUSPECTED"
4761
+ };
4762
+ var DIRECTION_MARGIN = 0.1;
4763
+ var mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length;
4764
+ var LEVEL_FLOOR = 1e-5;
4765
+ var toDb = (level) => 20 * Math.log10(Math.max(level, LEVEL_FLOOR));
4766
+ var AudioEchoDetector = class _AudioEchoDetector {
4767
+ constructor(call, config = {}) {
4768
+ this.call = call;
4769
+ this._config = {
4770
+ bucketMs: 5e3,
4771
+ windowBuckets: 24,
4772
+ activeRmsLevel: 0.01,
4773
+ minSourceActiveBuckets: 8,
4774
+ minFollowRatio: 0.8,
4775
+ minCorrelation: 0.6,
4776
+ cooldownMs: 12e4,
4777
+ loopbackAudioInputIssueType: "loopback-audio-input",
4778
+ ...config
4779
+ };
4780
+ }
4781
+ call;
4782
+ static NAME = "audio-echo-detector";
4783
+ name = _AudioEchoDetector.NAME;
4784
+ _config;
4785
+ _series = /* @__PURE__ */ new Map();
4786
+ _lastRaisedAt = /* @__PURE__ */ new Map();
4787
+ _lastEvaluatedBucket;
4788
+ close() {
4789
+ this._series.clear();
4790
+ this._lastRaisedAt.clear();
4791
+ }
4792
+ update() {
4793
+ const now = Date.now();
4794
+ const bucket = Math.floor(now / this._config.bucketMs);
4795
+ if (this._lastEvaluatedBucket === bucket) return;
4796
+ const closedBucket = bucket - 1;
4797
+ this._lastEvaluatedBucket = bucket;
4798
+ this._collect(now, closedBucket);
4799
+ this._evaluate(now, bucket);
4800
+ }
4801
+ _collect(now, bucket) {
4802
+ for (const client of this.call.observedClients.values()) {
4803
+ for (const peerConnection of client.observedPeerConnections.values()) {
4804
+ for (const track of peerConnection.observedOutboundTracks.values()) {
4805
+ if (track.kind !== "audio") continue;
4806
+ let series = this._series.get(track.id);
4807
+ if (!series) {
4808
+ series = { track, clientId: client.clientId, lastTimestamp: -1, samples: [], lastSeenAt: now };
4809
+ this._series.set(track.id, series);
4810
+ }
4811
+ series.lastSeenAt = now;
4812
+ if (series.lastTimestamp === track.timestamp) continue;
4813
+ series.lastTimestamp = track.timestamp;
4814
+ const level = track.rmsAudioLevel;
4815
+ if (level === void 0) continue;
4816
+ series.samples.push({ bucket, level });
4817
+ const oldest = bucket - this._config.windowBuckets;
4818
+ while (series.samples.length && series.samples[0].bucket <= oldest) series.samples.shift();
4819
+ }
4820
+ }
4821
+ }
4822
+ for (const [trackId, series] of this._series) {
4823
+ if (series.lastSeenAt !== now) this._series.delete(trackId);
4824
+ }
4825
+ }
4826
+ _evaluate(now, currentBucket) {
4827
+ if (this._series.size < 2) return;
4828
+ const tops = /* @__PURE__ */ new Map();
4829
+ for (const [trackId, series] of this._series) {
4830
+ for (const { bucket, level } of series.samples) {
4831
+ if (currentBucket <= bucket) continue;
4832
+ const top = tops.get(bucket);
4833
+ const entry = { id: trackId, level };
4834
+ if (!top) tops.set(bucket, [entry, void 0]);
4835
+ else if (top[0].level < level) tops.set(bucket, [entry, top[0]]);
4836
+ else if (!top[1] || top[1].level < level) top[1] = entry;
4837
+ }
4838
+ }
4839
+ const candidates = /* @__PURE__ */ new Map();
4840
+ for (const [trackId, series] of this._series) {
4841
+ const suspectDb = [];
4842
+ const sourceDb = [];
4843
+ const sourceCounts = /* @__PURE__ */ new Map();
4844
+ let followed = 0;
4845
+ for (const { bucket, level } of series.samples) {
4846
+ const top = tops.get(bucket);
4847
+ if (!top) continue;
4848
+ const source = top[0].id === trackId ? top[1] : top[0];
4849
+ if (!source || source.level < this._config.activeRmsLevel) continue;
4850
+ if (this._config.activeRmsLevel <= level) followed += 1;
4851
+ suspectDb.push(toDb(level));
4852
+ sourceDb.push(toDb(source.level));
4853
+ sourceCounts.set(source.id, (sourceCounts.get(source.id) ?? 0) + 1);
4854
+ }
4855
+ const sourceActiveBuckets = sourceDb.length;
4856
+ if (sourceActiveBuckets < this._config.minSourceActiveBuckets) continue;
4857
+ const followRatio = followed / sourceActiveBuckets;
4858
+ if (followRatio < this._config.minFollowRatio) continue;
4859
+ const levelCorrelation = correlation(suspectDb, sourceDb);
4860
+ if (levelCorrelation < this._config.minCorrelation) continue;
4861
+ const [sourceTrackId] = [...sourceCounts].reduce((a, b) => a[1] < b[1] ? b : a);
4862
+ const client = this.call.observedClients.get(series.clientId);
4863
+ candidates.set(trackId, {
4864
+ trackId,
4865
+ series,
4866
+ sourceTrackId,
4867
+ followRatio,
4868
+ levelCorrelation,
4869
+ sourceActiveBuckets,
4870
+ // How much quieter than its source the suspect is, in dB. An echo is at most as loud as
4871
+ // what it echoes, give or take AGC.
4872
+ relativeLevelDb: mean(suspectDb) - mean(sourceDb),
4873
+ loopbackAudioInput: client?.activeIssues.get(`${this._config.loopbackAudioInputIssueType}-track-${trackId}`) !== void 0
4874
+ });
4875
+ }
4876
+ for (const candidate of candidates.values()) {
4877
+ const reverse = candidates.get(candidate.sourceTrackId);
4878
+ let ambiguousDirection = false;
4879
+ if (reverse && reverse.sourceTrackId === candidate.trackId) {
4880
+ if (candidate.loopbackAudioInput !== reverse.loopbackAudioInput) {
4881
+ if (!candidate.loopbackAudioInput) continue;
4882
+ } else if (DIRECTION_MARGIN < Math.abs(candidate.followRatio - reverse.followRatio)) {
4883
+ if (candidate.followRatio < reverse.followRatio) continue;
4884
+ } else {
4885
+ ambiguousDirection = true;
4886
+ if (reverse.relativeLevelDb < candidate.relativeLevelDb) continue;
4887
+ if (reverse.relativeLevelDb === candidate.relativeLevelDb && reverse.trackId < candidate.trackId) continue;
4888
+ }
4889
+ }
4890
+ if (now - (this._lastRaisedAt.get(candidate.trackId) ?? -Infinity) < this._config.cooldownMs) continue;
4891
+ this._lastRaisedAt.set(candidate.trackId, now);
4892
+ const { series } = candidate;
4893
+ this.call.addIssue({
4894
+ type: AudioEchoTypes.audioEchoSuspected,
4895
+ timestamp: now,
4896
+ payload: {
4897
+ trackId: candidate.trackId,
4898
+ clientId: series.clientId,
4899
+ sourceTrackId: candidate.sourceTrackId,
4900
+ sourceClientId: this._series.get(candidate.sourceTrackId)?.clientId,
4901
+ followRatio: candidate.followRatio,
4902
+ levelCorrelation: candidate.levelCorrelation,
4903
+ relativeLevelDb: candidate.relativeLevelDb,
4904
+ sourceActiveBuckets: candidate.sourceActiveBuckets,
4905
+ bucketMs: this._config.bucketMs,
4906
+ ambiguousDirection,
4907
+ levelSource: series.track.rmsAudioLevelSource,
4908
+ audioInputLabel: series.track.audioInputDevice?.label,
4909
+ loopbackAudioInput: candidate.loopbackAudioInput
4910
+ }
4911
+ });
4912
+ }
4913
+ }
4914
+ };
4915
+
4329
4916
  // src/ObservedCall.ts
4330
- var logger4 = createLogger("ObservedCall");
4331
- var ObservedCall = class extends EventEmitter3 {
4917
+ var logger6 = createLogger("ObservedCall");
4918
+ var ObservedCall = class extends EventEmitter4 {
4332
4919
  constructor(settings, observer, activeIssuesRegistry) {
4333
4920
  super();
4334
4921
  this.observer = observer;
@@ -4336,6 +4923,8 @@ var ObservedCall = class extends EventEmitter3 {
4336
4923
  this.setMaxListeners(Infinity);
4337
4924
  this.eventScope = { observer: this.observer, observedCall: this };
4338
4925
  this.callId = settings.callId;
4926
+ this.sinks = new SinkRegistry(this.observer.sinks);
4927
+ this.extensionStats = new ExtensionStatsRegistry(this.callId, this.sinks);
4339
4928
  this.appData = settings.appData ?? {};
4340
4929
  this.scoreCalculator = new DefaultCallScoreCalculator(this);
4341
4930
  this.detectors = new Detectors();
@@ -4384,10 +4973,6 @@ var ObservedCall = class extends EventEmitter3 {
4384
4973
  numberOfDataChannels = 0;
4385
4974
  maxNumberOfClients = 0;
4386
4975
  deltaNumberOfIssues = 0;
4387
- // public deltaRttLt50Measurements = 0;
4388
- // public deltaRttLt150Measurements = 0;
4389
- // public deltaRttLt300Measurements = 0;
4390
- // public deltaRttGtOrEq300Measurements = 0;
4391
4976
  appData;
4392
4977
  closed = false;
4393
4978
  startedAt;
@@ -4396,6 +4981,8 @@ var ObservedCall = class extends EventEmitter3 {
4396
4981
  settings;
4397
4982
  /** Ancestry base shared by all Observer-bus events originating at this call. */
4398
4983
  eventScope;
4984
+ sinks;
4985
+ extensionStats;
4399
4986
  closeTimer;
4400
4987
  get numberOfClients() {
4401
4988
  return this.observedClients.size;
@@ -4433,8 +5020,12 @@ var ObservedCall = class extends EventEmitter3 {
4433
5020
  detector = new PublisherFaultCorroborationDetector(this, config);
4434
5021
  break;
4435
5022
  }
5023
+ case AudioEchoDetector.NAME: {
5024
+ detector = new AudioEchoDetector(this, config);
5025
+ break;
5026
+ }
4436
5027
  default: {
4437
- logger4.warn("Unknown detector name %s; skipping", name);
5028
+ logger6.warn("Unknown detector name %s; skipping", name);
4438
5029
  return this;
4439
5030
  }
4440
5031
  }
@@ -4504,6 +5095,8 @@ var ObservedCall = class extends EventEmitter3 {
4504
5095
  if (client.joinedAt) minSampleTimestamps = Math.min(minSampleTimestamps ?? client.joinedAt, client.joinedAt);
4505
5096
  if (client.leftAt) maxSampleTimestamps = Math.max(maxSampleTimestamps ?? client.leftAt, client.leftAt);
4506
5097
  }
5098
+ this.extensionStats.close();
5099
+ this.sinks.clear();
4507
5100
  if (this.startedAt === void 0) this.startedAt = minSampleTimestamps;
4508
5101
  if (this.endedAt === void 0) this.endedAt = maxSampleTimestamps;
4509
5102
  this.closedAt = Date.now();
@@ -4516,17 +5109,26 @@ var ObservedCall = class extends EventEmitter3 {
4516
5109
  this.emit("close");
4517
5110
  this._notify("call-closed", { ...this.eventScope });
4518
5111
  }
5112
+ createExtensionStats(settings) {
5113
+ return this.extensionStats.create(settings);
5114
+ }
5115
+ acceptExtensionStats(id, data) {
5116
+ this.extensionStats.accept(id, data);
5117
+ }
5118
+ getExtensionStats(id) {
5119
+ return this.extensionStats.get(id);
5120
+ }
4519
5121
  getObservedClient(clientId) {
4520
5122
  if (this.closed || !this.observedClients.has(clientId)) return;
4521
5123
  return this.observedClients.get(clientId);
4522
5124
  }
4523
5125
  createObservedClient(settings, acceptCtx) {
4524
5126
  if (this.closed) {
4525
- logger4.warn("Attempted to create a client (clientId: %s) on a closed call %s", settings.clientId, this.callId);
5127
+ logger6.warn("Attempted to create a client (clientId: %s) on a closed call %s", settings.clientId, this.callId);
4526
5128
  return void 0;
4527
5129
  }
4528
5130
  if (this.observedClients.has(settings.clientId)) {
4529
- logger4.warn("Client with id %s already exists in call %s; returning the existing instance", settings.clientId, this.callId);
5131
+ logger6.warn("Client with id %s already exists in call %s; returning the existing instance", settings.clientId, this.callId);
4530
5132
  return this.observedClients.get(settings.clientId);
4531
5133
  }
4532
5134
  const clientSettings = {
@@ -4576,10 +5178,17 @@ var ObservedCall = class extends EventEmitter3 {
4576
5178
  clearTimeout(this.closeTimer);
4577
5179
  this.closeTimer = void 0;
4578
5180
  }
5181
+ const jsonlPath = this.observer.config.createClientJsonlSink?.({ observedCall: this, clientId: settings.clientId });
5182
+ let jsonlSink;
5183
+ if (jsonlPath !== void 0 && this.observer.sinks.get(settings.clientId)) {
5184
+ logger6.warn("No sample file for client %s in call %s: sink id %s is already in use", settings.clientId, this.callId, settings.clientId);
5185
+ } else if (jsonlPath !== void 0) {
5186
+ jsonlSink = this.sinks.create(JsonlFileSink, { id: settings.clientId, path: jsonlPath });
5187
+ }
4579
5188
  this.emit("newclient", result);
4580
5189
  this._notify("client-added", { ...this.eventScope, observedClient: result });
4581
- if (result.sink) {
4582
- this._notify("client-sink-created", { ...this.eventScope, observedClient: result, sink: result.sink });
5190
+ if (jsonlSink) {
5191
+ this._notify("client-sink-created", { ...this.eventScope, observedClient: result, sink: jsonlSink });
4583
5192
  }
4584
5193
  if (wasEmpty) {
4585
5194
  this.emit("not-empty");
@@ -4632,10 +5241,10 @@ var ObservedCall = class extends EventEmitter3 {
4632
5241
  };
4633
5242
 
4634
5243
  // src/Observer.ts
4635
- import { EventEmitter as EventEmitter6 } from "events";
5244
+ import { EventEmitter as EventEmitter7 } from "events";
4636
5245
 
4637
5246
  // src/ObservedTURN.ts
4638
- import { EventEmitter as EventEmitter4 } from "events";
5247
+ import { EventEmitter as EventEmitter5 } from "events";
4639
5248
 
4640
5249
  // src/ObservedTurnServer.ts
4641
5250
  var ObservedTurnServer = class {
@@ -4695,8 +5304,8 @@ var ObservedTurnServer = class {
4695
5304
  };
4696
5305
 
4697
5306
  // src/ObservedTURN.ts
4698
- var logger5 = createLogger("ObservedTURN");
4699
- var ObservedTURN = class extends EventEmitter4 {
5307
+ var logger7 = createLogger("ObservedTURN");
5308
+ var ObservedTURN = class extends EventEmitter5 {
4700
5309
  totalBytesSent = 0;
4701
5310
  totalBytesReceived = 0;
4702
5311
  totalPacketsSent = 0;
@@ -4725,12 +5334,12 @@ var ObservedTURN = class extends EventEmitter4 {
4725
5334
  addPeerConnection(peerConnection) {
4726
5335
  const turnPairs = peerConnection.selectedIceCandiadtePairForTurn;
4727
5336
  if (turnPairs.length !== 1) {
4728
- return logger5.warn(`Expected exactly one TURN pair, but found for peerconnection ${peerConnection.peerConnectionId}`, turnPairs.length), void 0;
5337
+ return logger7.warn(`Expected exactly one TURN pair, but found for peerconnection ${peerConnection.peerConnectionId}`, turnPairs.length), void 0;
4729
5338
  }
4730
5339
  const candidatePair = turnPairs[0];
4731
5340
  const rawUrl = candidatePair.getLocalCandidate()?.url;
4732
5341
  if (!rawUrl) {
4733
- return logger5.warn(`No local candidate URL found for peerconnection ${peerConnection.peerConnectionId}`), void 0;
5342
+ return logger7.warn(`No local candidate URL found for peerconnection ${peerConnection.peerConnectionId}`), void 0;
4734
5343
  }
4735
5344
  const turnServerUrl = rawUrl.split("?")[0];
4736
5345
  let turnServer = this.servers.get(turnServerUrl);
@@ -4806,14 +5415,16 @@ var MiddlewareProcessor = class {
4806
5415
  };
4807
5416
 
4808
5417
  // src/ObservedMediasoupRouter.ts
4809
- import { EventEmitter as EventEmitter5 } from "events";
4810
- var logger6 = createLogger("ObservedMediasoupRouter");
4811
- var ObservedMediasoupRouter = class extends EventEmitter5 {
5418
+ import { EventEmitter as EventEmitter6 } from "events";
5419
+ var logger8 = createLogger("ObservedMediasoupRouter");
5420
+ var ObservedMediasoupRouter = class extends EventEmitter6 {
4812
5421
  router;
4813
5422
  appData;
4814
5423
  sample;
4815
5424
  webrtcTransportIds = /* @__PURE__ */ new Set();
4816
5425
  closed = false;
5426
+ sinks;
5427
+ extensionStats;
4817
5428
  // Id -> sample indexes over the arrays in `this.sample`, so an entity can be reached in O(1)
4818
5429
  // instead of scanning (`sample.producers.find(...)`). They hold the *same* objects as the arrays.
4819
5430
  _transportSamples = /* @__PURE__ */ new Map();
@@ -4822,11 +5433,17 @@ var ObservedMediasoupRouter = class extends EventEmitter5 {
4822
5433
  _dataProducerSamples = /* @__PURE__ */ new Map();
4823
5434
  _dataConsumerSamples = /* @__PURE__ */ new Map();
4824
5435
  _enrich;
4825
- constructor(settings) {
5436
+ /**
5437
+ * @param parentSinks the parent of `sinks`: `observer.sinks`, passed by
5438
+ * `Observer.createObservedMediasoupRouter`. Internal; a router constructed directly has none.
5439
+ */
5440
+ constructor(settings, parentSinks) {
4826
5441
  super();
4827
5442
  this.router = settings.router;
4828
5443
  this.appData = settings.appData ?? {};
4829
5444
  this._enrich = settings.enrich;
5445
+ this.sinks = new SinkRegistry(parentSinks);
5446
+ this.extensionStats = new ExtensionStatsRegistry(this.router.id, this.sinks);
4830
5447
  this.sample = {
4831
5448
  routerId: this.router.id,
4832
5449
  attachments: settings.attachments ?? {},
@@ -4895,8 +5512,19 @@ var ObservedMediasoupRouter = class extends EventEmitter5 {
4895
5512
  if (this.closed) return;
4896
5513
  this.closed = true;
4897
5514
  if (this.sample.closedAt === void 0) this.sample.closedAt = Date.now();
5515
+ this.extensionStats.close();
5516
+ this.sinks.clear();
4898
5517
  this.emit("close");
4899
5518
  }
5519
+ createExtensionStats(settings) {
5520
+ return this.extensionStats.create(settings);
5521
+ }
5522
+ acceptExtensionStats(id, data) {
5523
+ this.extensionStats.accept(id, data);
5524
+ }
5525
+ getExtensionStats(id) {
5526
+ return this.extensionStats.get(id);
5527
+ }
4900
5528
  addTransport = (transport) => {
4901
5529
  let transportType = transport.type;
4902
5530
  if (!transportType) {
@@ -4905,7 +5533,7 @@ var ObservedMediasoupRouter = class extends EventEmitter5 {
4905
5533
  else if (transport.constructor.name === "PipeTransport") transportType = "pipe";
4906
5534
  else if (transport.constructor.name === "DirectTransport") transportType = "direct";
4907
5535
  }
4908
- if (!transportType) return logger6.warn(`Unknown transport type for transport ${transport.id}: ${transport.constructor.name}`);
5536
+ if (!transportType) return logger8.warn(`Unknown transport type for transport ${transport.id}: ${transport.constructor.name}`);
4909
5537
  switch (transportType) {
4910
5538
  case "webrtc":
4911
5539
  return this.addWebRtcTransport(transport);
@@ -4916,7 +5544,7 @@ var ObservedMediasoupRouter = class extends EventEmitter5 {
4916
5544
  case "direct":
4917
5545
  return this.addDirectTransport(transport);
4918
5546
  default:
4919
- return logger6.warn(`Unsupported transport type for transport ${transport.id}: ${transportType}`);
5547
+ return logger8.warn(`Unsupported transport type for transport ${transport.id}: ${transportType}`);
4920
5548
  }
4921
5549
  };
4922
5550
  addWebRtcTransport(transport) {
@@ -5158,7 +5786,7 @@ var ObservedMediasoupRouter = class extends EventEmitter5 {
5158
5786
  const attachments = enrich();
5159
5787
  if (attachments) sample.attachments = { ...sample.attachments, ...attachments };
5160
5788
  } catch (err) {
5161
- logger6.warn("Mediasoup sample enricher threw for router %s: %o", this.id, err);
5789
+ logger8.warn("Mediasoup sample enricher threw for router %s: %o", this.id, err);
5162
5790
  }
5163
5791
  }
5164
5792
  _attachTransportObserverListeners(transport, transportSample, onClose) {
@@ -5183,7 +5811,7 @@ var ObservedMediasoupRouter = class extends EventEmitter5 {
5183
5811
  };
5184
5812
 
5185
5813
  // src/issues/ActiveIssuesRegistry.ts
5186
- var logger7 = createLogger("ActiveIssuesRegistry");
5814
+ var logger9 = createLogger("ActiveIssuesRegistry");
5187
5815
  var ActiveIssuesRegistry = class {
5188
5816
  constructor(parent) {
5189
5817
  this.parent = parent;
@@ -5269,7 +5897,7 @@ var ActiveIssuesRegistry = class {
5269
5897
  try {
5270
5898
  apply(target);
5271
5899
  } catch (err) {
5272
- logger7.warn("An issue tracker threw while being notified; continuing with the rest. %o", err);
5900
+ logger9.warn("An issue tracker threw while being notified; continuing with the rest. %o", err);
5273
5901
  }
5274
5902
  }
5275
5903
  };
@@ -5298,7 +5926,7 @@ function createCallSummary(callId, config) {
5298
5926
  }
5299
5927
 
5300
5928
  // src/summaries/CallSummaryCollector.ts
5301
- var logger8 = createLogger("CallSummaryCollector");
5929
+ var logger10 = createLogger("CallSummaryCollector");
5302
5930
  var CallSummaryCollector = class {
5303
5931
  constructor(_observer, _config) {
5304
5932
  this._observer = _observer;
@@ -5373,7 +6001,7 @@ var CallSummaryCollector = class {
5373
6001
  try {
5374
6002
  handler(summary, scratch, ...args);
5375
6003
  } catch (err) {
5376
- logger8.warn("A call-summary handler for %s threw; continuing. %o", event, err);
6004
+ logger10.warn("A call-summary handler for %s threw; continuing. %o", event, err);
5377
6005
  }
5378
6006
  };
5379
6007
  this._observer.on(event, listener);
@@ -5798,7 +6426,7 @@ function geohash(location, precision = 3) {
5798
6426
  }
5799
6427
 
5800
6428
  // src/detectors/ClientPopulationIssueDetector.ts
5801
- var logger9 = createLogger("ClientPopulationIssueDetector");
6429
+ var logger11 = createLogger("ClientPopulationIssueDetector");
5802
6430
  var ClientPopulationIssueTypes = {
5803
6431
  /** One issue type is concentrated on one client population while the rest of the fleet is fine. */
5804
6432
  clientPopulationIssue: "CLIENT_POPULATION_ISSUE"
@@ -5820,7 +6448,7 @@ var ClientPopulationIssueDetector = class _ClientPopulationIssueDetector {
5820
6448
  ...config
5821
6449
  };
5822
6450
  if (this._config.groupBy === "location" && !this._config.resolveClientLocation) {
5823
- logger9.warn(
6451
+ logger11.warn(
5824
6452
  "%s is configured with groupBy: 'location' but no resolveClientLocation, so no client can be placed and nothing will ever be detected",
5825
6453
  this.name
5826
6454
  );
@@ -6654,11 +7282,13 @@ function mediaKindOf(mimeType) {
6654
7282
  }
6655
7283
 
6656
7284
  // src/Observer.ts
6657
- var logger10 = createLogger("Observer");
6658
- var Observer = class extends EventEmitter6 {
7285
+ var logger12 = createLogger("Observer");
7286
+ var Observer = class extends EventEmitter7 {
6659
7287
  observedTURN = new ObservedTURN();
6660
7288
  observedCalls = /* @__PURE__ */ new Map();
6661
7289
  observedMediasoupRouters = /* @__PURE__ */ new Map();
7290
+ sinks = new SinkRegistry();
7291
+ extensionStats = new ExtensionStatsRegistry("observer", this.sinks);
6662
7292
  /** Ancestry base shared by all Observer-bus events originating at the observer. */
6663
7293
  eventScope = { observer: this };
6664
7294
  config;
@@ -6773,7 +7403,7 @@ var Observer = class extends EventEmitter6 {
6773
7403
  break;
6774
7404
  }
6775
7405
  default: {
6776
- logger10.warn("Unknown detector name %s; skipping", name);
7406
+ logger12.warn("Unknown detector name %s; skipping", name);
6777
7407
  return this;
6778
7408
  }
6779
7409
  }
@@ -6858,7 +7488,7 @@ var Observer = class extends EventEmitter6 {
6858
7488
  break;
6859
7489
  }
6860
7490
  if (!validator) {
6861
- logger10.warn("Unknown validator name %s; skipping", name);
7491
+ logger12.warn("Unknown validator name %s; skipping", name);
6862
7492
  return this;
6863
7493
  }
6864
7494
  this.validators.add(validator);
@@ -6889,7 +7519,7 @@ var Observer = class extends EventEmitter6 {
6889
7519
  try {
6890
7520
  validator.cancel(reason);
6891
7521
  } catch (err) {
6892
- logger10.warn("Error cancelling validator %s: %o", validator.name, err);
7522
+ logger12.warn("Error cancelling validator %s: %o", validator.name, err);
6893
7523
  }
6894
7524
  }
6895
7525
  return matching.length;
@@ -6905,11 +7535,11 @@ var Observer = class extends EventEmitter6 {
6905
7535
  */
6906
7536
  createObservedCall(settings, acceptCtx) {
6907
7537
  if (this.closed) {
6908
- logger10.warn("Attempted to create a call (callId: %s) on a closed observer", settings.callId);
7538
+ logger12.warn("Attempted to create a call (callId: %s) on a closed observer", settings.callId);
6909
7539
  return void 0;
6910
7540
  }
6911
7541
  if (this.observedCalls.has(settings.callId)) {
6912
- logger10.warn("Observed Call with id %s already exists; returning the existing instance", settings.callId);
7542
+ logger12.warn("Observed Call with id %s already exists; returning the existing instance", settings.callId);
6913
7543
  return this.observedCalls.get(settings.callId);
6914
7544
  }
6915
7545
  const callSettings = {
@@ -6947,14 +7577,14 @@ var Observer = class extends EventEmitter6 {
6947
7577
  }
6948
7578
  createObservedMediasoupRouter(settings) {
6949
7579
  if (this.closed) {
6950
- logger10.warn("Attempted to create mediasoup router (id: %d) on a closed observer", settings.router.id);
7580
+ logger12.warn("Attempted to create mediasoup router (id: %d) on a closed observer", settings.router.id);
6951
7581
  return void 0;
6952
7582
  }
6953
7583
  if (this.observedMediasoupRouters.has(settings.router.id)) {
6954
- logger10.warn("Observed Mediasoup Router (id %s) already exists; returning the existing instance", settings.router.id);
7584
+ logger12.warn("Observed Mediasoup Router (id %s) already exists; returning the existing instance", settings.router.id);
6955
7585
  return this.observedMediasoupRouters.get(settings.router.id);
6956
7586
  }
6957
- const observedMediasoupRouter = new ObservedMediasoupRouter(settings);
7587
+ const observedMediasoupRouter = new ObservedMediasoupRouter(settings, this.sinks);
6958
7588
  const observedMediasoupRouterScope = {
6959
7589
  observedMediasoupRouter,
6960
7590
  observer: this
@@ -6982,7 +7612,7 @@ var Observer = class extends EventEmitter6 {
6982
7612
  }
6983
7613
  close() {
6984
7614
  if (this.closed) {
6985
- return logger10.debug("Attempted to close twice");
7615
+ return logger12.debug("Attempted to close twice");
6986
7616
  }
6987
7617
  this.closed = true;
6988
7618
  for (const call of [...this.observedCalls.values()]) call.close();
@@ -6991,8 +7621,19 @@ var Observer = class extends EventEmitter6 {
6991
7621
  this.validators.clear();
6992
7622
  this.callSummaryCollector?.close();
6993
7623
  this.activeIssuesRegistry.clear();
7624
+ this.extensionStats.close();
7625
+ this.sinks.clear();
6994
7626
  this._notify("observer-closed", { ...this.eventScope });
6995
7627
  }
7628
+ createExtensionStats(settings) {
7629
+ return this.extensionStats.create(settings);
7630
+ }
7631
+ acceptExtensionStats(id, data) {
7632
+ this.extensionStats.accept(id, data);
7633
+ }
7634
+ getExtensionStats(id) {
7635
+ return this.extensionStats.get(id);
7636
+ }
6996
7637
  accept(sample, context) {
6997
7638
  if (this.closed) {
6998
7639
  return this._notify("sample-rejected", { ...this.eventScope, reason: "observer-closed", sample });
@@ -7000,7 +7641,7 @@ var Observer = class extends EventEmitter6 {
7000
7641
  try {
7001
7642
  this.acceptMiddlewares.process({ sample, context });
7002
7643
  } catch (err) {
7003
- logger10.warn("An accept middleware threw; dropping the sample. %o", err);
7644
+ logger12.warn("An accept middleware threw; dropping the sample. %o", err);
7004
7645
  }
7005
7646
  if (!sample.callId) {
7006
7647
  this._notify("sample-rejected", { ...this.eventScope, reason: "missing-callId", sample });
@@ -7040,7 +7681,7 @@ var Observer = class extends EventEmitter6 {
7040
7681
  try {
7041
7682
  validator.update();
7042
7683
  } catch (err) {
7043
- logger10.warn("Error running validator %s: %o", validator.name, err);
7684
+ logger12.warn("Error running validator %s: %o", validator.name, err);
7044
7685
  }
7045
7686
  }
7046
7687
  this._notify("observer-updated", { ...this.eventScope });
@@ -7276,67 +7917,34 @@ var TrendTester = class {
7276
7917
  }
7277
7918
  };
7278
7919
 
7279
- // src/sinks/ClientSampleSink.ts
7280
- import { EventEmitter as EventEmitter7 } from "events";
7281
- var ClientSampleSink = class extends EventEmitter7 {
7282
- };
7283
-
7284
- // src/sinks/JsonlFileSink.ts
7285
- import * as fs from "fs";
7286
- import * as path from "path";
7287
- var JsonlFileSink = class extends ClientSampleSink {
7288
- /** The file this sink writes to. Read it (e.g. in a `close` handler) to upload/move the file. */
7289
- path;
7290
- _stream;
7291
- _serializeSample;
7292
- constructor(options) {
7293
- super();
7294
- this.path = options.path;
7295
- this._stream = fs.createWriteStream(this.path, { flags: "a" });
7296
- this._stream.on("close", () => this.emit("close"));
7297
- this._stream.on("finish", () => this.emit("finish"));
7298
- this._stream.on("drain", () => this.emit("drain"));
7299
- this._stream.on("error", (err) => this.emit("error", err));
7300
- this._serializeSample = options.serializeSample ?? ((sample) => JSON.stringify(sample));
7301
- }
7302
- write(sample) {
7303
- return this._stream.write(`${this._serializeSample(sample)}
7304
- `);
7305
- }
7306
- end() {
7307
- this._stream.end();
7308
- }
7309
- };
7310
- function createJsonlFileSink(options) {
7311
- return new JsonlFileSink(options);
7312
- }
7313
- function createJsonlFileSinkFactory(options) {
7314
- return ({ clientId, observedCall }) => {
7315
- const filename = options.getFileName?.({ callId: observedCall.callId, clientId }) ?? `${observedCall.callId}__${clientId}.jsonl`;
7316
- return createJsonlFileSink({
7317
- path: path.join(options.directory, filename),
7318
- serializeSample: options.serializeSample
7319
- });
7320
- };
7321
- }
7322
-
7323
7920
  // src/sinks/InMemorySink.ts
7324
- var InMemorySink = class extends ClientSampleSink {
7325
- constructor(samples = []) {
7921
+ import { EventEmitter as EventEmitter8 } from "events";
7922
+ var InMemorySink = class extends EventEmitter8 {
7923
+ id;
7924
+ samples;
7925
+ _ended = false;
7926
+ constructor(settings = {}) {
7326
7927
  super();
7327
- this.samples = samples;
7928
+ this.id = settings.id ?? "in-memory";
7929
+ this.samples = settings.samples ?? [];
7930
+ }
7931
+ get ended() {
7932
+ return this._ended;
7328
7933
  }
7329
- samples;
7330
7934
  write(sample) {
7935
+ if (this._ended) return false;
7331
7936
  this.samples.push(sample);
7332
7937
  return true;
7333
7938
  }
7334
7939
  end() {
7940
+ if (this._ended) return;
7941
+ this._ended = true;
7942
+ this.emit("ended");
7335
7943
  this.emit("close");
7336
7944
  }
7337
7945
  };
7338
7946
  function createInMemorySink(samples) {
7339
- return new InMemorySink(samples);
7947
+ return new InMemorySink({ samples });
7340
7948
  }
7341
7949
 
7342
7950
  // src/resolvers/RemoteTrackResolver.ts
@@ -7513,6 +8121,8 @@ function createP2pRemoteTrackResolverFactory() {
7513
8121
  }
7514
8122
  export {
7515
8123
  ActiveIssuesRegistry,
8124
+ AudioEchoDetector,
8125
+ AudioEchoTypes,
7516
8126
  CODEC_MISMATCH_ISSUE,
7517
8127
  CallConcurrentIssueDetector,
7518
8128
  CallConcurrentIssueTypes,
@@ -7522,9 +8132,9 @@ export {
7522
8132
  ClientMetaTypes,
7523
8133
  ClientPopulationIssueDetector,
7524
8134
  ClientPopulationIssueTypes,
7525
- ClientSampleSink,
7526
8135
  CodecConsistencyValidator,
7527
8136
  Detectors,
8137
+ ExtensionStatsRegistry,
7528
8138
  GEOHASH_CELL_SIZES,
7529
8139
  InMemorySink,
7530
8140
  IssueFanOutDetector,
@@ -7560,10 +8170,15 @@ export {
7560
8170
  PublisherFaultCorroborationDetector,
7561
8171
  PublisherFaultTypes,
7562
8172
  RESOLVED_ISSUE_SUFFIX,
8173
+ RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS,
8174
+ RTT_HISTOGRAM_NUMBER_OF_BUCKETS,
7563
8175
  RemoteTrackResolver,
7564
8176
  RemoteTrackResolverValidator,
8177
+ SCORE_HISTOGRAM_BUCKET_WIDTH,
8178
+ SCORE_HISTOGRAM_NUMBER_OF_BUCKETS,
7565
8179
  SfuCongestionDetector,
7566
8180
  SimulcastReceiverValidator,
8181
+ SinkRegistry,
7567
8182
  SlidingWindow,
7568
8183
  TrackDeliveryMismatchDetector,
7569
8184
  TrackDeliveryMismatchTypes,
@@ -7584,7 +8199,6 @@ export {
7584
8199
  createDefaultMediasoupRemoteTrackResolverFactory,
7585
8200
  createInMemorySink,
7586
8201
  createJsonlFileSink,
7587
- createJsonlFileSinkFactory,
7588
8202
  createLogger,
7589
8203
  createP2pRemoteTrackResolverFactory,
7590
8204
  defaultCallSummaryConfig,
@@ -7599,6 +8213,7 @@ export {
7599
8213
  medianAbsoluteDeviation,
7600
8214
  pageHinkley,
7601
8215
  percentile,
8216
+ percentileOfHistogram,
7602
8217
  percentileOfSorted,
7603
8218
  robustZScore,
7604
8219
  schemaVersion,