diff --git a/.changeset/lucky-pandas-listen.md b/.changeset/lucky-pandas-listen.md new file mode 100644 index 00000000..94b32c47 --- /dev/null +++ b/.changeset/lucky-pandas-listen.md @@ -0,0 +1,5 @@ +--- +'@livekit/rtc-node': patch +--- + +Close audio streams when their track is unsubscribed. An unsubscribed track never receives `eos` from the FFI, so any `AudioStream` attached to it kept delivering frames — after a reconnect that meant the stale stream and the new subscription's stream both delivered the publisher's audio. diff --git a/.changeset/sixty-trains-win.md b/.changeset/sixty-trains-win.md new file mode 100644 index 00000000..84707ba5 --- /dev/null +++ b/.changeset/sixty-trains-win.md @@ -0,0 +1,5 @@ +--- +"@livekit/rtc-node": patch +--- + +bump FFI to 0.12.68 diff --git a/packages/livekit-rtc/package.json b/packages/livekit-rtc/package.json index b4ce7c47..ada6d7b1 100644 --- a/packages/livekit-rtc/package.json +++ b/packages/livekit-rtc/package.json @@ -32,7 +32,7 @@ "dependencies": { "@datastructures-js/deque": "1.0.8", "@livekit/mutex": "^1.0.0", - "@livekit/rtc-ffi-bindings": "0.12.60", + "@livekit/rtc-ffi-bindings": "0.12.68", "@livekit/typed-emitter": "^3.0.0", "pino": "^9.0.0", "pino-pretty": "^13.0.0" diff --git a/packages/livekit-rtc/src/audio_stream.ts b/packages/livekit-rtc/src/audio_stream.ts index d7dde0df..ae28a655 100644 --- a/packages/livekit-rtc/src/audio_stream.ts +++ b/packages/livekit-rtc/src/audio_stream.ts @@ -123,20 +123,9 @@ export class AudioStreamSource implements UnderlyingSource { this.controller.enqueue(frame); break; case 'eos': - FfiClient.instance.off(FfiClientEvent.FfiEvent, this.onEvent); - this.controller.close(); - // Dispose the native handle so the FD is released on stream end, - // not just when cancel() is called explicitly by the consumer. - // Guard against double-dispose if cancel() is called after EOS - // while buffered frames are still in the ReadableStream queue. - if (!this.disposed) { - this.disposed = true; - this.track.unregisterAudioStream(this); - this.ffiHandle.dispose(); - if (this.frameProcessor && this.autoCloseProcessor) { - this.frameProcessor.close(); - } - } + // Disposes the native handle so the FD is released on stream end, not + // just when cancel() is called explicitly by the consumer. + this.teardown(); break; } }; @@ -145,19 +134,43 @@ export class AudioStreamSource implements UnderlyingSource { this.controller = controller; } - cancel() { + /** + * Detach from the FFI and release resources: on `eos`, on `cancel()`, or + * because the track went away (e.g. it was unsubscribed — which never + * produces an `eos`, so without this the stream would keep delivering + * frames). Already-buffered frames stay readable and the consumer sees `done` + * after draining them. + * + * @remarks + * Idempotent, so `eos` arriving after a `cancel()` (or vice versa) doesn't + * double-dispose the handle while buffered frames are still queued. + * + * @internal + */ + teardown() { FfiClient.instance.off(FfiClientEvent.FfiEvent, this.onEvent); - if (!this.disposed) { - this.disposed = true; - this.track.unregisterAudioStream(this); - this.ffiHandle.dispose(); - // Also close the frame processor on cancel for symmetry with the EOS path, - // so resources are released regardless of how the stream ends. - if (this.frameProcessor && this.autoCloseProcessor) { - this.frameProcessor.close(); - } + if (this.disposed) { + return; + } + this.disposed = true; + this.track.unregisterAudioStream(this); + this.ffiHandle.dispose(); + // Close the frame processor on every teardown path so resources are + // released regardless of how the stream ended. + if (this.frameProcessor && this.autoCloseProcessor) { + this.frameProcessor.close(); + } + try { + this.controller?.close(); + } catch { + // Already closed — e.g. cancel(), where the consumer has torn the + // ReadableStream down before the underlying source is notified. } } + + cancel() { + this.teardown(); + } } export class AudioStream extends ReadableStream { diff --git a/packages/livekit-rtc/src/audio_stream_room_lifecycle.test.ts b/packages/livekit-rtc/src/audio_stream_room_lifecycle.test.ts index 8d846896..0ba4d163 100644 --- a/packages/livekit-rtc/src/audio_stream_room_lifecycle.test.ts +++ b/packages/livekit-rtc/src/audio_stream_room_lifecycle.test.ts @@ -125,10 +125,23 @@ function makeLocalAudioTrack(sid: string): LocalAudioTrack { function makeStream(processor: FrameProcessor | null): AudioStreamSource { // Minimal stub exercising only the surface the Track touches: the `processor` - // getter and a no-op `cancel()`. Keeping cancel inert isolates the - // metadata-push assertions from the real teardown path, which is covered + // getter and no-op `cancel()` / `teardown()`. Keeping teardown inert isolates + // the metadata-push assertions from the real teardown path, which is covered // separately via simulateStreamClose. - return { processor, cancel: () => {} } as unknown as AudioStreamSource; + return { processor, cancel: () => {}, teardown: () => {} } as unknown as AudioStreamSource; +} + +/** A stream stub that records whether the room tore it down. */ +function makeEndTrackingStream(): { stream: AudioStreamSource; endCount: () => number } { + let ended = 0; + const stream = { + processor: null, + cancel: () => {}, + teardown: () => { + ended += 1; + }, + } as unknown as AudioStreamSource; + return { stream, endCount: () => ended }; } function makeLocalParticipant(identity: string): LocalParticipant { @@ -577,6 +590,40 @@ describe('AudioStream room lifecycle', () => { }); }); + it('trackUnsubscribed ends the audio streams attached to the track', async () => { + // Regression: an unsubscribed track never receives `eos` from the FFI, so + // its AudioStreams kept delivering frames. After a reconnect that means the + // stale stream and the new subscription's stream both deliver the + // publisher's audio. + const room = makeRoom({ name: 'room-1', token: 'tok-1', serverUrl: 'wss://r' }); + attachRemoteParticipant(room, 'alice', [{ publicationSid: TRACK_SID, trackSid: TRACK_SID }]); + const track = makeTrack(TRACK_SID); + const publication = room.remoteParticipants.get('alice')!.trackPublications.get(TRACK_SID)!; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (publication as any).track = track; + track.setRoom(room); + + const first = makeEndTrackingStream(); + const second = makeEndTrackingStream(); + track.registerAudioStream(first.stream); + track.registerAudioStream(second.stream); + + // The handler still sees a live track — teardown happens after the event. + const seenDuringEvent: Array = []; + room.on('trackUnsubscribed', () => seenDuringEvent.push(first.endCount())); + + await dispatchRoomEvent(room, { + case: 'trackUnsubscribed', + value: { participantIdentity: 'alice', trackSid: TRACK_SID }, + }); + + expect(seenDuringEvent).toEqual([0]); + expect(first.endCount()).toBe(1); + expect(second.endCount()).toBe(1); + expect(publication.track).toBeUndefined(); + expect(publication.subscribed).toBe(false); + }); + it('localTrackUnpublished event nulls publication track', async () => { const room = makeRoom({ name: 'room-1', token: 'tok-1', serverUrl: 'wss://r' }); const track = makeTrack(TRACK_SID); diff --git a/packages/livekit-rtc/src/room.ts b/packages/livekit-rtc/src/room.ts index 6ee23de0..0d9445ca 100644 --- a/packages/livekit-rtc/src/room.ts +++ b/packages/livekit-rtc/src/room.ts @@ -679,6 +679,10 @@ export class Room extends (EventEmitter as new () => TypedEmitter publication.track = undefined; publication.subscribed = false; this.emit(RoomEvent.TrackUnsubscribed, track, publication, participant); + // An unsubscribed track never gets an `eos`, so any AudioStream on it + // keeps delivering frames. Tear them down here — after the event, so + // handlers still see a live track. + track.closeAudioStreams(); } catch (e: unknown) { log.warn(`RoomEvent.TrackUnsubscribed: ${(e as Error).message}`); } diff --git a/packages/livekit-rtc/src/tests/e2e.test.ts b/packages/livekit-rtc/src/tests/e2e.test.ts index 3ef96cc3..bc00a354 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -130,10 +130,14 @@ function waitForRoomEvent( event: RoomEvent, timeoutMs: number, take: (...args: any[]) => R, + match?: (...args: unknown[]) => boolean, ): Promise { return withTimeout( new Promise((resolve) => { const handler = (...args: any[]) => { + if (match && !match(...args)) { + return; + } // typed-emitter doesn't expose `.once` in the type surface, so do manual once+cleanup. room.off(event as any, handler as any); resolve(take(...args)); @@ -145,6 +149,39 @@ function waitForRoomEvent( ); } +/** + * Only tracks published by `identity` are the test's business. Anything else in + * the room — an agent the project dispatches, a stray participant — publishes + * audio that would otherwise be analyzed alongside the tone and read as + * corruption. + */ +function publishedBy(identity: string) { + return (...args: unknown[]) => + (args[2] as { identity?: string } | undefined)?.identity === identity; +} + +/** + * A stream header's `timestamp` is stamped when the send begins, so a valid one + * sits between "just before the call" and "now". + * + * @remarks + * Comparing it to `Date.now()` after awaiting the send would instead assert that + * the send finished within the tolerance — a latency budget, and the first send + * on a connection also waits for the data channel to open. Bracketing keeps the + * sanity check (the timestamp is real, current, and from this call) without + * depending on how long the send took. The tolerance covers the FFI stamping + * from a different clock source. + */ +function expectTimestampFromCall(timestamp: number, calledAt: number, what: string): void { + const clockToleranceMs = 1_000; + const now = Date.now(); + const detail = + `${what} timestamp=${timestamp} not bracketed by call window [${calledAt}, ${now}] ` + + `(offset from call start: ${timestamp - calledAt}ms)`; + expect(timestamp, detail).toBeGreaterThanOrEqual(calledAt - clockToleranceMs); + expect(timestamp, detail).toBeLessThanOrEqual(now + clockToleranceMs); +} + function concatUint8(chunks: Uint8Array[]): Uint8Array { const len = chunks.reduce((acc, c) => acc + c.byteLength, 0); const out = new Uint8Array(len); @@ -202,6 +239,19 @@ function estimateFreqHz(samples: Int16Array, sampleRate: number): number { return bestLag > 0 ? sampleRate / bestLag : 0; } +/** + * Fraction of a buffer that is digital silence. A tone that arrives with holes + * in it defeats the frequency estimator, so reporting this alongside a failed + * detection distinguishes "wrong frequency" from "not enough audio". + */ +function silentFraction(samples: Int16Array): number { + let zeros = 0; + for (let i = 0; i < samples.length; i++) { + if (samples[i] === 0) zeros++; + } + return samples.length ? zeros / samples.length : 1; +} + describeE2E('livekit-rtc e2e', () => { afterAll(async () => { await dispose(); @@ -272,7 +322,12 @@ describeE2E('livekit-rtc e2e', () => { testTimeoutMs, ); - it( + // Sequential, like the reconnect scenarios below. This test produces audio in + // real time from the event loop, and `AudioSource.captureFrame` awaits each + // frame's consumption, so the publisher never gets more than one frame ahead: + // sharing the loop with the rest of the suite turns scheduling jitter into + // transmitted silence, which the detector reads as a wrong frequency. + itRaw( 'transfers audio between two participants (sine detection)', async () => { const cases = [ @@ -286,11 +341,13 @@ describeE2E('livekit-rtc e2e', () => { const { rooms } = await connectTestRooms(2); const [subRoom, pubRoom] = rooms; + const publisherIdentity = pubRoom!.localParticipant!.identity; const subscribed = waitForRoomEvent( subRoom!, RoomEvent.TrackSubscribed, 15_000, (track: unknown) => track, + publishedBy(publisherIdentity), ); const source = new AudioSource(params.pubRateHz, params.pubChannels); @@ -313,6 +370,13 @@ describeE2E('livekit-rtc e2e', () => { () => new Int16Array(0), ); + // The subscription goes live before the publisher's first frame reaches + // it, so the stream opens with silence. Analyzing the first N frames + // blind measures that silence and reads it as a bogus frequency, so skip + // ahead to where the tone actually starts. + const silenceFloor = 0.05 * 32767; + let skippedSilentFrames = 0; + let toneStarted = false; const readTask = (async () => { let frames = 0; while (frames < framesToAnalyze) { @@ -320,6 +384,18 @@ describeE2E('livekit-rtc e2e', () => { if (done) break; expect(value.sampleRate).toBe(params.subRateHz); expect(value.channels).toBe(params.subChannels); + + if (!toneStarted) { + const probe = channelSamples(value, 0); + let peak = 0; + for (let i = 0; i < probe.length; i++) peak = Math.max(peak, Math.abs(probe[i]!)); + if (peak < silenceFloor) { + skippedSilentFrames++; + continue; + } + toneStarted = true; + } + for (let ch = 0; ch < params.subChannels; ch++) { const s = channelSamples(value, ch); const prev = collected[ch]!; @@ -330,14 +406,21 @@ describeE2E('livekit-rtc e2e', () => { } frames++; } - expect(frames).toBe(framesToAnalyze); + expect( + frames, + `stream ended after ${frames}/${framesToAnalyze} tone frames ` + + `(skipped ${skippedSilentFrames} leading silent frames)`, + ).toBe(framesToAnalyze); })(); + // Publish until the subscriber has its window rather than a fixed + // count, so however much silence has to be skipped, enough tone follows. const samplesPer10ms = Math.floor(params.pubRateHz / 100); const amplitude = 0.8 * 32767; + let toneRunning = true; const publishTask = (async () => { let t = 0; - for (let i = 0; i < framesToAnalyze + 20; i++) { + while (toneRunning) { const frame = AudioFrame.create(params.pubRateHz, params.pubChannels, samplesPer10ms); for (let s = 0; s < samplesPer10ms; s++) { const v = Math.round( @@ -350,18 +433,27 @@ describeE2E('livekit-rtc e2e', () => { } await source.captureFrame(frame); } - await source.waitForPlayout(); })(); - await withTimeout( - Promise.all([readTask, publishTask]), - 20_000, - 'Timed out during audio test', - ); + try { + await withTimeout(readTask, 20_000, 'Timed out during audio test'); + } finally { + // Let the tone loop finish its in-flight frame before the track and + // rooms are torn down, so a late capture can't reject after the test + // has moved on. Swallow its error: this is a `finally`, so throwing + // here would mask the assertion or timeout that actually failed. + toneRunning = false; + await publishTask.catch(() => {}); + } for (let ch = 0; ch < params.subChannels; ch++) { const detected = estimateFreqHz(collected[ch]!, params.subRateHz); - expect(Math.abs(detected - sineHz)).toBeLessThan(20); + expect( + Math.abs(detected - sineHz), + `${JSON.stringify(params)} ch${ch}: detected ${detected.toFixed(2)}Hz, ` + + `${(silentFraction(collected[ch]!) * 100).toFixed(0)}% of the analyzed audio is ` + + `silence (skipped ${skippedSilentFrames} leading silent frames)`, + ).toBeLessThan(20); } reader.releaseLock(); @@ -433,9 +525,10 @@ describeE2E('livekit-rtc e2e', () => { 'Timed out waiting for text stream', ); + const textSentAt = Date.now(); const textInfo = await sendingRoom!.localParticipant!.sendText(textToSend, { topic }); expect(textInfo.streamId).toBeTruthy(); - expect(Math.abs(textInfo.timestamp - Date.now())).toBeLessThanOrEqual(1_000); + expectTimestampFromCall(textInfo.timestamp, textSentAt, 'text stream'); expect(textInfo.mimeType).toBe('text/plain'); expect(textInfo.topic).toBe(topic); @@ -454,6 +547,7 @@ describeE2E('livekit-rtc e2e', () => { 'Timed out waiting for byte stream', ); + const bytesSentAt = Date.now(); const writer = await sendingRoom!.localParticipant!.streamBytes({ topic, totalSize: bytesToSend.byteLength, @@ -463,7 +557,7 @@ describeE2E('livekit-rtc e2e', () => { const byteInfo = writer.info; expect(byteInfo.streamId).toBeTruthy(); - expect(Math.abs(byteInfo.timestamp - Date.now())).toBeLessThanOrEqual(1_000); + expectTimestampFromCall(byteInfo.timestamp, bytesSentAt, 'byte stream'); expect(byteInfo.mimeType).toBe('application/octet-stream'); expect(byteInfo.topic).toBe(topic); @@ -485,12 +579,25 @@ describeE2E('livekit-rtc e2e', () => { calleeRoom!.localParticipant!.registerRpcMethod(method, async (data) => data.payload); + // `room.connect()` resolves on the signal handshake, so the first + // data-channel message still waits on ICE/DTLS/SCTP setup — seconds, on a + // small runner. Warm the channel up untimed so the assertions below + // measure RPC behavior rather than connection setup. + await callerRoom!.localParticipant!.performRpc({ + destinationIdentity: calleeRoom!.localParticipant!.identity, + method, + payload, + responseTimeout: testTimeoutMs, + }); + + const rpcResponseTimeoutMs = 1_000; + await expect( callerRoom!.localParticipant!.performRpc({ destinationIdentity: calleeRoom!.localParticipant!.identity, method, payload, - responseTimeout: 500, + responseTimeout: rpcResponseTimeoutMs, }), ).resolves.toBe(payload); @@ -499,10 +606,12 @@ describeE2E('livekit-rtc e2e', () => { destinationIdentity: calleeRoom!.localParticipant!.identity, method: 'unregistered-method', payload, - responseTimeout: 500, + responseTimeout: rpcResponseTimeoutMs, }), ).rejects.toMatchObject({ code: RpcError.ErrorCode.UNSUPPORTED_METHOD }); + // Short by design: no ack ever arrives for an absent participant, so the + // timeout expiring *is* the behavior under test. await expect( callerRoom!.localParticipant!.performRpc({ destinationIdentity: 'unknown-participant', @@ -752,7 +861,15 @@ describeE2E('livekit-rtc e2e', () => { } })(); }; - subRoom!.on(RoomEvent.TrackSubscribed, (t) => attach(t)); + const publisherIdentity = pubRoom!.localParticipant!.identity; + subRoom!.on(RoomEvent.TrackSubscribed, (t, _pub, participant) => { + // Ignore anything the test didn't publish (e.g. an agent the project + // dispatches into the room) — its audio would be analyzed as the tone. + if (participant.identity !== publisherIdentity) { + return; + } + attach(t); + }); try { await waitFor(() => sub.lastFrameAt > 0 && Date.now() - sub.lastFrameAt < 500, { @@ -791,7 +908,11 @@ describeE2E('livekit-rtc e2e', () => { // audio has brief discontinuities, and the autocorrelation is // integer-lag (next neighbors to 60Hz are exactly 80Hz/40Hz), so // ±20Hz lands right on the failure boundary under CI load. - expect(Math.abs(detected - sineHz)).toBeLessThan(25); + expect( + Math.abs(detected - sineHz), + `scenario=${scenario}: detected ${detected.toFixed(2)}Hz across ${sub.readers.length} ` + + `stream(s), ${(silentFraction(concat) * 100).toFixed(0)}% silence`, + ).toBeLessThan(25); return { rooms, subRoom: subRoom!, pubRoom: pubRoom! }; } finally { diff --git a/packages/livekit-rtc/src/track.ts b/packages/livekit-rtc/src/track.ts index bfe50628..b3cbe84b 100644 --- a/packages/livekit-rtc/src/track.ts +++ b/packages/livekit-rtc/src/track.ts @@ -99,6 +99,23 @@ export abstract class Track { } } + /** + * End every {@link AudioStream} attached to this track. + * + * @remarks + * The FFI keeps feeding an audio stream until the stream's own handle is + * dropped, so streams on a track that is no longer subscribed would otherwise + * go on delivering audio for the lifetime of the process. + * + * @internal + */ + closeAudioStreams(): void { + for (const stream of [...this.iterateStreams()]) { + stream.teardown(); + } + this.audioStreams.clear(); + } + private *iterateStreams(): Generator { const dead: Array> = []; for (const ref of this.audioStreams) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b71a279d..665f9e77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,8 +207,8 @@ importers: specifier: ^1.0.0 version: 1.1.1 '@livekit/rtc-ffi-bindings': - specifier: 0.12.60 - version: 0.12.60 + specifier: 0.12.68 + version: 0.12.68 '@livekit/typed-emitter': specifier: ^3.0.0 version: 3.0.0 @@ -994,40 +994,40 @@ packages: '@livekit/protocol@1.48.0': resolution: {integrity: sha512-fYHYgltH6YavAsokl3qsHLkBdQeKCl4UORVTub5crS3t8JtKFZ0uinHDFQ+XXdNKS6Ub9gcOjV+UHcDiqnWXoQ==} - '@livekit/rtc-ffi-bindings-darwin-arm64@0.12.60': - resolution: {integrity: sha512-YHXqybkYfaTc3txJXXWoVogiSP3yKJdkaZlIlZ6IDMGnN9elUoHDYU+ZSn/rbdGu0pp4HUOzffXkbkItN735Bw==} + '@livekit/rtc-ffi-bindings-darwin-arm64@0.12.68': + resolution: {integrity: sha512-xyBakR8fo3RC8CdrfV6/9U14JlmxXUYqB4eLITEsB+iCKW0CE7JfMqDFqV/JIlq43+xzf7wLJsNktgQRrlcdJg==} engines: {node: '>= 18'} cpu: [arm64] os: [darwin] - '@livekit/rtc-ffi-bindings-darwin-x64@0.12.60': - resolution: {integrity: sha512-SkPPWE2/nb2BAXrCWP6+vaR2I4EeyG3Vv+csUaa1EvDVMbFqBHWqNVTQcx/ChgecbYB9dIFZHVYpfjbFkVd84g==} + '@livekit/rtc-ffi-bindings-darwin-x64@0.12.68': + resolution: {integrity: sha512-nJFgFEbDePvPF9v9nqTBlKZ1otGSBgB3DJO+qjw+KowaE2AVubb6/qCF3gjJK/xxfyMcTgVnPr2SgcXWtNZ5Kg==} engines: {node: '>= 18'} cpu: [x64] os: [darwin] - '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.60': - resolution: {integrity: sha512-8umeMn9p/VZ41EGty1qX9zPV5mfGxCioYKeUnALpf8AKqT/yXDjnog1VkS5f8gFX/zY7HDaeE+s60nZXaOZJbw==} + '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.68': + resolution: {integrity: sha512-Hl9R9ZXqvq7lCbPqXnSNnojfxv8tmLISoxuv7zd0iA4Q7V99v/XCiali1jKpggXn5IqlyF3PHeW11a41glFjTw==} engines: {node: '>= 18'} cpu: [arm64] os: [linux] libc: [glibc] - '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.60': - resolution: {integrity: sha512-ttWrR/e8Ghaa9I+LaStxK8lh+aA9QBz6Dge6eXyKwTrAMHwHEtL2Rnf1rHQTiwadeH7AoytpfWf5FZl/OelLaQ==} + '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.68': + resolution: {integrity: sha512-2r99lXSkrmiHjhizCquQwPEbP7mCV/zESVXq1pdEliNvbBJXibMEYbRmuSr1lf4/VZhPpRGcF+Laqg5DgFoe+A==} engines: {node: '>= 18'} cpu: [x64] os: [linux] libc: [glibc] - '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.60': - resolution: {integrity: sha512-HfOBEf3rmpsG7hU3/BM9x2jnkVwKFve2v3cyjxlk41d6OkCthYb+g/ULEPFBKPafYyepDwcd2c8MDo5fMM+4Zw==} + '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.68': + resolution: {integrity: sha512-ec7MJBWx4mz40GvfXQ2Bn9uqvp2HueldTouPi5jzYUG2DECmhYhwai0cBcRFJOya3hBm/nRXP0A+pUBdWnY7Fg==} engines: {node: '>= 18'} cpu: [x64] os: [win32] - '@livekit/rtc-ffi-bindings@0.12.60': - resolution: {integrity: sha512-ZJD2DNoHfR8PzKeyDMH6i1zKpk7S4LlrQDIZvisxj6HPaJnKofzSssNMF8fpGFvVCZ844kbcOFogRPgHFno82w==} + '@livekit/rtc-ffi-bindings@0.12.68': + resolution: {integrity: sha512-PNr+EOwsT/ZUHALjTdSh+y1Dcj5Msnh0p5C8DaGBF9dCrFVZOxwp1rZovZo3cM+hiYo8TAvrjMEK3V5qD3++8g==} engines: {node: '>= 18'} '@livekit/typed-emitter@3.0.0': @@ -4450,30 +4450,30 @@ snapshots: dependencies: '@bufbuild/protobuf': 1.10.1 - '@livekit/rtc-ffi-bindings-darwin-arm64@0.12.60': + '@livekit/rtc-ffi-bindings-darwin-arm64@0.12.68': optional: true - '@livekit/rtc-ffi-bindings-darwin-x64@0.12.60': + '@livekit/rtc-ffi-bindings-darwin-x64@0.12.68': optional: true - '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.60': + '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.68': optional: true - '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.60': + '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.68': optional: true - '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.60': + '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.68': optional: true - '@livekit/rtc-ffi-bindings@0.12.60': + '@livekit/rtc-ffi-bindings@0.12.68': dependencies: '@bufbuild/protobuf': 1.10.1 optionalDependencies: - '@livekit/rtc-ffi-bindings-darwin-arm64': 0.12.60 - '@livekit/rtc-ffi-bindings-darwin-x64': 0.12.60 - '@livekit/rtc-ffi-bindings-linux-arm64-gnu': 0.12.60 - '@livekit/rtc-ffi-bindings-linux-x64-gnu': 0.12.60 - '@livekit/rtc-ffi-bindings-win32-x64-msvc': 0.12.60 + '@livekit/rtc-ffi-bindings-darwin-arm64': 0.12.68 + '@livekit/rtc-ffi-bindings-darwin-x64': 0.12.68 + '@livekit/rtc-ffi-bindings-linux-arm64-gnu': 0.12.68 + '@livekit/rtc-ffi-bindings-linux-x64-gnu': 0.12.68 + '@livekit/rtc-ffi-bindings-win32-x64-msvc': 0.12.68 '@livekit/typed-emitter@3.0.0': {}