From 6d884bdb9dfcd99e9f9afcdcf712fc531ace5152 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 27 Jul 2026 13:37:17 -0700 Subject: [PATCH 01/15] bump FFI to 0.12.71 --- packages/livekit-rtc/package.json | 2 +- pnpm-lock.yaml | 50 +++++++++++++++---------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/livekit-rtc/package.json b/packages/livekit-rtc/package.json index b4ce7c47..a53495fa 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.71", "@livekit/typed-emitter": "^3.0.0", "pino": "^9.0.0", "pino-pretty": "^13.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b71a279d..8deccd92 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.71 + version: 0.12.71 '@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.71': + resolution: {integrity: sha512-dBAsy4cdBVODL490p6d1yu2Y0bcG1EoY4iXPJ0iGiI79bUEiM51eXKMd9uteq479aJzvvnJ3nDUSTbYTzt03oQ==} 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.71': + resolution: {integrity: sha512-+3vqQS6knmXEUPRcQ4gY7b4zxJavcFMb2S7sNHJ+KiRU8jAAgQU8BKZoEHn8kyj6ilT86dQm//62hMb5Grt/ng==} 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.71': + resolution: {integrity: sha512-GZNJ5roycWy7YQmpZU0rX2YP+jwlaPvO04Jet4dg+v4TnAGkbsGqj62p9F7Obl9KWlIrP4BR/wdg/Pjt0vebTQ==} 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.71': + resolution: {integrity: sha512-RuduuWm4j7gyQEJFT10x44bJ19Y4+mG+VTEnTotRMcI1tNYhGjNmLaokwxHqYLJnxllOq0TpmsFfbXzcazOWtg==} 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.71': + resolution: {integrity: sha512-80v4pmJ5bt8YM7dZswRLksp1ezSgBEDhdKTPKEEfs1GHi4WugRdwE94jgqUifG1zQT14qWWK8W0YigP/tnKtpg==} engines: {node: '>= 18'} cpu: [x64] os: [win32] - '@livekit/rtc-ffi-bindings@0.12.60': - resolution: {integrity: sha512-ZJD2DNoHfR8PzKeyDMH6i1zKpk7S4LlrQDIZvisxj6HPaJnKofzSssNMF8fpGFvVCZ844kbcOFogRPgHFno82w==} + '@livekit/rtc-ffi-bindings@0.12.71': + resolution: {integrity: sha512-J5ITMGTTnDFne5VLx8rRyEfungyKKGkxj82ihoC6QHZ8BqSe53Af2DZnFs8Q7LMVcd8X1EP/ppn11LD5oVOBaw==} 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.71': optional: true - '@livekit/rtc-ffi-bindings-darwin-x64@0.12.60': + '@livekit/rtc-ffi-bindings-darwin-x64@0.12.71': optional: true - '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.60': + '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.71': optional: true - '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.60': + '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.71': optional: true - '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.60': + '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.71': optional: true - '@livekit/rtc-ffi-bindings@0.12.60': + '@livekit/rtc-ffi-bindings@0.12.71': 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.71 + '@livekit/rtc-ffi-bindings-darwin-x64': 0.12.71 + '@livekit/rtc-ffi-bindings-linux-arm64-gnu': 0.12.71 + '@livekit/rtc-ffi-bindings-linux-x64-gnu': 0.12.71 + '@livekit/rtc-ffi-bindings-win32-x64-msvc': 0.12.71 '@livekit/typed-emitter@3.0.0': {} From 0d608d0d2709b60016e0985be5d32ed6d4b0d848 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 27 Jul 2026 13:40:16 -0700 Subject: [PATCH 02/15] Create sixty-trains-win.md --- .changeset/sixty-trains-win.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sixty-trains-win.md diff --git a/.changeset/sixty-trains-win.md b/.changeset/sixty-trains-win.md new file mode 100644 index 00000000..34778fe5 --- /dev/null +++ b/.changeset/sixty-trains-win.md @@ -0,0 +1,5 @@ +--- +"@livekit/rtc-node": patch +--- + +bump FFI to 0.12.71 From 652573d905cf5d11561b59c3b2f3b7b60e0b4dd6 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 27 Jul 2026 14:02:50 -0700 Subject: [PATCH 03/15] use 70 --- packages/livekit-rtc/package.json | 2 +- pnpm-lock.yaml | 50 +++++++++++++++---------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/livekit-rtc/package.json b/packages/livekit-rtc/package.json index a53495fa..f98dd5e9 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.71", + "@livekit/rtc-ffi-bindings": "0.12.70", "@livekit/typed-emitter": "^3.0.0", "pino": "^9.0.0", "pino-pretty": "^13.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8deccd92..19edcc71 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.71 - version: 0.12.71 + specifier: 0.12.70 + version: 0.12.70 '@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.71': - resolution: {integrity: sha512-dBAsy4cdBVODL490p6d1yu2Y0bcG1EoY4iXPJ0iGiI79bUEiM51eXKMd9uteq479aJzvvnJ3nDUSTbYTzt03oQ==} + '@livekit/rtc-ffi-bindings-darwin-arm64@0.12.70': + resolution: {integrity: sha512-ctWn/25JCNEFKGF7peSRf66k6jCu1Mv1yBELT51Aay1VSKyfn0HJZmDGDjgOXMXaxqPiPSYv8d2w5CalVo0umQ==} engines: {node: '>= 18'} cpu: [arm64] os: [darwin] - '@livekit/rtc-ffi-bindings-darwin-x64@0.12.71': - resolution: {integrity: sha512-+3vqQS6knmXEUPRcQ4gY7b4zxJavcFMb2S7sNHJ+KiRU8jAAgQU8BKZoEHn8kyj6ilT86dQm//62hMb5Grt/ng==} + '@livekit/rtc-ffi-bindings-darwin-x64@0.12.70': + resolution: {integrity: sha512-DtXoI43GS2JZCVp7UGIVnHgA3AiO3HrGURiHwi5MEgurzrNpcIXRGwiMzykYm6HG3jLlZz9dsVf3JIXffYn1wg==} engines: {node: '>= 18'} cpu: [x64] os: [darwin] - '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.71': - resolution: {integrity: sha512-GZNJ5roycWy7YQmpZU0rX2YP+jwlaPvO04Jet4dg+v4TnAGkbsGqj62p9F7Obl9KWlIrP4BR/wdg/Pjt0vebTQ==} + '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.70': + resolution: {integrity: sha512-D4gCKeevkrm6GxYVRHTAexu/BOFnxnSaDu7+W077zu37fdzfE/aO23bp0pA/R1UPvPUOMzLilauBfUo5H4BwMQ==} engines: {node: '>= 18'} cpu: [arm64] os: [linux] libc: [glibc] - '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.71': - resolution: {integrity: sha512-RuduuWm4j7gyQEJFT10x44bJ19Y4+mG+VTEnTotRMcI1tNYhGjNmLaokwxHqYLJnxllOq0TpmsFfbXzcazOWtg==} + '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.70': + resolution: {integrity: sha512-uZnugW5PiOtgsM0rHz9/xh/3lg0FfkMZuRsHt46kfPjfT5yFuJ/bTvJ74SdyCK22ejemD5hz27oa2USCcL5lUg==} engines: {node: '>= 18'} cpu: [x64] os: [linux] libc: [glibc] - '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.71': - resolution: {integrity: sha512-80v4pmJ5bt8YM7dZswRLksp1ezSgBEDhdKTPKEEfs1GHi4WugRdwE94jgqUifG1zQT14qWWK8W0YigP/tnKtpg==} + '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.70': + resolution: {integrity: sha512-VsKR22+IaTbhVDWQvVmWMcU1kre1XG1Eg0YKm6gLJF4R3MWxwtiW4LaZb0/q8dPjK3fd3oWfgXiSynMIV6ouZw==} engines: {node: '>= 18'} cpu: [x64] os: [win32] - '@livekit/rtc-ffi-bindings@0.12.71': - resolution: {integrity: sha512-J5ITMGTTnDFne5VLx8rRyEfungyKKGkxj82ihoC6QHZ8BqSe53Af2DZnFs8Q7LMVcd8X1EP/ppn11LD5oVOBaw==} + '@livekit/rtc-ffi-bindings@0.12.70': + resolution: {integrity: sha512-NJVLuTCLVRp9rmj+lRG37uAU+MF1fExRpFtBMiNb1U7iPjaDe6eQhZyJfN/RKDv1Hj9sOJJlPLr88NiAHkFSjg==} 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.71': + '@livekit/rtc-ffi-bindings-darwin-arm64@0.12.70': optional: true - '@livekit/rtc-ffi-bindings-darwin-x64@0.12.71': + '@livekit/rtc-ffi-bindings-darwin-x64@0.12.70': optional: true - '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.71': + '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.70': optional: true - '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.71': + '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.70': optional: true - '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.71': + '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.70': optional: true - '@livekit/rtc-ffi-bindings@0.12.71': + '@livekit/rtc-ffi-bindings@0.12.70': dependencies: '@bufbuild/protobuf': 1.10.1 optionalDependencies: - '@livekit/rtc-ffi-bindings-darwin-arm64': 0.12.71 - '@livekit/rtc-ffi-bindings-darwin-x64': 0.12.71 - '@livekit/rtc-ffi-bindings-linux-arm64-gnu': 0.12.71 - '@livekit/rtc-ffi-bindings-linux-x64-gnu': 0.12.71 - '@livekit/rtc-ffi-bindings-win32-x64-msvc': 0.12.71 + '@livekit/rtc-ffi-bindings-darwin-arm64': 0.12.70 + '@livekit/rtc-ffi-bindings-darwin-x64': 0.12.70 + '@livekit/rtc-ffi-bindings-linux-arm64-gnu': 0.12.70 + '@livekit/rtc-ffi-bindings-linux-x64-gnu': 0.12.70 + '@livekit/rtc-ffi-bindings-win32-x64-msvc': 0.12.70 '@livekit/typed-emitter@3.0.0': {} From 850cf93711524b6c6484effc961bf37d2b91baa1 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 27 Jul 2026 14:08:29 -0700 Subject: [PATCH 04/15] 68 --- packages/livekit-rtc/package.json | 2 +- pnpm-lock.yaml | 50 +++++++++++++++---------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/livekit-rtc/package.json b/packages/livekit-rtc/package.json index f98dd5e9..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.70", + "@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/pnpm-lock.yaml b/pnpm-lock.yaml index 19edcc71..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.70 - version: 0.12.70 + 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.70': - resolution: {integrity: sha512-ctWn/25JCNEFKGF7peSRf66k6jCu1Mv1yBELT51Aay1VSKyfn0HJZmDGDjgOXMXaxqPiPSYv8d2w5CalVo0umQ==} + '@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.70': - resolution: {integrity: sha512-DtXoI43GS2JZCVp7UGIVnHgA3AiO3HrGURiHwi5MEgurzrNpcIXRGwiMzykYm6HG3jLlZz9dsVf3JIXffYn1wg==} + '@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.70': - resolution: {integrity: sha512-D4gCKeevkrm6GxYVRHTAexu/BOFnxnSaDu7+W077zu37fdzfE/aO23bp0pA/R1UPvPUOMzLilauBfUo5H4BwMQ==} + '@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.70': - resolution: {integrity: sha512-uZnugW5PiOtgsM0rHz9/xh/3lg0FfkMZuRsHt46kfPjfT5yFuJ/bTvJ74SdyCK22ejemD5hz27oa2USCcL5lUg==} + '@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.70': - resolution: {integrity: sha512-VsKR22+IaTbhVDWQvVmWMcU1kre1XG1Eg0YKm6gLJF4R3MWxwtiW4LaZb0/q8dPjK3fd3oWfgXiSynMIV6ouZw==} + '@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.70': - resolution: {integrity: sha512-NJVLuTCLVRp9rmj+lRG37uAU+MF1fExRpFtBMiNb1U7iPjaDe6eQhZyJfN/RKDv1Hj9sOJJlPLr88NiAHkFSjg==} + '@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.70': + '@livekit/rtc-ffi-bindings-darwin-arm64@0.12.68': optional: true - '@livekit/rtc-ffi-bindings-darwin-x64@0.12.70': + '@livekit/rtc-ffi-bindings-darwin-x64@0.12.68': optional: true - '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.70': + '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.68': optional: true - '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.70': + '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.68': optional: true - '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.70': + '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.68': optional: true - '@livekit/rtc-ffi-bindings@0.12.70': + '@livekit/rtc-ffi-bindings@0.12.68': dependencies: '@bufbuild/protobuf': 1.10.1 optionalDependencies: - '@livekit/rtc-ffi-bindings-darwin-arm64': 0.12.70 - '@livekit/rtc-ffi-bindings-darwin-x64': 0.12.70 - '@livekit/rtc-ffi-bindings-linux-arm64-gnu': 0.12.70 - '@livekit/rtc-ffi-bindings-linux-x64-gnu': 0.12.70 - '@livekit/rtc-ffi-bindings-win32-x64-msvc': 0.12.70 + '@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': {} From 5c1759eeeb3b6cb0c01b6f4c6405d8a9373ff0c8 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 27 Jul 2026 14:22:37 -0700 Subject: [PATCH 05/15] update changeset --- .changeset/sixty-trains-win.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/sixty-trains-win.md b/.changeset/sixty-trains-win.md index 34778fe5..b25fe902 100644 --- a/.changeset/sixty-trains-win.md +++ b/.changeset/sixty-trains-win.md @@ -2,4 +2,4 @@ "@livekit/rtc-node": patch --- -bump FFI to 0.12.71 +bump FFI to 0.12.68, including reconnect fixes From b34849184b4dbb8773c4d5be2fd63f043d4febde Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 27 Jul 2026 17:21:51 -0700 Subject: [PATCH 06/15] repro CI failure on 71 --- .changeset/lucky-pandas-listen.md | 5 + packages/livekit-rtc/package.json | 2 +- packages/livekit-rtc/src/audio_stream.ts | 61 +++++---- .../src/audio_stream_room_lifecycle.test.ts | 53 +++++++- packages/livekit-rtc/src/room.ts | 5 + packages/livekit-rtc/src/tests/e2e.test.ts | 120 +++++++++++++++++- packages/livekit-rtc/src/track.ts | 17 +++ pnpm-lock.yaml | 50 ++++---- 8 files changed, 257 insertions(+), 56 deletions(-) create mode 100644 .changeset/lucky-pandas-listen.md 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/packages/livekit-rtc/package.json b/packages/livekit-rtc/package.json index ada6d7b1..a53495fa 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.68", + "@livekit/rtc-ffi-bindings": "0.12.71", "@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..8dec6310 100644 --- a/packages/livekit-rtc/src/room.ts +++ b/packages/livekit-rtc/src/room.ts @@ -679,6 +679,11 @@ 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 + // Emit first so handlers still see a live track, then tear the streams + // down. + 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..3e0a3ff9 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,17 @@ 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; +} + function concatUint8(chunks: Uint8Array[]): Uint8Array { const len = chunks.reduce((acc, c) => acc + c.byteLength, 0); const out = new Uint8Array(len); @@ -202,6 +217,55 @@ function estimateFreqHz(samples: Int16Array, sampleRate: number): number { return bestLag > 0 ? sampleRate / bestLag : 0; } +/** + * Diagnostics for a tone buffer, attached to the detection assertions so a CI + * failure is self-explanatory. The estimator collapses very different problems + * into one number, and the per-window breakdown separates them: + * + * - every window ~60Hz but the whole buffer higher: audio was lost between + * frames and the concatenation spliced across the gaps (time-compressed) + * - every window equally wrong: the playout rate itself is wrong + * - exactly `sampleRate/lagStart` (120Hz at 48kHz): the buffer is silent, so + * autocorrelation is flat and the lowest lag wins + * - exactly 40Hz at 48kHz: two interleaved copies of the tone, i.e. frames from + * two streams merged into one buffer + */ +function describeToneBuffer(samples: Int16Array, sampleRate: number): string { + const windowSamples = Math.floor(sampleRate / 5); // 200ms, above the 100ms floor + const windowFreqs: string[] = []; + for (let off = 0; off + windowSamples <= samples.length; off += windowSamples) { + windowFreqs.push( + estimateFreqHz(samples.subarray(off, off + windowSamples), sampleRate).toFixed(1), + ); + } + + let zeros = 0; + let runStart = -1; + const silentRuns: string[] = []; + for (let i = 0; i <= samples.length; i++) { + const silent = i < samples.length && samples[i] === 0; + if (silent) { + zeros++; + if (runStart < 0) runStart = i; + } else if (runStart >= 0) { + const lengthMs = ((i - runStart) / sampleRate) * 1000; + if (lengthMs >= 10) { + silentRuns.push( + `@${((runStart / sampleRate) * 1000).toFixed(0)}ms+${lengthMs.toFixed(0)}ms`, + ); + } + runStart = -1; + } + } + + return [ + `audioMs=${((samples.length / sampleRate) * 1000).toFixed(0)}`, + `per200msFreq=[${windowFreqs.join(' ')}]`, + `zeroFrac=${(zeros / Math.max(1, samples.length)).toFixed(3)}`, + `silentRuns=[${silentRuns.join(' ') || 'none'}]`, + ].join(' '); +} + describeE2E('livekit-rtc e2e', () => { afterAll(async () => { await dispose(); @@ -286,11 +350,17 @@ describeE2E('livekit-rtc e2e', () => { const { rooms } = await connectTestRooms(2); const [subRoom, pubRoom] = rooms; + const publisherIdentity = pubRoom!.localParticipant!.identity; + const subscribedTracks: string[] = []; + subRoom!.on(RoomEvent.TrackSubscribed, (t, _pub, participant) => + subscribedTracks.push(`${participant.identity}/${t.sid}`), + ); const subscribed = waitForRoomEvent( subRoom!, RoomEvent.TrackSubscribed, 15_000, (track: unknown) => track, + publishedBy(publisherIdentity), ); const source = new AudioSource(params.pubRateHz, params.pubChannels); @@ -313,10 +383,14 @@ describeE2E('livekit-rtc e2e', () => { () => new Int16Array(0), ); + // Arrival wall-clock vs. audio duration: 100 frames is 1000ms of audio, + // so a materially longer wall-clock means audio went missing on the way. + const arrivals: number[] = []; const readTask = (async () => { let frames = 0; while (frames < framesToAnalyze) { const { done, value } = await reader.read(); + arrivals.push(Date.now()); if (done) break; expect(value.sampleRate).toBe(params.subRateHz); expect(value.channels).toBe(params.subChannels); @@ -359,9 +433,21 @@ describeE2E('livekit-rtc e2e', () => { 'Timed out during audio test', ); + const wallMs = arrivals.length > 1 ? arrivals[arrivals.length - 1]! - arrivals[0]! : 0; + let maxArrivalGapMs = 0; + for (let i = 1; i < arrivals.length; i++) { + maxArrivalGapMs = Math.max(maxArrivalGapMs, arrivals[i]! - arrivals[i - 1]!); + } + 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 ` + + `${describeToneBuffer(collected[ch]!, params.subRateHz)} ` + + `wallMs=${wallMs} maxArrivalGapMs=${maxArrivalGapMs} ` + + `subscribed=${JSON.stringify(subscribedTracks)}`, + ).toBeLessThan(20); } reader.releaseLock(); @@ -729,6 +815,12 @@ describeE2E('livekit-rtc e2e', () => { collectFromMs: Number.POSITIVE_INFINITY, collected: [] as Int16Array[], readers: [] as ReturnType[], + // Per-reader accounting: frames collected into the analysis window, and + // whether the reader ended. A stale reader that keeps delivering is the + // failure mode that made this test read the tone as 40Hz. + framesInWindow: [] as number[], + ended: [] as boolean[], + subscriptions: [] as string[], }; const attach = (remoteTrack: unknown) => { const stream = new AudioStream(remoteTrack as any, { @@ -737,6 +829,9 @@ describeE2E('livekit-rtc e2e', () => { }); const reader = stream.getReader(); sub.readers.push(reader); + const readerIdx = sub.readers.length - 1; + sub.framesInWindow.push(0); + sub.ended.push(false); (async () => { try { while (true) { @@ -745,14 +840,25 @@ describeE2E('livekit-rtc e2e', () => { sub.lastFrameAt = Date.now(); if (sub.lastFrameAt >= sub.collectFromMs) { sub.collected.push(channelSamples(value, 0)); + sub.framesInWindow[readerIdx]! += 1; } } } catch { // reader released } + sub.ended[readerIdx] = true; })(); }; - subRoom!.on(RoomEvent.TrackSubscribed, (t) => attach(t)); + const publisherIdentity = pubRoom!.localParticipant!.identity; + subRoom!.on(RoomEvent.TrackSubscribed, (t, _pub, participant) => { + sub.subscriptions.push(`${participant.identity}/${t.sid}`); + // 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 +897,15 @@ 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); + const liveReaders = sub.ended.filter((e) => !e).length; + expect( + Math.abs(detected - sineHz), + `scenario=${scenario} detected=${detected.toFixed(2)}Hz ` + + `${describeToneBuffer(concat, pubRateHz)} ` + + `readers=${sub.readers.length} liveReaders=${liveReaders} ` + + `framesInWindow=${JSON.stringify(sub.framesInWindow)} ` + + `subscriptions=${JSON.stringify(sub.subscriptions)}`, + ).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 665f9e77..8deccd92 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.68 - version: 0.12.68 + specifier: 0.12.71 + version: 0.12.71 '@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.68': - resolution: {integrity: sha512-xyBakR8fo3RC8CdrfV6/9U14JlmxXUYqB4eLITEsB+iCKW0CE7JfMqDFqV/JIlq43+xzf7wLJsNktgQRrlcdJg==} + '@livekit/rtc-ffi-bindings-darwin-arm64@0.12.71': + resolution: {integrity: sha512-dBAsy4cdBVODL490p6d1yu2Y0bcG1EoY4iXPJ0iGiI79bUEiM51eXKMd9uteq479aJzvvnJ3nDUSTbYTzt03oQ==} engines: {node: '>= 18'} cpu: [arm64] os: [darwin] - '@livekit/rtc-ffi-bindings-darwin-x64@0.12.68': - resolution: {integrity: sha512-nJFgFEbDePvPF9v9nqTBlKZ1otGSBgB3DJO+qjw+KowaE2AVubb6/qCF3gjJK/xxfyMcTgVnPr2SgcXWtNZ5Kg==} + '@livekit/rtc-ffi-bindings-darwin-x64@0.12.71': + resolution: {integrity: sha512-+3vqQS6knmXEUPRcQ4gY7b4zxJavcFMb2S7sNHJ+KiRU8jAAgQU8BKZoEHn8kyj6ilT86dQm//62hMb5Grt/ng==} engines: {node: '>= 18'} cpu: [x64] os: [darwin] - '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.68': - resolution: {integrity: sha512-Hl9R9ZXqvq7lCbPqXnSNnojfxv8tmLISoxuv7zd0iA4Q7V99v/XCiali1jKpggXn5IqlyF3PHeW11a41glFjTw==} + '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.71': + resolution: {integrity: sha512-GZNJ5roycWy7YQmpZU0rX2YP+jwlaPvO04Jet4dg+v4TnAGkbsGqj62p9F7Obl9KWlIrP4BR/wdg/Pjt0vebTQ==} engines: {node: '>= 18'} cpu: [arm64] os: [linux] libc: [glibc] - '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.68': - resolution: {integrity: sha512-2r99lXSkrmiHjhizCquQwPEbP7mCV/zESVXq1pdEliNvbBJXibMEYbRmuSr1lf4/VZhPpRGcF+Laqg5DgFoe+A==} + '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.71': + resolution: {integrity: sha512-RuduuWm4j7gyQEJFT10x44bJ19Y4+mG+VTEnTotRMcI1tNYhGjNmLaokwxHqYLJnxllOq0TpmsFfbXzcazOWtg==} engines: {node: '>= 18'} cpu: [x64] os: [linux] libc: [glibc] - '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.68': - resolution: {integrity: sha512-ec7MJBWx4mz40GvfXQ2Bn9uqvp2HueldTouPi5jzYUG2DECmhYhwai0cBcRFJOya3hBm/nRXP0A+pUBdWnY7Fg==} + '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.71': + resolution: {integrity: sha512-80v4pmJ5bt8YM7dZswRLksp1ezSgBEDhdKTPKEEfs1GHi4WugRdwE94jgqUifG1zQT14qWWK8W0YigP/tnKtpg==} engines: {node: '>= 18'} cpu: [x64] os: [win32] - '@livekit/rtc-ffi-bindings@0.12.68': - resolution: {integrity: sha512-PNr+EOwsT/ZUHALjTdSh+y1Dcj5Msnh0p5C8DaGBF9dCrFVZOxwp1rZovZo3cM+hiYo8TAvrjMEK3V5qD3++8g==} + '@livekit/rtc-ffi-bindings@0.12.71': + resolution: {integrity: sha512-J5ITMGTTnDFne5VLx8rRyEfungyKKGkxj82ihoC6QHZ8BqSe53Af2DZnFs8Q7LMVcd8X1EP/ppn11LD5oVOBaw==} 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.68': + '@livekit/rtc-ffi-bindings-darwin-arm64@0.12.71': optional: true - '@livekit/rtc-ffi-bindings-darwin-x64@0.12.68': + '@livekit/rtc-ffi-bindings-darwin-x64@0.12.71': optional: true - '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.68': + '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.71': optional: true - '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.68': + '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.71': optional: true - '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.68': + '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.71': optional: true - '@livekit/rtc-ffi-bindings@0.12.68': + '@livekit/rtc-ffi-bindings@0.12.71': dependencies: '@bufbuild/protobuf': 1.10.1 optionalDependencies: - '@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/rtc-ffi-bindings-darwin-arm64': 0.12.71 + '@livekit/rtc-ffi-bindings-darwin-x64': 0.12.71 + '@livekit/rtc-ffi-bindings-linux-arm64-gnu': 0.12.71 + '@livekit/rtc-ffi-bindings-linux-x64-gnu': 0.12.71 + '@livekit/rtc-ffi-bindings-win32-x64-msvc': 0.12.71 '@livekit/typed-emitter@3.0.0': {} From 96df2c555030e2c892d6217ba44d623df0d37d2e Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 27 Jul 2026 17:42:02 -0700 Subject: [PATCH 07/15] relax rpc timeout --- packages/livekit-rtc/src/tests/e2e.test.ts | 38 +++++++++++++++++++--- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/livekit-rtc/src/tests/e2e.test.ts b/packages/livekit-rtc/src/tests/e2e.test.ts index 3e0a3ff9..8ed6bc81 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -160,6 +160,28 @@ function publishedBy(identity: string) { (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 instead asserts that the + * send finished within the tolerance, which is a latency budget: the first send + * on a connection also waits for the data channel to open, and on a loaded CI + * runner that has taken 1.5s+. 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); @@ -519,9 +541,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); @@ -540,6 +563,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, @@ -549,7 +573,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); @@ -571,12 +595,16 @@ describeE2E('livekit-rtc e2e', () => { calleeRoom!.localParticipant!.registerRpcMethod(method, async (data) => data.payload); + // give the round trip room to complete: it is the first data-channel + // traffic on the connection. + const rpcResponseTimeoutMs = 1_000; + await expect( callerRoom!.localParticipant!.performRpc({ destinationIdentity: calleeRoom!.localParticipant!.identity, method, payload, - responseTimeout: 500, + responseTimeout: rpcResponseTimeoutMs, }), ).resolves.toBe(payload); @@ -585,10 +613,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', From e9d8b50c5bcdfd32a0de2c0340b8ff9f64342692 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 27 Jul 2026 18:11:21 -0700 Subject: [PATCH 08/15] test --- packages/livekit-rtc/src/tests/e2e.test.ts | 63 ++++++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/livekit-rtc/src/tests/e2e.test.ts b/packages/livekit-rtc/src/tests/e2e.test.ts index 8ed6bc81..5e2bf097 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -182,6 +182,32 @@ function expectTimestampFromCall(timestamp: number, calledAt: number, what: stri expect(timestamp, detail).toBeLessThanOrEqual(now + clockToleranceMs); } +/** + * Sample how late a fixed-interval timer actually fires, i.e. how long this + * process was unable to run its own callbacks. Distinguishes "we were blocked" + * from "the other side was slow" when an operation misses its budget. + */ +function startEventLoopLagSampler(intervalMs = 50) { + let maxMs = 0; + let totalMs = 0; + let samples = 0; + let last = Date.now(); + const timer = setInterval(() => { + const now = Date.now(); + const lag = Math.max(0, now - last - intervalMs); + last = now; + maxMs = Math.max(maxMs, lag); + totalMs += lag; + samples += 1; + }, intervalMs); + return { + stop() { + clearInterval(timer); + return { maxMs, meanMs: samples ? Math.round(totalMs / samples) : 0, samples }; + }, + }; +} + function concatUint8(chunks: Uint8Array[]): Uint8Array { const len = chunks.reduce((acc, c) => acc + c.byteLength, 0); const out = new Uint8Array(len); @@ -593,20 +619,47 @@ describeE2E('livekit-rtc e2e', () => { const method = 'test-method'; const payload = 'test-payload'; - calleeRoom!.localParticipant!.registerRpcMethod(method, async (data) => data.payload); + // Caller and callee are both in this process, so the round trip can be + // split at the handler: that separates a slow request path from a slow + // response path, and the event-loop lag says whether this process was + // simply too busy to run either. This has timed out in CI at budgets an + // idle machine covers in ~100ms, on 0.12.60 as well as 0.12.71. + let handlerInvokedAt = 0; + calleeRoom!.localParticipant!.registerRpcMethod(method, async (data) => { + handlerInvokedAt = Date.now(); + return data.payload; + }); // give the round trip room to complete: it is the first data-channel // traffic on the connection. const rpcResponseTimeoutMs = 1_000; - await expect( - callerRoom!.localParticipant!.performRpc({ + const lagSampler = startEventLoopLagSampler(); + const rpcStartedAt = Date.now(); + let rpcResult: string | undefined; + let rpcError: unknown; + try { + rpcResult = await callerRoom!.localParticipant!.performRpc({ destinationIdentity: calleeRoom!.localParticipant!.identity, method, payload, responseTimeout: rpcResponseTimeoutMs, - }), - ).resolves.toBe(payload); + }); + } catch (e: unknown) { + rpcError = e; + } + const rpcMs = Date.now() - rpcStartedAt; + const lag = lagSampler.stop(); + const rpcDiag = + `first RPC took ${rpcMs}ms (responseTimeout=${rpcResponseTimeoutMs}ms); ` + + `callee handler invoked at ${ + handlerInvokedAt ? `+${handlerInvokedAt - rpcStartedAt}ms` : 'never' + } (request path), remainder is the response path; ` + + `event-loop lag max=${lag.maxMs}ms mean=${lag.meanMs}ms over ${lag.samples} samples` + + (rpcError ? `; error: ${String(rpcError)}` : ''); + + expect(rpcError, rpcDiag).toBeUndefined(); + expect(rpcResult, rpcDiag).toBe(payload); await expect( callerRoom!.localParticipant!.performRpc({ From fa421cf040f97df11ce885ae1b9ec7add6008347 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 27 Jul 2026 18:29:55 -0700 Subject: [PATCH 09/15] test --- packages/livekit-rtc/src/tests/e2e.test.ts | 29 ++++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/livekit-rtc/src/tests/e2e.test.ts b/packages/livekit-rtc/src/tests/e2e.test.ts index 5e2bf097..c117a64f 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -622,16 +622,33 @@ describeE2E('livekit-rtc e2e', () => { // Caller and callee are both in this process, so the round trip can be // split at the handler: that separates a slow request path from a slow // response path, and the event-loop lag says whether this process was - // simply too busy to run either. This has timed out in CI at budgets an - // idle machine covers in ~100ms, on 0.12.60 as well as 0.12.71. + // simply too busy to run either. let handlerInvokedAt = 0; calleeRoom!.localParticipant!.registerRpcMethod(method, async (data) => { handlerInvokedAt = Date.now(); return data.payload; }); - // give the round trip room to complete: it is the first data-channel - // traffic on the connection. + // `room.connect()` resolves on the signal handshake, so the first + // data-channel message still pays for ICE/DTLS/SCTP setup. CI has taken + // 1261ms to deliver it (with this process idle — event-loop lag stayed at + // 30ms), which blows any per-call budget. Warm the channel up untimed so + // the assertions below measure RPC behavior instead of connection setup. + const warmupStartedAt = Date.now(); + let warmupError: unknown; + try { + await callerRoom!.localParticipant!.performRpc({ + destinationIdentity: calleeRoom!.localParticipant!.identity, + method, + payload, + responseTimeout: testTimeoutMs, + }); + } catch (e: unknown) { + warmupError = e; + } + const warmupMs = Date.now() - warmupStartedAt; + handlerInvokedAt = 0; + const rpcResponseTimeoutMs = 1_000; const lagSampler = startEventLoopLagSampler(); @@ -651,7 +668,9 @@ describeE2E('livekit-rtc e2e', () => { const rpcMs = Date.now() - rpcStartedAt; const lag = lagSampler.stop(); const rpcDiag = - `first RPC took ${rpcMs}ms (responseTimeout=${rpcResponseTimeoutMs}ms); ` + + `warm-up RPC (first data-channel traffic) took ${warmupMs}ms` + + (warmupError ? ` and failed: ${String(warmupError)}` : '') + + `; warmed RPC took ${rpcMs}ms (responseTimeout=${rpcResponseTimeoutMs}ms); ` + `callee handler invoked at ${ handlerInvokedAt ? `+${handlerInvokedAt - rpcStartedAt}ms` : 'never' } (request path), remainder is the response path; ` + From 15f81cff85f68c524fe08f86212507c36f9d94e2 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 27 Jul 2026 18:47:39 -0700 Subject: [PATCH 10/15] debug --- packages/livekit-rtc/src/tests/e2e.test.ts | 60 +++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/livekit-rtc/src/tests/e2e.test.ts b/packages/livekit-rtc/src/tests/e2e.test.ts index c117a64f..4133ee73 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -182,6 +182,58 @@ function expectTimestampFromCall(timestamp: number, calledAt: number, what: stri expect(timestamp, detail).toBeLessThanOrEqual(now + clockToleranceMs); } +/** + * ICE/SCTP state for the room's transports, plus how loaded this host is. + * + * @remarks + * Answers, for an operation that took far longer than the path should allow: + * was the path itself slow (`rtt`), was the sender starved of bandwidth + * (`availOutBitrate`, `discardedOnSend`), or was the machine simply + * oversubscribed (`load` vs `cpus`)? + */ +async function describeTransports(room: Room): Promise { + try { + const os = await import('node:os'); + const host = `cpus=${os.cpus().length} load=${os + .loadavg() + .map((l) => l.toFixed(1)) + .join('/')}`; + const stats = await room.getRtcStats(); + const describe = (label: string, list: unknown[]): string => { + const parts: string[] = []; + for (const entry of list) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stat = (entry as any)?.stats; + const pair = stat?.case === 'candidatePair' ? stat.value?.candidatePair : undefined; + const dc = stat?.case === 'dataChannel' ? stat.value?.dc : undefined; + if (pair?.nominated) { + parts.push( + `pair(rtt=${pair.currentRoundTripTime ?? '?'}s ` + + `state=${pair.state ?? '?'} ` + + `availOutBitrate=${pair.availableOutgoingBitrate ?? '?'} ` + + `packetsSent=${pair.packetsSent ?? '?'} ` + + `discardedOnSend=${pair.packetsDiscardedOnSend ?? 0})`, + ); + } else if (dc) { + parts.push( + `dc(${dc.label ?? '?'} ${dc.state ?? '?'} ` + + `sent=${dc.messagesSent ?? 0}/${dc.bytesSent ?? 0}B ` + + `recv=${dc.messagesReceived ?? 0}/${dc.bytesReceived ?? 0}B)`, + ); + } + } + return `${label}[${parts.join(' ') || 'none'}]`; + }; + return ( + `${host} ` + + `${describe('publisher', stats.publisherStats)} ` + + `${describe('subscriber', stats.subscriberStats)}` + ); + } catch (e: unknown) { + return `transport stats unavailable: ${String(e)}`; + } +} + /** * Sample how late a fixed-interval timer actually fires, i.e. how long this * process was unable to run its own callbacks. Distinguishes "we were blocked" @@ -667,6 +719,11 @@ describeE2E('livekit-rtc e2e', () => { } const rpcMs = Date.now() - rpcStartedAt; const lag = lagSampler.stop(); + // Only pay for stats when we're about to fail. + const transports = + rpcError || rpcResult !== payload + ? ` | caller ${await describeTransports(callerRoom!)}` + : ''; const rpcDiag = `warm-up RPC (first data-channel traffic) took ${warmupMs}ms` + (warmupError ? ` and failed: ${String(warmupError)}` : '') + @@ -675,7 +732,8 @@ describeE2E('livekit-rtc e2e', () => { handlerInvokedAt ? `+${handlerInvokedAt - rpcStartedAt}ms` : 'never' } (request path), remainder is the response path; ` + `event-loop lag max=${lag.maxMs}ms mean=${lag.meanMs}ms over ${lag.samples} samples` + - (rpcError ? `; error: ${String(rpcError)}` : ''); + (rpcError ? `; error: ${String(rpcError)}` : '') + + transports; expect(rpcError, rpcDiag).toBeUndefined(); expect(rpcResult, rpcDiag).toBe(payload); From 859d4d303dbf9185c206d150bba2a6e7effbb49e Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 27 Jul 2026 20:02:48 -0700 Subject: [PATCH 11/15] debug --- packages/livekit-rtc/src/tests/e2e.test.ts | 56 +++++++++++++++++----- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/packages/livekit-rtc/src/tests/e2e.test.ts b/packages/livekit-rtc/src/tests/e2e.test.ts index 4133ee73..069ecca5 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -483,17 +483,36 @@ describeE2E('livekit-rtc e2e', () => { () => new Int16Array(0), ); - // Arrival wall-clock vs. audio duration: 100 frames is 1000ms of audio, - // so a materially longer wall-clock means audio went missing on the way. + // The subscription is live before the publisher's first frame reaches + // it, so the stream opens with silence — and on a loaded runner the FFI + // hands that over in a burst (CI has delivered 1000ms of audio in 156ms, + // 90% zeros). Analyzing the first N frames blind measures the silence + // instead of the tone, which reads 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 arrivals: number[] = []; const readTask = (async () => { let frames = 0; while (frames < framesToAnalyze) { const { done, value } = await reader.read(); - arrivals.push(Date.now()); 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; + } + + arrivals.push(Date.now()); for (let ch = 0; ch < params.subChannels; ch++) { const s = channelSamples(value, ch); const prev = collected[ch]!; @@ -504,14 +523,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( @@ -524,14 +550,16 @@ 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 { + toneRunning = false; + await publishTask.catch(() => { + // the tone loop only fails once the source is closed below + }); + } const wallMs = arrivals.length > 1 ? arrivals[arrivals.length - 1]! - arrivals[0]! : 0; let maxArrivalGapMs = 0; @@ -546,6 +574,7 @@ describeE2E('livekit-rtc e2e', () => { `${JSON.stringify(params)} ch${ch}: detected=${detected.toFixed(2)}Hz ` + `${describeToneBuffer(collected[ch]!, params.subRateHz)} ` + `wallMs=${wallMs} maxArrivalGapMs=${maxArrivalGapMs} ` + + `skippedSilentFrames=${skippedSilentFrames} ` + `subscribed=${JSON.stringify(subscribedTracks)}`, ).toBeLessThan(20); } @@ -735,6 +764,11 @@ describeE2E('livekit-rtc e2e', () => { (rpcError ? `; error: ${String(rpcError)}` : '') + transports; + // Emit on success too: a run that passes in 10s is as interesting as one + // that fails, and the numbers are invisible otherwise. Drop this line once + // the data-channel latency question is settled. + process.stderr.write(`[rpc-timing] ${rpcDiag}\n`); + expect(rpcError, rpcDiag).toBeUndefined(); expect(rpcResult, rpcDiag).toBe(payload); From 048f561faefed5db8c17c199ff7296f1dc298299 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 27 Jul 2026 20:28:50 -0700 Subject: [PATCH 12/15] remove debug data --- .changeset/sixty-trains-win.md | 2 +- packages/livekit-rtc/src/tests/e2e.test.ts | 262 +++------------------ 2 files changed, 38 insertions(+), 226 deletions(-) diff --git a/.changeset/sixty-trains-win.md b/.changeset/sixty-trains-win.md index b25fe902..fcda3bc6 100644 --- a/.changeset/sixty-trains-win.md +++ b/.changeset/sixty-trains-win.md @@ -2,4 +2,4 @@ "@livekit/rtc-node": patch --- -bump FFI to 0.12.68, including reconnect fixes +bump FFI to 0.12.71, including reconnect fixes diff --git a/packages/livekit-rtc/src/tests/e2e.test.ts b/packages/livekit-rtc/src/tests/e2e.test.ts index 069ecca5..96535aab 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -182,84 +182,6 @@ function expectTimestampFromCall(timestamp: number, calledAt: number, what: stri expect(timestamp, detail).toBeLessThanOrEqual(now + clockToleranceMs); } -/** - * ICE/SCTP state for the room's transports, plus how loaded this host is. - * - * @remarks - * Answers, for an operation that took far longer than the path should allow: - * was the path itself slow (`rtt`), was the sender starved of bandwidth - * (`availOutBitrate`, `discardedOnSend`), or was the machine simply - * oversubscribed (`load` vs `cpus`)? - */ -async function describeTransports(room: Room): Promise { - try { - const os = await import('node:os'); - const host = `cpus=${os.cpus().length} load=${os - .loadavg() - .map((l) => l.toFixed(1)) - .join('/')}`; - const stats = await room.getRtcStats(); - const describe = (label: string, list: unknown[]): string => { - const parts: string[] = []; - for (const entry of list) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const stat = (entry as any)?.stats; - const pair = stat?.case === 'candidatePair' ? stat.value?.candidatePair : undefined; - const dc = stat?.case === 'dataChannel' ? stat.value?.dc : undefined; - if (pair?.nominated) { - parts.push( - `pair(rtt=${pair.currentRoundTripTime ?? '?'}s ` + - `state=${pair.state ?? '?'} ` + - `availOutBitrate=${pair.availableOutgoingBitrate ?? '?'} ` + - `packetsSent=${pair.packetsSent ?? '?'} ` + - `discardedOnSend=${pair.packetsDiscardedOnSend ?? 0})`, - ); - } else if (dc) { - parts.push( - `dc(${dc.label ?? '?'} ${dc.state ?? '?'} ` + - `sent=${dc.messagesSent ?? 0}/${dc.bytesSent ?? 0}B ` + - `recv=${dc.messagesReceived ?? 0}/${dc.bytesReceived ?? 0}B)`, - ); - } - } - return `${label}[${parts.join(' ') || 'none'}]`; - }; - return ( - `${host} ` + - `${describe('publisher', stats.publisherStats)} ` + - `${describe('subscriber', stats.subscriberStats)}` - ); - } catch (e: unknown) { - return `transport stats unavailable: ${String(e)}`; - } -} - -/** - * Sample how late a fixed-interval timer actually fires, i.e. how long this - * process was unable to run its own callbacks. Distinguishes "we were blocked" - * from "the other side was slow" when an operation misses its budget. - */ -function startEventLoopLagSampler(intervalMs = 50) { - let maxMs = 0; - let totalMs = 0; - let samples = 0; - let last = Date.now(); - const timer = setInterval(() => { - const now = Date.now(); - const lag = Math.max(0, now - last - intervalMs); - last = now; - maxMs = Math.max(maxMs, lag); - totalMs += lag; - samples += 1; - }, intervalMs); - return { - stop() { - clearInterval(timer); - return { maxMs, meanMs: samples ? Math.round(totalMs / samples) : 0, samples }; - }, - }; -} - function concatUint8(chunks: Uint8Array[]): Uint8Array { const len = chunks.reduce((acc, c) => acc + c.byteLength, 0); const out = new Uint8Array(len); @@ -318,52 +240,16 @@ function estimateFreqHz(samples: Int16Array, sampleRate: number): number { } /** - * Diagnostics for a tone buffer, attached to the detection assertions so a CI - * failure is self-explanatory. The estimator collapses very different problems - * into one number, and the per-window breakdown separates them: - * - * - every window ~60Hz but the whole buffer higher: audio was lost between - * frames and the concatenation spliced across the gaps (time-compressed) - * - every window equally wrong: the playout rate itself is wrong - * - exactly `sampleRate/lagStart` (120Hz at 48kHz): the buffer is silent, so - * autocorrelation is flat and the lowest lag wins - * - exactly 40Hz at 48kHz: two interleaved copies of the tone, i.e. frames from - * two streams merged into one buffer + * 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 describeToneBuffer(samples: Int16Array, sampleRate: number): string { - const windowSamples = Math.floor(sampleRate / 5); // 200ms, above the 100ms floor - const windowFreqs: string[] = []; - for (let off = 0; off + windowSamples <= samples.length; off += windowSamples) { - windowFreqs.push( - estimateFreqHz(samples.subarray(off, off + windowSamples), sampleRate).toFixed(1), - ); - } - +function silentFraction(samples: Int16Array): number { let zeros = 0; - let runStart = -1; - const silentRuns: string[] = []; - for (let i = 0; i <= samples.length; i++) { - const silent = i < samples.length && samples[i] === 0; - if (silent) { - zeros++; - if (runStart < 0) runStart = i; - } else if (runStart >= 0) { - const lengthMs = ((i - runStart) / sampleRate) * 1000; - if (lengthMs >= 10) { - silentRuns.push( - `@${((runStart / sampleRate) * 1000).toFixed(0)}ms+${lengthMs.toFixed(0)}ms`, - ); - } - runStart = -1; - } + for (let i = 0; i < samples.length; i++) { + if (samples[i] === 0) zeros++; } - - return [ - `audioMs=${((samples.length / sampleRate) * 1000).toFixed(0)}`, - `per200msFreq=[${windowFreqs.join(' ')}]`, - `zeroFrac=${(zeros / Math.max(1, samples.length)).toFixed(3)}`, - `silentRuns=[${silentRuns.join(' ') || 'none'}]`, - ].join(' '); + return samples.length ? zeros / samples.length : 1; } describeE2E('livekit-rtc e2e', () => { @@ -436,7 +322,13 @@ 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 ~10ms ahead. Run + // concurrently with the rest of the suite, event-loop jitter on a small runner + // turns straight into transmitted silence — CI has produced 79% zeros with a + // 394ms hole, which the detector reads as ~93Hz. + itRaw( 'transfers audio between two participants (sine detection)', async () => { const cases = [ @@ -451,10 +343,6 @@ describeE2E('livekit-rtc e2e', () => { const [subRoom, pubRoom] = rooms; const publisherIdentity = pubRoom!.localParticipant!.identity; - const subscribedTracks: string[] = []; - subRoom!.on(RoomEvent.TrackSubscribed, (t, _pub, participant) => - subscribedTracks.push(`${participant.identity}/${t.sid}`), - ); const subscribed = waitForRoomEvent( subRoom!, RoomEvent.TrackSubscribed, @@ -483,16 +371,13 @@ describeE2E('livekit-rtc e2e', () => { () => new Int16Array(0), ); - // The subscription is live before the publisher's first frame reaches - // it, so the stream opens with silence — and on a loaded runner the FFI - // hands that over in a burst (CI has delivered 1000ms of audio in 156ms, - // 90% zeros). Analyzing the first N frames blind measures the silence - // instead of the tone, which reads as a bogus frequency, so skip ahead - // to where the tone actually starts. + // 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 arrivals: number[] = []; const readTask = (async () => { let frames = 0; while (frames < framesToAnalyze) { @@ -512,7 +397,6 @@ describeE2E('livekit-rtc e2e', () => { toneStarted = true; } - arrivals.push(Date.now()); for (let ch = 0; ch < params.subChannels; ch++) { const s = channelSamples(value, ch); const prev = collected[ch]!; @@ -561,21 +445,13 @@ describeE2E('livekit-rtc e2e', () => { }); } - const wallMs = arrivals.length > 1 ? arrivals[arrivals.length - 1]! - arrivals[0]! : 0; - let maxArrivalGapMs = 0; - for (let i = 1; i < arrivals.length; i++) { - maxArrivalGapMs = Math.max(maxArrivalGapMs, arrivals[i]! - arrivals[i - 1]!); - } - for (let ch = 0; ch < params.subChannels; ch++) { const detected = estimateFreqHz(collected[ch]!, params.subRateHz); expect( Math.abs(detected - sineHz), - `${JSON.stringify(params)} ch${ch}: detected=${detected.toFixed(2)}Hz ` + - `${describeToneBuffer(collected[ch]!, params.subRateHz)} ` + - `wallMs=${wallMs} maxArrivalGapMs=${maxArrivalGapMs} ` + - `skippedSilentFrames=${skippedSilentFrames} ` + - `subscribed=${JSON.stringify(subscribedTracks)}`, + `${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); } @@ -700,77 +576,29 @@ describeE2E('livekit-rtc e2e', () => { const method = 'test-method'; const payload = 'test-payload'; - // Caller and callee are both in this process, so the round trip can be - // split at the handler: that separates a slow request path from a slow - // response path, and the event-loop lag says whether this process was - // simply too busy to run either. - let handlerInvokedAt = 0; - calleeRoom!.localParticipant!.registerRpcMethod(method, async (data) => { - handlerInvokedAt = Date.now(); - return data.payload; - }); + calleeRoom!.localParticipant!.registerRpcMethod(method, async (data) => data.payload); // `room.connect()` resolves on the signal handshake, so the first - // data-channel message still pays for ICE/DTLS/SCTP setup. CI has taken - // 1261ms to deliver it (with this process idle — event-loop lag stayed at - // 30ms), which blows any per-call budget. Warm the channel up untimed so - // the assertions below measure RPC behavior instead of connection setup. - const warmupStartedAt = Date.now(); - let warmupError: unknown; - try { - await callerRoom!.localParticipant!.performRpc({ - destinationIdentity: calleeRoom!.localParticipant!.identity, - method, - payload, - responseTimeout: testTimeoutMs, - }); - } catch (e: unknown) { - warmupError = e; - } - const warmupMs = Date.now() - warmupStartedAt; - handlerInvokedAt = 0; + // 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; - const lagSampler = startEventLoopLagSampler(); - const rpcStartedAt = Date.now(); - let rpcResult: string | undefined; - let rpcError: unknown; - try { - rpcResult = await callerRoom!.localParticipant!.performRpc({ + await expect( + callerRoom!.localParticipant!.performRpc({ destinationIdentity: calleeRoom!.localParticipant!.identity, method, payload, responseTimeout: rpcResponseTimeoutMs, - }); - } catch (e: unknown) { - rpcError = e; - } - const rpcMs = Date.now() - rpcStartedAt; - const lag = lagSampler.stop(); - // Only pay for stats when we're about to fail. - const transports = - rpcError || rpcResult !== payload - ? ` | caller ${await describeTransports(callerRoom!)}` - : ''; - const rpcDiag = - `warm-up RPC (first data-channel traffic) took ${warmupMs}ms` + - (warmupError ? ` and failed: ${String(warmupError)}` : '') + - `; warmed RPC took ${rpcMs}ms (responseTimeout=${rpcResponseTimeoutMs}ms); ` + - `callee handler invoked at ${ - handlerInvokedAt ? `+${handlerInvokedAt - rpcStartedAt}ms` : 'never' - } (request path), remainder is the response path; ` + - `event-loop lag max=${lag.maxMs}ms mean=${lag.meanMs}ms over ${lag.samples} samples` + - (rpcError ? `; error: ${String(rpcError)}` : '') + - transports; - - // Emit on success too: a run that passes in 10s is as interesting as one - // that fails, and the numbers are invisible otherwise. Drop this line once - // the data-channel latency question is settled. - process.stderr.write(`[rpc-timing] ${rpcDiag}\n`); - - expect(rpcError, rpcDiag).toBeUndefined(); - expect(rpcResult, rpcDiag).toBe(payload); + }), + ).resolves.toBe(payload); await expect( callerRoom!.localParticipant!.performRpc({ @@ -1009,12 +837,6 @@ describeE2E('livekit-rtc e2e', () => { collectFromMs: Number.POSITIVE_INFINITY, collected: [] as Int16Array[], readers: [] as ReturnType[], - // Per-reader accounting: frames collected into the analysis window, and - // whether the reader ended. A stale reader that keeps delivering is the - // failure mode that made this test read the tone as 40Hz. - framesInWindow: [] as number[], - ended: [] as boolean[], - subscriptions: [] as string[], }; const attach = (remoteTrack: unknown) => { const stream = new AudioStream(remoteTrack as any, { @@ -1023,9 +845,6 @@ describeE2E('livekit-rtc e2e', () => { }); const reader = stream.getReader(); sub.readers.push(reader); - const readerIdx = sub.readers.length - 1; - sub.framesInWindow.push(0); - sub.ended.push(false); (async () => { try { while (true) { @@ -1034,18 +853,15 @@ describeE2E('livekit-rtc e2e', () => { sub.lastFrameAt = Date.now(); if (sub.lastFrameAt >= sub.collectFromMs) { sub.collected.push(channelSamples(value, 0)); - sub.framesInWindow[readerIdx]! += 1; } } } catch { // reader released } - sub.ended[readerIdx] = true; })(); }; const publisherIdentity = pubRoom!.localParticipant!.identity; subRoom!.on(RoomEvent.TrackSubscribed, (t, _pub, participant) => { - sub.subscriptions.push(`${participant.identity}/${t.sid}`); // 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) { @@ -1091,14 +907,10 @@ 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. - const liveReaders = sub.ended.filter((e) => !e).length; expect( Math.abs(detected - sineHz), - `scenario=${scenario} detected=${detected.toFixed(2)}Hz ` + - `${describeToneBuffer(concat, pubRateHz)} ` + - `readers=${sub.readers.length} liveReaders=${liveReaders} ` + - `framesInWindow=${JSON.stringify(sub.framesInWindow)} ` + - `subscriptions=${JSON.stringify(sub.subscriptions)}`, + `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! }; From 4ef68dca287790d95c3667b7553163899759a01b Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 27 Jul 2026 21:50:13 -0700 Subject: [PATCH 13/15] cleanup --- packages/livekit-rtc/src/room.ts | 5 ++--- packages/livekit-rtc/src/tests/e2e.test.ts | 21 ++++++++++----------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/livekit-rtc/src/room.ts b/packages/livekit-rtc/src/room.ts index 8dec6310..0d9445ca 100644 --- a/packages/livekit-rtc/src/room.ts +++ b/packages/livekit-rtc/src/room.ts @@ -680,9 +680,8 @@ export class Room extends (EventEmitter as new () => TypedEmitter 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 - // Emit first so handlers still see a live track, then tear the streams - // down. + // 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 96535aab..4d5507a0 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -165,12 +165,12 @@ function publishedBy(identity: string) { * sits between "just before the call" and "now". * * @remarks - * Comparing it to `Date.now()` after awaiting the send instead asserts that the - * send finished within the tolerance, which is a latency budget: the first send - * on a connection also waits for the data channel to open, and on a loaded CI - * runner that has taken 1.5s+. 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. + * 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; @@ -322,12 +322,11 @@ describeE2E('livekit-rtc e2e', () => { testTimeoutMs, ); - // Sequential, like the reconnect scenarios below: this test produces audio in + // 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 ~10ms ahead. Run - // concurrently with the rest of the suite, event-loop jitter on a small runner - // turns straight into transmitted silence — CI has produced 79% zeros with a - // 394ms hole, which the detector reads as ~93Hz. + // 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 () => { From 6253d85d83830a59848ca7979a8457b00c0cec52 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 27 Jul 2026 22:04:54 -0700 Subject: [PATCH 14/15] cleanup --- packages/livekit-rtc/src/tests/e2e.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/livekit-rtc/src/tests/e2e.test.ts b/packages/livekit-rtc/src/tests/e2e.test.ts index 4d5507a0..bc00a354 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -438,10 +438,12 @@ describeE2E('livekit-rtc e2e', () => { 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(() => { - // the tone loop only fails once the source is closed below - }); + await publishTask.catch(() => {}); } for (let ch = 0; ch < params.subChannels; ch++) { From 5ff6574c1b09b7c1e980e09aaaf82dd75047ab44 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 27 Jul 2026 22:27:23 -0700 Subject: [PATCH 15/15] down to 68 --- .changeset/sixty-trains-win.md | 2 +- packages/livekit-rtc/package.json | 2 +- pnpm-lock.yaml | 50 +++++++++++++++---------------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.changeset/sixty-trains-win.md b/.changeset/sixty-trains-win.md index fcda3bc6..84707ba5 100644 --- a/.changeset/sixty-trains-win.md +++ b/.changeset/sixty-trains-win.md @@ -2,4 +2,4 @@ "@livekit/rtc-node": patch --- -bump FFI to 0.12.71, including reconnect fixes +bump FFI to 0.12.68 diff --git a/packages/livekit-rtc/package.json b/packages/livekit-rtc/package.json index a53495fa..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.71", + "@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/pnpm-lock.yaml b/pnpm-lock.yaml index 8deccd92..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.71 - version: 0.12.71 + 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.71': - resolution: {integrity: sha512-dBAsy4cdBVODL490p6d1yu2Y0bcG1EoY4iXPJ0iGiI79bUEiM51eXKMd9uteq479aJzvvnJ3nDUSTbYTzt03oQ==} + '@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.71': - resolution: {integrity: sha512-+3vqQS6knmXEUPRcQ4gY7b4zxJavcFMb2S7sNHJ+KiRU8jAAgQU8BKZoEHn8kyj6ilT86dQm//62hMb5Grt/ng==} + '@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.71': - resolution: {integrity: sha512-GZNJ5roycWy7YQmpZU0rX2YP+jwlaPvO04Jet4dg+v4TnAGkbsGqj62p9F7Obl9KWlIrP4BR/wdg/Pjt0vebTQ==} + '@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.71': - resolution: {integrity: sha512-RuduuWm4j7gyQEJFT10x44bJ19Y4+mG+VTEnTotRMcI1tNYhGjNmLaokwxHqYLJnxllOq0TpmsFfbXzcazOWtg==} + '@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.71': - resolution: {integrity: sha512-80v4pmJ5bt8YM7dZswRLksp1ezSgBEDhdKTPKEEfs1GHi4WugRdwE94jgqUifG1zQT14qWWK8W0YigP/tnKtpg==} + '@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.71': - resolution: {integrity: sha512-J5ITMGTTnDFne5VLx8rRyEfungyKKGkxj82ihoC6QHZ8BqSe53Af2DZnFs8Q7LMVcd8X1EP/ppn11LD5oVOBaw==} + '@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.71': + '@livekit/rtc-ffi-bindings-darwin-arm64@0.12.68': optional: true - '@livekit/rtc-ffi-bindings-darwin-x64@0.12.71': + '@livekit/rtc-ffi-bindings-darwin-x64@0.12.68': optional: true - '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.71': + '@livekit/rtc-ffi-bindings-linux-arm64-gnu@0.12.68': optional: true - '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.71': + '@livekit/rtc-ffi-bindings-linux-x64-gnu@0.12.68': optional: true - '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.71': + '@livekit/rtc-ffi-bindings-win32-x64-msvc@0.12.68': optional: true - '@livekit/rtc-ffi-bindings@0.12.71': + '@livekit/rtc-ffi-bindings@0.12.68': dependencies: '@bufbuild/protobuf': 1.10.1 optionalDependencies: - '@livekit/rtc-ffi-bindings-darwin-arm64': 0.12.71 - '@livekit/rtc-ffi-bindings-darwin-x64': 0.12.71 - '@livekit/rtc-ffi-bindings-linux-arm64-gnu': 0.12.71 - '@livekit/rtc-ffi-bindings-linux-x64-gnu': 0.12.71 - '@livekit/rtc-ffi-bindings-win32-x64-msvc': 0.12.71 + '@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': {}