From ccddd209e4080f1d729fa040288aca43b4066db1 Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Mon, 14 Sep 2026 14:39:17 +0200 Subject: [PATCH 1/6] Upgrade Python plugin to aic-sdk 3.2 The Python API is unchanged: the .pyi files for 3.1.0 and 3.2.0 are byte-identical, so no plugin code changes. What moves is the native core underneath, which get_sdk_version() reports as 0.23.0 for aic-sdk 3.1.0 and 0.24.0 for 3.2.0. Raise the floor to 3.2 rather than only relocking. The old >=3.1.0,<4 range already admitted 3.2.0, but it also still admitted core 0.23, leaving the resolved core up to chance. Wheels cover cp310 through cp314 on every supported platform, and the compatible model version stays at 7, so provisioned models keep working. This puts Python on core 0.24 ahead of Node, which follows in the next commits; until then the two packages run different cores. Python gains no dispose() in 3.2 and keeps relying on binding finalization. Verified with unit tests, the integration suite against the real SDK, ruff, and mypy. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 7 +++-- DEVELOPMENT.md | 2 +- python/pyproject.toml | 2 +- python/uv.lock | 68 +++++++++++++++++++++---------------------- 4 files changed, 41 insertions(+), 38 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d630d09..b31871a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,11 +7,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Two independently built packages that are functional mirrors of each other: - `python/` — `ai-coustics-livekit-plugin`, importable as `livekit.plugins.ai_coustics` (namespace - package under `python/src/livekit/plugins/ai_coustics/`), built on `aic-sdk` 3.1. + package under `python/src/livekit/plugins/ai_coustics/`), built on `aic-sdk` 3.2. - `node/` — `@ai-coustics/livekit-plugin` (`node/src/`), built on `@ai-coustics/aic-sdk` 0.23. Run all commands from inside `python/` or `node/`; there is no root-level build. The two packages -are released in lockstep and must always carry the same version. +are released in lockstep and must always carry the same version. Their SDK pins must also resolve +to the same ai-coustics native core, which the bindings report through `get_sdk_version()` / +`getVersion()` rather than through their own package version: `aic-sdk` 3.2 for Python wraps core +0.24, while Node is still on core 0.23. `DEVELOPMENT.md` is the authoritative long-form document for architecture rationale, the logging convention, the local end-to-end environment, release steps, and the planned upstream LiveKit diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index beaa761..87b5ba7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -16,7 +16,7 @@ process a throwaway frame to probe the license. Processor format initialization is lazy because LiveKit supplies the complete stream geometry with the first frame. Each LiveKit frame is processed in one fixed-size SDK call, avoiding the -additional latency of the SDK's variable-block-size mode. aic-sdk 3.1 for Python and 0.23 for Node +additional latency of the SDK's variable-block-size mode. aic-sdk 3.2 for Python and 0.23 for Node process mono audio only, so multichannel LiveKit frames are downmixed before processing and the enhanced signal is duplicated across the original channel count. This preserves the LiveKit frame geometry and metadata. diff --git a/python/pyproject.toml b/python/pyproject.toml index 00ce4e9..1acbbad 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ "Topic :: Multimedia :: Sound/Audio :: Speech", ] dependencies = [ - "aic-sdk>=3.1.0,<4", + "aic-sdk>=3.2.0,<4", "livekit-agents>=1.4.2,<2", "numpy>=2.0.2", "opentelemetry-api>=1.14,<2", diff --git a/python/uv.lock b/python/uv.lock index 5bb0a08..d6ae8de 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -33,7 +33,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aic-sdk", specifier = ">=3.1.0,<4" }, + { name = "aic-sdk", specifier = ">=3.2.0,<4" }, { name = "livekit-agents", specifier = ">=1.4.2,<2" }, { name = "numpy", specifier = ">=2.0.2" }, { name = "opentelemetry-api", specifier = ">=1.14,<2" }, @@ -49,45 +49,45 @@ dev = [ [[package]] name = "aic-sdk" -version = "3.1.0" +version = "3.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b0/42/c1d8fd7993bb940b8f0de82ce14bfa0efe779242b49919c49744807e1fd3/aic_sdk-3.1.0.tar.gz", hash = "sha256:0bf10876bede317af84ad7455dcf780a848466ced704e6079f9877ec44f36562", size = 1135664, upload-time = "2026-08-10T17:21:54.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/46/401ca61fa723524a65675b8be12dadd2bb75539934410d65c09451578a9e/aic_sdk-3.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9bae2370f770723db6654a343bf71462a7e3365f78932bf8fd8d2f24626f91cc", size = 4017733, upload-time = "2026-08-10T17:20:57.308Z" }, - { url = "https://files.pythonhosted.org/packages/8a/da/9f837c42e0d668ae82cfbf444367cda58199e44b9ae11a9a95a527e22a54/aic_sdk-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bb99911d4900c9526e2bb08ffb9fbe55b2a71efa816a1b22359d2e217d3f8794", size = 3616993, upload-time = "2026-08-10T17:21:00.405Z" }, - { url = "https://files.pythonhosted.org/packages/b8/16/9f2b467ed81f741006b8b538b715ee92606472cce9948a7a8d9b0d96f597/aic_sdk-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ecf5ee84512a018a3102c997854b8972b66c7d63171912be0c5692180541af", size = 3589623, upload-time = "2026-08-10T17:21:02.233Z" }, - { url = "https://files.pythonhosted.org/packages/8b/20/8a24ec8e533ab72c8488ff4229ad36dadbcdc3644798212cc0a26d617bd8/aic_sdk-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef24957d97bee873858f0ff4395bd0ca91935b17c4af36087f56c811050f8ce0", size = 3959692, upload-time = "2026-08-10T17:21:04.137Z" }, - { url = "https://files.pythonhosted.org/packages/0c/87/1dcc5be45b44d76d85e9b2280cdda72b28610d63d6939f1be0d0cc82465b/aic_sdk-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:57befda19b517f380b6b566ffe928ce2abb101f570e58ee01a638ac1f8ebf7b9", size = 3702623, upload-time = "2026-08-10T17:21:05.934Z" }, - { url = "https://files.pythonhosted.org/packages/15/c9/7e822d791a4d1e5a454bb150524fa03fe335962e1cd63db9bfe16a765b95/aic_sdk-3.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:e045fca63c4e098a108f63649995cb217ae07be19aba8a8a6bc3013d9adedb7f", size = 3345802, upload-time = "2026-08-10T17:21:07.749Z" }, - { url = "https://files.pythonhosted.org/packages/d0/38/edc148c8683a47195ca58d9e8bf79871d572205504726fb5c295b669862f/aic_sdk-3.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a460832d87169b57246cef2e470924d677d6dec0bab94db2b0c934adec0e15e7", size = 4017235, upload-time = "2026-08-10T17:21:09.478Z" }, - { url = "https://files.pythonhosted.org/packages/c6/fe/9b22c5fa4758c394b5b994f2e346eb75f9681b0a79ff289a1c0e51b2d0e3/aic_sdk-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0cb94344e021a08529f90250f4018547abc315151dc1a7d12c0ebcdf1ff0799", size = 3616958, upload-time = "2026-08-10T17:21:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/47/b3/4377b3d1feeb53c48597e725122bf2fdfea23a1ff92e1ef4e13c64d22d95/aic_sdk-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ae970326aae98d397dee3522c77b6f97b59e961451f16978409f6eff87656f6", size = 3589407, upload-time = "2026-08-10T17:21:13.107Z" }, - { url = "https://files.pythonhosted.org/packages/78/fd/4f7e15f1b1cabf37f5f52f066b3a44346c1fddbe049831972bfd40cc0d41/aic_sdk-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5495d34d96e4c9a095a14f4606b599443aaa25b1dcb0944cae2498f4192e5287", size = 3959866, upload-time = "2026-08-10T17:21:14.828Z" }, - { url = "https://files.pythonhosted.org/packages/0a/75/a497a18e4115c54088708ca23f0cdc44592781766fcd9e755835b88c981d/aic_sdk-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fa2d53249b7e31998ae0ab336fbe20727161381eae8a8724d47fe35941515f96", size = 3702781, upload-time = "2026-08-10T17:21:16.616Z" }, - { url = "https://files.pythonhosted.org/packages/38/4f/3f17d0ffc7afb351408af9d8957579f3640e18a2e3501c3c15e71641e8cc/aic_sdk-3.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:03fd0016e5cd6714b353af4c769621c3522f06844f473e2359f12d876a7d1e91", size = 3346032, upload-time = "2026-08-10T17:21:18.548Z" }, - { url = "https://files.pythonhosted.org/packages/45/88/c801ecb0193f41210998e6200cd977470e67d09203c7018ce2977d74e79e/aic_sdk-3.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ce8f1278c6c6167bb63cd1c30814f9032f63d33bc8afdd046ff412f0a1836294", size = 4017722, upload-time = "2026-08-10T17:21:20.832Z" }, - { url = "https://files.pythonhosted.org/packages/7f/81/5187f25d003e77e80cb4580ce7d537fa6927f2046346c6ef5d4aeae6b26c/aic_sdk-3.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fe8ff2eb82384792e41ddeb49b2563ae20439e966d40948421d6e441f3a06da", size = 3614554, upload-time = "2026-08-10T17:21:22.408Z" }, - { url = "https://files.pythonhosted.org/packages/60/7d/d3e8bad88eaf0854c895fa094e3d493908dd9b69443793c77d0441a13c03/aic_sdk-3.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7e12ae1720264556a80bf988baf3f9eb25aae0a5e8ffddb6926c473d8066219c", size = 3588593, upload-time = "2026-08-10T17:21:24.713Z" }, - { url = "https://files.pythonhosted.org/packages/18/15/026e56bf4b76f0f6ea713a7d77eaca039e2dd1c96e52ba6ae5985bec62b8/aic_sdk-3.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14a52e0942399f588be36dedcf4336133406dfb328f9e21830f2d3633503fd84", size = 3959135, upload-time = "2026-08-10T17:21:27.475Z" }, - { url = "https://files.pythonhosted.org/packages/51/a0/4d7bce7aada41f702d39db94e1b66a9c02d5d42586cf97cc2d129887cae1/aic_sdk-3.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c9cf38efac52c443e2e883864db8e5717e4d542854e82cec1f65290f46b20221", size = 3700579, upload-time = "2026-08-10T17:21:29.478Z" }, - { url = "https://files.pythonhosted.org/packages/e2/f5/5c71fe22015a1b788af09e644d4584a8e4fe542090200cdfefae7b906fe1/aic_sdk-3.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:ce7bd2cf9fae8e472cd8f5df7600b680cfd488d01e9d5e4afe82d509d74201cd", size = 3342952, upload-time = "2026-08-10T17:21:31.212Z" }, - { url = "https://files.pythonhosted.org/packages/c0/53/1409b8193276d0b2ae03290423645a5a423e965b9829a9bee36c74440144/aic_sdk-3.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ec4ddc31e17a1310017fda428f3a2431efc72e2fdee999e03d8f6f2f7d861a6e", size = 4017242, upload-time = "2026-08-10T17:21:33.022Z" }, - { url = "https://files.pythonhosted.org/packages/7a/12/5a7f1d5542c61444760a6672a08f295ffa1707a0ad3932f1a1333ae15346/aic_sdk-3.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d7464ff6cdbc7bcb2584c1770253cfb730331c7fb45afb9b3f3d5e249c67828a", size = 3613912, upload-time = "2026-08-10T17:21:34.762Z" }, - { url = "https://files.pythonhosted.org/packages/eb/10/6a862ce66fa8937f5f7bdd6c4525e819441b5e48b43501d68ac0dd856f2a/aic_sdk-3.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:152562a8efec472daec555ef3d1c65812e91c601a5d6578de9d19237e7376ca8", size = 3587684, upload-time = "2026-08-10T17:21:36.499Z" }, - { url = "https://files.pythonhosted.org/packages/21/be/7964e50a95b8f70e4375c9a237254d73db17b44e5e419acf43f6405bfd46/aic_sdk-3.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25a12c379ef24373b42a3de36e79c0fe482d8e3b29e75e2c4200611e068b63f", size = 3958257, upload-time = "2026-08-10T17:21:38.641Z" }, - { url = "https://files.pythonhosted.org/packages/de/2a/16cf09474509c6906edd1476a7ea14a35a11dfbe416b4edb6d0dcbb58763/aic_sdk-3.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:3ceb09fa228acc1dd9234b18a96e1dc99a02743da5de757c4d78bbdb1f33f04c", size = 3699997, upload-time = "2026-08-10T17:21:40.353Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c7/815403239e8d7a782e1024047ff9730cd4552eac396e2bb0856914c0e4a3/aic_sdk-3.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:3b955d947dedd59ab8796a740231d7b3a154429ad5cc3a8608f904b23495860e", size = 3342575, upload-time = "2026-08-10T17:21:42.16Z" }, - { url = "https://files.pythonhosted.org/packages/93/17/6a595716b25683f570643a8e0d53b4c81817c5b4486d44045b7b3cc1e1b9/aic_sdk-3.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:552b71459f75bea9798d96023639eff29cb9be4c2b05529e71f20837d5e58dea", size = 4020302, upload-time = "2026-08-10T17:21:43.961Z" }, - { url = "https://files.pythonhosted.org/packages/45/c5/e8c860592dadc39efb04f4554582500916b8d7818ab7a5097485d719f713/aic_sdk-3.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:359595d3c0383dcbaa825ebddbcc168cc14b9ab3e9ca651a6e2f8bfc08c91ff5", size = 3617126, upload-time = "2026-08-10T17:21:45.679Z" }, - { url = "https://files.pythonhosted.org/packages/13/f1/bc5e54fc1d0b23bee06263afb9bfadf1f47324867a8dc3c4a3767fc50620/aic_sdk-3.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6743ac35a02948aaf1a0b8495d5cb5efc3c1695b5bdfe53461b801076d647a8", size = 3590818, upload-time = "2026-08-10T17:21:47.457Z" }, - { url = "https://files.pythonhosted.org/packages/51/e1/5baf9c75c195d9aa22afe21d6f4051fdcb4ecb9adadfd67246a207691aff/aic_sdk-3.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:813ef816ba66b424e01058243a243dca494ec3160be1e163f51c39ec4148fac4", size = 3961873, upload-time = "2026-08-10T17:21:49.285Z" }, - { url = "https://files.pythonhosted.org/packages/fc/75/d9042f9e0fc7dc30f42bca094ced5863aacaeffbd4194e9628e43988a7f0/aic_sdk-3.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:5cee9782fb1ed2beb6b218e199df1644b11627cb62649b9b1c05485f8bd23532", size = 3705095, upload-time = "2026-08-10T17:21:51.057Z" }, - { url = "https://files.pythonhosted.org/packages/27/b3/07f7e51185795707640dc6e55bf881682a4a992f83f0714c80a0aeef70ba/aic_sdk-3.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:17247b9a177cf0eb0f601479970639bf44ca37e18c976e2314f332579b9a7c56", size = 3346130, upload-time = "2026-08-10T17:21:53.143Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/c0/40/382ce44002dd2321026132c58216cb841b0e7e9f8dd92b4fb519c05f746c/aic_sdk-3.2.0.tar.gz", hash = "sha256:e302206119a2befc20a260d3aeb54181e94f6463b675231c9dbb2e5c0f552468", size = 1135264, upload-time = "2026-09-07T16:15:59.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/1b/a4a49cc605535c1803ce664b12d4ebb715edaa84900c3503e8dbd9a13c02/aic_sdk-3.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:864fbe9c1151ff6a1ee14ba3bc15756ce138240359fa9ef9201f981f611f2734", size = 4072345, upload-time = "2026-09-07T16:15:18.057Z" }, + { url = "https://files.pythonhosted.org/packages/f4/69/6b88f2b540968a25e2c3c584a87a3f6ff0717bb9b459b934db37993a1330/aic_sdk-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:93025cbe934a4a723cce8680a741520f61581e664843a2dd32e0abc4c57143f7", size = 3670731, upload-time = "2026-09-07T16:15:19.814Z" }, + { url = "https://files.pythonhosted.org/packages/31/7d/af2ecfd20722ff9e8e02f70d1161fb38d3e66058a6c5957b3683f89465db/aic_sdk-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:458cf0213fca9d94dd3b0065696bc9429bdc51884110aa53b7421a3f82671494", size = 3641266, upload-time = "2026-09-07T16:15:20.952Z" }, + { url = "https://files.pythonhosted.org/packages/0f/52/a2e10a2c2a4f7e566722ed632860f3351c2ff05e448a885b8c75dcfa9a6e/aic_sdk-3.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28bbcc21a0c16a8e6770cee0b86621e6d951d6e793e9189bdc21d182a90ba910", size = 4013288, upload-time = "2026-09-07T16:15:22.291Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/c200496623f69de2a595422f23603c21b119302cac2260200236ea72c091/aic_sdk-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:24ea29ac99e32b938819c54cdeab4e117d21c04e6fe854ffdd923ece98a418d8", size = 3753314, upload-time = "2026-09-07T16:15:23.788Z" }, + { url = "https://files.pythonhosted.org/packages/e6/07/ca40dcb140ef59107a9faf07b2d1ad37a9c892b727cf79d56245862ac519/aic_sdk-3.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:10f73b021201dee859b16547d5c3d08665fab2a02157f33fa64d55db0342ca68", size = 3394708, upload-time = "2026-09-07T16:15:25.12Z" }, + { url = "https://files.pythonhosted.org/packages/58/c8/8a1d01b9655e7f4d9e12d5c45c18b0491b27d33e72bffe68cf6fc1122465/aic_sdk-3.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3092f4e82589765e3a026e347e18f2470ad5b8c307ed7cb5454b59fd7aff52c0", size = 4072057, upload-time = "2026-09-07T16:15:26.39Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fa/ffaba18668f261db5ceaed2162b1a09045bdac3cbf1375ffe1fc7bb96842/aic_sdk-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3aea1b9871ce4661c9bf95ecc2d3c48f9a95d06c787337908d16f42d4389460c", size = 3670678, upload-time = "2026-09-07T16:15:27.784Z" }, + { url = "https://files.pythonhosted.org/packages/b5/bb/a1251cca22be2f5b4660fafe4eb8e1537f1083a3b8e0736a023713b8f8d7/aic_sdk-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b55c7c1be1a2f86bf725828927531676cc464efe9d0191d9ba592230fa7be4e", size = 3641290, upload-time = "2026-09-07T16:15:29.137Z" }, + { url = "https://files.pythonhosted.org/packages/21/a1/838690576b9bce0785b8be1321dedb7496927b087bc88f46bb26a58e4632/aic_sdk-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:007a2aa38e96d76073e15b5dca4f6d75b32a3960f6d4369f659981ed751a39d8", size = 4013166, upload-time = "2026-09-07T16:15:30.38Z" }, + { url = "https://files.pythonhosted.org/packages/f1/57/362716e0de08db1a24737be528eb4cb3f228592eff97b40da3e0d2d5ed53/aic_sdk-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:8a63a531f2e0f37346531bb295b11007106e027b207befd9eb3a9dde07cfefd8", size = 3753080, upload-time = "2026-09-07T16:15:31.86Z" }, + { url = "https://files.pythonhosted.org/packages/38/5f/325a12f84c56611f7f16b215cf75333ca1391b75ad1541c3c20569d23785/aic_sdk-3.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:56e08efe602144a412b96474fd71eaab0b292e13edf62a4c6abc1bda96ba5eb1", size = 3394502, upload-time = "2026-09-07T16:15:32.967Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6c/e77973fc6d2f6a50f3e69aa8fd1ae8cddccbbccb8d07caa454806a78515a/aic_sdk-3.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:adcb5cb5dc64de40b62bea466e7bd141ff4edcd1769a3dda4517bc32d56c3478", size = 4071967, upload-time = "2026-09-07T16:15:34.316Z" }, + { url = "https://files.pythonhosted.org/packages/65/cd/3f215c6ba890bb183f12f078043b2b2625521447efafae966ced54faf95d/aic_sdk-3.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:372162f29a0f8417d8728dc353fdb54bc66ab73c184b3c6f760dbd38f5147e63", size = 3669570, upload-time = "2026-09-07T16:15:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/eb/43/bba63eab3b3bef9e2ec3dadf458ffb0bf40f358ed865d26a922b49b5e6a0/aic_sdk-3.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7dc79bcb3afaf302903ce34b72e699db85c5411ff2a29d3e51f7a1f7a46f0bbf", size = 3640334, upload-time = "2026-09-07T16:15:37.186Z" }, + { url = "https://files.pythonhosted.org/packages/34/a4/a2615a160e8712cc7627e8c74b9415d533cfe33845540b2a6e2aae0d2f2f/aic_sdk-3.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5a0cf28926258d4e32e0e402b8b0a3e71f92e536f6d10d66ac929844357fae9", size = 4012848, upload-time = "2026-09-07T16:15:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/32/72/5768eb7c6c2197ba48218a343594891fa48df2bb4f5e982be5830bf70cb7/aic_sdk-3.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:10b622d08545370913f71ca859be8e427cbe3f135466cff075a8d8c9a1599506", size = 3751358, upload-time = "2026-09-07T16:15:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/72/e5/036a0982af88274761a80f52e6fe785154123de556acb931a4c1f8e7586d/aic_sdk-3.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:16543e9ccea4bbadb4ed27da09a7428e51a8cde567fa29558a793e66bdbec404", size = 3390628, upload-time = "2026-09-07T16:15:41.12Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ac/66fc79b3c7fa40b706b51fe528420ff42c598f32d0ec712ef2e89e8b82be/aic_sdk-3.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5317b7141e798935ed2c3fdcc0053ed6b8d60167bbe49d916e399fce3374a57c", size = 4071510, upload-time = "2026-09-07T16:15:42.449Z" }, + { url = "https://files.pythonhosted.org/packages/fd/25/9918b619125a90ec875b9af9164595d58fea6c1310834e6c189cd03004a2/aic_sdk-3.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:efe8803a6e7d117062ef4ca3f98ac367bc71fdcd5cd228823e5107acd11d3ba7", size = 3669027, upload-time = "2026-09-07T16:15:43.77Z" }, + { url = "https://files.pythonhosted.org/packages/97/af/8bd93e3092bf79baa1b0873013f672bbec62f08962b42c902e39659c497c/aic_sdk-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:326efa24ddcdd1f224abdebbcb9878fb9166ebc9bdded9a93a481deb8c092fea", size = 3639663, upload-time = "2026-09-07T16:15:45.054Z" }, + { url = "https://files.pythonhosted.org/packages/6c/20/c0fa5d8ac1c93d31e39df7a617aeffbae60c6e54eaad1b2171784b431774/aic_sdk-3.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:867c51ae2c1ea564d0710517d7a88c0f7ff692600fb558d538ab97efe8666022", size = 4012295, upload-time = "2026-09-07T16:15:46.773Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/39a5fd1e12ac5fa7650f7ddcbf5b26576a7bb945f8ccd038ea49101b06d5/aic_sdk-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:23559d6e3506f437d4fe52af4821d1d7c31459371b8bb3739db2d99c05f065e6", size = 3750916, upload-time = "2026-09-07T16:15:48.098Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a3/a02e0da0bac0df835346a426070f9f87a8a6d54ab849b18ac3205a664ec9/aic_sdk-3.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:6373a39f445cbc40e9518eee57c377009d2ad3f85dc03a909abfeef1e1d724c5", size = 3389998, upload-time = "2026-09-07T16:15:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/43/56/3a30647bc41b66d02f506639f83680ee8dd3d828f800416d8200cfda67c0/aic_sdk-3.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ef33d40ea56e4e83b4953001deff339e734e7101094b0a49d39b0b27b85905bd", size = 4073490, upload-time = "2026-09-07T16:15:51.224Z" }, + { url = "https://files.pythonhosted.org/packages/25/99/d38d3871508e06eb80511b4ab7121450c7ad0109679ac271dfd324181306/aic_sdk-3.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:72f88e24cd1e43d1d48b7ed86d55f4d2254e328373c19a89aee8374821f4c0eb", size = 3671064, upload-time = "2026-09-07T16:15:52.351Z" }, + { url = "https://files.pythonhosted.org/packages/af/2f/b489fd1e4d2624477572a3164f44589f9d75ed3e8516302868008efb1fbb/aic_sdk-3.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36ea9fa6269f4b5e0c9cb7333e9a506feb8019f280485d203ee7f5a8ba0331ce", size = 3642061, upload-time = "2026-09-07T16:15:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/00/28/f5815ab73f4006ff2d4231911156b4cdc6b237923e5491243fb0abeaf37e/aic_sdk-3.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de970db5fdb1c5cea677078bda26976d539f2e844046443bdfe7c5dbed7deb45", size = 4015050, upload-time = "2026-09-07T16:15:55.11Z" }, + { url = "https://files.pythonhosted.org/packages/98/24/c1839a691cfad947ab1cc587078ad2a56b2a4a7acf2a6e144639d386a906/aic_sdk-3.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:36267efe27af4ce4fa30cdfb80603c375fc2cf0e413229c21b6c0204f65769ec", size = 3757177, upload-time = "2026-09-07T16:15:56.558Z" }, + { url = "https://files.pythonhosted.org/packages/1a/36/1e39c37fc5e482c9ca0c48a1cf93078a8711298ff050ac8e8472ab0a8ed4/aic_sdk-3.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:f94ebea4528d23661268f7f1151780426058ad278ff94dd60b65179b7d8557c9", size = 3394665, upload-time = "2026-09-07T16:15:57.878Z" }, ] [[package]] From bc3d7d38aac68ac990d1448a0669a3ac60a6a7be Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Mon, 14 Sep 2026 14:21:30 +0200 Subject: [PATCH 2/6] Upgrade Node plugin to aic-sdk 0.24 aic-sdk 0.24 reshapes the Node API. Adapt to it without changing plugin behavior; the async analysis it unlocks follows separately. The separate Collector and Analyzer natives created by analyzerPair() are merged into a single Analyzer class carrying both initialize()/buffer() and analyze(). Our public Collector and Analyzer now hold the same native instance, Collector calling only the buffering half. Its close path also disposes the native handle rather than leaving it to garbage collection. The package ships TypeScript declarations now, so drop the hand-written aic-sdk.d.ts and reduce sdk.ts to a re-export boundary. That ambient module declaration was shadowing the package's own types, which is why tsc kept passing against an API that no longer exists. ProcessorParameter and VadParameter stay hand-mirrored because they are const enums with no runtime form, and the plugin re-exports ProcessorParameter publicly. Two further renames ride along: VadContext.rawVadProbability() is now getRawVadProbability(), and Model.download() returns a promise, so the README and the tests that provision models await it. Python is unaffected: aic-sdk 3.2 still exposes analyzer_pair(), and the mirrored plugin objects keep the same shape in both runtimes. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 15 ++-- DEVELOPMENT.md | 2 +- node/README.md | 6 +- node/package-lock.json | 98 ++++++++++++++----------- node/package.json | 4 +- node/src/aic-sdk.d.ts | 99 ------------------------- node/src/analyzer.ts | 57 ++++++++++----- node/src/sdk.ts | 133 +++++++--------------------------- node/src/vad.ts | 7 +- node/test/analyzer.test.ts | 41 ++++++----- node/test/e2e-room.test.ts | 6 +- node/test/integration.test.ts | 12 +-- node/test/processor.test.ts | 8 +- node/test/vad.test.ts | 4 +- 14 files changed, 174 insertions(+), 318 deletions(-) delete mode 100644 node/src/aic-sdk.d.ts diff --git a/CLAUDE.md b/CLAUDE.md index b31871a..6ce8eff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,13 +8,13 @@ Two independently built packages that are functional mirrors of each other: - `python/` — `ai-coustics-livekit-plugin`, importable as `livekit.plugins.ai_coustics` (namespace package under `python/src/livekit/plugins/ai_coustics/`), built on `aic-sdk` 3.2. -- `node/` — `@ai-coustics/livekit-plugin` (`node/src/`), built on `@ai-coustics/aic-sdk` 0.23. +- `node/` — `@ai-coustics/livekit-plugin` (`node/src/`), built on `@ai-coustics/aic-sdk` 0.24. Run all commands from inside `python/` or `node/`; there is no root-level build. The two packages -are released in lockstep and must always carry the same version. Their SDK pins must also resolve -to the same ai-coustics native core, which the bindings report through `get_sdk_version()` / -`getVersion()` rather than through their own package version: `aic-sdk` 3.2 for Python wraps core -0.24, while Node is still on core 0.23. +are released in lockstep and must always carry the same version. Their SDK pins must resolve to the +same ai-coustics native core, which the bindings report through `get_sdk_version()` / `getVersion()` +rather than through their own package version: `aic-sdk` 3.2 for Python and 0.24 for Node both wrap +core 0.24. `DEVELOPMENT.md` is the authoritative long-form document for architecture rationale, the logging convention, the local end-to-end environment, release steps, and the planned upstream LiveKit @@ -87,8 +87,9 @@ Four public objects, each mirrored across both runtimes: the enhancement `Processor`, so VAD and analysis see original input audio. `ProcessorContext` wraps the SDK context purely to add structured logging around parameter and -bearer-token changes. Node's `sdk.ts` hand-declares structural types because aic-sdk 0.23 ships no -TypeScript declarations. +bearer-token changes. Node's `sdk.ts` is a thin re-export boundary over the declarations aic-sdk +0.24 ships; it only hand-mirrors `ProcessorParameter` and `VadParameter`, which are `const enum`s +and so have no runtime form of their own. **Fail-open is a hard invariant.** Any processing error is logged and the *original* frame is returned; room audio must keep flowing whatever the SDK does. Repeated failures and diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 87b5ba7..937ed8f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -16,7 +16,7 @@ process a throwaway frame to probe the license. Processor format initialization is lazy because LiveKit supplies the complete stream geometry with the first frame. Each LiveKit frame is processed in one fixed-size SDK call, avoiding the -additional latency of the SDK's variable-block-size mode. aic-sdk 3.2 for Python and 0.23 for Node +additional latency of the SDK's variable-block-size mode. aic-sdk 3.2 for Python and 0.24 for Node process mono audio only, so multichannel LiveKit frames are downmixed before processing and the enhanced signal is duplicated across the original channel count. This preserves the LiveKit frame geometry and metadata. diff --git a/node/README.md b/node/README.md index ef1dd89..38c0c65 100644 --- a/node/README.md +++ b/node/README.md @@ -71,9 +71,9 @@ Download models during deployment or container setup: ```ts import { Model } from "@ai-coustics/livekit-plugin"; -const enhancementPath = Model.download("quail-vf-2.2-l-16khz", "./models"); -const vadPath = Model.download("vad-2.1-xxs-16khz", "./models"); -const analysisPath = Model.download("tyto-1.1-l-16khz", "./models"); +const enhancementPath = await Model.download("quail-vf-2.2-l-16khz", "./models"); +const vadPath = await Model.download("vad-2.1-xxs-16khz", "./models"); +const analysisPath = await Model.download("tyto-1.1-l-16khz", "./models"); ``` Enhancement and VAD models are different model types. Make the returned paths available to your diff --git a/node/package-lock.json b/node/package-lock.json index 6ff87da..a7fbd5d 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "@ai-coustics/aic-sdk": "^0.23.0", + "@ai-coustics/aic-sdk": "^0.24.0", "@livekit/typed-emitter": "^3.0.0", "@opentelemetry/api": "^1.9.0" }, @@ -28,23 +28,26 @@ } }, "node_modules/@ai-coustics/aic-sdk": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@ai-coustics/aic-sdk/-/aic-sdk-0.23.0.tgz", - "integrity": "sha512-2HL13DLjUsADkCr+nB+ROJW1GMclk27z+A+HGJ6IfIT4e37C3CfTnu6uX3a8M9eOuMVMQnTxS/v1CxhgCsUXng==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@ai-coustics/aic-sdk/-/aic-sdk-0.24.0.tgz", + "integrity": "sha512-5yMW1vZ5pK+7RBtXJrIyxdrOTaQE2/sBgfRXM88BBY41J/5Jv/57HJ89Hoe6TTDtN9msYLxqB3A14LqZl9uAbg==", "license": "Apache-2.0", + "engines": { + "node": ">= 18" + }, "optionalDependencies": { - "@ai-coustics/aic-sdk-darwin-arm64": "0.23.0", - "@ai-coustics/aic-sdk-darwin-x64": "0.23.0", - "@ai-coustics/aic-sdk-linux-arm64-gnu": "0.23.0", - "@ai-coustics/aic-sdk-linux-x64-gnu": "0.23.0", - "@ai-coustics/aic-sdk-win32-arm64-msvc": "0.23.0", - "@ai-coustics/aic-sdk-win32-x64-msvc": "0.23.0" + "@ai-coustics/aic-sdk-darwin-arm64": "0.24.0", + "@ai-coustics/aic-sdk-darwin-x64": "0.24.0", + "@ai-coustics/aic-sdk-linux-arm64-gnu": "0.24.0", + "@ai-coustics/aic-sdk-linux-x64-gnu": "0.24.0", + "@ai-coustics/aic-sdk-win32-arm64-msvc": "0.24.0", + "@ai-coustics/aic-sdk-win32-x64-msvc": "0.24.0" } }, "node_modules/@ai-coustics/aic-sdk-darwin-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@ai-coustics/aic-sdk-darwin-arm64/-/aic-sdk-darwin-arm64-0.23.0.tgz", - "integrity": "sha512-nfFtriEflvzgZym47JlIrfrcw18zjtYT5f7LuOkY6KJ6GEbXIC798GGVcvWZh04GN7gXx8M/wd3am1zrUxcAkg==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@ai-coustics/aic-sdk-darwin-arm64/-/aic-sdk-darwin-arm64-0.24.0.tgz", + "integrity": "sha512-j7pbXjvS3J6wI4eLcy+A6SvnxvyaCeBhbjpu9QrtyKpsrKbRR/bVZYP+RQeXFi80Ox8e2PYn1hhg+W6E0w9jyw==", "cpu": [ "arm64" ], @@ -52,12 +55,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": ">= 18" + } }, "node_modules/@ai-coustics/aic-sdk-darwin-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@ai-coustics/aic-sdk-darwin-x64/-/aic-sdk-darwin-x64-0.23.0.tgz", - "integrity": "sha512-2NuuOkXjuzG6ynVBOvI7RZ1qI3zlOa/tmVPui0qpt3fkJl0A0kLg6WbjqP7uddKfAZynX6MqVBbZhnS6PG14hA==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@ai-coustics/aic-sdk-darwin-x64/-/aic-sdk-darwin-x64-0.24.0.tgz", + "integrity": "sha512-7yDO2BmEJjbEMMfWZgSsevnYoceadSFiULt7H/ZbmZqQ3rd4FuEHM1CzhXGwcx7U0iYKJp/JoB3U1YeMH6C5sg==", "cpu": [ "x64" ], @@ -65,12 +71,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": ">= 18" + } }, "node_modules/@ai-coustics/aic-sdk-linux-arm64-gnu": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@ai-coustics/aic-sdk-linux-arm64-gnu/-/aic-sdk-linux-arm64-gnu-0.23.0.tgz", - "integrity": "sha512-V76GBOxGx7brSKER6bLbrlzL8yO+yfoS2E0c6LbR6zVJVDA59y4p3tcS8yXNb16NmJA0SreNr/rHAw2nbxftZA==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@ai-coustics/aic-sdk-linux-arm64-gnu/-/aic-sdk-linux-arm64-gnu-0.24.0.tgz", + "integrity": "sha512-FHkfEzVtfj+whr3MXWhvGassa1/9cure27RvR/KOxo5pHBZyzPyKUvVQPKqJgtS3KbPGvw8HnTWtwya3IjBESw==", "cpu": [ "arm64" ], @@ -78,12 +87,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 18" + } }, "node_modules/@ai-coustics/aic-sdk-linux-x64-gnu": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@ai-coustics/aic-sdk-linux-x64-gnu/-/aic-sdk-linux-x64-gnu-0.23.0.tgz", - "integrity": "sha512-EFznZMxBVrm+rBlRbqd3eT27vV+HLZ6n/CkVGGkjAXQy26oUFjFb5B8lRQ4tJ4GqGQasslEe7bM275r+E9nMiQ==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@ai-coustics/aic-sdk-linux-x64-gnu/-/aic-sdk-linux-x64-gnu-0.24.0.tgz", + "integrity": "sha512-RXWfujV9u0pmBkwLLESP9+Zi+boZ75XhscwlIzZ2hDUINLNutmAbzr+ZOyKuvrGXiBD92w18i3HdbvB+alrNQg==", "cpu": [ "x64" ], @@ -91,12 +103,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 18" + } }, "node_modules/@ai-coustics/aic-sdk-win32-arm64-msvc": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@ai-coustics/aic-sdk-win32-arm64-msvc/-/aic-sdk-win32-arm64-msvc-0.23.0.tgz", - "integrity": "sha512-wsLiVHnHJJ34jmZbITK25Yt9EamCTQDXMyauOncUlqp47FB1/Ix5pN7tslAyEPV6ljNzEw2USXSjUz5izr8Rsg==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@ai-coustics/aic-sdk-win32-arm64-msvc/-/aic-sdk-win32-arm64-msvc-0.24.0.tgz", + "integrity": "sha512-TbkykcA0lJuotx6vDj0AmnVIji0XvgFDUnZVLEg2zCXtfvv0jMaSRHLddsGA7nvotYUqofOpBZxaJFItK5W3wA==", "cpu": [ "arm64" ], @@ -104,12 +119,15 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">= 18" + } }, "node_modules/@ai-coustics/aic-sdk-win32-x64-msvc": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@ai-coustics/aic-sdk-win32-x64-msvc/-/aic-sdk-win32-x64-msvc-0.23.0.tgz", - "integrity": "sha512-nQb/P8GR/hZdCcAPCgJuqolBeiFwZoP6+cRxns1HYwQfFHQuH0UoQmoMTqR03S3TMUJqIk3N8Vpj1l3NqA8r4w==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@ai-coustics/aic-sdk-win32-x64-msvc/-/aic-sdk-win32-x64-msvc-0.24.0.tgz", + "integrity": "sha512-n2HZREV7oJVxaYxIJUDhNPBk4N4i/d05c76Hsx7kP1RVnddNX/0LFFiA1dDtgcCgRAj3WEVFOOS4lGKEp0F9zQ==", "cpu": [ "x64" ], @@ -117,7 +135,10 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">= 18" + } }, "node_modules/@bufbuild/protobuf": { "version": "1.10.1", @@ -1509,7 +1530,6 @@ "integrity": "sha512-NL9CxiscU4uL4+r2Ym675j5nIOw3XOy1YXiYH4oA9RftZTFFsU4+nvuvqYDWC9JWsOFFoin4hNdmqrDiRnvSuA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@datastructures-js/deque": "1.0.8", "@livekit/mutex": "^1.0.0", @@ -1715,7 +1735,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2580,7 +2599,6 @@ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3132,7 +3150,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3873,7 +3890,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4023,7 +4039,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", @@ -4631,7 +4646,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4660,7 +4674,6 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -4876,7 +4889,6 @@ "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/node/package.json b/node/package.json index b20fe87..83844cf 100644 --- a/node/package.json +++ b/node/package.json @@ -39,7 +39,7 @@ "prepack": "npm run build" }, "dependencies": { - "@ai-coustics/aic-sdk": "^0.23.0", + "@ai-coustics/aic-sdk": "^0.24.0", "@livekit/typed-emitter": "^3.0.0", "@opentelemetry/api": "^1.9.0" }, @@ -48,9 +48,9 @@ "@livekit/rtc-node": ">=0.13.24 <1" }, "devDependencies": { - "@types/node": "^22.0.0", "@livekit/agents": "^1.0.43", "@livekit/rtc-node": "^0.13.24", + "@types/node": "^22.0.0", "livekit-server-sdk": "^2.14.1", "tsup": "^8.5.0", "typescript": "^5.9.3", diff --git a/node/src/aic-sdk.d.ts b/node/src/aic-sdk.d.ts deleted file mode 100644 index 4cdec86..0000000 --- a/node/src/aic-sdk.d.ts +++ /dev/null @@ -1,99 +0,0 @@ -declare module "@ai-coustics/aic-sdk" { - export class Model { - static fromFile(path: string): Model; - static download(modelId: string, downloadDir: string): string; - getId(): string; - getOptimalSampleRate(): number; - getOptimalBlockSize(sampleRate: number): number; - } - - export interface AnalysisResult { - riskScore: number; - speakerReverb: number; - speakerLoudness: number; - interferingSpeech: number; - noise: number; - codecDegradation: number; - packetLoss: number; - } - - export class Collector { - initialize( - sampleRate: number, - blockSize: number, - variableBlockSize?: boolean, - ): void; - buffer(samples: Float32Array): void; - } - - export class Analyzer { - reset(): void; - analyzeBuffered(): AnalysisResult; - terminateSession(): void; - updateBearerToken(token: string): void; - } - - export function analyzerPair( - model: Model, - licenseKey: string, - ): { collector: Collector; analyzer: Analyzer }; - - export const ProcessorParameter: { - readonly Bypass: number; - readonly EnhancementLevel: number; - }; - export type ProcessorParameter = - (typeof ProcessorParameter)[keyof typeof ProcessorParameter]; - - export const VadParameter: { - readonly SpeechHoldDuration: number; - readonly Sensitivity: number; - readonly MinimumSpeechDuration: number; - }; - export type VadParameter = (typeof VadParameter)[keyof typeof VadParameter]; - - export class ProcessorContext { - reset(): void; - setParameter(parameter: ProcessorParameter, value: number): void; - getParameter(parameter: ProcessorParameter): number; - getAudioDelay(): number; - updateBearerToken(token: string): void; - } - - export class Processor { - constructor(model: Model, licenseKey: string); - initialize( - sampleRate: number, - blockSize: number, - variableBlockSize?: boolean, - ): void; - process(buffer: Float32Array): void; - getContext(): ProcessorContext; - terminateSession(): void; - } - - export class VadContext { - reset(): void; - isSpeechDetected(): boolean; - rawVadProbability(): number; - setParameter(parameter: VadParameter, value: number): void; - getParameter(parameter: VadParameter): number; - getPredictionDelay(): number; - updateBearerToken(token: string): void; - } - - export class Vad { - constructor(model: Model, licenseKey: string); - initialize( - sampleRate: number, - blockSize: number, - variableBlockSize?: boolean, - ): void; - process(buffer: Float32Array): void; - getContext(): VadContext; - terminateSession(): void; - } - - /** Internal integration hook exported by the SDK for official wrappers. */ - export function _setSdkId(id: number): void; -} diff --git a/node/src/analyzer.ts b/node/src/analyzer.ts index 5a470e5..1d76aa9 100644 --- a/node/src/analyzer.ts +++ b/node/src/analyzer.ts @@ -10,11 +10,9 @@ import { import { writeLog } from "./log.js"; import { pcm16ToFloat32 } from "./processor.js"; import { + Analyzer as AicAnalyzer, type AnalysisResult, - type AnalyzerInstance, - type CollectorInstance, type Model, - analyzerPair, setSdkId, } from "./sdk.js"; @@ -85,9 +83,14 @@ function errorDetail(error: unknown): string { return error instanceof Error ? error.message : String(error); } -/** Transparent LiveKit frame processor that collects audio for an Analyzer. */ +/** + * Transparent LiveKit frame processor that collects audio for an Analyzer. + * + * Holds the same native analyzer as its owning {@link Analyzer} but only ever calls the + * buffering half of that API, which does not take the analyzer lock. + */ export class Collector extends FrameProcessor { - private nativeCollector: CollectorInstance | null; + private nativeAnalyzer: AicAnalyzer | null; private readonly resetAnalyzer: () => void; private readonly closeAnalyzer: () => void; private streamFormat: [number, number, number] | null = null; @@ -97,12 +100,12 @@ export class Collector extends FrameProcessor { private closed = false; constructor( - nativeCollector: CollectorInstance, + nativeAnalyzer: AicAnalyzer, resetAnalyzer: () => void, closeAnalyzer: () => void, ) { super(); - this.nativeCollector = nativeCollector; + this.nativeAnalyzer = nativeAnalyzer; this.resetAnalyzer = resetAnalyzer; this.closeAnalyzer = closeAnalyzer; } @@ -120,7 +123,7 @@ export class Collector extends FrameProcessor { /** True while the collector has collected some audio the analyzer can act on. */ get initialized(): boolean { return ( - this.collectingEnabled && this.hasBufferedAudio && this.nativeCollector !== null + this.collectingEnabled && this.hasBufferedAudio && this.nativeAnalyzer !== null ); } @@ -139,8 +142,8 @@ export class Collector extends FrameProcessor { } process(frame: AudioFrame): AudioFrame { - const collector = this.nativeCollector; - if (!this.collectingEnabled || !collector) return frame; + const native = this.nativeAnalyzer; + if (!this.collectingEnabled || !native) return frame; try { const streamFormat: [number, number, number] = [ @@ -154,7 +157,7 @@ export class Collector extends FrameProcessor { this.streamFormat[1] !== streamFormat[1] || this.streamFormat[2] !== streamFormat[2] ) { - collector.initialize(frame.sampleRate, frame.samplesPerChannel, false); + native.initialize(frame.sampleRate, frame.samplesPerChannel, false); this.streamFormat = streamFormat; } @@ -178,7 +181,7 @@ export class Collector extends FrameProcessor { mono[sample] = sum / frame.channels; } } - collector.buffer(mono); + native.buffer(mono); this.hasBufferedAudio = true; } catch (error) { writeLog( @@ -215,7 +218,7 @@ export class Collector extends FrameProcessor { this.streamFormat = null; this.streamInfo = null; this.hasBufferedAudio = false; - this.nativeCollector = null; + this.nativeAnalyzer = null; } close(): void { @@ -229,7 +232,7 @@ export class Collector extends FrameProcessor { export class Analyzer extends (EventEmitter as new () => TypedEmitter) { readonly collector: Collector; - private nativeAnalyzer: AnalyzerInstance | null; + private nativeAnalyzer: AicAnalyzer | null; private readonly timer: ReturnType; private readonly modelId: string; private readonly enableMetrics: boolean; @@ -245,21 +248,24 @@ export class Analyzer extends (EventEmitter as new () => TypedEmitter; + let nativeAnalyzer: AicAnalyzer; try { this.modelId = options.model.getId(); - pair = analyzerPair(options.model, resolveLicenseKey(options.licenseKey)); + nativeAnalyzer = new AicAnalyzer( + options.model, + resolveLicenseKey(options.licenseKey), + ); } catch (error) { throw new Error(`Failed to create ai-coustics Analyzer: ${errorDetail(error)}`, { cause: error, }); } - this.nativeAnalyzer = pair.analyzer; + this.nativeAnalyzer = nativeAnalyzer; this.enableMetrics = options.enableMetrics ?? true; this.collector = new Collector( - pair.collector, - () => pair.analyzer.reset(), + nativeAnalyzer, + () => nativeAnalyzer.reset(), () => this.close(), ); this.timer = setInterval(() => this.analyze(), analysisInterval * 1000); @@ -272,7 +278,7 @@ export class Analyzer extends (EventEmitter as new () => TypedEmitter TypedEmitter void = nativeSetSdkId; diff --git a/node/src/vad.ts b/node/src/vad.ts index ad182a9..8fafa0f 100644 --- a/node/src/vad.ts +++ b/node/src/vad.ts @@ -15,7 +15,6 @@ import { type Model, Vad as AicVad, type VadContext, - type VadInstance, VadParameter as AicVadParameter, setSdkId, } from "./sdk.js"; @@ -83,7 +82,7 @@ function errorDetail(error: unknown): string { export class VADProcessor extends FrameProcessor { private readonly model: Model; private readonly modelId: string; - private nativeVad: VadInstance | null; + private nativeVad: AicVad | null; private context: VadContext | null; private processorEnabled = true; private closed = false; @@ -98,7 +97,7 @@ export class VADProcessor extends FrameProcessor { constructor(model: Model, licenseKey: string) { super(); setSdkId(9); - let nativeVad: VadInstance; + let nativeVad: AicVad; try { nativeVad = new AicVad(model, licenseKey); } catch (error) { @@ -273,7 +272,7 @@ export class VADProcessor extends FrameProcessor { results.push({ pcm, sampleRate: frame.sampleRate, - probability: context.rawVadProbability(), + probability: context.getRawVadProbability(), detected: context.isSpeechDetected(), sensitivity: context.getParameter(AicVadParameter.Sensitivity), speechHoldDuration: context.getParameter( diff --git a/node/test/analyzer.test.ts b/node/test/analyzer.test.ts index 42e0d08..891443e 100644 --- a/node/test/analyzer.test.ts +++ b/node/test/analyzer.test.ts @@ -31,9 +31,14 @@ const telemetry = vi.hoisted(() => { }); const sdk = vi.hoisted(() => { - class FakeCollector { + class FakeAnalyzer { initializations: Array<[number, number, boolean]> = []; blocks: number[][] = []; + analyzeCalls = 0; + resetCalls = 0; + terminateCalls = 0; + disposeCalls = 0; + error: Error | null = null; initialize(sampleRate: number, blockSize: number, variable: boolean): void { this.initializations.push([sampleRate, blockSize, variable]); @@ -42,15 +47,8 @@ const sdk = vi.hoisted(() => { buffer(samples: Float32Array): void { this.blocks.push(Array.from(samples)); } - } - class FakeAnalyzer { - analyzeCalls = 0; - resetCalls = 0; - terminateCalls = 0; - error: Error | null = null; - - analyzeBuffered() { + analyze() { this.analyzeCalls += 1; if (this.error) throw this.error; return { @@ -72,13 +70,16 @@ const sdk = vi.hoisted(() => { this.terminateCalls += 1; } + dispose(): void { + this.disposeCalls += 1; + } + updateBearerToken(): void {} } - const collectors: FakeCollector[] = []; const analyzers: FakeAnalyzer[] = []; const sdkIds: number[] = []; - return { FakeCollector, FakeAnalyzer, collectors, analyzers, sdkIds }; + return { FakeAnalyzer, analyzers, sdkIds }; }); vi.mock("@ai-coustics/aic-sdk", () => ({ @@ -91,12 +92,12 @@ vi.mock("@ai-coustics/aic-sdk", () => ({ Sensitivity: 3, MinimumSpeechDuration: 4, }, - analyzerPair: () => { - const collector = new sdk.FakeCollector(); - const analyzer = new sdk.FakeAnalyzer(); - sdk.collectors.push(collector); - sdk.analyzers.push(analyzer); - return { collector, analyzer }; + Analyzer: class { + constructor() { + const analyzer = new sdk.FakeAnalyzer(); + sdk.analyzers.push(analyzer); + return analyzer; + } }, _setSdkId: (id: number) => sdk.sdkIds.push(id), })); @@ -141,7 +142,6 @@ describe("Analyzer", () => { beforeEach(() => { vi.useFakeTimers(); logging.calls.length = 0; - sdk.collectors.length = 0; sdk.analyzers.length = 0; sdk.sdkIds.length = 0; telemetry.measurements.analysis.length = 0; @@ -164,8 +164,8 @@ describe("Analyzer", () => { const frame = makeFrame(2); expect(analyzer.collector.process(frame)).toBe(frame); - expect(sdk.collectors[0]!.initializations).toEqual([[16000, 4, false]]); - expect(sdk.collectors[0]!.blocks[0]).toEqual([ + expect(sdk.analyzers[0]!.initializations).toEqual([[16000, 4, false]]); + expect(sdk.analyzers[0]!.blocks[0]).toEqual([ 32767 / 32768, -1, 0.5, @@ -176,6 +176,7 @@ describe("Analyzer", () => { expect(sdk.analyzers[0]!.resetCalls).toBe(1); analyzer.close(); expect(sdk.analyzers[0]!.terminateCalls).toBe(1); + expect(sdk.analyzers[0]!.disposeCalls).toBe(1); }); it("analyzes on the configured interval and emits without logging the result", () => { diff --git a/node/test/e2e-room.test.ts b/node/test/e2e-room.test.ts index 90d0836..c054494 100644 --- a/node/test/e2e-room.test.ts +++ b/node/test/e2e-room.test.ts @@ -177,8 +177,10 @@ describeIf("Processor and VAD in a real AgentSession room", () => { "processes microphone audio and consumes VAD events after the license grace period", async () => { fs.mkdirSync(modelDir, { recursive: true }); - const model = Model.fromFile(Model.download(modelId, modelDir)); - const vadModel = Model.fromFile(Model.download(vadModelId, modelDir)); + const model = Model.fromFile(await Model.download(modelId, modelDir)); + const vadModel = Model.fromFile( + await Model.download(vadModelId, modelDir), + ); const processor = new ObservedProcessor({ model }); const detector = new ObservedVAD({ model: vadModel }); const frameProcessor = new FrameProcessorChain(detector.processor, processor); diff --git a/node/test/integration.test.ts b/node/test/integration.test.ts index 09978dc..0009dca 100644 --- a/node/test/integration.test.ts +++ b/node/test/integration.test.ts @@ -82,9 +82,9 @@ function frame(index: number, channels = 1): AudioFrame { describeIf("native Processor integration", () => { let model: ReturnType; - beforeAll(() => { + beforeAll(async () => { fs.mkdirSync(modelDir, { recursive: true }); - model = Model.fromFile(Model.download(modelId, modelDir)); + model = Model.fromFile(await Model.download(modelId, modelDir)); }); it("processes 50 ms frames with a downloaded model", () => { @@ -123,9 +123,9 @@ describeIf("native Processor integration", () => { describeIf("native VAD integration", () => { let model: ReturnType; - beforeAll(() => { + beforeAll(async () => { fs.mkdirSync(modelDir, { recursive: true }); - model = Model.fromFile(Model.download(vadModelId, modelDir)); + model = Model.fromFile(await Model.download(vadModelId, modelDir)); }); it("runs the SDK at the LiveKit input rate without plugin resampling", async () => { @@ -219,9 +219,9 @@ describeIf("native VAD integration", () => { describeIf("native Analyzer integration", () => { let model: ReturnType; - beforeAll(() => { + beforeAll(async () => { fs.mkdirSync(modelDir, { recursive: true }); - model = Model.fromFile(Model.download(analysisModelId, modelDir)); + model = Model.fromFile(await Model.download(analysisModelId, modelDir)); }); it("emits the current SDK result schema", async () => { diff --git a/node/test/processor.test.ts b/node/test/processor.test.ts index 1ef0eb2..035ac0c 100644 --- a/node/test/processor.test.ts +++ b/node/test/processor.test.ts @@ -86,10 +86,12 @@ const sdk = vi.hoisted(() => { return new FakeModel(); } - static download(modelId: string, downloadDir: string): string { + static async download(modelId: string, downloadDir: string): Promise { this.downloadCalls.push([modelId, downloadDir]); return `${downloadDir}/${modelId}.aicmodel`; } + + dispose(): void {} } class FakeProcessor { @@ -516,8 +518,8 @@ describe("Processor", () => { now.mockRestore(); }); - it("exposes SDK model download and file loading", () => { - const modelPath = Model.download( + it("exposes SDK model download and file loading", async () => { + const modelPath = await Model.download( "quail-vf-2.2-l-16khz", "/tmp/aic-test-models", ); diff --git a/node/test/vad.test.ts b/node/test/vad.test.ts index 3136881..ec3728e 100644 --- a/node/test/vad.test.ts +++ b/node/test/vad.test.ts @@ -40,7 +40,7 @@ const sdk = vi.hoisted(() => { return this.detected; } - rawVadProbability(): number { + getRawVadProbability(): number { return this.probability; } @@ -76,6 +76,8 @@ const sdk = vi.hoisted(() => { getOptimalBlockSize(sampleRate: number): number { return Math.round((this.blockSize * sampleRate) / this.sampleRate); } + + dispose(): void {} } class FakeVad { From d5430f4b4ec61c26537c4776cd10eaacd7e6f115 Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Mon, 14 Sep 2026 14:25:24 +0200 Subject: [PATCH 3/6] Run Node buffered analysis on a worker thread aic-sdk 0.24 adds Analyzer.analyzeAsync(), which runs inference on a libuv worker thread. Use it instead of the synchronous analyze(), so periodic analysis no longer freezes the agent's event loop. Measured against the real SDK with a tyto-1.1-l model and a 5 ms timer: the synchronous call blocked for its full 120 ms of inference and the timer fired zero times, while analyzeAsync() let it fire 22 times with 0.4 ms worst-case lag. Buffering does not take the analyzer lock, so the collector keeps feeding audio for the whole duration. A tick arriving while an analysis is still in flight is skipped rather than queued, reported through a rate-limited warning carrying the skip count. This matches how the Processor and VAD report falling behind. close() now returns a promise and releases the native analyzer only once any in-flight analysis has settled, because terminateSession() and dispose() wait for the analyzer lock. It stays safe to call without awaiting, which is how RoomIO closes the collector, and repeated calls return the same promise. This mirrors the aclose() shutdown path Python already has. Verified with the integration suite against the real SDK. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 7 +- DEVELOPMENT.md | 24 ++++--- node/README.md | 4 ++ node/src/analyzer.ts | 64 +++++++++++++++--- node/test/analyzer.test.ts | 122 ++++++++++++++++++++++++++++------ node/test/integration.test.ts | 2 +- 6 files changed, 181 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6ce8eff..a678d24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,9 +79,12 @@ Four public objects, each mirrored across both runtimes: LiveKit's streaming turn detector); explicit `VADParameters` still win. - **`Analyzer` / `Collector`** (`analyzer.py` / `analyzer.ts`) — the public `collector` is a transparent `FrameProcessor` that buffers mono float32; the analyzer runs periodic - `analyze_buffered()`/`analyzeBuffered()` inference off the audio path and emits + `analyze_buffered()`/`analyzeAsync()` inference off the audio path and emits `analysis_result` / `analysisResult` events plus aggregate OpenTelemetry instruments. Python uses - an asyncio task + `asyncio.to_thread()`; Node uses a timer around the synchronous SDK call. + an asyncio task + `asyncio.to_thread()`; Node uses a timer around the SDK's own `analyzeAsync()`, + which runs on a libuv worker thread. Both skip a tick whose predecessor is still running, and + both defer session teardown until in-flight inference settles — Node's `Analyzer.close()` + therefore returns a promise. - **`FrameProcessorChain`** (`frame_processor_chain.py` / `.ts`) — lets these share RoomIO's single `noise_cancellation` slot. Order matters: `vad.processor` first, then `analyzer.collector`, then the enhancement `Processor`, so VAD and analysis see original input audio. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 937ed8f..392f8f8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -61,8 +61,11 @@ is recommended: analyzing original input audio helps explain how its quality aff the pipeline. Python schedules inference with an asyncio task and runs each blocking `analyze_buffered()` call -through `asyncio.to_thread()`. Shutdown waits for an active inference before terminating the SDK -session. Node uses a timer around the SDK's synchronous `analyzeBuffered()` API. Both runtimes emit +through `asyncio.to_thread()`. Node uses a timer around `analyzeAsync()`, which the SDK runs on a +libuv worker thread; a tick that arrives while an analysis is still running is skipped rather than +queued, and the skip is reported through a rate-limited warning. In both runtimes shutdown waits +for an active inference before terminating the SDK session, because termination and disposal wait +for the analyzer lock. Both runtimes emit a plugin-level result event after every successful scheduled call without logging the result by default. They also record aggregate score, inference-duration, and success/error count instruments through the process-wide OpenTelemetry metrics API; operational errors remain logged and fail-open @@ -117,10 +120,11 @@ such as package metadata or an explicit package list. Until those pieces exist, ### First-class streaming Analyzer integration -The aic-sdk streaming analysis API is split into a `Collector` and an `Analyzer`. The collector -accepts mono float32 audio synchronously and is safe to feed from the audio path, while -`analyze_buffered()` / `analyzeBuffered()` runs an expensive model inference and must execute away -from that path. The result contains risk, speaker reverb, speaker loudness, interfering speech, +The aic-sdk streaming analysis API separates buffering from inference: Python splits it across a +`Collector` and an `Analyzer`, Node carries both on one `Analyzer`. Buffering accepts mono float32 +audio synchronously, does not take the analyzer lock, and is safe to feed from the audio path, +while `analyze_buffered()` / `analyzeAsync()` runs an expensive model inference and must execute +away from that path. The result contains risk, speaker reverb, speaker loudness, interfering speech, noise, codec-degradation, and packet-loss scores. `FileAnalyzer` is intended for complete in-memory signals and is not appropriate for a live agent stream. @@ -203,10 +207,10 @@ so `window_duration` must remain optional until aic-sdk provides it. If future a different context windows, that API will also avoid hard-coding the current five-second window. Python runs `analyze_buffered()` through `asyncio.to_thread()` because the binding releases the -GIL during inference. Node aic-sdk 0.23 exposes only synchronous `analyzeBuffered()` and -`terminateSession()`, so calling them from a timer would still block the agent's JavaScript event -loop. A production Node integration first needs native asynchronous APIs such as -`analyzeBufferedAsync()` and `terminateSessionAsync()` that execute on a worker pool. +GIL during inference. Node uses the SDK's own `analyzeAsync()`, added in aic-sdk 0.24, which runs +on a libuv worker thread. `terminateSession()` and `dispose()` remain synchronous and wait for the +analyzer lock, so the Node `Analyzer.close()` returns a promise and releases the native instance +only after any in-flight analysis has settled. ### First-class Processor metrics diff --git a/node/README.md b/node/README.md index 38c0c65..88069ef 100644 --- a/node/README.md +++ b/node/README.md @@ -170,6 +170,10 @@ rest of the pipeline. This still uses LiveKit's `noiseCancellation` slot as a temporary integration. RoomIO owns the chain and closes the processor, collector, and analyzer together. +Analysis inference runs on a worker thread, so it never blocks the agent's event loop. If you own +an `Analyzer` outside RoomIO, `analyzer.close()` returns a promise that resolves once any in-flight +analysis has settled and the SDK session is released; awaiting it is optional. + ## Configuration Set the enhancement level through the Processor context, and configure all SDK VAD parameters on diff --git a/node/src/analyzer.ts b/node/src/analyzer.ts index 1d76aa9..b7ddb85 100644 --- a/node/src/analyzer.ts +++ b/node/src/analyzer.ts @@ -17,6 +17,7 @@ import { } from "./sdk.js"; const DEFAULT_ANALYSIS_INTERVAL_SECONDS = 5; +const OVERLAP_WARNING_INTERVAL_MS = 10_000; const meter = metrics.getMeter("ai-coustics-livekit-plugin"); const analysisCount = meter.createCounter("ai_coustics.analyzer.analysis", { description: "Number of ai-coustics buffered audio analyses", @@ -236,6 +237,11 @@ export class Analyzer extends (EventEmitter as new () => TypedEmitter; private readonly modelId: string; private readonly enableMetrics: boolean; + private readonly analysisIntervalMs: number; + private analysisInFlight: Promise | null = null; + private teardown: Promise | null = null; + private skippedAnalyses = 0; + private lastOverlapWarning: number | null = null; private sequence = 0; private closed = false; @@ -265,20 +271,49 @@ export class Analyzer extends (EventEmitter as new () => TypedEmitter nativeAnalyzer.reset(), - () => this.close(), + () => void this.close(), ); - this.timer = setInterval(() => this.analyze(), analysisInterval * 1000); + this.analysisIntervalMs = analysisInterval * 1000; + this.timer = setInterval(() => this.scheduleAnalysis(), this.analysisIntervalMs); this.timer.unref?.(); } - private analyze(): void { + private scheduleAnalysis(): void { const analyzer = this.nativeAnalyzer; - if (!analyzer || !this.collector.initialized) return; + if (this.closed || !analyzer || !this.collector.initialized) return; + if (this.analysisInFlight) { + this.skippedAnalyses += 1; + const now = performance.now(); + if ( + this.lastOverlapWarning === null || + now - this.lastOverlapWarning >= OVERLAP_WARNING_INTERVAL_MS + ) { + this.lastOverlapWarning = now; + writeLog("warn", "analyzer", "analysis falling behind its interval", { + modelName: this.modelId, + analysisIntervalMs: this.analysisIntervalMs, + skippedAnalyses: this.skippedAnalyses, + ...(this.collector.currentStreamInfo ?? {}), + }); + } + return; + } + + const tracked = this.analyze(analyzer).finally(() => { + if (this.analysisInFlight === tracked) this.analysisInFlight = null; + }); + this.analysisInFlight = tracked; + } + + /** Runs one analysis on a libuv worker thread. Never rejects; failures are logged. */ + private async analyze(analyzer: AicAnalyzer): Promise { const started = performance.now(); try { - const nativeResult = analyzer.analyze(); + const nativeResult = await analyzer.analyzeAsync(); const elapsed = performance.now() - started; const result = Object.freeze({ ...nativeResult }); this.sequence += 1; @@ -346,14 +381,27 @@ export class Analyzer extends (EventEmitter as new () => TypedEmitter { + if (this.teardown) return this.teardown; this.closed = true; clearInterval(this.timer); this.collector.detach(); const analyzer = this.nativeAnalyzer; this.nativeAnalyzer = null; - if (!analyzer) return; + this.teardown = (this.analysisInFlight ?? Promise.resolve()).then(() => { + if (analyzer) this.release(analyzer); + }); + return this.teardown; + } + + private release(analyzer: AicAnalyzer): void { try { analyzer.terminateSession(); } catch (error) { diff --git a/node/test/analyzer.test.ts b/node/test/analyzer.test.ts index 891443e..1d7470e 100644 --- a/node/test/analyzer.test.ts +++ b/node/test/analyzer.test.ts @@ -39,6 +39,7 @@ const sdk = vi.hoisted(() => { terminateCalls = 0; disposeCalls = 0; error: Error | null = null; + gate: Promise | null = null; initialize(sampleRate: number, blockSize: number, variable: boolean): void { this.initializations.push([sampleRate, blockSize, variable]); @@ -62,6 +63,21 @@ const sdk = vi.hoisted(() => { }; } + async analyzeAsync() { + this.analyzeCalls += 1; + if (this.error) throw this.error; + if (this.gate) await this.gate; + return { + riskScore: 0.1, + speakerReverb: 0.2, + speakerLoudness: 0.3, + interferingSpeech: 0.4, + noise: 0.5, + codecDegradation: 0.6, + packetLoss: 0.7, + }; + } + reset(): void { this.resetCalls += 1; } @@ -151,7 +167,7 @@ describe("Analyzer", () => { afterEach(() => vi.useRealTimers()); - it("exposes a transparent FrameProcessor collector that downmixes audio", () => { + it("exposes a transparent FrameProcessor collector that downmixes audio", async () => { const analyzer = new Analyzer({ model: { getId: () => "analysis-test-model" } as never, licenseKey: "test-license", @@ -174,12 +190,12 @@ describe("Analyzer", () => { analyzer.collector.onStreamInfoCleared(); expect(sdk.analyzers[0]!.resetCalls).toBe(1); - analyzer.close(); + await analyzer.close(); expect(sdk.analyzers[0]!.terminateCalls).toBe(1); expect(sdk.analyzers[0]!.disposeCalls).toBe(1); }); - it("analyzes on the configured interval and emits without logging the result", () => { + it("analyzes on the configured interval and emits without logging the result", async () => { const analyzer = new Analyzer({ model: { getId: () => "analysis-test-model" } as never, licenseKey: "test-license", @@ -194,7 +210,7 @@ describe("Analyzer", () => { }); analyzer.collector.process(makeFrame()); - vi.advanceTimersByTime(10); + await vi.advanceTimersByTimeAsync(10); expect(sdk.analyzers[0]!.analyzeCalls).toBe(1); expect(logging.calls).toEqual([]); @@ -221,10 +237,10 @@ describe("Analyzer", () => { value: 0.1, attributes: { model_provider: "ai-coustics", "score.name": "risk_score" }, }); - analyzer.close(); + await analyzer.close(); }); - it("pauses analysis while the collector is disabled", () => { + it("pauses analysis while the collector is disabled", async () => { const analyzer = new Analyzer({ model: { getId: () => "analysis-test-model" } as never, licenseKey: "test-license", @@ -234,33 +250,33 @@ describe("Analyzer", () => { analyzer.on("analysisResult", (event) => events.push(event)); analyzer.collector.process(makeFrame()); - vi.advanceTimersByTime(10); + await vi.advanceTimersByTimeAsync(10); expect(sdk.analyzers[0]!.analyzeCalls).toBe(1); analyzer.collector.setEnabled(false); const frame = makeFrame(); expect(analyzer.collector.process(frame)).toBe(frame); - vi.advanceTimersByTime(50); + await vi.advanceTimersByTimeAsync(50); - expect(sdk.collectors[0]!.blocks).toHaveLength(1); + expect(sdk.analyzers[0]!.blocks).toHaveLength(1); expect(sdk.analyzers[0]!.analyzeCalls).toBe(1); expect(events).toHaveLength(1); // Re-enabling drops the stale buffer and waits for fresh audio. analyzer.collector.setEnabled(true); expect(sdk.analyzers[0]!.resetCalls).toBe(1); - vi.advanceTimersByTime(50); + await vi.advanceTimersByTimeAsync(50); expect(sdk.analyzers[0]!.analyzeCalls).toBe(1); analyzer.collector.process(makeFrame()); - vi.advanceTimersByTime(10); + await vi.advanceTimersByTimeAsync(10); expect(sdk.analyzers[0]!.analyzeCalls).toBe(2); expect(events).toHaveLength(2); - analyzer.close(); + await analyzer.close(); }); - it("stops the analyzer when RoomIO closes its collector", () => { + it("stops the analyzer when RoomIO closes its collector", async () => { const analyzer = new Analyzer({ model: { getId: () => "analysis-test-model" } as never, licenseKey: "test-license", @@ -268,14 +284,14 @@ describe("Analyzer", () => { }); analyzer.collector.close(); - vi.advanceTimersByTime(1000); + await vi.advanceTimersByTimeAsync(1000); expect(analyzer.collector.isEnabled()).toBe(false); expect(sdk.analyzers[0]!.terminateCalls).toBe(1); expect(sdk.analyzers[0]!.analyzeCalls).toBe(0); }); - it("records failed analyses without score measurements", () => { + it("records failed analyses without score measurements", async () => { const analyzer = new Analyzer({ model: { getId: () => "analysis-test-model" } as never, licenseKey: "test-license", @@ -284,7 +300,7 @@ describe("Analyzer", () => { sdk.analyzers[0]!.error = new Error("analysis failed"); analyzer.collector.process(makeFrame()); - vi.advanceTimersByTime(10); + await vi.advanceTimersByTimeAsync(10); expect(telemetry.measurements.analysis).toEqual([ { @@ -305,22 +321,86 @@ describe("Analyzer", () => { }), }), ); - analyzer.close(); + await analyzer.close(); }); - it("defaults to analyzing every five seconds", () => { + it("defaults to analyzing every five seconds", async () => { const analyzer = new Analyzer({ model: { getId: () => "analysis-test-model" } as never, licenseKey: "test-license", }); analyzer.collector.process(makeFrame()); - vi.advanceTimersByTime(4999); + await vi.advanceTimersByTimeAsync(4999); expect(sdk.analyzers[0]!.analyzeCalls).toBe(0); - vi.advanceTimersByTime(1); + await vi.advanceTimersByTimeAsync(1); + expect(sdk.analyzers[0]!.analyzeCalls).toBe(1); + + await analyzer.close(); + }); + + it("skips ticks while the previous analysis is still running", async () => { + const analyzer = new Analyzer({ + model: { getId: () => "analysis-test-model" } as never, + licenseKey: "test-license", + analysisInterval: 0.01, + }); + let release!: () => void; + sdk.analyzers[0]!.gate = new Promise((resolve) => { + release = resolve; + }); + analyzer.collector.process(makeFrame()); + + await vi.advanceTimersByTimeAsync(30); + expect(sdk.analyzers[0]!.analyzeCalls).toBe(1); + expect(logging.calls).toContainEqual( + expect.objectContaining({ + level: "warn", + message: "Analyzer: analysis falling behind its interval", + fields: expect.objectContaining({ + plugin: "ai-coustics", + component: "analyzer", + modelName: "analysis-test-model", + analysisIntervalMs: 10, + }), + }), + ); + + // Buffering does not take the analyzer lock, so audio keeps flowing meanwhile. + const frame = makeFrame(); + expect(analyzer.collector.process(frame)).toBe(frame); + expect(sdk.analyzers[0]!.blocks).toHaveLength(2); + + release(); + await vi.advanceTimersByTimeAsync(10); + expect(sdk.analyzers[0]!.analyzeCalls).toBe(2); + await analyzer.close(); + }); + + it("releases the native analyzer only once in-flight analysis settles", async () => { + const analyzer = new Analyzer({ + model: { getId: () => "analysis-test-model" } as never, + licenseKey: "test-license", + analysisInterval: 0.01, + }); + let release!: () => void; + sdk.analyzers[0]!.gate = new Promise((resolve) => { + release = resolve; + }); + analyzer.collector.process(makeFrame()); + await vi.advanceTimersByTimeAsync(10); expect(sdk.analyzers[0]!.analyzeCalls).toBe(1); - analyzer.close(); + const closed = analyzer.close(); + await Promise.resolve(); + expect(sdk.analyzers[0]!.terminateCalls).toBe(0); + expect(sdk.analyzers[0]!.disposeCalls).toBe(0); + + release(); + await closed; + expect(sdk.analyzers[0]!.terminateCalls).toBe(1); + expect(sdk.analyzers[0]!.disposeCalls).toBe(1); + expect(analyzer.close()).toBe(closed); }); it.each([0, -1, Number.POSITIVE_INFINITY, Number.NaN])( diff --git a/node/test/integration.test.ts b/node/test/integration.test.ts index 0009dca..dfe4dd3 100644 --- a/node/test/integration.test.ts +++ b/node/test/integration.test.ts @@ -257,7 +257,7 @@ describeIf("native Analyzer integration", () => { true, ); } finally { - analyzer.close(); + await analyzer.close(); } }, 120_000); }); From 5c9e561824c4f2465d633966be6ae6f18917eaf3 Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Mon, 14 Sep 2026 14:34:21 +0200 Subject: [PATCH 4/6] Dispose native Processor and VAD instances on close The analyzer upgrade already released its native instance explicitly; do the same for the other two components rather than leaving half the plugin relying on garbage collection. Both close paths were already shaped for this: each clears its native references before tearing down, and every process() guards on them, so nothing can reach a disposed instance. Confirmed against the real SDK that terminateSession() followed by dispose() succeeds, that repeated disposal is a no-op, and that use after disposal throws. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 4 ++++ DEVELOPMENT.md | 9 ++++++++- node/src/processor.ts | 5 +++++ node/src/vad.ts | 14 ++++++++++++++ node/test/processor.test.ts | 6 ++++++ node/test/vad.test.ts | 6 ++++++ 6 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index a678d24..ac1698c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,6 +94,10 @@ bearer-token changes. Node's `sdk.ts` is a thin re-export boundary over the decl 0.24 ships; it only hand-mirrors `ProcessorParameter` and `VadParameter`, which are `const enum`s and so have no runtime form of their own. +In Node, `close()` on all three components terminates the SDK session and then calls the SDK's +`dispose()`; disposal is idempotent and any later call on a disposed instance throws, so close +clears native references before disposing. Python relies on binding finalization instead. + **Fail-open is a hard invariant.** Any processing error is logged and the *original* frame is returned; room audio must keep flowing whatever the SDK does. Repeated failures and slower-than-realtime warnings are rate-limited, with recovery/close records summarizing affected diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 392f8f8..6f5fd57 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -48,12 +48,19 @@ by LiveKit's streaming turn detector. Because the SDK hold uses a rolling-majori wrapper also keeps an active LiveKit speech segment open until that much continuous raw silence has accumulated. Explicit `VADParameters` values still take precedence. -Each `Analyzer` owns one SDK collector/analyzer pair. Its public `collector` is a transparent +Each `Analyzer` owns one SDK analysis instance: a collector/analyzer pair in Python, a single +`Analyzer` carrying both halves in Node since aic-sdk 0.24. Its public `collector` is a transparent `FrameProcessor` installed in RoomIO's `noise_cancellation` slot: it lazily initializes from the first frame, downmixes PCM16 input to mono float32, buffers it, and returns the original frame unchanged. Stream boundaries reset the analyzer. Closing either the analyzer or its collector stops scheduling and terminates the SDK telemetry session. +In Node, every component's `close()` follows its `terminateSession()` with the SDK's `dispose()`, +releasing the native instance at a known point instead of leaving it to garbage collection. +Disposal is idempotent, and any call on a disposed instance throws, so each `close()` clears its +native references first and every `process()` guards on them. Python has no equivalent call and +relies on the binding's own finalization. + `FrameProcessorChain` forwards stream-info lifecycle hooks and applies any number of enabled processors in constructor order. It lets a `Processor`, VAD processor, and Collector share RoomIO's single `noise_cancellation` slot. Placing the Collector before the enhancement Processor diff --git a/node/src/processor.ts b/node/src/processor.ts index c03d4e8..6fbbbf4 100644 --- a/node/src/processor.ts +++ b/node/src/processor.ts @@ -286,6 +286,11 @@ export class Processor extends FrameProcessor { error, ); } + try { + processor.dispose(); + } catch (error) { + this.writeLog("error", "native disposal failed", {}, false, error); + } const summary = this.diagnosticFields({ frameCount: this.frameCount, diff --git a/node/src/vad.ts b/node/src/vad.ts index 8fafa0f..5ce3c14 100644 --- a/node/src/vad.ts +++ b/node/src/vad.ts @@ -193,6 +193,20 @@ export class VADProcessor extends FrameProcessor { error, ); } + try { + nativeVad.dispose(); + } catch (error) { + writeLog( + "error", + "vad", + "native disposal failed", + this.diagnosticFields({ + errorType: error instanceof Error ? error.name : typeof error, + errorMessage: errorDetail(error), + }), + error, + ); + } } } diff --git a/node/test/processor.test.ts b/node/test/processor.test.ts index 035ac0c..5504e62 100644 --- a/node/test/processor.test.ts +++ b/node/test/processor.test.ts @@ -102,6 +102,7 @@ const sdk = vi.hoisted(() => { readonly blocks: number[][] = []; error: Error | null = null; terminateCalls = 0; + disposeCalls = 0; constructor() { if (FakeProcessor.constructorError) { @@ -128,6 +129,10 @@ const sdk = vi.hoisted(() => { terminateSession(): void { this.terminateCalls += 1; } + + dispose(): void { + this.disposeCalls += 1; + } } return { FakeModel, FakeProcessor, instances, nativeCalls }; @@ -444,6 +449,7 @@ describe("Processor", () => { enhancer.close(); expect(processor.terminateCalls).toBe(1); + expect(processor.disposeCalls).toBe(1); expect(enhancer.process(frame)).toBe(frame); const summary = logging.calls.find( ({ message }) => message === "Processor: closed", diff --git a/node/test/vad.test.ts b/node/test/vad.test.ts index ec3728e..333855b 100644 --- a/node/test/vad.test.ts +++ b/node/test/vad.test.ts @@ -89,6 +89,7 @@ const sdk = vi.hoisted(() => { initializeError: Error | null = null; processError: Error | null = null; terminateCalls = 0; + disposeCalls = 0; constructor() { if (FakeVad.constructorError) throw FakeVad.constructorError; @@ -117,6 +118,10 @@ const sdk = vi.hoisted(() => { terminateSession(): void { this.terminateCalls += 1; } + + dispose(): void { + this.disposeCalls += 1; + } } class FakeProcessor {} @@ -216,6 +221,7 @@ describe("VAD", () => { await vad.close(); expect(native.terminateCalls).toBe(1); + expect(native.disposeCalls).toBe(1); }); it("wraps native construction errors", () => { From 4ffa756abfe20fc627d2f934ebca1a54037a7f3a Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Mon, 14 Sep 2026 17:17:41 +0200 Subject: [PATCH 5/6] Label analysis results with the stream they were collected for The analyzer read the collector's current stream info after awaiting inference. A stream change lands on the collector meanwhile, replacing that info and resetting the buffer, so the completed result was emitted and logged with the new participant and publication while describing audio collected for the previous one. Snapshot the stream info when scheduling an analysis and carry it through the event and the diagnostics. A stream change resets the collector, so the audio being analyzed always belongs to the stream current at that point. Co-Authored-By: Claude Opus 5 (1M context) --- node/src/analyzer.ts | 14 +++-- node/test/analyzer.test.ts | 42 +++++++++++++++ .../livekit/plugins/ai_coustics/analyzer.py | 13 +++-- python/tests/test_analyzer.py | 53 +++++++++++++++++++ 4 files changed, 114 insertions(+), 8 deletions(-) diff --git a/node/src/analyzer.ts b/node/src/analyzer.ts index b7ddb85..b8076e6 100644 --- a/node/src/analyzer.ts +++ b/node/src/analyzer.ts @@ -303,21 +303,27 @@ export class Analyzer extends (EventEmitter as new () => TypedEmitter { + // Snapshot the stream now: a stream change resets the collector, so the audio about + // to be analyzed belongs to this stream, while `currentStreamInfo` may already have + // moved on by the time inference completes. + const streamInfo = this.collector.currentStreamInfo; + const tracked = this.analyze(analyzer, streamInfo).finally(() => { if (this.analysisInFlight === tracked) this.analysisInFlight = null; }); this.analysisInFlight = tracked; } /** Runs one analysis on a libuv worker thread. Never rejects; failures are logged. */ - private async analyze(analyzer: AicAnalyzer): Promise { + private async analyze( + analyzer: AicAnalyzer, + streamInfo: FrameProcessorStreamInfo | null, + ): Promise { const started = performance.now(); try { const nativeResult = await analyzer.analyzeAsync(); const elapsed = performance.now() - started; const result = Object.freeze({ ...nativeResult }); this.sequence += 1; - const streamInfo = this.collector.currentStreamInfo; const event = Object.freeze({ result, timestamp: Date.now(), @@ -345,7 +351,7 @@ export class Analyzer extends (EventEmitter as new () => TypedEmitter { await analyzer.close(); }); + it("labels a result with the stream it was collected for", async () => { + const analyzer = new Analyzer({ + model: { getId: () => "analysis-test-model" } as never, + licenseKey: "test-license", + analysisInterval: 0.01, + }); + const events: AnalysisEvent[] = []; + analyzer.on("analysisResult", (event) => events.push(event)); + let release!: () => void; + sdk.analyzers[0]!.gate = new Promise((resolve) => { + release = resolve; + }); + analyzer.collector.onStreamInfoUpdated({ + roomName: "room-a", + participantIdentity: "speaker-a", + publicationSid: "TR_a", + }); + analyzer.collector.process(makeFrame()); + + await vi.advanceTimersByTimeAsync(10); + expect(sdk.analyzers[0]!.analyzeCalls).toBe(1); + + // The stream switches while the analysis is still in flight. + analyzer.collector.onStreamInfoUpdated({ + roomName: "room-b", + participantIdentity: "speaker-b", + publicationSid: "TR_b", + }); + release(); + await vi.advanceTimersByTimeAsync(1); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual( + expect.objectContaining({ + roomName: "room-a", + participantIdentity: "speaker-a", + publicationSid: "TR_a", + }), + ); + await analyzer.close(); + }); + it("releases the native analyzer only once in-flight analysis settles", async () => { const analyzer = new Analyzer({ model: { getId: () => "analysis-test-model" } as never, diff --git a/python/src/livekit/plugins/ai_coustics/analyzer.py b/python/src/livekit/plugins/ai_coustics/analyzer.py index 094bebd..0bc88c8 100644 --- a/python/src/livekit/plugins/ai_coustics/analyzer.py +++ b/python/src/livekit/plugins/ai_coustics/analyzer.py @@ -254,7 +254,9 @@ async def _terminate_session(self) -> None: extra=log_fields("analyzer", model_name=self._model_id), ) - async def _analyze_once(self, native_analyzer: aic_sdk.Analyzer) -> None: + async def _analyze_once( + self, native_analyzer: aic_sdk.Analyzer, stream_info: dict[str, str] + ) -> None: started = time.perf_counter() try: result = await asyncio.to_thread(native_analyzer.analyze_buffered) @@ -265,7 +267,6 @@ async def _analyze_once(self, native_analyzer: aic_sdk.Analyzer) -> None: inference_duration = time.perf_counter() - started self._sequence += 1 - stream_info = self.collector.stream_info event = AnalysisEvent( result=result, timestamp=time.time(), @@ -336,15 +337,19 @@ async def _analysis_loop(self) -> None: pass if not self.collector.initialized: continue + # Snapshot the stream now: a stream change resets the collector, so the + # audio about to be analyzed belongs to this stream, while `stream_info` + # may already have moved on by the time inference completes. + stream_info = self.collector.stream_info try: - await self._analyze_once(native_analyzer) + await self._analyze_once(native_analyzer, stream_info) except Exception: logger.exception( "Analyzer: buffered audio analysis failed", extra=log_fields( "analyzer", model_name=self._model_id, - **self.collector.stream_info, + **stream_info, ), ) finally: diff --git a/python/tests/test_analyzer.py b/python/tests/test_analyzer.py index f759f8d..9f8dd96 100644 --- a/python/tests/test_analyzer.py +++ b/python/tests/test_analyzer.py @@ -317,6 +317,59 @@ async def test_disabled_collector_pauses_analysis( assert len(events) > events_while_disabled +@pytest.mark.asyncio +async def test_result_keeps_the_stream_it_was_collected_for( + fake_sdk: tuple[FakeCollector, FakeNativeAnalyzer, list[int]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + _, native_analyzer, _ = fake_sdk + started = asyncio.Event() + release = asyncio.Event() + + async def gated(function: Callable[..., object], /, *args: object) -> object: + started.set() + await release.wait() + return function(*args) + + monkeypatch.setattr("livekit.plugins.ai_coustics.analyzer.asyncio.to_thread", gated) + + analyzer = Analyzer( + model=cast(aic_sdk.Model, FakeModel()), + license_key="test-license", + analysis_interval=0.01, + ) + events: list[AnalysisEvent] = [] + analyzer.on("analysis_result", events.append) + analyzer.collector._on_stream_info_updated( + room_name="room-a", + participant_identity="speaker-a", + publication_sid="TR_a", + ) + analyzer.collector._process(make_frame()) + + await asyncio.wait_for(started.wait(), timeout=1.0) + + # The stream switches while the analysis is still in flight. + analyzer.collector._on_stream_info_updated( + room_name="room-b", + participant_identity="speaker-b", + publication_sid="TR_b", + ) + release.set() + + for _ in range(20): + if events: + break + await asyncio.sleep(0.005) + + await asyncio.wait_for(analyzer.aclose(), timeout=1.0) + assert native_analyzer.analyze_calls == 1 + assert len(events) == 1 + assert events[0].room_name == "room-a" + assert events[0].participant_identity == "speaker-a" + assert events[0].publication_sid == "TR_a" + + @pytest.mark.asyncio async def test_room_closing_collector_stops_analyzer( fake_sdk: tuple[FakeCollector, FakeNativeAnalyzer, list[int]], From 6bcedcfd2c4a5ce831c3e21258e4d1b7277b8749 Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Mon, 14 Sep 2026 17:23:17 +0200 Subject: [PATCH 6/6] Correct the rationale for mirroring the SDK const enums The comment claimed ProcessorParameter and VadParameter have no runtime form of their own. napi-rs does export both from aic-sdk's index.js, with members matching the declarations; only TypeScript's const enum treatment is compile-time-only. State that instead, and keep the reason for the hand-written mirrors: the plugin owns the objects it re-exports rather than leaning on declarations a bundler may erase. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 5 +++-- node/src/sdk.ts | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ac1698c..815d000 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,8 +91,9 @@ Four public objects, each mirrored across both runtimes: `ProcessorContext` wraps the SDK context purely to add structured logging around parameter and bearer-token changes. Node's `sdk.ts` is a thin re-export boundary over the declarations aic-sdk -0.24 ships; it only hand-mirrors `ProcessorParameter` and `VadParameter`, which are `const enum`s -and so have no runtime form of their own. +0.24 ships; it only hand-mirrors `ProcessorParameter` and `VadParameter`, which are declared as +`const enum`s that TypeScript treats as compile-time-only, so the plugin owns the runtime objects +it re-exports rather than leaning on declarations a bundler may erase. In Node, `close()` on all three components terminates the SDK session and then calls the SDK's `dispose()`; disposal is idempotent and any later call on a disposed instance throws, so close diff --git a/node/src/sdk.ts b/node/src/sdk.ts index 0b2b112..f755b2d 100644 --- a/node/src/sdk.ts +++ b/node/src/sdk.ts @@ -8,9 +8,11 @@ import { * Single import boundary for the ai-coustics SDK. * * aic-sdk ships its own TypeScript declarations, so the classes and result types are - * re-exported unchanged. `ProcessorParameter` and `VadParameter` are `const enum`s and - * therefore exist at compile time only; each is mirrored as a plain object so the plugin - * can re-export it as part of its public runtime API. + * re-exported unchanged. `ProcessorParameter` and `VadParameter` are declared as + * `const enum`s, which TypeScript treats as compile-time-only even though napi-rs does + * emit runtime objects for them. Each is therefore mirrored as a plain object the plugin + * owns, so it can be re-exported as part of the public runtime API without depending on + * declarations a bundler is free to erase. */ export { Analyzer,