@livekit/rtc-node 0.13.31 → 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.
- package/dist/{audio_stream-B4hGVcKz.d.cts → audio_stream-4M8XlO4-.d.cts} +25 -0
- package/dist/{audio_stream-DEG1JKge.d.ts → audio_stream-CTyN1Ldy.d.ts} +25 -0
- package/dist/audio_stream.cjs +31 -18
- package/dist/audio_stream.cjs.map +1 -1
- package/dist/audio_stream.d.cts +1 -1
- package/dist/audio_stream.d.ts +1 -1
- package/dist/audio_stream.d.ts.map +1 -1
- package/dist/audio_stream.js +31 -18
- package/dist/audio_stream.js.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/participant.d.cts +1 -1
- package/dist/participant.d.ts +1 -1
- package/dist/room.cjs +1 -0
- package/dist/room.cjs.map +1 -1
- package/dist/room.d.cts +1 -1
- package/dist/room.d.ts +1 -1
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +1 -0
- package/dist/room.js.map +1 -1
- package/dist/track.cjs +16 -0
- package/dist/track.cjs.map +1 -1
- package/dist/track.d.cts +1 -1
- package/dist/track.d.ts +1 -1
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +16 -0
- package/dist/track.js.map +1 -1
- package/dist/track_publication.d.cts +1 -1
- package/dist/track_publication.d.ts +1 -1
- package/dist/version.cjs +1 -1
- package/dist/version.cjs.map +1 -1
- package/dist/version.d.cts +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/dist/video_stream.d.cts +1 -1
- package/dist/video_stream.d.ts +1 -1
- package/package.json +2 -2
- package/src/audio_stream.ts +37 -24
- package/src/audio_stream_room_lifecycle.test.ts +50 -3
- package/src/room.ts +4 -0
- package/src/tests/e2e.test.ts +137 -16
- package/src/track.ts +17 -0
- 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.
|
|
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.
|
|
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"
|
package/src/audio_stream.ts
CHANGED
|
@@ -123,20 +123,9 @@ export class AudioStreamSource implements UnderlyingSource<AudioFrame> {
|
|
|
123
123
|
this.controller.enqueue(frame);
|
|
124
124
|
break;
|
|
125
125
|
case 'eos':
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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 (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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
|
}
|
package/src/tests/e2e.test.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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 {
|
package/src/track.ts
CHANGED
|
@@ -99,6 +99,23 @@ export abstract class Track {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* End every {@link AudioStream} attached to this track.
|
|
104
|
+
*
|
|
105
|
+
* @remarks
|
|
106
|
+
* The FFI keeps feeding an audio stream until the stream's own handle is
|
|
107
|
+
* dropped, so streams on a track that is no longer subscribed would otherwise
|
|
108
|
+
* go on delivering audio for the lifetime of the process.
|
|
109
|
+
*
|
|
110
|
+
* @internal
|
|
111
|
+
*/
|
|
112
|
+
closeAudioStreams(): void {
|
|
113
|
+
for (const stream of [...this.iterateStreams()]) {
|
|
114
|
+
stream.teardown();
|
|
115
|
+
}
|
|
116
|
+
this.audioStreams.clear();
|
|
117
|
+
}
|
|
118
|
+
|
|
102
119
|
private *iterateStreams(): Generator<AudioStreamSource> {
|
|
103
120
|
const dead: Array<WeakRef<AudioStreamSource>> = [];
|
|
104
121
|
for (const ref of this.audioStreams) {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = "0.13.
|
|
1
|
+
export const SDK_VERSION = "0.13.32";
|