@livekit/rtc-node 0.13.30 → 0.13.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/audio_source.cjs +38 -19
  2. package/dist/audio_source.cjs.map +1 -1
  3. package/dist/audio_source.d.cts +12 -4
  4. package/dist/audio_source.d.ts +12 -4
  5. package/dist/audio_source.d.ts.map +1 -1
  6. package/dist/audio_source.js +38 -19
  7. package/dist/audio_source.js.map +1 -1
  8. package/dist/{audio_stream-B4hGVcKz.d.cts → audio_stream-4M8XlO4-.d.cts} +25 -0
  9. package/dist/{audio_stream-DEG1JKge.d.ts → audio_stream-CTyN1Ldy.d.ts} +25 -0
  10. package/dist/audio_stream.cjs +31 -18
  11. package/dist/audio_stream.cjs.map +1 -1
  12. package/dist/audio_stream.d.cts +1 -1
  13. package/dist/audio_stream.d.ts +1 -1
  14. package/dist/audio_stream.d.ts.map +1 -1
  15. package/dist/audio_stream.js +31 -18
  16. package/dist/audio_stream.js.map +1 -1
  17. package/dist/index.d.cts +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/participant.d.cts +1 -1
  20. package/dist/participant.d.ts +1 -1
  21. package/dist/room.cjs +1 -0
  22. package/dist/room.cjs.map +1 -1
  23. package/dist/room.d.cts +1 -1
  24. package/dist/room.d.ts +1 -1
  25. package/dist/room.d.ts.map +1 -1
  26. package/dist/room.js +1 -0
  27. package/dist/room.js.map +1 -1
  28. package/dist/track.cjs +16 -0
  29. package/dist/track.cjs.map +1 -1
  30. package/dist/track.d.cts +1 -1
  31. package/dist/track.d.ts +1 -1
  32. package/dist/track.d.ts.map +1 -1
  33. package/dist/track.js +16 -0
  34. package/dist/track.js.map +1 -1
  35. package/dist/track_publication.d.cts +1 -1
  36. package/dist/track_publication.d.ts +1 -1
  37. package/dist/version.cjs +1 -1
  38. package/dist/version.cjs.map +1 -1
  39. package/dist/version.d.cts +1 -1
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/dist/version.js.map +1 -1
  43. package/dist/video_stream.d.cts +1 -1
  44. package/dist/video_stream.d.ts +1 -1
  45. package/package.json +3 -3
  46. package/src/audio_source.test.ts +92 -0
  47. package/src/audio_source.ts +42 -18
  48. package/src/audio_stream.ts +37 -24
  49. package/src/audio_stream_room_lifecycle.test.ts +50 -3
  50. package/src/room.ts +4 -0
  51. package/src/tests/e2e.test.ts +137 -16
  52. package/src/track.ts +17 -0
  53. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "LiveKit RTC Node",
4
4
  "license": "Apache-2.0",
5
5
  "author": "LiveKit",
6
- "version": "0.13.30",
6
+ "version": "0.13.32",
7
7
  "main": "dist/index.js",
8
8
  "require": "dist/index.cjs",
9
9
  "types": "dist/index.d.ts",
@@ -32,7 +32,7 @@
32
32
  "dependencies": {
33
33
  "@datastructures-js/deque": "1.0.8",
34
34
  "@livekit/mutex": "^1.0.0",
35
- "@livekit/rtc-ffi-bindings": "0.12.60",
35
+ "@livekit/rtc-ffi-bindings": "0.12.68",
36
36
  "@livekit/typed-emitter": "^3.0.0",
37
37
  "pino": "^9.0.0",
38
38
  "pino-pretty": "^13.0.0"
@@ -44,7 +44,7 @@
44
44
  "tsup": "^8.3.5",
45
45
  "typescript": "5.8.2",
46
46
  "vitest": "^4.0.0",
47
- "livekit-server-sdk": "2.16.0"
47
+ "livekit-server-sdk": "2.17.0"
48
48
  },
49
49
  "engines": {
50
50
  "node": ">= 18"
@@ -0,0 +1,92 @@
1
+ // SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import { describe, expect, it } from 'vitest';
5
+ import { AudioFrame } from './audio_frame.js';
6
+ import { AudioSource } from './audio_source.js';
7
+
8
+ const SAMPLE_RATE = 24000;
9
+ const FRAME_MS = 20;
10
+ const SAMPLES = (SAMPLE_RATE * FRAME_MS) / 1000;
11
+ const QUEUE_MS = 200;
12
+
13
+ const makeFrame = () => new AudioFrame(new Int16Array(SAMPLES).fill(1000), SAMPLE_RATE, 1, SAMPLES);
14
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
15
+
16
+ async function pushAudio(source: AudioSource, durationMs: number) {
17
+ for (let i = 0; i < durationMs / FRAME_MS; i++) {
18
+ await source.captureFrame(makeFrame());
19
+ }
20
+ }
21
+
22
+ describe('AudioSource', () => {
23
+ it('waitForPlayout waits for the queued audio to drain', async () => {
24
+ const source = new AudioSource(SAMPLE_RATE, 1, QUEUE_MS);
25
+ await pushAudio(source, 600);
26
+ const pushedAt = Date.now();
27
+ await source.waitForPlayout();
28
+ // ~QUEUE_MS of audio is still buffered when the last capture returns
29
+ expect(Date.now() - pushedAt).toBeGreaterThanOrEqual(QUEUE_MS - 50);
30
+ await source.close();
31
+ });
32
+
33
+ it('waitForPlayout waits for drain after a capture gap fired the drain timer', async () => {
34
+ const source = new AudioSource(SAMPLE_RATE, 1, QUEUE_MS);
35
+ // a single frame followed by a gap longer than its duration: the internal
36
+ // drain timer fires and resolves the playout promise while the segment is
37
+ // still streaming
38
+ await source.captureFrame(makeFrame());
39
+ await sleep(FRAME_MS * 3);
40
+ await pushAudio(source, 600);
41
+ const pushedAt = Date.now();
42
+ await source.waitForPlayout();
43
+ expect(Date.now() - pushedAt).toBeGreaterThanOrEqual(QUEUE_MS - 50);
44
+ await source.close();
45
+ });
46
+
47
+ it('waitForPlayout waits for drain after a previous clearQueue', async () => {
48
+ const source = new AudioSource(SAMPLE_RATE, 1, QUEUE_MS);
49
+ // e.g. an interrupted turn: buffered audio is dropped, releasing the
50
+ // playout promise
51
+ await source.captureFrame(makeFrame());
52
+ source.clearQueue();
53
+ // the next turn must not consume the stale resolution
54
+ await pushAudio(source, 600);
55
+ const pushedAt = Date.now();
56
+ await source.waitForPlayout();
57
+ expect(Date.now() - pushedAt).toBeGreaterThanOrEqual(QUEUE_MS - 50);
58
+ await source.close();
59
+ });
60
+
61
+ it('waitForPlayout resolves promptly when interrupted by clearQueue', async () => {
62
+ const source = new AudioSource(SAMPLE_RATE, 1, QUEUE_MS);
63
+ await pushAudio(source, 600);
64
+ const playout = source.waitForPlayout();
65
+ source.clearQueue();
66
+ const clearedAt = Date.now();
67
+ await playout;
68
+ expect(Date.now() - clearedAt).toBeLessThan(50);
69
+ await source.close();
70
+ });
71
+
72
+ it('waitForPlayout resolves immediately when no audio is queued', async () => {
73
+ const source = new AudioSource(SAMPLE_RATE, 1, QUEUE_MS);
74
+ // nothing ever captured
75
+ const before = Date.now();
76
+ await source.waitForPlayout();
77
+ // fully drained (drain timer fired and released the waiter)
78
+ await pushAudio(source, 100);
79
+ await sleep(QUEUE_MS + 100);
80
+ await source.waitForPlayout();
81
+ expect(Date.now() - before).toBeLessThan(QUEUE_MS + 400);
82
+ await source.close();
83
+ });
84
+
85
+ it('close resolves a pending waitForPlayout', async () => {
86
+ const source = new AudioSource(SAMPLE_RATE, 1, QUEUE_MS);
87
+ await pushAudio(source, 600);
88
+ const playout = source.waitForPlayout();
89
+ await source.close();
90
+ await playout;
91
+ });
92
+ });
@@ -28,8 +28,9 @@ export class AudioSource {
28
28
  /** @internal */
29
29
  currentQueueSize: number;
30
30
  /** @internal */
31
- release = () => {};
32
- promise = this.newPromise();
31
+ promise?: Promise<void> = undefined;
32
+ /** @internal */
33
+ resolvePromise?: () => void = undefined;
33
34
  /** @internal */
34
35
  timeout?: ReturnType<typeof setTimeout> = undefined;
35
36
  /** @internal */
@@ -84,24 +85,41 @@ export class AudioSource {
84
85
  },
85
86
  });
86
87
 
87
- this.currentQueueSize = 0;
88
- this.release();
88
+ this.releaseWaiter();
89
89
  }
90
90
 
91
- /** @internal */
92
- async newPromise() {
93
- return new Promise<void>((resolve) => {
94
- this.release = resolve;
95
- });
96
- }
91
+ /**
92
+ * Resolve the pending waitForPlayout() promise (if any) and reset the queue
93
+ * bookkeeping. Mirrors python-sdks' AudioSource._release_waiter: the promise is
94
+ * discarded here and lazily re-created by the next captureFrame, so a later
95
+ * waitForPlayout() can never consume a stale resolution and report playout
96
+ * complete while audio is still queued.
97
+ * @internal
98
+ */
99
+ releaseWaiter = () => {
100
+ if (!this.promise) {
101
+ return;
102
+ }
97
103
 
98
- async waitForPlayout() {
99
- return this.promise.then(() => {
100
- this.lastCapture = 0;
101
- this.currentQueueSize = 0;
102
- this.promise = this.newPromise();
104
+ this.resolvePromise?.();
105
+ this.lastCapture = 0;
106
+ this.currentQueueSize = 0;
107
+ this.promise = undefined;
108
+ this.resolvePromise = undefined;
109
+ // cancel the drain timer (e.g. when released early by clearQueue), otherwise
110
+ // it would fire later and release the waiter of a subsequent segment
111
+ if (this.timeout) {
112
+ clearTimeout(this.timeout);
103
113
  this.timeout = undefined;
104
- });
114
+ }
115
+ };
116
+
117
+ async waitForPlayout() {
118
+ if (!this.promise) {
119
+ return;
120
+ }
121
+
122
+ await this.promise;
105
123
  }
106
124
 
107
125
  async captureFrame(frame: AudioFrame) {
@@ -124,7 +142,13 @@ export class AudioSource {
124
142
  clearTimeout(this.timeout);
125
143
  }
126
144
 
127
- this.timeout = setTimeout(this.release, this.currentQueueSize);
145
+ if (!this.promise) {
146
+ this.promise = new Promise<void>((resolve) => {
147
+ this.resolvePromise = resolve;
148
+ });
149
+ }
150
+
151
+ this.timeout = setTimeout(this.releaseWaiter, this.currentQueueSize);
128
152
 
129
153
  const req = new CaptureAudioFrameRequest({
130
154
  sourceHandle: this.ffiHandle.handle,
@@ -152,7 +176,7 @@ export class AudioSource {
152
176
  this.timeout = undefined;
153
177
  }
154
178
  // Resolve any pending waitForPlayout() promise so callers don't hang.
155
- this.release();
179
+ this.releaseWaiter();
156
180
  this.ffiHandle.dispose();
157
181
  this.closed = true;
158
182
  }
@@ -123,20 +123,9 @@ export class AudioStreamSource implements UnderlyingSource<AudioFrame> {
123
123
  this.controller.enqueue(frame);
124
124
  break;
125
125
  case 'eos':
126
- FfiClient.instance.off(FfiClientEvent.FfiEvent, this.onEvent);
127
- this.controller.close();
128
- // Dispose the native handle so the FD is released on stream end,
129
- // not just when cancel() is called explicitly by the consumer.
130
- // Guard against double-dispose if cancel() is called after EOS
131
- // while buffered frames are still in the ReadableStream queue.
132
- if (!this.disposed) {
133
- this.disposed = true;
134
- this.track.unregisterAudioStream(this);
135
- this.ffiHandle.dispose();
136
- if (this.frameProcessor && this.autoCloseProcessor) {
137
- this.frameProcessor.close();
138
- }
139
- }
126
+ // Disposes the native handle so the FD is released on stream end, not
127
+ // just when cancel() is called explicitly by the consumer.
128
+ this.teardown();
140
129
  break;
141
130
  }
142
131
  };
@@ -145,19 +134,43 @@ export class AudioStreamSource implements UnderlyingSource<AudioFrame> {
145
134
  this.controller = controller;
146
135
  }
147
136
 
148
- cancel() {
137
+ /**
138
+ * Detach from the FFI and release resources: on `eos`, on `cancel()`, or
139
+ * because the track went away (e.g. it was unsubscribed — which never
140
+ * produces an `eos`, so without this the stream would keep delivering
141
+ * frames). Already-buffered frames stay readable and the consumer sees `done`
142
+ * after draining them.
143
+ *
144
+ * @remarks
145
+ * Idempotent, so `eos` arriving after a `cancel()` (or vice versa) doesn't
146
+ * double-dispose the handle while buffered frames are still queued.
147
+ *
148
+ * @internal
149
+ */
150
+ teardown() {
149
151
  FfiClient.instance.off(FfiClientEvent.FfiEvent, this.onEvent);
150
- if (!this.disposed) {
151
- this.disposed = true;
152
- this.track.unregisterAudioStream(this);
153
- this.ffiHandle.dispose();
154
- // Also close the frame processor on cancel for symmetry with the EOS path,
155
- // so resources are released regardless of how the stream ends.
156
- if (this.frameProcessor && this.autoCloseProcessor) {
157
- this.frameProcessor.close();
158
- }
152
+ if (this.disposed) {
153
+ return;
154
+ }
155
+ this.disposed = true;
156
+ this.track.unregisterAudioStream(this);
157
+ this.ffiHandle.dispose();
158
+ // Close the frame processor on every teardown path so resources are
159
+ // released regardless of how the stream ended.
160
+ if (this.frameProcessor && this.autoCloseProcessor) {
161
+ this.frameProcessor.close();
162
+ }
163
+ try {
164
+ this.controller?.close();
165
+ } catch {
166
+ // Already closed — e.g. cancel(), where the consumer has torn the
167
+ // ReadableStream down before the underlying source is notified.
159
168
  }
160
169
  }
170
+
171
+ cancel() {
172
+ this.teardown();
173
+ }
161
174
  }
162
175
 
163
176
  export class AudioStream extends ReadableStream<AudioFrame> {
@@ -125,10 +125,23 @@ function makeLocalAudioTrack(sid: string): LocalAudioTrack {
125
125
 
126
126
  function makeStream(processor: FrameProcessor<AudioFrame> | null): AudioStreamSource {
127
127
  // Minimal stub exercising only the surface the Track touches: the `processor`
128
- // getter and a no-op `cancel()`. Keeping cancel inert isolates the
129
- // metadata-push assertions from the real teardown path, which is covered
128
+ // getter and no-op `cancel()` / `teardown()`. Keeping teardown inert isolates
129
+ // the metadata-push assertions from the real teardown path, which is covered
130
130
  // separately via simulateStreamClose.
131
- return { processor, cancel: () => {} } as unknown as AudioStreamSource;
131
+ return { processor, cancel: () => {}, teardown: () => {} } as unknown as AudioStreamSource;
132
+ }
133
+
134
+ /** A stream stub that records whether the room tore it down. */
135
+ function makeEndTrackingStream(): { stream: AudioStreamSource; endCount: () => number } {
136
+ let ended = 0;
137
+ const stream = {
138
+ processor: null,
139
+ cancel: () => {},
140
+ teardown: () => {
141
+ ended += 1;
142
+ },
143
+ } as unknown as AudioStreamSource;
144
+ return { stream, endCount: () => ended };
132
145
  }
133
146
 
134
147
  function makeLocalParticipant(identity: string): LocalParticipant {
@@ -577,6 +590,40 @@ describe('AudioStream room lifecycle', () => {
577
590
  });
578
591
  });
579
592
 
593
+ it('trackUnsubscribed ends the audio streams attached to the track', async () => {
594
+ // Regression: an unsubscribed track never receives `eos` from the FFI, so
595
+ // its AudioStreams kept delivering frames. After a reconnect that means the
596
+ // stale stream and the new subscription's stream both deliver the
597
+ // publisher's audio.
598
+ const room = makeRoom({ name: 'room-1', token: 'tok-1', serverUrl: 'wss://r' });
599
+ attachRemoteParticipant(room, 'alice', [{ publicationSid: TRACK_SID, trackSid: TRACK_SID }]);
600
+ const track = makeTrack(TRACK_SID);
601
+ const publication = room.remoteParticipants.get('alice')!.trackPublications.get(TRACK_SID)!;
602
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
603
+ (publication as any).track = track;
604
+ track.setRoom(room);
605
+
606
+ const first = makeEndTrackingStream();
607
+ const second = makeEndTrackingStream();
608
+ track.registerAudioStream(first.stream);
609
+ track.registerAudioStream(second.stream);
610
+
611
+ // The handler still sees a live track — teardown happens after the event.
612
+ const seenDuringEvent: Array<number> = [];
613
+ room.on('trackUnsubscribed', () => seenDuringEvent.push(first.endCount()));
614
+
615
+ await dispatchRoomEvent(room, {
616
+ case: 'trackUnsubscribed',
617
+ value: { participantIdentity: 'alice', trackSid: TRACK_SID },
618
+ });
619
+
620
+ expect(seenDuringEvent).toEqual([0]);
621
+ expect(first.endCount()).toBe(1);
622
+ expect(second.endCount()).toBe(1);
623
+ expect(publication.track).toBeUndefined();
624
+ expect(publication.subscribed).toBe(false);
625
+ });
626
+
580
627
  it('localTrackUnpublished event nulls publication track', async () => {
581
628
  const room = makeRoom({ name: 'room-1', token: 'tok-1', serverUrl: 'wss://r' });
582
629
  const track = makeTrack(TRACK_SID);
package/src/room.ts CHANGED
@@ -679,6 +679,10 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
679
679
  publication.track = undefined;
680
680
  publication.subscribed = false;
681
681
  this.emit(RoomEvent.TrackUnsubscribed, track, publication, participant);
682
+ // An unsubscribed track never gets an `eos`, so any AudioStream on it
683
+ // keeps delivering frames. Tear them down here — after the event, so
684
+ // handlers still see a live track.
685
+ track.closeAudioStreams();
682
686
  } catch (e: unknown) {
683
687
  log.warn(`RoomEvent.TrackUnsubscribed: ${(e as Error).message}`);
684
688
  }
@@ -130,10 +130,14 @@ function waitForRoomEvent<R>(
130
130
  event: RoomEvent,
131
131
  timeoutMs: number,
132
132
  take: (...args: any[]) => R,
133
+ match?: (...args: unknown[]) => boolean,
133
134
  ): Promise<R> {
134
135
  return withTimeout(
135
136
  new Promise<R>((resolve) => {
136
137
  const handler = (...args: any[]) => {
138
+ if (match && !match(...args)) {
139
+ return;
140
+ }
137
141
  // typed-emitter doesn't expose `.once` in the type surface, so do manual once+cleanup.
138
142
  room.off(event as any, handler as any);
139
143
  resolve(take(...args));
@@ -145,6 +149,39 @@ function waitForRoomEvent<R>(
145
149
  );
146
150
  }
147
151
 
152
+ /**
153
+ * Only tracks published by `identity` are the test's business. Anything else in
154
+ * the room — an agent the project dispatches, a stray participant — publishes
155
+ * audio that would otherwise be analyzed alongside the tone and read as
156
+ * corruption.
157
+ */
158
+ function publishedBy(identity: string) {
159
+ return (...args: unknown[]) =>
160
+ (args[2] as { identity?: string } | undefined)?.identity === identity;
161
+ }
162
+
163
+ /**
164
+ * A stream header's `timestamp` is stamped when the send begins, so a valid one
165
+ * sits between "just before the call" and "now".
166
+ *
167
+ * @remarks
168
+ * Comparing it to `Date.now()` after awaiting the send would instead assert that
169
+ * the send finished within the tolerance — a latency budget, and the first send
170
+ * on a connection also waits for the data channel to open. Bracketing keeps the
171
+ * sanity check (the timestamp is real, current, and from this call) without
172
+ * depending on how long the send took. The tolerance covers the FFI stamping
173
+ * from a different clock source.
174
+ */
175
+ function expectTimestampFromCall(timestamp: number, calledAt: number, what: string): void {
176
+ const clockToleranceMs = 1_000;
177
+ const now = Date.now();
178
+ const detail =
179
+ `${what} timestamp=${timestamp} not bracketed by call window [${calledAt}, ${now}] ` +
180
+ `(offset from call start: ${timestamp - calledAt}ms)`;
181
+ expect(timestamp, detail).toBeGreaterThanOrEqual(calledAt - clockToleranceMs);
182
+ expect(timestamp, detail).toBeLessThanOrEqual(now + clockToleranceMs);
183
+ }
184
+
148
185
  function concatUint8(chunks: Uint8Array[]): Uint8Array {
149
186
  const len = chunks.reduce((acc, c) => acc + c.byteLength, 0);
150
187
  const out = new Uint8Array(len);
@@ -202,6 +239,19 @@ function estimateFreqHz(samples: Int16Array, sampleRate: number): number {
202
239
  return bestLag > 0 ? sampleRate / bestLag : 0;
203
240
  }
204
241
 
242
+ /**
243
+ * Fraction of a buffer that is digital silence. A tone that arrives with holes
244
+ * in it defeats the frequency estimator, so reporting this alongside a failed
245
+ * detection distinguishes "wrong frequency" from "not enough audio".
246
+ */
247
+ function silentFraction(samples: Int16Array): number {
248
+ let zeros = 0;
249
+ for (let i = 0; i < samples.length; i++) {
250
+ if (samples[i] === 0) zeros++;
251
+ }
252
+ return samples.length ? zeros / samples.length : 1;
253
+ }
254
+
205
255
  describeE2E('livekit-rtc e2e', () => {
206
256
  afterAll(async () => {
207
257
  await dispose();
@@ -272,7 +322,12 @@ describeE2E('livekit-rtc e2e', () => {
272
322
  testTimeoutMs,
273
323
  );
274
324
 
275
- it(
325
+ // Sequential, like the reconnect scenarios below. This test produces audio in
326
+ // real time from the event loop, and `AudioSource.captureFrame` awaits each
327
+ // frame's consumption, so the publisher never gets more than one frame ahead:
328
+ // sharing the loop with the rest of the suite turns scheduling jitter into
329
+ // transmitted silence, which the detector reads as a wrong frequency.
330
+ itRaw(
276
331
  'transfers audio between two participants (sine detection)',
277
332
  async () => {
278
333
  const cases = [
@@ -286,11 +341,13 @@ describeE2E('livekit-rtc e2e', () => {
286
341
  const { rooms } = await connectTestRooms(2);
287
342
  const [subRoom, pubRoom] = rooms;
288
343
 
344
+ const publisherIdentity = pubRoom!.localParticipant!.identity;
289
345
  const subscribed = waitForRoomEvent(
290
346
  subRoom!,
291
347
  RoomEvent.TrackSubscribed,
292
348
  15_000,
293
349
  (track: unknown) => track,
350
+ publishedBy(publisherIdentity),
294
351
  );
295
352
 
296
353
  const source = new AudioSource(params.pubRateHz, params.pubChannels);
@@ -313,6 +370,13 @@ describeE2E('livekit-rtc e2e', () => {
313
370
  () => new Int16Array(0),
314
371
  );
315
372
 
373
+ // The subscription goes live before the publisher's first frame reaches
374
+ // it, so the stream opens with silence. Analyzing the first N frames
375
+ // blind measures that silence and reads it as a bogus frequency, so skip
376
+ // ahead to where the tone actually starts.
377
+ const silenceFloor = 0.05 * 32767;
378
+ let skippedSilentFrames = 0;
379
+ let toneStarted = false;
316
380
  const readTask = (async () => {
317
381
  let frames = 0;
318
382
  while (frames < framesToAnalyze) {
@@ -320,6 +384,18 @@ describeE2E('livekit-rtc e2e', () => {
320
384
  if (done) break;
321
385
  expect(value.sampleRate).toBe(params.subRateHz);
322
386
  expect(value.channels).toBe(params.subChannels);
387
+
388
+ if (!toneStarted) {
389
+ const probe = channelSamples(value, 0);
390
+ let peak = 0;
391
+ for (let i = 0; i < probe.length; i++) peak = Math.max(peak, Math.abs(probe[i]!));
392
+ if (peak < silenceFloor) {
393
+ skippedSilentFrames++;
394
+ continue;
395
+ }
396
+ toneStarted = true;
397
+ }
398
+
323
399
  for (let ch = 0; ch < params.subChannels; ch++) {
324
400
  const s = channelSamples(value, ch);
325
401
  const prev = collected[ch]!;
@@ -330,14 +406,21 @@ describeE2E('livekit-rtc e2e', () => {
330
406
  }
331
407
  frames++;
332
408
  }
333
- expect(frames).toBe(framesToAnalyze);
409
+ expect(
410
+ frames,
411
+ `stream ended after ${frames}/${framesToAnalyze} tone frames ` +
412
+ `(skipped ${skippedSilentFrames} leading silent frames)`,
413
+ ).toBe(framesToAnalyze);
334
414
  })();
335
415
 
416
+ // Publish until the subscriber has its window rather than a fixed
417
+ // count, so however much silence has to be skipped, enough tone follows.
336
418
  const samplesPer10ms = Math.floor(params.pubRateHz / 100);
337
419
  const amplitude = 0.8 * 32767;
420
+ let toneRunning = true;
338
421
  const publishTask = (async () => {
339
422
  let t = 0;
340
- for (let i = 0; i < framesToAnalyze + 20; i++) {
423
+ while (toneRunning) {
341
424
  const frame = AudioFrame.create(params.pubRateHz, params.pubChannels, samplesPer10ms);
342
425
  for (let s = 0; s < samplesPer10ms; s++) {
343
426
  const v = Math.round(
@@ -350,18 +433,27 @@ describeE2E('livekit-rtc e2e', () => {
350
433
  }
351
434
  await source.captureFrame(frame);
352
435
  }
353
- await source.waitForPlayout();
354
436
  })();
355
437
 
356
- await withTimeout(
357
- Promise.all([readTask, publishTask]),
358
- 20_000,
359
- 'Timed out during audio test',
360
- );
438
+ try {
439
+ await withTimeout(readTask, 20_000, 'Timed out during audio test');
440
+ } finally {
441
+ // Let the tone loop finish its in-flight frame before the track and
442
+ // rooms are torn down, so a late capture can't reject after the test
443
+ // has moved on. Swallow its error: this is a `finally`, so throwing
444
+ // here would mask the assertion or timeout that actually failed.
445
+ toneRunning = false;
446
+ await publishTask.catch(() => {});
447
+ }
361
448
 
362
449
  for (let ch = 0; ch < params.subChannels; ch++) {
363
450
  const detected = estimateFreqHz(collected[ch]!, params.subRateHz);
364
- expect(Math.abs(detected - sineHz)).toBeLessThan(20);
451
+ expect(
452
+ Math.abs(detected - sineHz),
453
+ `${JSON.stringify(params)} ch${ch}: detected ${detected.toFixed(2)}Hz, ` +
454
+ `${(silentFraction(collected[ch]!) * 100).toFixed(0)}% of the analyzed audio is ` +
455
+ `silence (skipped ${skippedSilentFrames} leading silent frames)`,
456
+ ).toBeLessThan(20);
365
457
  }
366
458
 
367
459
  reader.releaseLock();
@@ -433,9 +525,10 @@ describeE2E('livekit-rtc e2e', () => {
433
525
  'Timed out waiting for text stream',
434
526
  );
435
527
 
528
+ const textSentAt = Date.now();
436
529
  const textInfo = await sendingRoom!.localParticipant!.sendText(textToSend, { topic });
437
530
  expect(textInfo.streamId).toBeTruthy();
438
- expect(Math.abs(textInfo.timestamp - Date.now())).toBeLessThanOrEqual(1_000);
531
+ expectTimestampFromCall(textInfo.timestamp, textSentAt, 'text stream');
439
532
  expect(textInfo.mimeType).toBe('text/plain');
440
533
  expect(textInfo.topic).toBe(topic);
441
534
 
@@ -454,6 +547,7 @@ describeE2E('livekit-rtc e2e', () => {
454
547
  'Timed out waiting for byte stream',
455
548
  );
456
549
 
550
+ const bytesSentAt = Date.now();
457
551
  const writer = await sendingRoom!.localParticipant!.streamBytes({
458
552
  topic,
459
553
  totalSize: bytesToSend.byteLength,
@@ -463,7 +557,7 @@ describeE2E('livekit-rtc e2e', () => {
463
557
 
464
558
  const byteInfo = writer.info;
465
559
  expect(byteInfo.streamId).toBeTruthy();
466
- expect(Math.abs(byteInfo.timestamp - Date.now())).toBeLessThanOrEqual(1_000);
560
+ expectTimestampFromCall(byteInfo.timestamp, bytesSentAt, 'byte stream');
467
561
  expect(byteInfo.mimeType).toBe('application/octet-stream');
468
562
  expect(byteInfo.topic).toBe(topic);
469
563
 
@@ -485,12 +579,25 @@ describeE2E('livekit-rtc e2e', () => {
485
579
 
486
580
  calleeRoom!.localParticipant!.registerRpcMethod(method, async (data) => data.payload);
487
581
 
582
+ // `room.connect()` resolves on the signal handshake, so the first
583
+ // data-channel message still waits on ICE/DTLS/SCTP setup — seconds, on a
584
+ // small runner. Warm the channel up untimed so the assertions below
585
+ // measure RPC behavior rather than connection setup.
586
+ await callerRoom!.localParticipant!.performRpc({
587
+ destinationIdentity: calleeRoom!.localParticipant!.identity,
588
+ method,
589
+ payload,
590
+ responseTimeout: testTimeoutMs,
591
+ });
592
+
593
+ const rpcResponseTimeoutMs = 1_000;
594
+
488
595
  await expect(
489
596
  callerRoom!.localParticipant!.performRpc({
490
597
  destinationIdentity: calleeRoom!.localParticipant!.identity,
491
598
  method,
492
599
  payload,
493
- responseTimeout: 500,
600
+ responseTimeout: rpcResponseTimeoutMs,
494
601
  }),
495
602
  ).resolves.toBe(payload);
496
603
 
@@ -499,10 +606,12 @@ describeE2E('livekit-rtc e2e', () => {
499
606
  destinationIdentity: calleeRoom!.localParticipant!.identity,
500
607
  method: 'unregistered-method',
501
608
  payload,
502
- responseTimeout: 500,
609
+ responseTimeout: rpcResponseTimeoutMs,
503
610
  }),
504
611
  ).rejects.toMatchObject({ code: RpcError.ErrorCode.UNSUPPORTED_METHOD });
505
612
 
613
+ // Short by design: no ack ever arrives for an absent participant, so the
614
+ // timeout expiring *is* the behavior under test.
506
615
  await expect(
507
616
  callerRoom!.localParticipant!.performRpc({
508
617
  destinationIdentity: 'unknown-participant',
@@ -752,7 +861,15 @@ describeE2E('livekit-rtc e2e', () => {
752
861
  }
753
862
  })();
754
863
  };
755
- subRoom!.on(RoomEvent.TrackSubscribed, (t) => attach(t));
864
+ const publisherIdentity = pubRoom!.localParticipant!.identity;
865
+ subRoom!.on(RoomEvent.TrackSubscribed, (t, _pub, participant) => {
866
+ // Ignore anything the test didn't publish (e.g. an agent the project
867
+ // dispatches into the room) — its audio would be analyzed as the tone.
868
+ if (participant.identity !== publisherIdentity) {
869
+ return;
870
+ }
871
+ attach(t);
872
+ });
756
873
 
757
874
  try {
758
875
  await waitFor(() => sub.lastFrameAt > 0 && Date.now() - sub.lastFrameAt < 500, {
@@ -791,7 +908,11 @@ describeE2E('livekit-rtc e2e', () => {
791
908
  // audio has brief discontinuities, and the autocorrelation is
792
909
  // integer-lag (next neighbors to 60Hz are exactly 80Hz/40Hz), so
793
910
  // ±20Hz lands right on the failure boundary under CI load.
794
- expect(Math.abs(detected - sineHz)).toBeLessThan(25);
911
+ expect(
912
+ Math.abs(detected - sineHz),
913
+ `scenario=${scenario}: detected ${detected.toFixed(2)}Hz across ${sub.readers.length} ` +
914
+ `stream(s), ${(silentFraction(concat) * 100).toFixed(0)}% silence`,
915
+ ).toBeLessThan(25);
795
916
 
796
917
  return { rooms, subRoom: subRoom!, pubRoom: pubRoom! };
797
918
  } finally {