diff --git a/CLAUDE.md b/CLAUDE.md index d630d09..815d000 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. -- `node/` — `@ai-coustics/livekit-plugin` (`node/src/`), built on `@ai-coustics/aic-sdk` 0.23. + 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.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. +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 @@ -76,16 +79,25 @@ 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. `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 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 +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 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index beaa761..6f5fd57 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.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. @@ -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 @@ -61,8 +68,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 +127,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 +214,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 ef1dd89..88069ef 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 @@ -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/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..b8076e6 100644 --- a/node/src/analyzer.ts +++ b/node/src/analyzer.ts @@ -10,15 +10,14 @@ 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"; 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", @@ -85,9 +84,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 +101,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 +124,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 +143,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 +158,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 +182,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 +219,7 @@ export class Collector extends FrameProcessor { this.streamFormat = null; this.streamInfo = null; this.hasBufferedAudio = false; - this.nativeCollector = null; + this.nativeAnalyzer = null; } close(): void { @@ -229,10 +233,15 @@ 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; + 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; @@ -245,38 +254,76 @@ 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(), - () => this.close(), + nativeAnalyzer, + // Stream changes are rare and off the audio path, so reset stays synchronous even + // though it waits for the analyzer lock when an analysis is in flight. + () => nativeAnalyzer.reset(), + () => 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; + } + + // 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, + streamInfo: FrameProcessorStreamInfo | null, + ): Promise { const started = performance.now(); try { - const nativeResult = analyzer.analyzeBuffered(); + 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(), @@ -304,7 +351,7 @@ export class Analyzer extends (EventEmitter as new () => TypedEmitter 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) { @@ -359,5 +419,16 @@ export class Analyzer extends (EventEmitter as new () => TypedEmitter { 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/sdk.ts b/node/src/sdk.ts index af5377b..f755b2d 100644 --- a/node/src/sdk.ts +++ b/node/src/sdk.ts @@ -1,120 +1,41 @@ import { - analyzerPair as nativeAnalyzerPair, - Model as NativeModel, - Processor as NativeProcessor, ProcessorParameter as NativeProcessorParameter, - Vad as NativeVad, VadParameter as NativeVadParameter, _setSdkId as nativeSetSdkId, } from "@ai-coustics/aic-sdk"; -/** Public structural types for aic-sdk 0.23, which does not ship TypeScript declarations. */ -export interface Model { - 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 interface CollectorInstance { - initialize( - sampleRate: number, - blockSize: number, - variableBlockSize?: boolean, - ): void; - buffer(samples: Float32Array): void; -} - -export interface AnalyzerInstance { - reset(): void; - analyzeBuffered(): AnalysisResult; - terminateSession(): void; - updateBearerToken(token: string): void; -} - -interface ModelConstructor { - fromFile(path: string): Model; - download(modelId: string, downloadDir: string): string; -} +/** + * 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 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, + Model, + Processor, + Vad, + type AnalysisResult, + type ProcessorContext, + type VadContext, +} from "@ai-coustics/aic-sdk"; -export const ProcessorParameter: { - readonly Bypass: number; - readonly EnhancementLevel: number; -} = NativeProcessorParameter; +export const ProcessorParameter = { + Bypass: NativeProcessorParameter.Bypass, + EnhancementLevel: NativeProcessorParameter.EnhancementLevel, +} as const; export type ProcessorParameter = (typeof ProcessorParameter)[keyof typeof ProcessorParameter]; -export const VadParameter: { - readonly SpeechHoldDuration: number; - readonly Sensitivity: number; - readonly MinimumSpeechDuration: number; -} = NativeVadParameter; +export const VadParameter = { + SpeechHoldDuration: NativeVadParameter.SpeechHoldDuration, + Sensitivity: NativeVadParameter.Sensitivity, + MinimumSpeechDuration: NativeVadParameter.MinimumSpeechDuration, +} as const; export type VadParameter = (typeof VadParameter)[keyof typeof VadParameter]; -export interface ProcessorContext { - reset(): void; - setParameter(parameter: ProcessorParameter, value: number): void; - getParameter(parameter: ProcessorParameter): number; - getAudioDelay(): number; - updateBearerToken(token: string): void; -} - -interface ProcessorInstance { - initialize( - sampleRate: number, - blockSize: number, - variableBlockSize?: boolean, - ): void; - process(buffer: Float32Array): void; - getContext(): ProcessorContext; - terminateSession(): void; -} - -interface ProcessorConstructor { - new (model: Model, licenseKey: string): ProcessorInstance; -} - -export interface VadContext { - reset(): void; - isSpeechDetected(): boolean; - rawVadProbability(): number; - setParameter(parameter: VadParameter, value: number): void; - getParameter(parameter: VadParameter): number; - getPredictionDelay(): number; - updateBearerToken(token: string): void; -} - -export interface VadInstance { - initialize( - sampleRate: number, - blockSize: number, - variableBlockSize?: boolean, - ): void; - process(buffer: Float32Array): void; - getContext(): VadContext; - terminateSession(): void; -} - -interface VadConstructor { - new (model: Model, licenseKey: string): VadInstance; -} - -export const Model: ModelConstructor = NativeModel; -export const Processor: ProcessorConstructor = NativeProcessor; -export const Vad: VadConstructor = NativeVad; -export function analyzerPair( - model: Model, - licenseKey: string, -): { collector: CollectorInstance; analyzer: AnalyzerInstance } { - return nativeAnalyzerPair(model, licenseKey); -} export const setSdkId: (id: number) => void = nativeSetSdkId; diff --git a/node/src/vad.ts b/node/src/vad.ts index ad182a9..5ce3c14 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) { @@ -194,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, + ); + } } } @@ -273,7 +286,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..82cba09 100644 --- a/node/test/analyzer.test.ts +++ b/node/test/analyzer.test.ts @@ -31,9 +31,15 @@ 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; + gate: Promise | null = null; initialize(sampleRate: number, blockSize: number, variable: boolean): void { this.initializations.push([sampleRate, blockSize, variable]); @@ -42,17 +48,25 @@ 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; + analyze() { + this.analyzeCalls += 1; + if (this.error) throw this.error; + return { + riskScore: 0.1, + speakerReverb: 0.2, + speakerLoudness: 0.3, + interferingSpeech: 0.4, + noise: 0.5, + codecDegradation: 0.6, + packetLoss: 0.7, + }; + } - analyzeBuffered() { + async analyzeAsync() { this.analyzeCalls += 1; if (this.error) throw this.error; + if (this.gate) await this.gate; return { riskScore: 0.1, speakerReverb: 0.2, @@ -72,13 +86,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 +108,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 +158,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; @@ -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", @@ -164,8 +180,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, @@ -174,11 +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", @@ -193,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([]); @@ -220,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", @@ -233,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", @@ -267,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", @@ -283,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([ { @@ -304,22 +321,128 @@ 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("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, + 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/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..dfe4dd3 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 () => { @@ -257,7 +257,7 @@ describeIf("native Analyzer integration", () => { true, ); } finally { - analyzer.close(); + await analyzer.close(); } }, 120_000); }); diff --git a/node/test/processor.test.ts b/node/test/processor.test.ts index 1ef0eb2..5504e62 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 { @@ -100,6 +102,7 @@ const sdk = vi.hoisted(() => { readonly blocks: number[][] = []; error: Error | null = null; terminateCalls = 0; + disposeCalls = 0; constructor() { if (FakeProcessor.constructorError) { @@ -126,6 +129,10 @@ const sdk = vi.hoisted(() => { terminateSession(): void { this.terminateCalls += 1; } + + dispose(): void { + this.disposeCalls += 1; + } } return { FakeModel, FakeProcessor, instances, nativeCalls }; @@ -442,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", @@ -516,8 +524,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..333855b 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 { @@ -87,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; @@ -115,6 +118,10 @@ const sdk = vi.hoisted(() => { terminateSession(): void { this.terminateCalls += 1; } + + dispose(): void { + this.disposeCalls += 1; + } } class FakeProcessor {} @@ -214,6 +221,7 @@ describe("VAD", () => { await vad.close(); expect(native.terminateCalls).toBe(1); + expect(native.disposeCalls).toBe(1); }); it("wraps native construction errors", () => { 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/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]], 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]]