diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..7e766c3 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] + +[target.i686-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..866eb17 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/index.js linguist-generated=true +/index.d.ts linguist-generated=true diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..e3e5dc3 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended", "group:allNonMajor", ":preserveSemverRanges", ":disablePeerDependencies"], + "labels": ["dependencies"], + "packageRules": [ + { + "matchPackageNames": ["@napi-rs/cli", "napi", "napi-build", "napi-derive"], + "addLabels": ["napi-rs"], + "groupName": "napi-rs" + }, + { + "matchPackageNames": ["oxlint", "prettier", "@taplo/cli"], + "groupName": "lint and format" + } + ], + "commitMessagePrefix": "chore: ", + "commitMessageAction": "bump up", + "commitMessageTopic": "{{depName}} version", + "ignoreDeps": [] +} diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..0c35a9c --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,403 @@ +name: CI +env: + DEBUG: napi:* + APP_NAME: aic-sdk + MACOSX_DEPLOYMENT_TARGET: '10.13' + CARGO_INCREMENTAL: '1' +permissions: + contents: write + id-token: write +'on': + push: + branches: + - main + tags-ignore: + - '**' + paths-ignore: + - '**/*.md' + - LICENSE + - '**/*.gitignore' + - .editorconfig + - docs/** + pull_request: null +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: setup pnpm + uses: pnpm/action-setup@v6 + - name: Setup node + uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + - name: Install + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - name: Install dependencies + run: pnpm install + - name: Oxlint + run: pnpm lint + - name: Prettier + run: pnpm format:prettier:check + - name: Typecheck + run: pnpm typecheck + - name: Cargo fmt + run: cargo fmt -- --check + - name: Clippy + run: cargo clippy --locked + env: + RUSTFLAGS: -D warnings + - name: TOML fmt + uses: uncenter/setup-taplo@09968a8ae38d66ddd3d23802c44bf6122d7aa991 + - run: taplo fmt --check + - name: Documentation + run: cargo doc --locked --document-private-items --all-features --workspace + env: + RUSTDOCFLAGS: -D warnings + - name: Unused dependencies + uses: taiki-e/install-action@c44f6b046f1c29ae5918b1e0bfdbb2f1813836fd + with: + tool: cargo-shear + - run: cargo shear + build: + strategy: + fail-fast: false + matrix: + # The native SDK publishes prebuilt libraries for these six targets only, so this + # list must stay in sync with `napi.targets` in package.json. + settings: + - host: macos-latest + target: x86_64-apple-darwin + build: pnpm build --target x86_64-apple-darwin + - host: macos-latest + target: aarch64-apple-darwin + build: pnpm build --target aarch64-apple-darwin + # Both Linux targets build inside the napi-rs Debian images rather than with + # `--use-napi-cross`. That flag's sysroot is too thin for this dependency tree: + # `aic-sdk-sys` runs bindgen over a header that includes , and `ring` + # assembles hand-written ARM asm. These images carry a complete sysroot, gcc and + # clang, and their older glibc keeps the published binaries widely usable. + # + # Both images are amd64 (the aarch64 one bundles a cross toolchain), so the + # host-installed node_modules stay compatible inside the container. + - host: ubuntu-latest + target: x86_64-unknown-linux-gnu + docker: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian + build: npm run build -- --target x86_64-unknown-linux-gnu + - host: ubuntu-latest + target: aarch64-unknown-linux-gnu + docker: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64 + build: npm run build -- --target aarch64-unknown-linux-gnu + - host: windows-latest + target: x86_64-pc-windows-msvc + build: pnpm build --target x86_64-pc-windows-msvc + - host: windows-latest + target: aarch64-pc-windows-msvc + build: pnpm build --target aarch64-pc-windows-msvc + name: stable - ${{ matrix.settings.target }} - node@22 + runs-on: ${{ matrix.settings.host }} + steps: + - uses: actions/checkout@v7 + - name: setup pnpm + uses: pnpm/action-setup@v6 + - name: Setup node + uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + - name: Install + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + targets: ${{ matrix.settings.target }} + - name: Cache cargo + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ~/.napi-rs + .cargo-cache + target/ + key: ${{ matrix.settings.target }}-cargo-${{ matrix.settings.host }} + - name: Setup toolchain + run: ${{ matrix.settings.setup }} + if: ${{ matrix.settings.setup }} + shell: bash + # Installed on the host so the container does not need pnpm; the workspace is mounted + # into it, so `npm run build` finds the napi CLI in node_modules. + - name: Install dependencies + run: pnpm install + # The build downloads the matching prebuilt native SDK for the target, so these steps + # need network access. + # The image ships Rust 1.82, which predates edition 2024. That edition is required + # by this crate and by `aic-sdk` and `aic-sdk-sys` themselves, so lowering our own + # would not help. The toolchain is updated in the container before building. + # + # The workspace is mounted at its own path rather than somewhere like /build, so the + # absolute paths in pnpm's node_modules symlinks still resolve. + - name: Build in docker + if: ${{ matrix.settings.docker }} + shell: bash + run: | + docker run --rm \ + -v "$GITHUB_WORKSPACE":"$GITHUB_WORKSPACE" \ + -w "$GITHUB_WORKSPACE" \ + -e CARGO_TERM_COLOR \ + -e DEBUG \ + "${{ matrix.settings.docker }}" \ + bash -c 'rustup update stable && rustup default stable && rustup target add ${{ matrix.settings.target }} && ${{ matrix.settings.build }}' + - name: Build + run: ${{ matrix.settings.build }} + if: ${{ !matrix.settings.docker }} + shell: bash + # `index.js` and `index.d.ts` are generated by the build but committed, so a change to + # a Rust signature or doc comment can land without them being regenerated, and the + # package then ships type declarations that do not describe the addon. One target is + # enough: both files are generated from `napi.targets` in package.json rather than from + # the target being built. + - name: Check the generated bindings are committed + if: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' }} + shell: bash + run: | + if ! git diff --exit-code -- index.js index.d.ts; then + echo '::error::index.js and index.d.ts are stale. Run `pnpm build` and commit the result.' + exit 1 + fi + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: bindings-${{ matrix.settings.target }} + path: '*.node' + if-no-files-found: error + test-macOS-windows-binding: + name: Test bindings on ${{ matrix.settings.target }} - node@${{ matrix.node }} + needs: + - build + strategy: + fail-fast: false + matrix: + settings: + - host: windows-latest + target: x86_64-pc-windows-msvc + architecture: x64 + - host: macos-latest + target: x86_64-apple-darwin + architecture: x64 + - host: macos-latest + target: aarch64-apple-darwin + architecture: arm64 + node: + - '22' + runs-on: ${{ matrix.settings.host }} + steps: + - uses: actions/checkout@v7 + - name: setup pnpm + uses: pnpm/action-setup@v6 + - name: Setup node + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + cache: pnpm + architecture: ${{ matrix.settings.architecture }} + - name: Install dependencies + run: pnpm install + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + name: bindings-${{ matrix.settings.target }} + path: . + - name: List packages + run: ls -R . + shell: bash + # `pretest` downloads the model fixtures, and the SDK needs a license key to + # construct a processor, VAD or analyzer. + - name: Test bindings + run: pnpm test + env: + AIC_SDK_LICENSE: ${{ secrets.AIC_SDK_LICENSE }} + test-linux-binding: + name: Test ${{ matrix.target }} - node@${{ matrix.node }} + needs: + - build + strategy: + fail-fast: false + matrix: + target: + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-gnu + node: + - '22' + runs-on: ${{ contains(matrix.target, 'aarch64') && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@v7 + - name: setup pnpm + uses: pnpm/action-setup@v6 + - name: Setup node + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + cache: pnpm + - name: Output docker params + id: docker + run: | + node -e " + if ('${{ matrix.target }}'.startsWith('aarch64')) { + console.log('PLATFORM=linux/arm64') + } else { + console.log('PLATFORM=linux/amd64') + } + " >> $GITHUB_OUTPUT + echo "IMAGE=node:${{ matrix.node }}-slim" >> $GITHUB_OUTPUT + echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT + - name: Install dependencies + run: pnpm install --force + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + name: bindings-${{ matrix.target }} + path: . + - name: List packages + run: ls -R . + shell: bash + - name: Test bindings + uses: tj-actions/docker-run@v2 + env: + AIC_SDK_LICENSE: ${{ secrets.AIC_SDK_LICENSE }} + with: + image: ${{ steps.docker.outputs.IMAGE }} + name: test-binding + options: '-v ${{ steps.docker.outputs.PNPM_STORE_PATH }}:${{ steps.docker.outputs.PNPM_STORE_PATH }} -v ${{ github.workspace }}:${{ github.workspace }} -w ${{ github.workspace }} --platform ${{ steps.docker.outputs.PLATFORM }} -e AIC_SDK_LICENSE' + args: npm run test + # Runs every example end to end. The lint job typechecks them against `index.d.ts`, which + # catches a renamed method, but only running them catches a retired model id, a change in + # SDK behaviour, or an example whose own logic has gone wrong. + run-examples: + name: Run examples - node@22 + needs: + - build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: setup pnpm + uses: pnpm/action-setup@v6 + - name: Setup node + uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + - name: Install dependencies + run: pnpm install + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + name: bindings-x86_64-unknown-linux-gnu + path: . + # Pull requests from forks cannot read secrets, and the examples cannot construct + # anything without a license key. Rather than fail those runs, the steps below are + # skipped and the job reports why. + - name: Check for a license key + id: license + env: + AIC_SDK_LICENSE: ${{ secrets.AIC_SDK_LICENSE }} + run: | + if [ -n "$AIC_SDK_LICENSE" ]; then + echo 'available=true' >> "$GITHUB_OUTPUT" + else + echo 'available=false' >> "$GITHUB_OUTPUT" + echo '::warning::AIC_SDK_LICENSE is unavailable, so the examples were not run. This is expected for pull requests from forks.' + fi + # `file-processing.js` needs real audio. A second of a 220 Hz tone is enough to + # exercise the read, block loop, delay compensation and write. + - name: Synthesize an input file + if: steps.license.outputs.available == 'true' + run: | + node - <<'EOF' + const fs = require('node:fs') + const { WaveFile } = require('wavefile') + + const sampleRate = 16000 + const samples = Float32Array.from( + { length: sampleRate }, + (_, i) => Math.sin((2 * Math.PI * 220 * i) / sampleRate) * 0.3, + ) + + const wav = new WaveFile() + wav.fromScratch(1, sampleRate, '32f', samples) + fs.writeFileSync('example-input.wav', wav.toBuffer()) + EOF + # One example per line so a failure names itself. The models are downloaded into + # ./models on first use and reused by the later examples, so this costs three + # downloads rather than six. + - name: Run examples + if: steps.license.outputs.available == 'true' + env: + AIC_SDK_LICENSE: ${{ secrets.AIC_SDK_LICENSE }} + run: | + set -euxo pipefail + node examples/enhancement.js + node examples/enhancement-async.js + node examples/vad.js + node examples/vad-async.js + node examples/analysis.js + node examples/file-processing.js --input example-input.wav + # The example writes alongside its input, so a missing file means the run silently + # produced nothing. + - name: Check the enhanced file was written + if: steps.license.outputs.available == 'true' + run: test -s example-input_enhanced.wav + publish: + name: Publish + runs-on: ubuntu-latest + needs: + - lint + - test-macOS-windows-binding + - test-linux-binding + - run-examples + steps: + - uses: actions/checkout@v7 + - name: setup pnpm + uses: pnpm/action-setup@v6 + - name: Setup node + uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + - name: Install dependencies + run: pnpm install + - name: Download all artifacts + uses: actions/download-artifact@v8 + with: + path: artifacts + - name: create npm dirs + run: pnpm napi create-npm-dirs + - name: Move artifacts + run: pnpm artifacts + - name: List packages + run: ls -R ./npm + shell: bash + - name: Publish + run: | + npm config set provenance true + if git log -1 --pretty=%B | grep "^v\?[0-9]\+\.[0-9]\+\.[0-9]\+$"; + then + echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc + npm publish --access public + elif git log -1 --pretty=%B | grep "^v\?[0-9]\+\.[0-9]\+\.[0-9]\+"; + then + echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc + npm publish --tag next --access public + else + echo "Not a release, skipping publish" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index c71388a..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,257 +0,0 @@ -name: Build and Publish - -on: - push: - tags: - - "*.*.*" - branches: - - main - pull_request: - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -jobs: - build: - strategy: - fail-fast: false - matrix: - include: - # Linux x64 - - os: ubuntu-22.04 - target: x86_64-unknown-linux-gnu - platform: linux-x64-gnu - # Linux ARM64 - - os: ubuntu-22.04 - target: aarch64-unknown-linux-gnu - platform: linux-arm64-gnu - # macOS x64 - - os: macos-latest - target: x86_64-apple-darwin - platform: darwin-x64 - # macOS ARM64 - - os: macos-latest - target: aarch64-apple-darwin - platform: darwin-arm64 - # Windows x64 - - os: windows-latest - target: x86_64-pc-windows-msvc - platform: win32-x64-msvc - # Windows ARM64 - - os: windows-latest - target: aarch64-pc-windows-msvc - platform: win32-arm64-msvc - - runs-on: ${{ matrix.os }} - name: Build ${{ matrix.platform }} - - steps: - - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: "22" - - - name: Install Rust - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c - with: - toolchain: stable - targets: ${{ matrix.target }} - - - name: Install cross-compilation tools (Linux ARM64) - if: matrix.platform == 'linux-arm64-gnu' - run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu - echo "[target.aarch64-unknown-linux-gnu]" >> ~/.cargo/config.toml - echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml - - # Builds and places the binary in one step. The script asks cargo for the artifact path - # instead of reconstructing the per-platform file name, so `npm run build` can share it. - - name: Build native module - shell: bash - run: bash scripts/build-target.sh npm/${{ matrix.platform }}/index.node ${{ matrix.target }} - - - name: Upload platform binary - uses: actions/upload-artifact@v7 - with: - name: ${{ matrix.platform }} - path: npm/${{ matrix.platform }}/index.node - retention-days: 1 - - test: - needs: build - runs-on: ubuntu-latest - if: "!startsWith(github.ref, 'refs/tags/')" - - steps: - - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: "22" - - - name: Download Linux x64 binary - uses: actions/download-artifact@v8 - with: - name: linux-x64-gnu - path: . - - - name: Install dependencies - run: npm install - - - name: Run tests and examples - run: | - npm test - node examples/enhancement.js - node examples/vad.js - node examples/file-processing.js --input ./tests/data/test_signal.wav --output ./tmp.wav - env: - AIC_SDK_LICENSE: ${{ secrets.AIC_SDK_LICENSE }} - - publish: - needs: build - runs-on: ubuntu-latest - # Only publish when pushing a version tag - if: startsWith(github.ref, 'refs/tags/') - - # Required for OIDC trusted publishing - permissions: - id-token: write - contents: read - - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - # Ensure tagged releases only happen from main branch - - name: Verify tag is on main branch - run: | - if ! git branch -r --contains ${{ github.ref }} | grep -q 'origin/main'; then - echo "Error: Tag ${{ github.ref }} is not on the main branch" - exit 1 - fi - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: "22" - - - name: Install latest npm for OIDC support - run: npm install -g npm@latest - - # Download each binary by name directly into its platform package. - # A named download does a direct lookup (not a list-all), so it fails - # loudly if an artifact is missing instead of silently downloading a - # partial set, which is how a previous release half-published. - - name: Download linux-x64-gnu binary - uses: actions/download-artifact@v8 - with: - name: linux-x64-gnu - path: npm/linux-x64-gnu - - - name: Download linux-arm64-gnu binary - uses: actions/download-artifact@v8 - with: - name: linux-arm64-gnu - path: npm/linux-arm64-gnu - - - name: Download darwin-x64 binary - uses: actions/download-artifact@v8 - with: - name: darwin-x64 - path: npm/darwin-x64 - - - name: Download darwin-arm64 binary - uses: actions/download-artifact@v8 - with: - name: darwin-arm64 - path: npm/darwin-arm64 - - - name: Download win32-x64-msvc binary - uses: actions/download-artifact@v8 - with: - name: win32-x64-msvc - path: npm/win32-x64-msvc - - - name: Download win32-arm64-msvc binary - uses: actions/download-artifact@v8 - with: - name: win32-arm64-msvc - path: npm/win32-arm64-msvc - - - name: Verify all platform binaries are present - run: | - missing=0 - for platform in linux-x64-gnu linux-arm64-gnu darwin-x64 darwin-arm64 win32-x64-msvc win32-arm64-msvc; do - if [[ ! -f "npm/$platform/index.node" ]]; then - echo "Error: npm/$platform/index.node is missing" - missing=1 - fi - done - if [[ "$missing" -ne 0 ]]; then - echo "One or more platform binaries are missing; aborting before publish." - exit 1 - fi - - - name: Publish platform packages - run: | - for platform in linux-x64-gnu linux-arm64-gnu darwin-x64 darwin-arm64 win32-x64-msvc win32-arm64-msvc; do - cd npm/$platform - name=$(node -p "require('./package.json').name") - version=$(node -p "require('./package.json').version") - if npm view "$name@$version" version >/dev/null 2>&1; then - echo "Skipping $name@$version, already published" - else - npm publish --access public --ignore-scripts - fi - cd ../.. - done - - - name: Publish main package - run: | - npm pkg delete scripts.prepublishOnly - name=$(node -p "require('./package.json').name") - version=$(node -p "require('./package.json').version") - if npm view "$name@$version" version >/dev/null 2>&1; then - echo "Skipping $name@$version, already published" - else - npm publish --access public - fi - - github-release: - needs: publish - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') - - permissions: - contents: write - - steps: - - uses: actions/checkout@v7 - - # CHANGELOG.md sections are "## x.y.z - YYYY-MM-DD"; extract the body of the section - # matching the pushed tag and use it verbatim as the release body. - - name: Extract changelog section - run: | - TAG="${{ github.ref_name }}" - awk -v ver="$TAG" ' - $0 ~ "^## " ver " - " { flag=1; next } - flag && /^## / { flag=0 } - flag { print } - ' CHANGELOG.md > release-notes.md - - if [ ! -s release-notes.md ]; then - echo "No CHANGELOG.md section found for tag $TAG" >&2 - exit 1 - fi - - - name: Create GitHub Release - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 - with: - body_path: release-notes.md diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml deleted file mode 100644 index 4642056..0000000 --- a/.github/workflows/quality.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: Quality Checks - -on: - push: - branches: [main] - pull_request: - merge_group: - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - CARGO_TERM_COLOR: always - -jobs: - cargo-fmt: - name: Code Format - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c - with: - toolchain: stable - components: rustfmt - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 - - run: cargo fmt --check - - taplo-fmt: - name: TOML Format - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: uncenter/setup-taplo@09968a8ae38d66ddd3d23802c44bf6122d7aa991 - - run: taplo fmt --check - - clippy: - name: Clippy - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c - with: - toolchain: stable - components: clippy - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 - - run: RUSTFLAGS="-D warnings" cargo clippy --locked - - doc: - name: Documentation - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c - with: - toolchain: stable - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 - - run: RUSTDOCFLAGS="-D warnings" cargo doc --locked --document-private-items --all-features --workspace - - unused-dependencies: - name: Unused Dependencies - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v7 - - name: Install Cargo Shear - uses: taiki-e/install-action@c44f6b046f1c29ae5918b1e0bfdbb2f1813836fd - with: - tool: cargo-shear - - name: Run Cargo Shear - run: | - cargo shear - - msrv: - name: MSRV Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c - with: - toolchain: 1.88.0 - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 - - run: cargo check --all-targets --locked diff --git a/.gitignore b/.gitignore index 6e4f5bc..778b085 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,210 @@ -target -index.node -**/node_modules -**/.DS_Store +### Created by https://www.gitignore.io +### Rust ### +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Cargo.lock is deliberately committed, contrary to the template default below. This crate +# is a cdylib published as prebuilt binaries, an artifact rather than a library other +# crates compile against, so its builds should be reproducible. Leaving it out meant every +# CI run re-resolved the whole Rust graph from crates.io, so an upstream release could +# break the build with no change to this repository. +# +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +### Created by https://www.gitignore.io +### Node ### +# Logs +logs +*.log npm-debug.log* -.claude +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' *.tgz -npm/*/index.node + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +*.node +*.wasm + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache +.stylelintcache + +# SvelteKit build / generate output +.svelte-kit + +### Created by https://www.gitignore.io +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +# Test model fixtures, downloaded by scripts/fetch-test-models.mjs +__test__/data/ + +# Models the examples download on first run, and the files they write +models/ +*_enhanced.wav + +# Input synthesized by the "Run examples" CI job +example-input.wav diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f0e933e --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +target +.yarn +/*.js +/*.mjs +/*.cjs +/index.js +index.d.ts +pnpm-lock.yaml \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0886073..c8355b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,95 @@ # Changelog +## 0.24.0 - 2026-09-07 + +Rewritten on [napi-rs](https://napi.rs), on top of the public `aic-sdk` Rust crate. + +The previous release was a raw native addon exporting low-level primitives, wrapped in a +hand-written JavaScript ergonomics layer and documented only with JSDoc. That layer is gone: +the binding and its type declarations are now generated from annotated Rust. + +### Added + +- **TypeScript declarations.** `index.d.ts` ships with the package, with doc comments on + every class, method and enum member. Previous releases published no types. +- **`Analyzer.analyzeAsync`**, running the analysis model on a worker thread. The SDK's + analysis models are too expensive for an audio thread, and Node cannot move the analyzer + into a worker the way the Rust SDK's collector/analyzer split allows, so this is how that + capability is reached here. There is no `AnalyzerAsync` class: only this one call moves + off-thread, and `buffer` stays synchronous and lock-free so audio can keep arriving while + an analysis is in flight. +- **`ProcessorAsync` and `VadAsync`**, mirroring the Rust SDK. Each call returns a promise + and runs on Node's libuv thread pool, keeping enhancement and detection off the event + loop. `process` copies its input and resolves to the samples rather than writing in place, + so the caller's array stays valid while the promise is pending. Parallelism is across + instances: give each stream its own, and raise `UV_THREADPOOL_SIZE` to run more than four + at once. +- `Model.download` is asynchronous and resolves to the model path, so a cold download no + longer blocks the event loop. +- **`dispose()` on every class holding native resources** (`Model`, `Processor`, + `ProcessorAsync`, `Vad`, `VadAsync`, `Analyzer`), destroying the native object + immediately instead of waiting for garbage collection. Every later method throws and a + repeat `dispose()` does nothing; where work can still be in flight (the async classes, + or an `Analyzer` mid-`analyzeAsync`) it blocks until that work finishes. + `Model.dispose()` unmaps the model file while objects created from it keep working. + `ProcessorContext` and `VadContext` handles stay valid after their object is disposed; + calls on them just no longer reach a live one. +- **Native footprints are reported to V8's garbage collector** (`napi_adjust_external_memory`). + These classes hold large native allocations behind small JavaScript objects; without the + report the collector felt no pressure to reclaim dropped instances, so native memory + grew unbounded. +- **SDK-internal error reporting.** The SDK reports its own backend failures to ai-coustics error + tracking. Covered are failed session activations, failed usage reports, and bearer token refreshes + rejected by `ProcessorContext.updateBearerToken`, `VadContext.updateBearerToken` or + `Analyzer.updateBearerToken`. + + A report contains the error class and message, the SDK version and wrapper, the model ID, the + operating system, the CPU architecture, and the account the license was issued to. It contains no + audio, no license key and no bearer token. + + Disable reporting with `DO_NOT_TRACK=1`. The variable is read once per process. Licenses with an + offline entitlement never report. + +### Changed + +The API was modernized: + +| 0.23 | 0.24 | +| -------------------------------- | ----------------------------------- | +| `Model.download(...)` (blocking) | `await Model.download(...)` | +| `analyzerPair(model, key)` | `new Analyzer(model, key)` | +| `collector.buffer(...)` | `analyzer.buffer(...)` | +| `OtelConfig.enabled()` | `{ enable: true }` | +| `VadContext.rawVadProbability()` | `VadContext.getRawVadProbability()` | + +- `analyzerPair()` and the separate `Collector` are replaced by a single `Analyzer` class + with `buffer()` and `analyze()`. The SDK separates collection from analysis so the + halves can live on different threads; that does not apply in Node, where an instance cannot + cross into another worker. +- `OtelConfig` is a plain object (`{ enable, sessionId?, exportIntervalMs? }`) rather than a + class with static factories, still passed as the optional third constructor argument. +- `ProcessorParameter` and `VadParameter` are real enums with stable numeric values + (`Bypass = 0`, `EnhancementLevel = 1`; `SpeechHoldDuration = 0`, `Sensitivity = 1`, + `MinimumSpeechDuration = 2`). +- Minimum supported Node version is 18. + +### Removed + +- `FileAnalyzer`. Its convenience windowing is not yet reimplemented on the new binding; + window over `Analyzer` directly in the meantime. + ## 0.23.1 - 2026-09-07 -`Model.download()` no longer fetches the artifact manifest on every call. A cached manifest is now stored next to the models .manifest-cache.json and serves every model until the validity window returned by the artifact server expires. +### Improvements + +- `Model.download()` no longer fetches the artifact manifest on every call. A cached manifest is now + stored next to the models as `.manifest-cache.json` and serves every model until the validity + window returned by the artifact server expires. + +### Bug Fixes -Manifest requests now give up after 30 seconds. They previously had no timeout and could hang for as long as the operating system kept retrying the connection. +- Manifest requests now give up after 30 seconds. They previously had no timeout and could hang for + as long as the operating system kept retrying the connection. ## 0.23.0 - 2026-08-11 @@ -19,13 +104,13 @@ with a "model version is not supported" error. See the [compatibility matrix](https://docs.ai-coustics.com/reference/sdk/compatibility-matrix). ```javascript -const { Model, getCompatibleModelVersion } = require("@ai-coustics/aic-sdk"); +const { Model, getCompatibleModelVersion } = require('@ai-coustics/aic-sdk') // Reports the model file version this SDK build expects. -console.log(getCompatibleModelVersion()); // 7 +console.log(getCompatibleModelVersion()) // 7 // Re-download the models your integration uses. -const modelPath = Model.download("quail-vf-2.2-s-16khz", "./models"); +const modelPath = Model.download('quail-vf-2.2-s-16khz', './models') ``` ### New Features @@ -38,13 +123,13 @@ this SDK version any more. Before: ```javascript -const modelPath = Model.download("tyto-l-16khz", "./models"); +const modelPath = Model.download('tyto-l-16khz', './models') ``` After: ```javascript -const modelPath = Model.download("tyto-1.1-l-16khz", "./models"); +const modelPath = Model.download('tyto-1.1-l-16khz', './models') ``` #### Analysis result fields changed @@ -103,24 +188,24 @@ Before: ```javascript // Stereo in, stereo out. The SDK mixed both channels down to mono internally. -const processor = new Processor(model, licenseKey); -processor.initialize(sampleRate, 2, numFrames, false); -processor.processPlanar([left, right]); +const processor = new Processor(model, licenseKey) +processor.initialize(sampleRate, 2, numFrames, false) +processor.processPlanar([left, right]) ``` After: ```javascript -const processor = new Processor(model, licenseKey); -processor.initialize(sampleRate, blockSize, false); +const processor = new Processor(model, licenseKey) +processor.initialize(sampleRate, blockSize, false) // Downmix to mono yourself, then process a single buffer in place. -const mono = new Float32Array(blockSize); +const mono = new Float32Array(blockSize) for (let i = 0; i < blockSize; i++) { - mono[i] = 0.5 * (left[i] + right[i]); + mono[i] = 0.5 * (left[i] + right[i]) } -processor.process(mono); +processor.process(mono) ``` If you need per-channel output instead of a downmix, create one processor per channel and call @@ -152,35 +237,35 @@ Before: ```javascript // One model, one processor: enhancement and VAD were coupled. -const processor = new Processor(model, licenseKey); -processor.initialize(sampleRate, 1, numFrames, false); +const processor = new Processor(model, licenseKey) +processor.initialize(sampleRate, 1, numFrames, false) -const vadContext = processor.getVadContext(); -vadContext.setParameter(VadParameter.Sensitivity, 5.0); // energy threshold +const vadContext = processor.getVadContext() +vadContext.setParameter(VadParameter.Sensitivity, 5.0) // energy threshold // The VAD updated as a side effect of enhancement. -processor.processInterleaved(audio); +processor.processInterleaved(audio) -console.log("Speech detected:", vadContext.isSpeechDetected()); +console.log('Speech detected:', vadContext.isSpeechDetected()) ``` After: ```javascript // Load a dedicated VAD model and create a `Vad` from it. -const vadModel = Model.fromFile("path/to/vad_model.aicmodel"); +const vadModel = Model.fromFile('path/to/vad_model.aicmodel') // Throws if the model is not a VAD model. -const vad = new Vad(vadModel, licenseKey); -vad.initialize(sampleRate, blockSize, false); +const vad = new Vad(vadModel, licenseKey) +vad.initialize(sampleRate, blockSize, false) -const vadContext = vad.getContext(); -vadContext.setParameter(VadParameter.Sensitivity, 0.8); // probability +const vadContext = vad.getContext() +vadContext.setParameter(VadParameter.Sensitivity, 0.8) // probability // The VAD is driven explicitly and does not modify the audio. -vad.process(audio); +vad.process(audio) -console.log("Speech detected:", vadContext.isSpeechDetected()); +console.log('Speech detected:', vadContext.isSpeechDetected()) ``` ##### Run the VAD on the original audio @@ -191,8 +276,8 @@ them: ```javascript // Recommended: both see the same original input block. -vad.process(audio); // reads the block, does not modify it -processor.process(audio); // enhances the block in place +vad.process(audio) // reads the block, does not modify it +processor.process(audio) // enhances the block in place ``` `Vad.process()` leaves the buffer untouched, so calling it on the same buffer before @@ -210,15 +295,15 @@ queries are now named after what they actually report: - `ProcessorContext.getOutputDelay()` is now `ProcessorContext.getAudioDelay()`. It is an **audio** delay: the enhanced samples leave `Processor.process()` that many samples behind their input. - `VadContext.getPredictionDelay()` replaces the processor-driven delay query for the VAD. It is a - **prediction** delay and is *not* applied to the audio, since `Vad.process()` leaves the buffer + **prediction** delay and is _not_ applied to the audio, since `Vad.process()` leaves the buffer untouched. It tells you how far behind its own input the published prediction is, so you can line speech decisions up with the audio timeline. With both fed from the same input block, the two delays are independent of each other: ```javascript -const audioDelay = procCtx.getAudioDelay(); -const predictionDelay = vadContext.getPredictionDelay(); +const audioDelay = procCtx.getAudioDelay() +const predictionDelay = vadContext.getPredictionDelay() // The enhanced audio lags the input by `audioDelay`. // The VAD prediction lags the same input by `predictionDelay`. @@ -245,7 +330,7 @@ See [`examples/vad.js`](examples/vad.js) for a complete example. ```javascript // Ends the telemetry session without waiting for the object to be collected. - processor.terminateSession(); + processor.terminateSession() ``` ### Bug Fixes @@ -268,7 +353,7 @@ Reduced the necessary output delay of the `Processor` when using `allowVariableF ### New Features -This release includes several new APIs for running our newest audio intelligence model, *Tyto*. +This release includes several new APIs for running our newest audio intelligence model, _Tyto_. Analysis models score audio quality instead of enhancing it. Each result reports a headline `riskScore` alongside individual measures for speaker reverb, speaker loudness, interfering @@ -276,8 +361,9 @@ speech, media speech, noise and packet loss. The new APIs introduce two new concepts: the `Collector` and the `Analyzer`, created together with `analyzerPair`. - - The `Collector` is designed to be placed in the audio thread, buffering audio chunks for later analysis. - - The `Analyzer` is designed to be run separately. Analysis models are computationally expensive and cannot run in the audio thread. The analyzer has access to the audio buffered by the collector, and it can access it safely across threads. + +- The `Collector` is designed to be placed in the audio thread, buffering audio chunks for later analysis. +- The `Analyzer` is designed to be run separately. Analysis models are computationally expensive and cannot run in the audio thread. The analyzer has access to the audio buffered by the collector, and it can access it safely across threads. Initialize the `Collector` with the same configuration as your existing `Processor` and you can call the `collector.buffer*` methods in the same manner as the `processor.process*` methods. @@ -359,7 +445,7 @@ collector/analyzer pair yourself. ### Fixes -- Fixed an issue causing the VAD's state to be reset on every process* call. +- Fixed an issue causing the VAD's state to be reset on every process\* call. ## 0.13.0 - 2026-01-20 @@ -421,37 +507,37 @@ This release comes with a number of new features and several breaking changes. M ```javascript // Old (0.12) -const { Model, ModelType, EnhancementParameter, VadParameter } = require("@ai-coustics/aic-sdk"); +const { Model, ModelType, EnhancementParameter, VadParameter } = require('@ai-coustics/aic-sdk') -const model = new Model(ModelType.QuailL48, licenseKey); -model.initialize(48000, 1, 480, false); -model.setParameter(EnhancementParameter.EnhancementLevel, 0.8); -model.processInterleaved(audio); +const model = new Model(ModelType.QuailL48, licenseKey) +model.initialize(48000, 1, 480, false) +model.setParameter(EnhancementParameter.EnhancementLevel, 0.8) +model.processInterleaved(audio) -const vad = model.createVad(); -vad.setParameter(VadParameter.Sensitivity, 5.0); +const vad = model.createVad() +vad.setParameter(VadParameter.Sensitivity, 5.0) if (vad.isSpeechDetected()) { - console.log("Speech!"); + console.log('Speech!') } // New (0.13) -const { Model, Processor, ProcessorParameter, VadParameter } = require("@ai-coustics/aic-sdk"); +const { Model, Processor, ProcessorParameter, VadParameter } = require('@ai-coustics/aic-sdk') -const modelPath = Model.download("sparrow-l-48khz", "/tmp/models"); -const model = Model.fromFile(modelPath); +const modelPath = Model.download('sparrow-l-48khz', '/tmp/models') +const model = Model.fromFile(modelPath) // Or load directly: const model = Model.fromFile("/path/to/sparrow-l-48khz.aicmodel"); -const processor = new Processor(model, licenseKey); -processor.initialize(48000, 1, 480, false); +const processor = new Processor(model, licenseKey) +processor.initialize(48000, 1, 480, false) -const ctx = processor.getProcessorContext(); -ctx.setParameter(ProcessorParameter.EnhancementLevel, 0.8); -processor.processInterleaved(audio); +const ctx = processor.getProcessorContext() +ctx.setParameter(ProcessorParameter.EnhancementLevel, 0.8) +processor.processInterleaved(audio) -const vad = processor.getVadContext(); -vad.setParameter(VadParameter.Sensitivity, 5.0); +const vad = processor.getVadContext() +vad.setParameter(VadParameter.Sensitivity, 5.0) if (vad.isSpeechDetected()) { - console.log("Speech!"); + console.log('Speech!') } ``` @@ -484,17 +570,21 @@ if (vad.isSpeechDetected()) { ### New Features **Speech Enhancement & Models:** + - **Quail STT Model** (v0.10.0): New speech enhancement model optimized for human-to-machine interaction (voice agents, speech-to-text), operates at 16 kHz native sample rate with fixed enhancement parameters - **Voice Activity Detection (VAD)** (v0.9.0): Quail-based VAD that automatically calculates voice activity predictions from model output **Platform Support:** + - **Windows ARM64 support** (v0.9.1) **Licensing & Usage:** + - **Self-Service Licenses** (v0.8.0): Direct license access from development portal - **Usage-Based Telemetry** (v0.8.0): Collects processing time and diagnostic data (no audio content collected). Requires constant internet connection; offline licenses available on request **Processing Improvements:** + - **Variable Frame Processing** (v0.7.0): Support for variable number of frames per call (results in higher processing delay when enabled) - **Bypass Parameter** (v0.7.0): New parameter to bypass audio processing while preserving algorithmic delay for seamless transitions diff --git a/Cargo.lock b/Cargo.lock index c29d48a..9e01958 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ dependencies = [ [[package]] name = "aic-model-downloader" -version = "0.23.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03453b43c8506088b4c777021b67609a0d3beaed9092e43d6de6d18b2f8fc693" +checksum = "05a0a939f48d83cf9d1f11b3606df0914967e80b7dc9942a87db4d734847dee1" dependencies = [ "serde", "serde_json", @@ -33,28 +33,20 @@ dependencies = [ [[package]] name = "aic-sdk" -version = "0.23.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "722e5cde0ea57b74b13809bae34721b22f487a3e797cad168c094ff325d55ed2" +checksum = "41af1bb1ff01e573b683d6a7ae68af32a5c1efad4056019828c3e8d61f0695ee" dependencies = [ "aic-model-downloader", "aic-sdk-sys", "thiserror", ] -[[package]] -name = "aic-sdk-node" -version = "0.23.1" -dependencies = [ - "aic-sdk", - "neon", -] - [[package]] name = "aic-sdk-sys" -version = "0.23.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8287759ce2c94bd1da7848a639b15c3204fc7d4e0135534fd28c6363b9c1e7b1" +checksum = "bc9a207d4352a0d09a8e36c021675b6896ecd78b58b01d180c0841c6c84d7f96" dependencies = [ "bindgen", "flate2", @@ -66,6 +58,16 @@ dependencies = [ "zip", ] +[[package]] +name = "aic_sdk_node" +version = "0.24.0" +dependencies = [ + "aic-sdk", + "napi", + "napi-build", + "napi-derive", +] + [[package]] name = "base64" version = "0.23.1" @@ -121,9 +123,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.4.5" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "shlex 2.0.1", @@ -152,7 +154,16 @@ checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.9", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", ] [[package]] @@ -166,9 +177,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -183,6 +194,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + [[package]] name = "digest" version = "0.10.7" @@ -195,9 +212,9 @@ dependencies = [ [[package]] name = "either" -version = "1.18.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "equivalent" @@ -227,21 +244,109 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.12" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "flate2" -version = "1.1.10" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", "zlib-rs", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -293,9 +398,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "indexmap" -version = "2.14.2" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown", @@ -333,23 +438,13 @@ dependencies = [ ] [[package]] -name = "linkme" -version = "0.3.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3045e122bd98aef8ec3ad58ce84f0791f64e70163d1a02710af4aa11a4d54cc5" -dependencies = [ - "linkme-impl", -] - -[[package]] -name = "linkme-impl" -version = "0.3.37" +name = "libloading" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77060ebe535362c3da75682cd17b0431017b6e7c5661e714fc69a7ad017d1301" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", + "cfg-if", + "windows-link", ] [[package]] @@ -360,9 +455,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "log" -version = "0.4.34" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "memchr" @@ -378,42 +473,78 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.9.1" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", "simd-adler32", ] [[package]] -name = "neon" -version = "1.1.1" +name = "napi" +version = "3.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74c1d298c79e60a3f5a1e638ace1f9c1229d2a97bd3a9e40a63b67c8efa0f1e1" +checksum = "459197f1592f4c3dbbf9c1b13f5a4599a343e4ef66b96bc340e2a518b36a6662" dependencies = [ - "either", - "getrandom", - "libloading", - "linkme", - "neon-macros", - "once_cell", - "semver", - "send_wrapper", - "smallvec", + "bitflags", + "ctor", + "futures", + "libc", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", ] [[package]] -name = "neon-macros" -version = "1.1.1" +name = "napi-build" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39e43767817fc963f90f400600967a2b2403602c6440685d09a6bc4e02b70b1" +checksum = "60fdf9b392c50e7c4170fa633bd909490ed7835cea4c046776d1a4dd8d2ae0ab" + +[[package]] +name = "napi-derive" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa55ea69990c90b888e9e77044410e304ce7f35de599dc6d0b5c1923d2e59af" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "napi-derive-backend" +version = "6.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df4056ac7c18e4438ccf0edaed4340ca0d269278c8ec19284f7b23cb039fd0ae" dependencies = [ + "convert_case", "proc-macro2", "quote", + "semver", "syn 2.0.119", ] +[[package]] +name = "napi-sys" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a" +dependencies = [ + "libloading 0.9.0", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -534,9 +665,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.44" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "log", "once_cell", @@ -558,9 +689,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "ring", "rustls-pki-types", @@ -573,12 +704,6 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -[[package]] -name = "send_wrapper" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" - [[package]] name = "serde" version = "1.0.229" @@ -606,7 +731,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.3", ] [[package]] @@ -652,10 +777,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] -name = "smallvec" -version = "1.16.0" +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "subtle" @@ -676,9 +801,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.5" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -713,7 +838,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.3", ] [[package]] @@ -765,6 +890,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "untrusted" version = "0.9.0" @@ -773,9 +904,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.4.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" dependencies = [ "base64", "log", @@ -789,9 +920,9 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" dependencies = [ "base64", "http", diff --git a/Cargo.toml b/Cargo.toml index 37ce2ae..e065f7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,22 +1,24 @@ + [package] -authors = ["ai-coustics GmbH"] -description = "Node.js Bindings for ai-coustics SDK" edition = "2024" -exclude = ["index.node"] -license = "Apache-2.0" -name = "aic-sdk-node" -rust-version = "1.88" -version = "0.23.1" +name = "aic_sdk_node" +version = "0.24.0" [lib] crate-type = ["cdylib"] [dependencies] -aic-sdk = { version = "0.23.1", features = ["download-lib", "download-model"] } -neon = "1.1" +# `download-lib` fetches the matching prebuilt native library for $TARGET during the +# build, so each `napi build --target ` produces a self-contained `.node` +# with no runtime library discovery. `download-model` backs `Model.download`. +aic-sdk = { version = "0.24.0", features = ["download-lib", "download-model"] } +napi = { version = "3.0.0", default-features = false, features = ["napi4"] } +napi-derive = "3.0.0" + +[build-dependencies] +napi-build = "2" [profile.release] -codegen-units = 1 lto = true -opt-level = 3 -panic = "abort" +codegen-units = 1 +strip = "symbols" diff --git a/PUBLISHING.md b/PUBLISHING.md deleted file mode 100644 index f784da3..0000000 --- a/PUBLISHING.md +++ /dev/null @@ -1,11 +0,0 @@ -# Version Update and Publishing - -```bash -npm run version:update x.x.x -``` - -Also add a new `## x.x.x - YYYY-MM-DD` section to the top of [CHANGELOG.md](./CHANGELOG.md) -describing the release. Its heading must match the tag being pushed: GitHub Actions extracts -that section and uses it as the GitHub Release body. - -After merging the changes to main and creating a tag, GitHub Actions builds and publishes all packages automatically. diff --git a/README.md b/README.md index a40a312..2c8801a 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ -# aic-sdk - Node.js Bindings for ai-coustics SDK +# @ai-coustics/aic-sdk -Node.js wrapper for the ai-coustics SDK. +Node.js bindings for the ai-coustics SDK: speech enhancement, voice +activity detection and audio analysis. -For comprehensive documentation, visit [docs.ai-coustics.com](https://docs.ai-coustics.com). +For product documentation see [docs.ai-coustics.com](https://docs.ai-coustics.com). > [!NOTE] -> This SDK requires a license key. Generate your key at [developers.ai-coustics.com](https://developers.ai-coustics.com). +> This SDK requires a license key. Generate one at +> [developers.ai-coustics.com](https://developers.ai-coustics.com). ## Installation @@ -13,317 +15,313 @@ For comprehensive documentation, visit [docs.ai-coustics.com](https://docs.ai-co npm install @ai-coustics/aic-sdk ``` -## Quick Start +Prebuilt binaries are published for macOS (x64, arm64), Linux (x64, arm64, glibc) and +Windows (x64, arm64, MSVC). The native SDK is linked statically, so there is no separate +library to install or put on a search path. + +## Quick start ```javascript -const { Model, Processor } = require("@ai-coustics/aic-sdk"); +const { Model, Processor } = require('@ai-coustics/aic-sdk') -// Get your license key from the environment variable -const licenseKey = process.env.AIC_SDK_LICENSE; +async function main() { + // Download a model once, or fetch one manually from https://artifacts.ai-coustics.io + const modelPath = await Model.download('quail-vf-2.2-s-16khz', './models') + const model = Model.fromFile(modelPath) -// Download and load a model (or download manually at https://artifacts.ai-coustics.io/) -const modelPath = Model.download("quail-vf-2.2-s-16khz", "./models"); -const model = Model.fromFile(modelPath); + // Use the model's optimal configuration for the lowest delay + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) -// Get optimal configuration -const sampleRate = model.getOptimalSampleRate(); -const blockSize = model.getOptimalBlockSize(sampleRate); + const processor = new Processor(model, process.env.AIC_SDK_LICENSE) + processor.initialize(sampleRate, blockSize) -// Create and initialize processor -const processor = new Processor(model, licenseKey); -processor.initialize(sampleRate, blockSize, false); + // Enhance a mono block in place + const audio = new Float32Array(blockSize) + processor.process(audio) +} -// Process mono audio (Float32Array, modified in-place) -const audioBlock = new Float32Array(blockSize); -processor.process(audioBlock); +main() ``` -## Usage +Processing is mono. For multichannel audio, mix down to mono or use one processor per +channel. Initialize each instance before passing audio. Blocks must contain exactly +`blockSize` samples. Set the third `initialize` argument, `variableBlockSize`, to `true` +to allow shorter blocks; longer blocks are always rejected. This also applies to VAD +processing and analyzer buffering. -### SDK Information +The following snippets assume an enclosing `async` function. CommonJS files do not support +top-level `await`. -```javascript -const { getVersion, getCompatibleModelVersion } = require("@ai-coustics/aic-sdk"); +Runnable scripts for enhancement, VAD, analysis and whole-file processing, synchronous and +async, are in [`examples/`](examples). -// Get SDK version -console.log(`SDK version: ${getVersion()}`); +## Models -// Get compatible model version -console.log(`Compatible model version: ${getCompatibleModelVersion()}`); -``` +Available models and their IDs are listed at +[artifacts.ai-coustics.io](https://artifacts.ai-coustics.io). Each class accepts the model types +listed below and rejects other types: -### Loading Models +| Class | Accepted models | +| ----------------------------- | ------------------- | +| `Processor`, `ProcessorAsync` | enhancement, bypass | +| `Vad`, `VadAsync` | dedicated VAD | +| `Analyzer` | analysis | -Download models and find available IDs at [artifacts.ai-coustics.io](https://artifacts.ai-coustics.io/). +`Model.download` runs on Node's libuv thread pool and returns a promise for the model's path. +`Model.fromFile` memory-maps the file. Do not modify or delete it while the model or any +instance created from it is still alive. -#### From File -```javascript -const model = Model.fromFile("path/to/model.aicmodel"); -``` +## Enhancement -#### Download from CDN -```javascript -const modelPath = Model.download("quail-vf-2.2-s-16khz", "./models"); -const model = Model.fromFile(modelPath); -``` - -### Model Information +Parameters can be changed while audio is being processed, through a context handle: ```javascript -// Get model ID -const modelId = model.getId(); +const { ProcessorParameter } = require('@ai-coustics/aic-sdk') -// Get optimal sample rate for the model -const optimalRate = model.getOptimalSampleRate(); +const context = processor.getContext() -// Get optimal block size for a specific sample rate -const optimalBlockSize = model.getOptimalBlockSize(48000); -``` +context.setParameter(ProcessorParameter.EnhancementLevel, 0.8) +context.setParameter(ProcessorParameter.Bypass, 0) -### Configuring the Processor +console.log(context.getParameter(ProcessorParameter.EnhancementLevel)) -```javascript -// Create processor -const processor = new Processor(model, licenseKey); - -// Initialize with audio settings -processor.initialize( - sampleRate, // Sample rate in Hz (8000 - 192000) - blockSize, // Samples per processing call - variableBlockSize // Allow variable block sizes (default: false) -); +// Get the audio delay in samples to align the output with other streams +console.log(context.getAudioDelay()) + +// Clear internal state on a stream discontinuity or seek +context.reset() ``` -### OpenTelemetry Configuration +## Async processing -```javascript -const { Model, OtelConfig, Processor } = require("@ai-coustics/aic-sdk"); +`ProcessorAsync` and `VadAsync` run initialization and processing on Node's libuv thread pool. +Await these calls to keep the event loop available for other work, such as network requests. +Construction and disposal remain synchronous. -const licenseKey = process.env.AIC_SDK_LICENSE; -const model = Model.fromFile("path/to/model.aicmodel"); +Use `Processor` or `Vad` on a dedicated worker thread, or in a batch script that can block +the calling thread. These classes process each block without a promise or input copy. -// Override AIC_SDK_OTEL_ENABLE for this processor only. -// The same configuration can be passed to a Vad. -const otel = OtelConfig.withSessionId("session-1"); -const processor = new Processor(model, licenseKey, otel); +```javascript +const { Model, ProcessorAsync } = require('@ai-coustics/aic-sdk') -// Other options: -// const processor = new Processor(model, licenseKey, OtelConfig.enabled()); -// const processor = new Processor(model, licenseKey, OtelConfig.disabled()); +const processor = await new ProcessorAsync(model, process.env.AIC_SDK_LICENSE).withConfig(sampleRate, blockSize) -// Control how often metrics are exported. Set to 0 to keep the SDK default -// of 60000 ms. -const fast = OtelConfig.enabled(); -fast.exportIntervalMs = 5000; -const fastProcessor = new Processor(model, licenseKey, fast); +// The input remains unmodified; the promise resolves to the enhanced samples. +const audio = new Float32Array(blockSize) +const enhanced = await processor.process(audio) ``` -### Refreshing a JWT Bearer Token +Both async classes copy the input before queuing work and return a new `Float32Array`. +`ProcessorAsync.process` returns enhanced samples; `VadAsync.process` returns the original +samples. The caller's input remains unmodified. -When the processor was created with a JWT license, you can swap in a renewed -token while audio processing continues uninterrupted. If either the configured -key or the new token is not a JWT, an error is thrown and the existing token -stays in use. +Context creation is asynchronous. The returned context's methods are synchronous and can +be used while processing runs: ```javascript -const model = Model.fromFile("path/to/model.aicmodel"); -const processor = new Processor(model, jwtLicense); -const processorContext = processor.getContext(); - -processorContext.updateBearerToken(renewedJwt); +const context = await processor.getContext() +context.setParameter(ProcessorParameter.EnhancementLevel, 0.8) ``` -The same applies to `VadContext.updateBearerToken()` for a VAD created with a JWT license. +### Running several streams -### Processing Audio +Use one instance per stream and await each operation before submitting the next. Concurrent +calls on one instance are not guaranteed to execute in submission order. Separate instances +can process streams concurrently. ```javascript -// Mono audio (Float32Array), enhanced in-place -const audioBlock = new Float32Array(blockSize); -processor.process(audioBlock); -``` +const processors = await Promise.all( + streams.map(() => new ProcessorAsync(model, licenseKey).withConfig(sampleRate, blockSize)), +) -### Ending a Session +const enhanced = await Promise.all(processors.map((processor, i) => processor.process(blocks[i]))) +``` -Telemetry sessions end automatically when their processor, VAD, or analyzer is destroyed. Call -`terminateSession()` to end one at a specific lifecycle event. The instance cannot process or -analyze more audio afterwards. +Work runs on Node's libuv thread pool, which is four threads by default and shared with +`fs`, `dns` and `crypto`. To run more streams in parallel, raise `UV_THREADPOOL_SIZE` before +Node starts: -```javascript -processor.terminateSession(); -// vad.terminateSession(); -// analyzer.terminateSession(); +```bash +UV_THREADPOOL_SIZE=16 node server.js ``` -### Processor Context +`AIC_NUM_THREADS` does not apply to these bindings; async audio work uses libuv. -```javascript -const { ProcessorParameter } = require("@ai-coustics/aic-sdk"); +## Voice activity detection -// Get processor context -const procCtx = processor.getContext(); +VAD runs a dedicated VAD model and is driven independently of any processor. -// Get the delay applied to the audio in samples -const delay = procCtx.getAudioDelay(); +```javascript +const { Model, Vad, VadParameter } = require('@ai-coustics/aic-sdk') -// Reset processor state (clears internal state) -procCtx.reset(); +const vadModel = Model.fromFile(await Model.download('vad-2.1-xxs-16khz', './models')) +const vad = new Vad(vadModel, process.env.AIC_SDK_LICENSE) -// Set enhancement parameters -procCtx.setParameter(ProcessorParameter.EnhancementLevel, 0.8); -procCtx.setParameter(ProcessorParameter.Bypass, 0.0); +const sampleRate = vadModel.getOptimalSampleRate() +vad.initialize(sampleRate, vadModel.getOptimalBlockSize(sampleRate)) -// Get parameter values -const level = procCtx.getParameter(ProcessorParameter.EnhancementLevel); -console.log(`Enhancement level: ${level}`); -``` +const context = vad.getContext() +context.setParameter(VadParameter.Sensitivity, 0.8) -### Voice Activity Detection (VAD) +vad.process(block) // reads the block, does not modify it -Voice activity detection runs on its own `Vad` instance, created from a dedicated VAD model -(for example `vad-2.1-xxs-16khz`). Enhancement models are rejected. +if (context.isSpeechDetected()) { + console.log('speech') +} -```javascript -const { Model, Vad } = require("@ai-coustics/aic-sdk"); +// Raw model output, before speech-hold and thresholding +console.log(context.getRawVadProbability()) +``` -const vadModelPath = Model.download("vad-2.1-xxs-16khz", "./models"); -const vadModel = Model.fromFile(vadModelPath); -const sampleRate = vadModel.getOptimalSampleRate(); -const blockSize = vadModel.getOptimalBlockSize(sampleRate); +### Run the VAD on the original audio -const vad = new Vad(vadModel, licenseKey); -vad.initialize(sampleRate, blockSize, false); +When enhancement and detection run together, feed the VAD the **original** input, not the +processor's output. Enhancement is designed to change the signal, so detecting on its output +means running the VAD on audio that no longer matches what its model expects, and it stacks +the processor's delay on top of the prediction delay. Because `vad.process` leaves its input +untouched, run it before enhancement. Configure both instances with the same sample rate and +block size: -// Feed mono audio to the detector. The audio block is not modified. -const audioBlock = new Float32Array(blockSize); -vad.process(audioBlock); +```javascript +vad.process(block) // reads the block +processor.process(block) // enhances it in place ``` -When enhancement and VAD run together, feed the VAD the original input audio, not the processor's -enhanced output. Run both on the same block instead of chaining them: +The two delays describe different things and are independent: ```javascript -const audioBlock = new Float32Array(blockSize); - -vad.process(audioBlock); // reads the block, does not modify it -processor.process(audioBlock); // enhances the block in place +context.getAudioDelay() // enhanced audio lags the input by this many samples +vadContext.getPredictionDelay() // the VAD decision lags the same input by this many ``` -Enhancement is designed to change the signal, so running the VAD on its output means detecting -speech in audio that no longer matches what the VAD model expects, and it stacks the processor's -audio delay on top of the VAD's prediction delay. +The prediction delay is not applied to the audio. Use it to align speech decisions with +the input audio. -The VAD context provides thread-safe access to the prediction, the VAD parameters and its state. -You can create multiple contexts from one VAD. +## Analysis -```javascript -const { VadParameter } = require("@ai-coustics/aic-sdk"); +Analysis models score audio quality. Collect audio with `buffer`, then run the model with +`analyzeAsync` or `analyze`. -// Get VAD context from the VAD -const vadContext = vad.getContext(); - -// Configure VAD parameters. Sensitivity is the probability threshold of the model output. -vadContext.setParameter(VadParameter.Sensitivity, 0.5); -vadContext.setParameter(VadParameter.SpeechHoldDuration, 0.05); -vadContext.setParameter(VadParameter.MinimumSpeechDuration, 0.0); +```javascript +const { Model, Analyzer } = require('@ai-coustics/aic-sdk') -// Get parameter values -console.log(`VAD sensitivity: ${vadContext.getParameter(VadParameter.Sensitivity)}`); +const analysisModel = Model.fromFile(await Model.download('tyto-1.1-l-16khz', './models')) +const analyzer = new Analyzer(analysisModel, process.env.AIC_SDK_LICENSE) -// How many samples the prediction lags behind the input. This delay is not applied to the -// audio, Vad.process() leaves the buffer untouched. -console.log(`Prediction delay: ${vadContext.getPredictionDelay()} samples`); +const sampleRate = analysisModel.getOptimalSampleRate() +analyzer.initialize(sampleRate, analysisModel.getOptimalBlockSize(sampleRate)) -// Check for speech (after processing audio through the VAD) -console.log(`Speech detected: ${vadContext.isSpeechDetected()}`); -console.log(`Raw probability: ${vadContext.rawVadProbability()}`); +analyzer.buffer(block) -// Clear the prediction and all internal state, e.g. when the stream is interrupted -vadContext.reset(); +// Runs the model on a worker thread. `analyze()` does the same on the calling thread. +const result = await analyzer.analyzeAsync() +console.log(result.riskScore, result.noise, result.speakerReverb) ``` -### Audio Analysis - -Analysis models (for example `tyto-1.1-l-16khz`) score audio quality instead of enhancing it. Use -`FileAnalyzer` for complete audio files, or `analyzerPair` for streaming -analysis. +`buffer` is synchronous and does not acquire the analyzer lock. Audio collection can +continue while `analyzeAsync` runs on a worker thread. Use `analyze` when blocking the +calling thread is acceptable, such as in a CLI or dedicated worker. -#### FileAnalyzer - -```javascript -const { Model, FileAnalyzer } = require("@ai-coustics/aic-sdk"); +Analysis uses a fixed duration of audio determined by the model. Older samples are discarded +as new audio arrives. If insufficient audio has been collected, analysis pads the remaining +input with silence. -const licenseKey = process.env.AIC_SDK_LICENSE; -const modelPath = Model.download("tyto-1.1-l-16khz", "./models"); -const model = Model.fromFile(modelPath); +Calls to `analyze`, `reset`, `updateBearerToken`, `terminateSession` and `dispose` acquire the +analyzer lock and may block while async analysis is running. `initialize` only configures +the collector. -const analyzer = new FileAnalyzer(model, licenseKey); +All scores range from 0.0 to 1.0. For every field except `speakerLoudness`, lower values +indicate less problematic audio. `riskScore` predicts the likelihood of failure in downstream +models such as speech-to-text, VAD or turn-taking. -// Mono Float32 samples. No channel mixing or resampling is performed. -const sampleRate = 16000; -const audio = new Float32Array(sampleRate * 12); // 12 seconds +## Telemetry -// Analyze independent five-second windows. Pass a step in samples to control overlap, -// or omit it to step by the full window (no overlap). -const results = analyzer.analyze(audio, sampleRate, sampleRate * 5); +Telemetry follows the runtime environment (e.g. `AIC_SDK_OTEL_ENABLE`). To override it for a +single instance: -for (const result of results) { - console.log("Risk score:", result.riskScore); - console.log("Noise:", result.noise); - console.log("Packet loss:", result.packetLoss); -} +```javascript +const processor = new Processor(model, licenseKey, { + enable: true, + sessionId: 'my-session', + exportIntervalMs: 60000, +}) ``` -Each result is an object with the fields `riskScore`, `speakerReverb`, `speakerLoudness`, -`interferingSpeech`, `noise`, `codecDegradation` and `packetLoss`. All scores are in the range 0.0 -to 1.0. For every field except `speakerLoudness`, lower values indicate less problematic audio. +A telemetry session ends when its native object is destroyed. Use `terminateSession()` to +request termination at a specific lifecycle event. Once termination is handled, processors +and VADs can no longer process audio, and analyzers can no longer analyze buffered audio. +On `ProcessorAsync` and `VadAsync`, termination runs on a libuv worker thread and returns +a promise because it may block. -#### Collector and Analyzer pair +If your license key is a JWT, refresh it in place instead of rebuilding the object: ```javascript -const { Model, analyzerPair } = require("@ai-coustics/aic-sdk"); - -const model = Model.fromFile("path/to/tyto-1.1-l-16khz.aicmodel"); -const { collector, analyzer } = analyzerPair(model, licenseKey); +context.updateBearerToken(renewedJwt) +``` -const sampleRate = model.getOptimalSampleRate(); -const blockSize = model.getOptimalBlockSize(sampleRate); -collector.initialize(sampleRate, blockSize, false); +## Memory management -// Pass one mono audio block at a time (for example on an audio thread). -const audioBlock = new Float32Array(blockSize); -collector.buffer(audioBlock); +`Model`, `Processor`, `ProcessorAsync`, `Vad`, `VadAsync` and `Analyzer` hold large native +allocations behind small JavaScript objects. The binding reports estimated native memory +usage to V8 so the garbage collector can account for these allocations. This influences +collection frequency but does not guarantee when an object will be released. -// Analyze the collected audio off the audio thread. -const result = analyzer.analyzeBuffered(); -console.log("Risk score:", result.riskScore); +Use `dispose()` to release native resources at a specific point instead of waiting for +garbage collection: -// Clear state when the stream is interrupted or when seeking. -analyzer.reset(); +```javascript +const processor = new Processor(model, licenseKey) +try { + processor.initialize(sampleRate, blockSize) + processor.process(block) +} finally { + processor.dispose() +} ``` -## Examples +After `dispose()`, all methods except `dispose()` fail; repeated disposal has no effect. +Async methods reject their promises. Disposal blocks the calling thread if a worker holds +the native object's lock. Queued work that acquires the lock after disposal fails. -See the example files for complete working examples: +Disposing a model releases its reference to the model data. Processors, VADs and analyzers +created from it retain their own references and remain usable. -- [`examples/enhancement.js`](examples/enhancement.js) - Basic usage example -- [`examples/vad.js`](examples/vad.js) - Voice activity detection with a dedicated VAD model -- [`examples/analysis.js`](examples/analysis.js) - Audio analysis with `FileAnalyzer` and `analyzerPair` -- [`examples/file-processing.js`](examples/file-processing.js) - Enhance a WAV file block by block +Cleanup timing also affects native memory usage: -Run examples with: +- Native cleanup runs on the event loop when the object is finalized, not synchronously at + garbage collection. Finalizers run on event-loop turns, so batches that create many of + these objects back to back hold native memory until the loop turns. An `await` on an + already-resolved promise (a microtask) is not enough; real async boundaries such as I/O, + `setTimeout` or `setImmediate` are. A tight synchronous loop that creates thousands of + objects accumulates their native memory for the duration of the loop; create these + objects per unit of work behind real async boundaries, or reuse a single instance. +- RSS may remain elevated after disposal because the allocator can retain freed memory + for reuse. Peak memory use depends on the number of concurrently live instances, + including objects awaiting finalization. + +## Development + +Requires a recent Rust toolchain and Node 18+. ```bash -export AIC_SDK_LICENSE="your_license_key_here" -node examples/enhancement.js +pnpm install +pnpm build # release build; use build:debug while iterating +pnpm pretest # download model fixtures into __test__/data +AIC_SDK_LICENSE= pnpm test ``` -## Documentation +The native library for the host target is downloaded during `cargo build`, so the first +build needs network access. + +To benchmark, point the harness at a model file: -- **Full Documentation**: [docs.ai-coustics.com](https://docs.ai-coustics.com) -- **Node.js API Reference**: See the [index.js](index.js) for detailed JSDoc documentation -- **Available Models**: [artifacts.ai-coustics.io](https://artifacts.ai-coustics.io) +```bash +AIC_SDK_LICENSE= AIC_SDK_MODEL=__test__/data/.aicmodel pnpm bench +``` ## License -This Node.js wrapper is distributed under the Apache 2.0 license. The core C SDK is distributed under the proprietary AIC-SDK license. +This Node wrapper is distributed under the Apache 2.0 license (`LICENSE`). The core SDK it +links against is distributed under the proprietary AIC-SDK license +(`LICENSE.AIC_SDK`). diff --git a/__test__/common.ts b/__test__/common.ts new file mode 100644 index 0000000..72b7134 --- /dev/null +++ b/__test__/common.ts @@ -0,0 +1,40 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { TEST_DATA_DIR, TEST_MODELS } from './models.js' + +type ModelKind = keyof typeof TEST_MODELS + +/** + * Resolves a downloaded model fixture. + * + * Fixtures are not committed; `pnpm pretest` fetches them into `__test__/data` via + * `Model.download()` and records the resolved paths in `paths.json`. + */ +export function modelPath(kind: ModelKind): string { + const sidecar = path.join(TEST_DATA_DIR, 'paths.json') + if (!fs.existsSync(sidecar)) { + throw new Error(`Test models not downloaded. Run "pnpm build" then "pnpm pretest" to fetch them.`) + } + + const paths = JSON.parse(fs.readFileSync(sidecar, 'utf8')) as Record + const resolved = paths[kind] + if (!resolved || !fs.existsSync(resolved)) { + throw new Error(`Test ${kind} model not found. Run "pnpm pretest" to download it.`) + } + + return resolved +} + +/** + * Returns the SDK license key used by the tests. + * + * Throws if AIC_SDK_LICENSE is missing. + */ +export function licenseKey(): string { + const key = process.env.AIC_SDK_LICENSE + if (!key) { + throw new Error('AIC_SDK_LICENSE environment variable must be set to run these tests') + } + return key +} diff --git a/__test__/dispose.spec.ts b/__test__/dispose.spec.ts new file mode 100644 index 0000000..3c80f2b --- /dev/null +++ b/__test__/dispose.spec.ts @@ -0,0 +1,227 @@ +import test from 'ava' + +import { Analyzer, Model, Processor, ProcessorAsync, Vad, VadAsync } from '../index.js' +import { licenseKey, modelPath } from './common.js' + +const enhancementModel = () => Model.fromFile(modelPath('enhancement')) +const vadModel = () => Model.fromFile(modelPath('vad')) +const analysisModel = () => Model.fromFile(modelPath('analysis')) + +/** An initialized analyzer with a span of audio already buffered. */ +function bufferedAnalyzer() { + const model = analysisModel() + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + + const analyzer = new Analyzer(model, licenseKey()) + analyzer.initialize(sampleRate, blockSize) + + const audio = Float32Array.from({ length: blockSize }, (_, i) => Math.sin(i / 10) * 0.5) + for (let block = 0; block < 100; block += 1) { + analyzer.buffer(audio) + } + + return { analyzer, audio, blockSize } +} + +/** Milliseconds since a `process.hrtime.bigint()` reading. */ +function elapsedMs(since: bigint): number { + return Number(process.hrtime.bigint() - since) / 1e6 +} + +/** Holds the calling thread for `ms`, leaving worker threads free to run. */ +function spin(ms: number) { + const until = process.hrtime.bigint() + BigInt(Math.round(ms * 1e6)) + while (process.hrtime.bigint() < until) { + // Busy on purpose. Yielding to the event loop, or waiting on a timer, would let the + // analysis finish before dispose() is called. + } +} + +/** An initialized processor at the model's optimal settings, plus that block size. */ +function initializedProcessor() { + const model = enhancementModel() + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + + const processor = new Processor(model, licenseKey()) + processor.initialize(sampleRate, blockSize) + + return { processor, sampleRate, blockSize, model } +} + +test('sync processor dispose releases memory and rejects later use', (t) => { + const { processor, blockSize, model } = initializedProcessor() + processor.dispose() + + t.throws(() => processor.process(new Float32Array(blockSize)), { message: /disposed/ }) + t.throws(() => processor.initialize(16000, blockSize), { message: /disposed/ }) + t.throws(() => processor.getContext(), { message: /disposed/ }) + t.throws(() => processor.terminateSession(), { message: /disposed/ }) + + // Idempotent: a second dispose() must not throw. + processor.dispose() + + // The model stays usable after one of its processors is disposed. + const second = new Processor(model, licenseKey()) + second.dispose() + t.pass() +}) + +test('sync vad dispose releases memory and rejects later use', (t) => { + const model = vadModel() + const vad = new Vad(model, licenseKey()) + vad.initialize(model.getOptimalSampleRate(), model.getOptimalBlockSize(model.getOptimalSampleRate())) + const context = vad.getContext() + vad.dispose() + + t.throws(() => vad.process(new Float32Array(160)), { message: /disposed/ }) + t.throws(() => vad.getContext(), { message: /disposed/ }) + vad.dispose() + + // The SDK guarantees a VAD context stays valid after its VAD is destroyed + // (the prediction just stops updating), so using it must not crash. + context.isSpeechDetected() + t.pass() +}) + +test('model dispose rejects later use but keeps created processors working', (t) => { + const model = enhancementModel() + const { processor, blockSize } = initializedProcessorOn(model) + model.dispose() + + t.throws(() => model.getId(), { message: /disposed/ }) + t.throws(() => model.getOptimalSampleRate(), { message: /disposed/ }) + + // The SDK reference-counts the weights, so the processor keeps working. + processor.process(new Float32Array(blockSize)) + t.pass() + + function initializedProcessorOn(model: Model) { + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + const processor = new Processor(model, licenseKey()) + processor.initialize(sampleRate, blockSize) + return { processor, blockSize } + } +}) + +test('async processor dispose rejects later use and is idempotent', async (t) => { + const model = enhancementModel() + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + + const processor = new ProcessorAsync(model, licenseKey()) + await processor.initialize(sampleRate, blockSize) + processor.dispose() + + await t.throwsAsync(() => processor.process(new Float32Array(blockSize)), { + message: /disposed/, + }) + await t.throwsAsync(() => processor.terminateSession(), { message: /disposed/ }) + processor.dispose() + t.pass() +}) + +test('async vad dispose rejects later use', async (t) => { + const model = vadModel() + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + + const vad = new VadAsync(model, licenseKey()) + await vad.initialize(sampleRate, blockSize) + vad.dispose() + + await t.throwsAsync(() => vad.process(new Float32Array(blockSize)), { message: /disposed/ }) + t.pass() +}) + +test('analyzer dispose rejects later use and is idempotent', async (t) => { + const model = analysisModel() + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + + const analyzer = new Analyzer(model, licenseKey()) + analyzer.initialize(sampleRate, blockSize) + analyzer.buffer(new Float32Array(blockSize)) + analyzer.dispose() + + t.throws(() => analyzer.buffer(new Float32Array(blockSize)), { message: /disposed/ }) + t.throws(() => analyzer.analyze(), { message: /disposed/ }) + await t.throwsAsync(() => analyzer.analyzeAsync(), { message: /disposed/ }) + + // Idempotent: a second dispose() must not throw. + analyzer.dispose() + t.pass() +}) + +test('analyzer dispose racing an analyzeAsync settles it without crashing', async (t) => { + const { analyzer } = bufferedAnalyzer() + + // Disposing in the same tick leaves it undecided which side reaches the lock first, so + // the promise may resolve or reject and neither outcome is asserted. The next test + // covers the case where dispose() does block. + const inFlight = analyzer.analyzeAsync().catch(() => {}) + analyzer.dispose() + await inFlight + + t.throws(() => analyzer.analyze(), { message: /disposed/ }) + t.pass() +}) + +test('analyzer dispose waits for an in-flight analyzeAsync to finish', async (t) => { + const { analyzer } = bufferedAnalyzer() + + // Time a warm analysis so the thresholds below scale to this machine. The first run + // pays any lazy setup, so the second is the estimate. + await analyzer.analyzeAsync() + const calibrationStarted = process.hrtime.bigint() + await analyzer.analyzeAsync() + const analysisMs = elapsedMs(calibrationStarted) + + // Hand the task to libuv and let the worker take the analyzer lock. It spins rather + // than waiting on a timer, whose granularity could overshoot the whole analysis and + // leave dispose() with nothing to wait for. + const inFlight = analyzer.analyzeAsync() + await new Promise((resolve) => setImmediate(resolve)) + spin(Math.min(analysisMs / 8, 5)) + + const disposeStarted = process.hrtime.bigint() + analyzer.dispose() + const blockedMs = elapsedMs(disposeStarted) + + // The worker completed analysis before disposal acquired the lock. + const result = await inFlight + t.is(typeof result.riskScore, 'number', 'the in-flight analysis must complete, not be cancelled') + + // The analysis was still running when dispose() was called, so the wait above was the + // lock being held, not a no-op on an already-finished analysis. + t.true( + blockedMs >= analysisMs / 4, + `dispose() blocked ${blockedMs.toFixed(1)}ms of a ~${analysisMs.toFixed(1)}ms analysis`, + ) + + t.throws(() => analyzer.analyze(), { message: /disposed/ }) +}) + +test('a withConfig handle outlives its original handle being collected', async (t) => { + const model = enhancementModel() + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + + // The constructor's temporary handle may be finalized after `withConfig` resolves. + // Its finalizer must preserve the shared processor and its single memory report. + const processor = await new ProcessorAsync(model, licenseKey()).withConfig(sampleRate, blockSize) + + // Push V8 towards a GC so the dropped handle's finalizer runs. + const churn: unknown[] = [] + for (let i = 0; i < 10_000; i++) churn.push({ i }) + churn.length = 0 + if (typeof global.gc === 'function') global.gc() + await new Promise((resolve) => setImmediate(resolve)) + + const block = new Float32Array(blockSize) + await processor.process(block) + processor.dispose() + t.pass() +}) diff --git a/__test__/index.spec.ts b/__test__/index.spec.ts new file mode 100644 index 0000000..8b8be2b --- /dev/null +++ b/__test__/index.spec.ts @@ -0,0 +1,437 @@ +import test from 'ava' + +import { + Analyzer, + Model, + Processor, + ProcessorAsync, + ProcessorParameter, + Vad, + VadAsync, + VadParameter, + getCompatibleModelVersion, + getVersion, +} from '../index.js' +import { licenseKey, modelPath } from './common.js' +import { TEST_MODELS, TEST_MODEL_VERSION } from './models.js' + +const enhancementModel = () => Model.fromFile(modelPath('enhancement')) +const vadModel = () => Model.fromFile(modelPath('vad')) +const analysisModel = () => Model.fromFile(modelPath('analysis')) + +/** An initialized processor at the model's optimal settings, plus that block size. */ +function initializedProcessor() { + const model = enhancementModel() + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + + const processor = new Processor(model, licenseKey()) + processor.initialize(sampleRate, blockSize) + + return { processor, blockSize } +} + +test('sdk reports a version', (t) => { + t.regex(getVersion(), /^\d+\.\d+\.\d+/) +}) + +test('sdk expects the model version the fixtures are published under', (t) => { + // Guards against the fixture IDs in models.ts drifting from the addon's expectation, + // which would otherwise surface as a confusing "model version unsupported" much later. + t.is(getCompatibleModelVersion(), TEST_MODEL_VERSION) +}) + +test('model exposes its id and optimal settings', (t) => { + const model = enhancementModel() + + // The model file's own ID carries the build hash and version, e.g. + // `quail-vf-2.2-s-16khz-gf70x7zf-v14`, so it extends the manifest ID used to download it. + t.true( + model.getId().startsWith(TEST_MODELS.enhancement.id), + `expected ${model.getId()} to start with ${TEST_MODELS.enhancement.id}`, + ) + t.true(model.getOptimalSampleRate() >= 8000) + + const blockSize = model.getOptimalBlockSize(model.getOptimalSampleRate()) + t.true(blockSize > 0) + // Must be a plain number, not a BigInt: the SDK reports sizes as usize, and a BigInt + // would throw on `new Float32Array(blockSize)`. + t.is(typeof blockSize, 'number') + t.is(new Float32Array(blockSize).length, blockSize) +}) + +test('optimal block size scales with sample rate', (t) => { + const model = enhancementModel() + + // A model works on a fixed time window, so a higher rate needs proportionally more samples. + t.true(model.getOptimalBlockSize(48000) > model.getOptimalBlockSize(16000)) +}) + +test('model download resolves to a path without blocking the event loop', async (t) => { + let ticks = 0 + const ticker = setInterval(() => { + ticks += 1 + }, 1) + + try { + const path = await Model.download(TEST_MODELS.enhancement.id, 'target') + t.true(path.endsWith('.aicmodel')) + } finally { + clearInterval(ticker) + } + + // A blocking download would starve the timer and leave this at 0. + t.true(ticks > 0, 'event loop kept running during the download') +}) + +test('processor enhances audio in place', (t) => { + const { processor, blockSize } = initializedProcessor() + + // A ramp, not silence, so an in-place write is visible. + const audio = Float32Array.from({ length: blockSize }, (_, i) => Math.sin(i / 10) * 0.5) + const original = audio.slice() + + t.notThrows(() => processor.process(audio)) + t.notDeepEqual(Array.from(audio), Array.from(original), 'process should write into the caller buffer') +}) + +test('processor rejects a short block unless variable block size is enabled', (t) => { + const { processor, blockSize } = initializedProcessor() + + processor.process(new Float32Array(blockSize)) + t.throws(() => processor.process(new Float32Array(20))) +}) + +test('processor accepts a short block when variable block size is enabled', (t) => { + const model = enhancementModel() + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + + const processor = new Processor(model, licenseKey()) + processor.initialize(sampleRate, blockSize, true) + + t.notThrows(() => processor.process(new Float32Array(blockSize))) + t.notThrows(() => processor.process(new Float32Array(20))) +}) + +test('processor rejects processing before initialize', (t) => { + const processor = new Processor(enhancementModel(), licenseKey()) + + t.throws(() => processor.process(new Float32Array(160))) +}) + +test('processor context round-trips parameters and reports delay', (t) => { + const { processor } = initializedProcessor() + const context = processor.getContext() + + // Parameters are stored as f32 in the SDK, and 0.8 is not exactly representable in + // f32, so the read-back widens to 0.800000011920929. Math.fround applies the same + // rounding, keeping the assertion exact. + context.setParameter(ProcessorParameter.EnhancementLevel, 0.8) + t.is(context.getParameter(ProcessorParameter.EnhancementLevel), Math.fround(0.8)) + + context.setParameter(ProcessorParameter.Bypass, 1) + t.is(context.getParameter(ProcessorParameter.Bypass), 1) + + t.true(context.getAudioDelay() > 0) + t.is(typeof context.getAudioDelay(), 'number') + t.notThrows(() => context.reset()) +}) + +test('processor context rejects an out-of-range parameter', (t) => { + const { processor } = initializedProcessor() + + t.throws(() => processor.getContext().setParameter(ProcessorParameter.EnhancementLevel, 7)) +}) + +/** A ramp, not silence, so that processing has a visible effect. */ +function ramp(length: number) { + return Float32Array.from({ length }, (_, i) => Math.sin(i / 10) * 0.5) +} + +test('async processor enhances audio without touching the caller buffer', async (t) => { + const model = enhancementModel() + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + + const processor = await new ProcessorAsync(model, licenseKey()).withConfig(sampleRate, blockSize) + + const audio = ramp(blockSize) + const original = audio.slice() + const enhanced = await processor.process(audio) + + // Unlike the sync class, the input is copied before the work is queued. + t.deepEqual(Array.from(audio), Array.from(original), 'process must not write into the input') + t.is(enhanced.length, blockSize) + t.notDeepEqual(Array.from(enhanced), Array.from(original), 'process should return enhanced samples') +}) + +test('async processor matches the sync processor sample for sample', async (t) => { + const { processor: syncProcessor, blockSize } = initializedProcessor() + + const model = enhancementModel() + const asyncProcessor = await new ProcessorAsync(model, licenseKey()).withConfig( + model.getOptimalSampleRate(), + model.getOptimalBlockSize(model.getOptimalSampleRate()), + ) + + // Compare four blocks from fresh processors with identical input and settings. + // Async scheduling and buffer copies must preserve output and state transitions. + for (let block = 0; block < 4; block += 1) { + const audio = ramp(blockSize) + const expected = audio.slice() + syncProcessor.process(expected) + + t.deepEqual(Array.from(await asyncProcessor.process(audio)), Array.from(expected), `block ${block} should match`) + } +}) + +test('async processor keeps the event loop responsive', async (t) => { + const model = enhancementModel() + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + const processor = await new ProcessorAsync(model, licenseKey()).withConfig(sampleRate, blockSize) + + let ticks = 0 + const ticker = setInterval(() => { + ticks += 1 + }, 1) + + try { + let audio = ramp(blockSize) + for (let block = 0; block < 200; block += 1) { + audio = await processor.process(audio) + } + } finally { + clearInterval(ticker) + } + + // Processing on the JS thread would starve the timer and leave this at 0. + t.true(ticks > 0, 'event loop kept running during processing') +}) + +test('async processors on separate instances can run concurrently', async (t) => { + const model = enhancementModel() + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + + // Use independent processors for concurrency; calls within each stream must be ordered. + const processors = await Promise.all( + Array.from({ length: 4 }, () => new ProcessorAsync(model, licenseKey()).withConfig(sampleRate, blockSize)), + ) + + const results = await Promise.all(processors.map((processor) => processor.process(ramp(blockSize)))) + + // Every instance holds its own state, so identical input must give identical output. + for (const result of results) { + t.is(result.length, blockSize) + t.deepEqual(Array.from(result), Array.from(results[0]), 'independent instances should agree') + } +}) + +test('async processor rejects instead of throwing', async (t) => { + const processor = new ProcessorAsync(enhancementModel(), licenseKey()) + + // Errors surface as a rejected promise, not a synchronous throw. + await t.throwsAsync(processor.process(new Float32Array(160)), undefined, 'processing before initialize should reject') +}) + +test('async processor context controls a live processor', async (t) => { + const model = enhancementModel() + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + const processor = await new ProcessorAsync(model, licenseKey()).withConfig(sampleRate, blockSize) + + // The handle itself is awaited, but its methods are synchronous, so it stays usable + // from inside an audio callback. + const context = await processor.getContext() + // f32-backed parameter: see the note in 'processor context round-trips parameters'. + context.setParameter(ProcessorParameter.EnhancementLevel, 0.8) + t.is(context.getParameter(ProcessorParameter.EnhancementLevel), Math.fround(0.8)) + t.true(context.getAudioDelay() > 0) + + await processor.process(new Float32Array(blockSize)) + t.notThrows(() => context.reset()) +}) + +test('async vad hands the block back unmodified and reports no speech for silence', async (t) => { + const model = vadModel() + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + + const vad = await new VadAsync(model, licenseKey()).withConfig(sampleRate, blockSize) + const context = await vad.getContext() + + t.true(context.getPredictionDelay() > 0) + + const audio = ramp(blockSize) + const returned = await vad.process(audio) + t.deepEqual(Array.from(returned), Array.from(audio), 'vad must return its input unmodified') + + await vad.process(new Float32Array(blockSize)) + t.false(context.isSpeechDetected(), 'silence must not be reported as speech') + t.true(context.getRawVadProbability() >= 0 && context.getRawVadProbability() <= 1) +}) + +test('vad reports no speech for silence and leaves audio untouched', (t) => { + const model = vadModel() + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + + const vad = new Vad(model, licenseKey()) + vad.initialize(sampleRate, blockSize) + const context = vad.getContext() + + t.true(context.getPredictionDelay() > 0) + + const audio = Float32Array.from({ length: blockSize }, (_, i) => Math.sin(i / 10) * 0.5) + const original = audio.slice() + vad.process(audio) + + t.deepEqual(Array.from(audio), Array.from(original), 'vad must not modify its input') + + vad.process(new Float32Array(blockSize)) + t.false(context.isSpeechDetected(), 'silence must not be reported as speech') + t.true(context.getRawVadProbability() >= 0 && context.getRawVadProbability() <= 1) + t.notThrows(() => context.reset()) +}) + +test('vad context round-trips parameters', (t) => { + const vad = new Vad(vadModel(), licenseKey()) + const context = vad.getContext() + + context.setParameter(VadParameter.Sensitivity, 0.5) + t.is(context.getParameter(VadParameter.Sensitivity), 0.5) + + // Sensitivity is a probability threshold, so it is capped at 1.0. + t.throws(() => context.setParameter(VadParameter.Sensitivity, 7)) +}) + +test('analyzer scores buffered audio', (t) => { + const model = analysisModel() + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + + const analyzer = new Analyzer(model, licenseKey()) + analyzer.initialize(sampleRate, blockSize) + analyzer.buffer(new Float32Array(blockSize)) + + const result = analyzer.analyze() + + for (const [name, score] of Object.entries(result)) { + t.is(typeof score, 'number', `${name} should be a number`) + t.true(score >= 0 && score <= 1, `${name} should be within 0..=1, got ${score}`) + } + + // All seven documented scores must be present. + t.deepEqual(Object.keys(result).sort(), [ + 'codecDegradation', + 'interferingSpeech', + 'noise', + 'packetLoss', + 'riskScore', + 'speakerLoudness', + 'speakerReverb', + ]) + + t.notThrows(() => analyzer.reset()) +}) + +/** An initialized analyzer with a span of audio already buffered, plus that block size. */ +function bufferedAnalyzer() { + const model = analysisModel() + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + + const analyzer = new Analyzer(model, licenseKey()) + analyzer.initialize(sampleRate, blockSize) + + const audio = Float32Array.from({ length: blockSize }, (_, i) => Math.sin(i / 10) * 0.5) + for (let block = 0; block < 100; block += 1) { + analyzer.buffer(audio) + } + + return { analyzer, audio, blockSize } +} + +test('async analysis returns the same scores as the blocking call', async (t) => { + const { analyzer } = bufferedAnalyzer() + + // Same buffered audio and no state change between the two, so the worker thread must + // produce exactly what the calling thread does. + const expected = analyzer.analyze() + const actual = await analyzer.analyzeAsync() + + t.deepEqual(actual, expected) + t.deepEqual(Object.keys(actual).sort(), Object.keys(expected).sort()) + for (const [name, score] of Object.entries(actual)) { + t.is(typeof score, 'number', `${name} should be a number`) + } +}) + +test('async analysis keeps the event loop responsive', async (t) => { + const { analyzer } = bufferedAnalyzer() + + let ticks = 0 + const ticker = setInterval(() => { + ticks += 1 + }, 1) + + try { + // Several rounds, so the test does not hinge on one analysis outrunning the timer. + for (let round = 0; round < 20; round += 1) { + await analyzer.analyzeAsync() + } + } finally { + clearInterval(ticker) + } + + t.true(ticks > 0, 'event loop kept running during analysis') +}) + +test('audio can be buffered while an analysis is in flight', async (t) => { + const { analyzer, audio } = bufferedAnalyzer() + + // Only the analyzer half sits behind the lock, and `buffer` drives the collector, so + // it neither waits on a running analysis nor throws. + const pending = analyzer.analyzeAsync() + + t.notThrows(() => { + for (let block = 0; block < 50; block += 1) { + analyzer.buffer(audio) + } + }, 'buffer must stay callable while analyzing') + + const result = await pending + t.is(typeof result.riskScore, 'number') +}) + +// Async rejection behavior is covered by the processor test, which processes before +// initialization. No equivalent analyzer failure is assumed here. + +test('each class rejects the wrong model type', (t) => { + const key = licenseKey() + + t.throws(() => new Vad(enhancementModel(), key), undefined, 'Vad should reject an enhancement model') + t.throws(() => new Processor(vadModel(), key), undefined, 'Processor should reject a VAD model') + t.throws(() => new Analyzer(enhancementModel(), key), undefined, 'Analyzer should reject an enhancement model') + + // The async classes construct synchronously, so a wrong model throws here as well + // instead of rejecting later. + t.throws(() => new VadAsync(enhancementModel(), key), undefined, 'VadAsync should reject an enhancement model') + t.throws(() => new ProcessorAsync(vadModel(), key), undefined, 'ProcessorAsync should reject a VAD model') +}) + +test('loading a missing model file throws', (t) => { + t.throws(() => Model.fromFile('does-not-exist.aicmodel')) +}) + +test('removed pre-rewrite API is absent', async (t) => { + // The rewrite dropped these exports; the CHANGELOG's migration guide lists them. + // Asserting the names are gone catches an accidental re-introduction. + const sdk = (await import('../index.js')) as Record + + for (const removed of ['analyzerPair', 'Collector', 'OtelConfig', 'FileAnalyzer']) { + t.is(sdk[removed], undefined, `${removed} should no longer be exported`) + } +}) diff --git a/__test__/models.ts b/__test__/models.ts new file mode 100644 index 0000000..0cb8c70 --- /dev/null +++ b/__test__/models.ts @@ -0,0 +1,30 @@ +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const currentDir = path.dirname(fileURLToPath(import.meta.url)) + +/** Where `scripts/fetch-test-models.mjs` puts the downloaded fixtures. */ +export const TEST_DATA_DIR = path.join(currentDir, 'data') + +export interface TestModel { + /** Model id as listed in the manifest. */ + id: string +} + +/** + * Model fixtures the end-to-end tests run against. + * + * `scripts/fetch-test-models.mjs` resolves these through `Model.download()`, which + * fetches the manifest and selects the latest compatible model version. The model file + * format version is tied to the SDK version, so `getCompatibleModelVersion()` reports + * the version the built addon expects, and the `sdk expects the model version the + * fixtures are published under` test asserts the fixtures still match it. + */ +export const TEST_MODELS = { + enhancement: { id: 'quail-vf-2.2-s-16khz' }, + vad: { id: 'vad-2.1-xxs-16khz' }, + analysis: { id: 'tyto-1.1-l-16khz' }, +} satisfies Record + +/** The model file format version the built addon expects. */ +export const TEST_MODEL_VERSION = 7 diff --git a/__test__/package.json b/__test__/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/__test__/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/__test__/tsconfig.json b/__test__/tsconfig.json new file mode 100644 index 0000000..659905b --- /dev/null +++ b/__test__/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "lib" + }, + "include": ["."], + "exclude": ["lib"] +} diff --git a/benchmark/bench.ts b/benchmark/bench.ts new file mode 100644 index 0000000..8461bbb --- /dev/null +++ b/benchmark/bench.ts @@ -0,0 +1,130 @@ +import os from 'node:os' + +import { Bench } from 'tinybench' + +import { Analyzer, Model, Processor, ProcessorAsync } from '../index.js' + +const licenseKey = process.env.AIC_SDK_LICENSE +if (!licenseKey) { + throw new Error('AIC_SDK_LICENSE environment variable must be set to run the benchmark') +} + +const modelPath = process.env.AIC_SDK_MODEL +if (!modelPath) { + throw new Error( + 'AIC_SDK_MODEL must point to a .aicmodel file. Run "pnpm pretest" to download the ' + + 'test fixtures, then point it at one of the files in __test__/data.', + ) +} + +const model = Model.fromFile(modelPath) +const sampleRate = model.getOptimalSampleRate() +const blockSize = model.getOptimalBlockSize(sampleRate) + +const processor = new Processor(model, licenseKey) +processor.initialize(sampleRate, blockSize) + +// Number of concurrent streams. Each needs an independent processor to preserve block order. +const concurrency = Number(process.env.AIC_BENCH_CONCURRENCY ?? 4) + +const asyncProcessor = await new ProcessorAsync(model, licenseKey).withConfig(sampleRate, blockSize) +const asyncProcessors = await Promise.all( + Array.from({ length: concurrency }, () => new ProcessorAsync(model, licenseKey).withConfig(sampleRate, blockSize)), +) + +// Reuse a reference signal to exclude input allocation from the measurement. +const audio = Float32Array.from({ length: blockSize }, (_, i) => Math.sin(i / 10) * 0.5) + +// Refill the synchronous processor's scratch buffer before each iteration. +// This prevents enhanced output from becoming the next iteration's input. +const syncAudio = new Float32Array(blockSize) + +// Keep a separate result buffer for each async stream. +const asyncAudio = asyncProcessors.map(() => audio.slice()) + +const syncTask = `sync: 1 block` +const asyncTask = `async: 1 block` +const concurrentTask = `async: ${concurrency} blocks concurrently` + +// Track blocks per iteration to normalize throughput across benchmark cases. +const blocksPerIteration = new Map([ + [syncTask, 1], + [asyncTask, 1], + [concurrentTask, concurrency], +]) + +const bench = new Bench() + +bench.add( + syncTask, + () => { + processor.process(syncAudio) + }, + // Hooks run outside the measurement, so the refill does not count towards the timing. + { beforeEach: () => syncAudio.set(audio) }, +) + +// Measure async scheduling, promise and input-copy overhead relative to synchronous processing. +bench.add(asyncTask, async () => { + await asyncProcessor.process(audio) +}) + +// The throughput case: one processor per stream, all in flight at once. Bounded by the +// libuv pool, which is 4 threads unless UV_THREADPOOL_SIZE says otherwise. +bench.add(concurrentTask, async () => { + await Promise.all(asyncProcessors.map((instance, stream) => instance.process(asyncAudio[stream]))) +}) + +// Benchmark analysis separately when an analysis model is supplied. +// Analysis processes a fixed duration of buffered audio per call. +const analysisModelPath = process.env.AIC_SDK_ANALYSIS_MODEL +if (analysisModelPath) { + const analysisModel = Model.fromFile(analysisModelPath) + const analysisRate = analysisModel.getOptimalSampleRate() + const analysisBlock = analysisModel.getOptimalBlockSize(analysisRate) + + const analyzer = new Analyzer(analysisModel, licenseKey) + analyzer.initialize(analysisRate, analysisBlock) + + // The model consumes a fixed span, so fill it before measuring. `analyze` does not consume + // the buffer, so one fill serves every iteration. + const analysisAudio = Float32Array.from({ length: analysisBlock }, (_, i) => Math.sin(i / 10) * 0.5) + for (let block = 0; block < 200; block += 1) { + analyzer.buffer(analysisAudio) + } + + bench.add('analysis: analyze (blocking)', () => { + analyzer.analyze() + }) + + // Measure async scheduling overhead relative to synchronous analysis. + bench.add('analysis: analyzeAsync', async () => { + await analyzer.analyzeAsync() + }) +} else { + console.info('Set AIC_SDK_ANALYSIS_MODEL to a Tyto model to include analysis in this run.\n') +} + +await bench.run() + +console.info( + `${os.cpus().length} logical CPUs, libuv pool ${process.env.UV_THREADPOOL_SIZE ?? '4 (default)'}, ` + + `${blockSize} samples @ ${sampleRate} Hz`, +) +console.table(bench.table()) + +// A block covers blockSize / sampleRate seconds of audio. The ratio of that to the mean +// time per block is the real-time factor: how many streams the machine could keep up with. +// Only a completed run carries statistics; aborted, errored and not-started ones do not. +const blockDurationMs = (blockSize / sampleRate) * 1000 +for (const task of bench.tasks) { + const blocks = blocksPerIteration.get(task.name) + const result = task.result + + // Only the per-block enhancement tasks are in the map. Analysis works over a span rather + // than a block, so a real-time factor would not mean anything for it. + if (blocks && result?.state === 'completed' && result.latency.mean > 0) { + const perBlock = result.latency.mean / blocks + console.info(`${task.name} — real-time factor: ${(blockDurationMs / perBlock).toFixed(1)}x`) + } +} diff --git a/benchmark/package.json b/benchmark/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/benchmark/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/benchmark/tsconfig.json b/benchmark/tsconfig.json new file mode 100644 index 0000000..659905b --- /dev/null +++ b/benchmark/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "lib" + }, + "include": ["."], + "exclude": ["lib"] +} diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..1f866b6 --- /dev/null +++ b/build.rs @@ -0,0 +1,5 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); +} diff --git a/examples/README.md b/examples/README.md index d5c8cc4..affd5d2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,40 +1,75 @@ # Examples -## Usage +Runnable scripts for each part of the SDK. -Build locally: +| Example | Shows | +| ---------------------- | -------------------------------------------------------- | +| `enhancement.js` | Speech enhancement, in place on the calling thread | +| `enhancement-async.js` | Async speech enhancement and concurrent streams | +| `vad.js` | Voice activity detection and its parameters | +| `vad-async.js` | Async detection, and detection combined with enhancement | +| `analysis.js` | Audio quality scoring, blocking and on a worker thread | +| `file-processing.js` | WAV file enhancement with delay compensation | + +`analysis.js` demonstrates both `analyze` and `analyzeAsync`. The file processing example +uses synchronous processing. For parallel batch processing, run several processes. + +## Setup + +Install and build the addon from a checkout of this repository: ```bash -npm install -npm run build +pnpm install +pnpm build ``` -Run the enhancement example: +Then set your license key, from +[developers.ai-coustics.com](https://developers.ai-coustics.com): ```bash export AIC_SDK_LICENSE="your-license-key" -node examples/enhancement.js ``` -Run the voice activity detection example: +Each example downloads the model it needs into `./models` on first run. + +## Running ```bash -export AIC_SDK_LICENSE="your-license-key" +node examples/enhancement.js +node examples/enhancement-async.js + node examples/vad.js +node examples/vad-async.js + +node examples/analysis.js ``` -Run the file processing example: +The file example takes a WAV path. It also needs `wavefile`, which `pnpm install` already +provides in this repository; outside it, install it alongside the SDK. ```bash -export AIC_SDK_LICENSE="your-license-key" -node examples/file-processing.js --input ./tests/data/test_signal.wav --output ./test_signal_enhanced.wav +node examples/file-processing.js --input speech.wav +node examples/file-processing.js --input speech.wav --output enhanced.wav --enhancement 0.7 ``` -Run the audio analysis example: +`--model` selects the model and `--help` lists all options. A model only enhances up +to its own Nyquist limit, so pair a 48 kHz source with a 48 kHz model such as `rook-l-48khz`. +Browse the catalogue at [artifacts.ai-coustics.io](https://artifacts.ai-coustics.io). -```bash -export AIC_SDK_LICENSE="your-license-key" -node examples/analysis.js -``` +## Choosing between sync and async + +`Processor` and `Vad` run on the calling thread. Use them in a dedicated worker or a batch +script where blocking is acceptable. + +`ProcessorAsync` and `VadAsync` run processing on Node's libuv thread pool, keeping the event +loop available for other work. Their constructors and `dispose()` methods are synchronous. + +- `process` copies the input and returns a promise for a new array. The input remains + unmodified. Enhancement returns enhanced samples; VAD returns the original samples. +- Await each operation before submitting the next on the same instance. Calls are not + guaranteed to execute in submission order. Use one instance per stream to process + multiple streams concurrently. -Get your license key from [ai-coustics Developer Portal](https://developers.ai-coustics.com). +The pool defaults to four threads and is shared with filesystem, DNS and crypto work. Set +`UV_THREADPOOL_SIZE` before starting Node to change its size. `AIC_NUM_THREADS` does not apply +to these bindings. diff --git a/examples/analysis.js b/examples/analysis.js index eeca23e..3845364 100644 --- a/examples/analysis.js +++ b/examples/analysis.js @@ -1,83 +1,88 @@ -const { Model, FileAnalyzer, analyzerPair, getVersion } = require(".."); +// Audio quality analysis on the calling thread and on a libuv worker. +// +// `buffer` collects audio synchronously and can continue during `analyzeAsync`. +// Analysis is computationally expensive; avoid running `analyze` in audio processing callbacks. + +const { Analyzer, Model, getVersion } = require('..') + +const MODEL_ID = 'tyto-1.1-l-16khz' +const MODEL_DIR = './models' + +async function main() { + const licenseKey = process.env.AIC_SDK_LICENSE + if (!licenseKey) { + console.error('Error: AIC_SDK_LICENSE environment variable not set') + console.error('Get your license key from https://developers.ai-coustics.com') + process.exit(1) + } -// Check for license key -if (!process.env.AIC_SDK_LICENSE) { - console.error("Error: AIC_SDK_LICENSE environment variable not set"); - console.error("Get your license key from https://developers.ai-coustics.com"); - process.exit(1); -} + console.log('SDK version:', getVersion()) -const licenseKey = process.env.AIC_SDK_LICENSE; - -console.log("SDK version:", getVersion()); - -// Download and load an analysis model. Analysis models score audio quality -// instead of enhancing it. -let model; -try { - const modelPath = Model.download("tyto-1.1-l-16khz", "/tmp/aic-models"); - console.log("Model downloaded to:", modelPath); - model = Model.fromFile(modelPath); - console.log("Model ID:", model.getId()); -} catch (error) { - console.error("Failed to load model:", error.message); - process.exit(1); -} + // An analysis model. Enhancement and VAD models are rejected. + const modelPath = await Model.download(MODEL_ID, MODEL_DIR) + const model = Model.fromFile(modelPath) + console.log('Model id:', model.getId()) -const sampleRate = 16000; - -function printResult(label, result) { - console.log(`\n${label}:`); - console.log(" Risk score: ", result.riskScore.toFixed(4)); - console.log(" Speaker reverb: ", result.speakerReverb.toFixed(4)); - console.log(" Speaker loudness: ", result.speakerLoudness.toFixed(4)); - console.log(" Interfering speech:", result.interferingSpeech.toFixed(4)); - console.log(" Noise: ", result.noise.toFixed(4)); - console.log(" Codec degradation: ", result.codecDegradation.toFixed(4)); - console.log(" Packet loss: ", result.packetLoss.toFixed(4)); -} + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + console.log(`Audio format: ${blockSize} samples @ ${sampleRate} Hz`) -// --- FileAnalyzer: analyze a complete mono signal --------------------------- + const analyzer = new Analyzer(model, licenseKey) + analyzer.initialize(sampleRate, blockSize) -// Twelve seconds of low-level test audio. -const audio = new Float32Array(sampleRate * 12); -for (let i = 0; i < audio.length; i++) { - audio[i] = Math.sin(i * 0.05) * 0.1; -} + // Only a fixed span of audio is retained, set by the model; older audio is discarded as + // more is buffered. Analyzing before that much has arrived pads with silence. + // + // Analysis is mono. Mix multichannel audio down, or use one analyzer per channel. + const audio = Float32Array.from({ length: blockSize }, () => (Math.random() - 0.5) * 0.2) + for (let block = 0; block < 100; block += 1) { + analyzer.buffer(audio) + } + console.log('Buffered 100 blocks') -try { - const fileAnalyzer = new FileAnalyzer(model, licenseKey); + // Run analysis on a worker and count event-loop timer callbacks while it runs. + let ticks = 0 + const ticker = setInterval(() => { + ticks += 1 + }, 1) - // Step by the full five-second window (no overlap). Pass a smaller step to overlap windows. - const results = fileAnalyzer.analyze(audio, sampleRate, sampleRate * 5); - console.log(`\nFileAnalyzer produced ${results.length} window(s)`); - results.forEach((result, index) => printResult(`Window ${index}`, result)); -} catch (error) { - console.error("Failed to analyze file:", error.message); - process.exit(1); -} + const asyncStart = performance.now() + const result = await analyzer.analyzeAsync() + const asyncElapsed = performance.now() - asyncStart -// --- Collector/Analyzer pair: streaming analysis ---------------------------- + clearInterval(ticker) -try { - const { collector, analyzer } = analyzerPair(model, licenseKey); + // Scores range from 0.0 to 1.0. Lower values indicate fewer problems, except for + // speakerLoudness. riskScore predicts failure in downstream speech models. + console.log('\nAnalysis:') + for (const [name, score] of Object.entries(result)) { + console.log(` ${name.padEnd(20)} ${score.toFixed(4)}`) + } - const blockSize = model.getOptimalBlockSize(sampleRate); - collector.initialize(sampleRate, blockSize, false); + // The blocking form, for a CLI or a worker thread where nothing is waiting on the loop. + const syncStart = performance.now() + analyzer.analyze() + const syncElapsed = performance.now() - syncStart - // Pass five seconds of audio to the collector in optimal-size blocks. - for (let offset = 0; offset + blockSize <= sampleRate * 5; offset += blockSize) { - collector.buffer(audio.subarray(offset, offset + blockSize)); - } + console.log(`\nanalyzeAsync: ${asyncElapsed.toFixed(1)} ms, analyze: ${syncElapsed.toFixed(1)} ms`) + // Report how many timer callbacks ran during async analysis. + console.log(`Timer fired ${ticks} times during analyzeAsync`) + + // `buffer` drives the collector, not the analyzer, so audio can keep arriving while an + // analysis is still running. + const pending = analyzer.analyzeAsync() + analyzer.buffer(audio) + await pending + console.log('Buffered audio while an analysis was in flight') - const result = analyzer.analyzeBuffered(); - printResult("Collector/Analyzer pair", result); + // Clears buffered audio and internal state, keeping the audio settings. + analyzer.reset() - // Clear state when the stream is interrupted or when seeking. - analyzer.reset(); -} catch (error) { - console.error("Failed to analyze stream:", error.message); - process.exit(1); + analyzer.terminateSession() + console.log('\nAnalysis example completed successfully') } -console.log("\nAnalysis example completed successfully"); +main().catch((error) => { + console.error('Error:', error.message) + process.exit(1) +}) diff --git a/examples/audio-file.js b/examples/audio-file.js new file mode 100644 index 0000000..706013d --- /dev/null +++ b/examples/audio-file.js @@ -0,0 +1,63 @@ +// WAV reading and writing for file-processing.js. +// +// Requires `wavefile`, included in this repository's devDependencies. +// For use outside this repository: npm install wavefile + +const fs = require('node:fs') + +const { WaveFile } = require('wavefile') + +/** + * Reads a WAV file into one Float32Array per channel. + * + * @param {string} path + * @returns {{ channels: Float32Array[], sampleRate: number }} + */ +function readWav(path) { + const wav = new WaveFile(fs.readFileSync(path)) + + // Convert PCM and floating-point WAV samples to the floating-point format used by the SDK. + wav.toBitDepth('32f') + + // The wavefile type declaration does not reflect the requested Float32Array output. + const samples = /** @type {Float32Array[] | Float32Array} */ ( + /** @type {unknown} */ (wav.getSamples(false, Float32Array)) + ) + + // `fmt` is declared as a bare object. + const { sampleRate } = /** @type {{ sampleRate: number }} */ (wav.fmt) + + return { + // Return an array of channels for both mono and multichannel input. + channels: Array.isArray(samples) ? samples : [samples], + sampleRate, + } +} + +/** + * Writes one Float32Array per channel to a 32-bit float WAV file. + * + * @param {string} path + * @param {Float32Array[]} channels + * @param {number} sampleRate + */ +function writeWav(path, channels, sampleRate) { + const wav = new WaveFile() + + // `fromScratch` expects a flat array for mono and an array of channels otherwise. + wav.fromScratch(channels.length, sampleRate, '32f', channels.length === 1 ? channels[0] : channels) + + fs.writeFileSync(path, wav.toBuffer()) +} + +/** + * Appends `_enhanced` to a path, keeping the extension: `a/b.wav` -> `a/b_enhanced.wav`. + * + * @param {string} inputPath + * @returns {string} + */ +function enhancedPath(inputPath) { + return inputPath.replace(/(\.[^.\\/]+)?$/, '_enhanced$1') +} + +module.exports = { enhancedPath, readWav, writeWav } diff --git a/examples/enhancement-async.js b/examples/enhancement-async.js new file mode 100644 index 0000000..b18c5e0 --- /dev/null +++ b/examples/enhancement-async.js @@ -0,0 +1,104 @@ +// Speech enhancement on Node's libuv thread pool. +// +// `process` copies its input and returns a promise for the enhanced samples. +// `getContext` returns a promise; the context's methods are synchronous. +// The final example processes several independent streams concurrently. + +const os = require('node:os') + +const { Model, ProcessorAsync, ProcessorParameter, getCompatibleModelVersion, getVersion } = require('..') + +const MODEL_ID = 'quail-vf-2.2-s-16khz' +const MODEL_DIR = './models' +const BLOCKS = 100 +const STREAMS = 4 + +async function main() { + const licenseKey = process.env.AIC_SDK_LICENSE + if (!licenseKey) { + console.error('Error: AIC_SDK_LICENSE environment variable not set') + console.error('Get your license key from https://developers.ai-coustics.com') + process.exit(1) + } + + console.log('SDK version:', getVersion()) + console.log('Compatible model version:', getCompatibleModelVersion()) + + const modelPath = await Model.download(MODEL_ID, MODEL_DIR) + const model = Model.fromFile(modelPath) + console.log('Model id:', model.getId()) + + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + console.log(`Audio format: ${blockSize} samples @ ${sampleRate} Hz`) + + // Create and initialize the processor. Calling `initialize` on a constructed + // instance is equivalent. + const processor = await new ProcessorAsync(model, licenseKey).withConfig(sampleRate, blockSize) + + const context = await processor.getContext() + console.log('Audio delay:', context.getAudioDelay(), 'samples') + + // Context methods are synchronous and can be used during processing. + context.setParameter(ProcessorParameter.EnhancementLevel, 0.7) + console.log('Enhancement level:', context.getParameter(ProcessorParameter.EnhancementLevel)) + + // Count event-loop timer callbacks during processing. + let ticks = 0 + const ticker = setInterval(() => { + ticks += 1 + }, 1) + + // Await each block in sequence. The result is a new array; the input is unmodified. + let audio = Float32Array.from({ length: blockSize }, () => (Math.random() - 0.5) * 0.2) + console.log('Before:', audio.slice(0, 4)) + + const started = performance.now() + for (let block = 0; block < BLOCKS; block += 1) { + audio = await processor.process(audio) + } + const elapsed = performance.now() - started + + clearInterval(ticker) + + console.log('After: ', audio.slice(0, 4)) + + const audioMs = (BLOCKS * blockSize * 1000) / sampleRate + console.log(`\nProcessed ${BLOCKS} blocks (${audioMs.toFixed(0)} ms of audio) in ${elapsed.toFixed(0)} ms`) + console.log(`Real-time factor: ${(audioMs / elapsed).toFixed(1)}x`) + // Report how many timer callbacks ran during processing. + console.log(`Timer fired ${ticks} times while processing, so the event loop stayed responsive`) + + // Process independent streams concurrently, with one processor per stream. + // Await calls within each stream to preserve block order. + console.log(`\nEnhancing ${STREAMS} streams concurrently`) + + const processors = await Promise.all( + Array.from({ length: STREAMS }, () => new ProcessorAsync(model, licenseKey).withConfig(sampleRate, blockSize)), + ) + + const concurrentStart = performance.now() + await Promise.all( + processors.map(async (instance) => { + // Sequential within a stream, concurrent across them. + let block = new Float32Array(blockSize) + for (let i = 0; i < BLOCKS; i += 1) { + block = await instance.process(block) + } + }), + ) + const concurrentElapsed = performance.now() - concurrentStart + + // Concurrency is limited by the libuv pool size, configured with UV_THREADPOOL_SIZE. + console.log(`${STREAMS} x ${BLOCKS} blocks in ${concurrentElapsed.toFixed(0)} ms`) + console.log(`Aggregate: ${((STREAMS * audioMs) / concurrentElapsed).toFixed(1)}x real time`) + console.log(`libuv pool: ${process.env.UV_THREADPOOL_SIZE ?? '4 (default)'}, ${os.cpus().length} logical CPUs`) + + await Promise.all([processor.terminateSession(), ...processors.map((instance) => instance.terminateSession())]) + console.log('\nAsync enhancement example completed successfully') +} + +main().catch((error) => { + console.error('Error:', error.message) + process.exit(1) +}) diff --git a/examples/enhancement.js b/examples/enhancement.js index b9bc189..dd2d0a7 100644 --- a/examples/enhancement.js +++ b/examples/enhancement.js @@ -1,99 +1,67 @@ -const { - Model, - Processor, - ProcessorParameter, - getVersion, - getCompatibleModelVersion, -} = require(".."); +// Speech enhancement on the calling thread. +// +// `process` modifies a mono block in place. Use this API in a dedicated worker or +// batch script. See enhancement-async.js for processing through the libuv thread pool. -// Check for license key -if (!process.env.AIC_SDK_LICENSE) { - console.error("Error: AIC_SDK_LICENSE environment variable not set"); - console.error("Get your license key from https://developers.ai-coustics.com"); - process.exit(1); -} +const { Model, Processor, ProcessorParameter, getCompatibleModelVersion, getVersion } = require('..') -console.log("SDK version:", getVersion()); -console.log("Compatible model version:", getCompatibleModelVersion()); +const MODEL_ID = 'quail-vf-2.2-s-16khz' +const MODEL_DIR = './models' -// Download and load an enhancement model. Enhancement models improve speech quality; use -// examples/vad.js for voice activity detection with a dedicated VAD model. -// Select a model at https://artifacts.ai-coustics.io/ -let model; -try { - const modelPath = Model.download("quail-vf-2.2-s-16khz", "/tmp/aic-models"); - console.log("Model downloaded to:", modelPath); - model = Model.fromFile(modelPath); - console.log("Model ID:", model.getId()); -} catch (error) { - console.error("Failed to load model:", error.message); - process.exit(1); -} +async function main() { + const licenseKey = process.env.AIC_SDK_LICENSE + if (!licenseKey) { + console.error('Error: AIC_SDK_LICENSE environment variable not set') + console.error('Get your license key from https://developers.ai-coustics.com') + process.exit(1) + } -// Get optimal settings -const sampleRate = model.getOptimalSampleRate(); -const blockSize = model.getOptimalBlockSize(sampleRate); + console.log('SDK version:', getVersion()) + console.log('Compatible model version:', getCompatibleModelVersion()) -console.log("Sample rate:", sampleRate); -console.log("Block size:", blockSize); + // Enhancement models improve speech quality. See vad.js for voice activity detection and + // analysis.js for audio analysis; each class accepts only its own family of models. + // Browse the catalogue at https://artifacts.ai-coustics.io + const modelPath = await Model.download(MODEL_ID, MODEL_DIR) + console.log('Model downloaded to:', modelPath) -// Create processor -let processor; -try { - processor = new Processor(model, process.env.AIC_SDK_LICENSE); -} catch (error) { - console.error("Failed to create processor:", error.message); - process.exit(1); -} + const model = Model.fromFile(modelPath) + console.log('Model id:', model.getId()) -// Initialize for mono audio -try { - processor.initialize(sampleRate, blockSize, false); -} catch (error) { - console.error("Failed to initialize processor:", error.message); - process.exit(1); -} + // Use the model's optimal configuration for the lowest delay. + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + console.log(`Audio format: ${blockSize} samples @ ${sampleRate} Hz`) -// Get processor context for parameter control -const processorContext = processor.getContext(); + const processor = new Processor(model, licenseKey) + processor.initialize(sampleRate, blockSize) -// Get the delay applied to the audio -console.log("Audio delay:", processorContext.getAudioDelay(), "samples"); + // Parameters and state are reached through a context handle, which stays usable while + // audio is being processed. + const context = processor.getContext() + console.log('Audio delay:', context.getAudioDelay(), 'samples') -// Set enhancement parameters -try { - processorContext.setParameter(ProcessorParameter.EnhancementLevel, 0.7); -} catch (error) { - console.error("Failed to set parameters:", error.message); - // Failing is fine here, so do not end the process -} + context.setParameter(ProcessorParameter.EnhancementLevel, 0.7) + console.log('Enhancement level:', context.getParameter(ProcessorParameter.EnhancementLevel)) -console.log( - "Enhancement level:", - processorContext.getParameter(ProcessorParameter.EnhancementLevel), -); + // Generate low-level noise as sample input. Replace this with audio from your source. + const audio = Float32Array.from({ length: blockSize }, () => (Math.random() - 0.5) * 0.2) + console.log('Before:', audio.slice(0, 4)) -// Process mono audio -const audioBlock = new Float32Array(blockSize); -for (let i = 0; i < audioBlock.length; i++) { - audioBlock[i] = Math.random() * 0.1; -} -console.log("Before:", audioBlock.slice(0, 8)); -try { - processor.process(audioBlock); - console.log("After: ", audioBlock.slice(0, 8)); - console.log("Processed mono audio"); -} catch (error) { - console.error("Failed to process audio:", error.message); - process.exit(1); -} + // Enhances in place, so the caller's array holds the result afterwards. + processor.process(audio) + console.log('After: ', audio.slice(0, 4)) -// Reset processor state -processorContext.reset(); + // Clear internal state on a stream discontinuity or seek. + context.reset() -// End the telemetry session on demand instead of waiting for the processor to be destroyed. -// The processor can no longer process audio after this call. -processor.terminateSession(); -console.log("Telemetry session terminated"); + // Ends the telemetry session now rather than waiting for garbage collection. The + // processor cannot process audio afterwards. + processor.terminateSession() + console.log('\nEnhancement example completed successfully') +} -console.log("\nEnhancement example completed successfully"); +main().catch((error) => { + console.error('Error:', error.message) + process.exit(1) +}) diff --git a/examples/file-processing.js b/examples/file-processing.js index 0dab528..8d86bad 100644 --- a/examples/file-processing.js +++ b/examples/file-processing.js @@ -1,204 +1,168 @@ -const { Model, Processor, ProcessorParameter, getVersion } = require(".."); -const fs = require("fs"); -const path = require("path"); -const WaveFile = require("wavefile").WaveFile; - -// Parse command line arguments -const args = process.argv.slice(2); -let inputFile = null; -let outputFile = null; -let modelId = "rook-l-48khz"; -let enhancementLevel = 1.0; - -for (let i = 0; i < args.length; i++) { - if (args[i] === "--input" || args[i] === "-i") { - inputFile = args[++i]; - } else if (args[i] === "--output" || args[i] === "-o") { - outputFile = args[++i]; - } else if (args[i] === "--model" || args[i] === "-m") { - modelId = args[++i]; - } else if (args[i] === "--enhancement" || args[i] === "-e") { - enhancementLevel = parseFloat(args[++i]); - } else if (args[i] === "--help" || args[i] === "-h") { - console.log(` -Usage: node file-processing.js --input [options] +// Enhances a WAV file using one processor per channel and compensates for audio delay. +// +// Usage: +// node examples/file-processing.js --input speech.wav [--output enhanced.wav] +// [--model quail-vf-2.2-s-16khz] [--enhancement 1.0] + +const { Model, Processor, ProcessorParameter, getVersion } = require('..') +const { enhancedPath, readWav, writeWav } = require('./audio-file.js') + +const MODEL_DIR = './models' + +const USAGE = `Enhances a WAV file. + +Usage: node examples/file-processing.js --input [options] Options: - -i, --input Input WAV file (required) - -o, --output Output WAV file (default: input_enhanced.wav) - -m, --model Model ID (default: rook-l-48khz) - -e, --enhancement Enhancement level 0.0-1.0 (default: 1.0) - -h, --help Show this help - -Requires: npm install wavefile -`); - process.exit(0); - } else if (!inputFile) { - inputFile = args[i]; + -i, --input WAV file to enhance (required) + -o, --output Where to write the result (default: _enhanced.wav) + -m, --model Model to use (default: quail-vf-2.2-s-16khz) + -e, --enhancement <0..1> Enhancement level (default: 1.0) + -h, --help Show this message + +A model only enhances up to its own Nyquist limit, so pair a 48 kHz source with a 48 kHz +model such as rook-l-48khz. Browse models at https://artifacts.ai-coustics.io` + +/** + * @typedef {object} Options + * @property {string} [input] WAV file to enhance + * @property {string} [output] Where to write the result + * @property {string} model Model ID + * @property {number} enhancement Enhancement level, 0.0 to 1.0 + * @property {boolean} [help] Whether usage was requested + */ + +/** + * @param {string[]} argv + * @returns {Options} + */ +function parseArgs(argv) { + /** @type {Options} */ + const options = { model: 'quail-vf-2.2-s-16khz', enhancement: 1.0 } + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + switch (arg) { + case '-i': + case '--input': + options.input = argv[(i += 1)] + break + case '-o': + case '--output': + options.output = argv[(i += 1)] + break + case '-m': + case '--model': + options.model = argv[(i += 1)] + break + case '-e': + case '--enhancement': + options.enhancement = Number(argv[(i += 1)]) + break + case '-h': + case '--help': + options.help = true + break + default: + // Accept a positional input path. + if (!arg.startsWith('-') && !options.input) { + options.input = arg + } else { + throw new Error(`Unrecognized argument: ${arg}`) + } + } } -} -if (!inputFile) { - console.error("Error: Input file is required"); - console.error("Usage: node file-processing.js --input "); - process.exit(1); -} + // This only validates what was actually passed. The caller checks that an input was + // given, since it needs the narrowed value. + if (!options.help && (!Number.isFinite(options.enhancement) || options.enhancement < 0 || options.enhancement > 1)) { + throw new Error('--enhancement must be a number between 0.0 and 1.0') + } -// Generate default output filename -if (!outputFile) { - const parsed = path.parse(inputFile); - outputFile = path.join(parsed.dir, `${parsed.name}_enhanced${parsed.ext}`); + return options } -// Check for license key -if (!process.env.AIC_SDK_LICENSE) { - console.error("Error: AIC_SDK_LICENSE environment variable not set"); - console.error("Get your license key from https://developers.ai-coustics.com"); - process.exit(1); -} +async function main() { + const options = parseArgs(process.argv.slice(2)) + if (options.help) { + console.log(USAGE) + return + } -console.log("SDK version:", getVersion()); -console.log("Input file:", inputFile); -console.log("Output file:", outputFile); - -// Read input file -let samples; -let sampleRate; - -try { - const fileBytes = fs.readFileSync(inputFile); - const wav = new WaveFile(fileBytes); - sampleRate = wav.fmt.sampleRate; - const numChannels = wav.fmt.numChannels; - - // Convert to 32-bit float samples (normalized to -1.0 to 1.0) - wav.toBitDepth("32f"); - const interleaved = wav.getSamples(true, Float32Array); - - // The SDK processes mono audio. Down-mix multi-channel input by averaging channels. - if (numChannels === 1) { - samples = interleaved; - } else { - const frames = interleaved.length / numChannels; - samples = new Float32Array(frames); - for (let frame = 0; frame < frames; frame++) { - let sum = 0; - for (let ch = 0; ch < numChannels; ch++) { - sum += interleaved[frame * numChannels + ch]; - } - samples[frame] = sum / numChannels; - } + const { input } = options + if (!input) { + throw new Error('An input file is required. Pass --input , or --help for usage.') } - console.log( - `Loaded: ${sampleRate}Hz, ${numChannels} channel(s) -> mono, ${samples.length} samples`, - ); -} catch (error) { - console.error("Failed to read input file:", error.message); - process.exit(1); -} + const licenseKey = process.env.AIC_SDK_LICENSE + if (!licenseKey) { + console.error('Error: AIC_SDK_LICENSE environment variable not set') + console.error('Get your license key from https://developers.ai-coustics.com') + process.exit(1) + } -// Download and load model -let model; -try { - console.log("Loading model:", modelId); - const modelPath = Model.download(modelId, "/tmp/aic-models"); - model = Model.fromFile(modelPath); - console.log("Model ID:", model.getId()); -} catch (error) { - console.error("Failed to load model:", error.message); - process.exit(1); -} + console.log('SDK version:', getVersion()) -// Get the optimal block size for the file's sample rate -const blockSize = model.getOptimalBlockSize(sampleRate); + const { channels, sampleRate } = readWav(input) + const frames = channels[0].length + console.log( + `Read ${input}: ${channels.length} channel(s), ${frames} frames @ ${sampleRate} Hz ` + + `(${(frames / sampleRate).toFixed(2)} s)`, + ) -console.log("Sample rate:", sampleRate); -console.log("Block size:", blockSize, "samples"); + const model = Model.fromFile(await Model.download(options.model, MODEL_DIR)) + console.log('Model id:', model.getId()) -// Create processor -let processor; -try { - processor = new Processor(model, process.env.AIC_SDK_LICENSE); - processor.initialize(sampleRate, blockSize, false); -} catch (error) { - console.error("Failed to create processor:", error.message); - process.exit(1); -} + // Use the file's sample rate; the SDK resamples internally as needed. + // The optimal block size depends on this rate. + const blockSize = model.getOptimalBlockSize(sampleRate) -// Get processor context and the delay applied to the audio -const processorContext = processor.getContext(); -const audioDelay = processorContext.getAudioDelay(); -console.log("Audio delay:", audioDelay, "samples"); - -// Set enhancement parameters -try { - processorContext.setParameter( - ProcessorParameter.EnhancementLevel, - enhancementLevel, - ); - console.log("Enhancement level:", enhancementLevel); -} catch (error) { - console.error("Warning: Failed to set parameters:", error.message); -} + // Processing is mono, so each channel gets its own processor and its own internal state. + const processors = channels.map(() => { + const processor = new Processor(model, licenseKey) + processor.initialize(sampleRate, blockSize) -// Calculate padding and total samples -const paddedLength = samples.length + audioDelay; -const totalBlocks = Math.ceil(paddedLength / blockSize); -const totalPaddedSamples = totalBlocks * blockSize; + const context = processor.getContext() + context.setParameter(ProcessorParameter.EnhancementLevel, options.enhancement) -console.log("Original samples:", samples.length); -console.log("Padded samples:", totalPaddedSamples); -console.log("Total blocks to process:", totalBlocks); + return { processor, context } + }) -// Create the padded input signal -const paddedInput = new Float32Array(totalPaddedSamples); + // Append silence to flush the delayed output, then skip the initial delay samples + // to align the enhanced file with the input. + const delay = processors[0].context.getAudioDelay() + console.log(`Block size: ${blockSize}, enhancement level: ${options.enhancement}, delay: ${delay} samples`) -// Copy the original audio into the padded signal -paddedInput.set(samples); -// Remaining samples are already zero (padding at the end to flush the audio delay) + // Padded to a whole number of blocks so every call gets exactly `blockSize` samples. + const paddedLength = Math.ceil((frames + delay) / blockSize) * blockSize -// Allocate storage for the processed output -const processedOutput = new Float32Array(totalPaddedSamples); + const started = performance.now() + const enhanced = channels.map((channel, index) => { + const padded = new Float32Array(paddedLength) + padded.set(channel) -// Process one audio block at a time -console.log("Processing..."); -const audioBlock = new Float32Array(blockSize); + for (let offset = 0; offset < paddedLength; offset += blockSize) { + // `subarray` shares the output buffer, so in-place processing writes directly into it. + processors[index].processor.process(padded.subarray(offset, offset + blockSize)) + } -for (let blockIndex = 0; blockIndex < totalBlocks; blockIndex++) { - const offset = blockIndex * blockSize; + return padded.subarray(delay, delay + frames) + }) + const elapsed = performance.now() - started - // Copy the next block from the padded input - audioBlock.set(paddedInput.subarray(offset, offset + blockSize)); + const output = options.output ?? enhancedPath(input) + writeWav(output, enhanced, sampleRate) - // Process the audio block in-place - try { - processor.process(audioBlock); - } catch (error) { - console.error(`Failed to process block ${blockIndex}:`, error.message); - process.exit(1); - } + const audioMs = (frames / sampleRate) * 1000 + console.log(`\nProcessed in ${elapsed.toFixed(0)} ms (${(audioMs / elapsed).toFixed(1)}x real time)`) + console.log('Wrote', output) - // Copy the processed block to the output signal - processedOutput.set(audioBlock, offset); -} -console.log(); - -// Remove the audio delay from the beginning -const finalOutput = processedOutput.slice( - audioDelay, - audioDelay + samples.length, -); - -// Write mono output file -try { - const outWav = new WaveFile(); - outWav.fromScratch(1, sampleRate, "32f", finalOutput); - fs.writeFileSync(outputFile, outWav.toBuffer()); - console.log("Output written to:", outputFile); -} catch (error) { - console.error("Failed to write output file:", error.message); - process.exit(1); + for (const { processor } of processors) { + processor.terminateSession() + } } -console.log("\nFile processing completed successfully"); +main().catch((error) => { + console.error('Error:', error.message) + process.exit(1) +}) diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 0000000..7bccec7 --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + // The examples are plain CommonJS `.js`, which the root project ignores. Checking them + // here is what catches an example drifting from the generated `index.d.ts`: a renamed + // method would otherwise only surface when someone ran the script by hand. + // Module settings are inherited: there is no package.json here, so the root's + // "type": "commonjs" applies and `nodenext` resolves these as CommonJS. + "allowJs": true, + "checkJs": true, + "noEmit": true, + // `wavefile`'s own declarations use `declare module` for a namespace, which TypeScript 7 + // rejects. Nothing here can fix that, and its API surface is still checked at the call + // sites in audio-file.js. + "skipLibCheck": true + }, + "include": ["."] +} diff --git a/examples/vad-async.js b/examples/vad-async.js new file mode 100644 index 0000000..0533681 --- /dev/null +++ b/examples/vad-async.js @@ -0,0 +1,85 @@ +// Voice activity detection on Node's libuv thread pool. +// +// `process` returns a promise for a copy of the original samples. The final example +// passes this audio to an enhancement processor after detection. + +const { Model, ProcessorAsync, VadAsync, VadParameter, getVersion } = require('..') + +const VAD_MODEL_ID = 'vad-2.1-xxs-16khz' +const ENHANCEMENT_MODEL_ID = 'quail-vf-2.2-s-16khz' +const MODEL_DIR = './models' + +async function main() { + const licenseKey = process.env.AIC_SDK_LICENSE + if (!licenseKey) { + console.error('Error: AIC_SDK_LICENSE environment variable not set') + console.error('Get your license key from https://developers.ai-coustics.com') + process.exit(1) + } + + console.log('SDK version:', getVersion()) + + const model = Model.fromFile(await Model.download(VAD_MODEL_ID, MODEL_DIR)) + console.log('Model id:', model.getId()) + + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + console.log(`Audio format: ${blockSize} samples @ ${sampleRate} Hz`) + + const vad = await new VadAsync(model, licenseKey).withConfig(sampleRate, blockSize) + + // Await context creation. Prediction and parameter methods are synchronous. + const context = await vad.getContext() + + context.setParameter(VadParameter.Sensitivity, 0.8) + context.setParameter(VadParameter.MinimumSpeechDuration, 0.1) + context.setParameter(VadParameter.SpeechHoldDuration, 0.2) + + console.log('Sensitivity:', context.getParameter(VadParameter.Sensitivity)) + console.log('Prediction delay:', context.getPredictionDelay(), 'samples') + + // Process silence as sample input. Replace this with audio from your source. + let audio = new Float32Array(blockSize) + for (let block = 0; block < 10; block += 1) { + // Resolves to the same samples, unmodified, so one variable carries the stream. + audio = await vad.process(audio) + } + + console.log('\nSpeech detected:', context.isSpeechDetected()) + console.log('Raw probability:', context.getRawVadProbability().toFixed(4)) + + // Run detection before enhancement so the VAD receives the original input. + // Enhanced audio changes the signal seen by the VAD and adds processing delay. + console.log('\nRunning detection and enhancement on the same stream') + + const enhancementModel = Model.fromFile(await Model.download(ENHANCEMENT_MODEL_ID, MODEL_DIR)) + + // Configure both instances with the same block size to share input blocks. + // Models may have different optimal block sizes. + const processor = await new ProcessorAsync(enhancementModel, licenseKey).withConfig(sampleRate, blockSize) + const processorContext = await processor.getContext() + + for (let i = 0; i < 10; i += 1) { + // Read a new input block for each iteration; do not reuse enhanced output as VAD input. + const block = Float32Array.from({ length: blockSize }, () => (Math.random() - 0.5) * 0.2) + + // The VAD sees the input, then the processor enhances it. + const enhanced = await processor.process(await vad.process(block)) + + // Hand the enhanced audio on to playback or an encoder from here. + console.log(`Block ${i}: speech ${context.isSpeechDetected()}, ${enhanced.length} samples out`) + } + + // Audio delay and VAD prediction delay are independent measurements in samples. + console.log('Audio delay:', processorContext.getAudioDelay(), 'samples') + console.log('Prediction delay:', context.getPredictionDelay(), 'samples') + + await vad.terminateSession() + await processor.terminateSession() + console.log('\nAsync VAD example completed successfully') +} + +main().catch((error) => { + console.error('Error:', error.message) + process.exit(1) +}) diff --git a/examples/vad.js b/examples/vad.js index d7652bd..8169351 100644 --- a/examples/vad.js +++ b/examples/vad.js @@ -1,81 +1,72 @@ -const { - Model, - Vad, - VadParameter, - getVersion, - getCompatibleModelVersion, -} = require(".."); -const os = require("os"); -const path = require("path"); - -const licenseKey = process.env.AIC_SDK_LICENSE; -if (!licenseKey) { - console.error("Error: AIC_SDK_LICENSE environment variable not set"); - console.error("Get your license key from https://developers.ai-coustics.com"); - process.exit(1); -} +// Voice activity detection on the main thread. +// +// The VAD runs a dedicated VAD model and is driven independently of any processor. +// `process` reads the block and leaves it untouched; predictions are read from the context. + +const { Model, Vad, VadParameter, getVersion } = require('..') + +const MODEL_ID = 'vad-2.1-xxs-16khz' +const MODEL_DIR = './models' + +async function main() { + const licenseKey = process.env.AIC_SDK_LICENSE + if (!licenseKey) { + console.error('Error: AIC_SDK_LICENSE environment variable not set') + console.error('Get your license key from https://developers.ai-coustics.com') + process.exit(1) + } + + console.log('SDK version:', getVersion()) + + // A dedicated VAD model. Enhancement models are rejected. + const modelPath = await Model.download(MODEL_ID, MODEL_DIR) + const model = Model.fromFile(modelPath) + console.log('Model id:', model.getId()) + + const sampleRate = model.getOptimalSampleRate() + const blockSize = model.getOptimalBlockSize(sampleRate) + console.log(`Audio format: ${blockSize} samples @ ${sampleRate} Hz`) + + const vad = new Vad(model, licenseKey) + vad.initialize(sampleRate, blockSize) -console.log("SDK version:", getVersion()); -console.log("Compatible model version:", getCompatibleModelVersion()); - -try { - // Voice activity detection requires a dedicated VAD model. Enhancement models are rejected - // because the model type is not supported by this operation. - // Select a model id at https://artifacts.ai-coustics.io/ - const downloadDir = path.join(os.tmpdir(), "aic-models"); - const modelPath = Model.download("vad-2.1-xxs-16khz", downloadDir); - const model = Model.fromFile(modelPath); - const sampleRate = model.getOptimalSampleRate(); - const blockSize = model.getOptimalBlockSize(sampleRate); - - // Create the VAD with the license key and initialize it - const vad = new Vad(model, licenseKey); - vad.initialize(sampleRate, blockSize, false); - - console.log("Model ID:", model.getId()); - console.log("Sample rate:", sampleRate); - console.log("Block size:", blockSize); - - // Get VAD context for thread safe interaction with the prediction and its parameters - const vadContext = vad.getContext(); - - // How far the prediction lags behind the input. This delay is not applied to the audio, - // Vad.process() leaves the buffer untouched. - console.log("Prediction delay:", vadContext.getPredictionDelay(), "samples"); - - // Configure the detector. Sensitivity is the probability threshold of the model output. - vadContext.setParameter(VadParameter.SpeechHoldDuration, 0.08); - vadContext.setParameter(VadParameter.Sensitivity, 0.5); - vadContext.setParameter(VadParameter.MinimumSpeechDuration, 0.0); - - console.log( - "Speech hold duration:", - vadContext.getParameter(VadParameter.SpeechHoldDuration), - ); - console.log( - "Sensitivity:", - vadContext.getParameter(VadParameter.Sensitivity), - ); - - // Feed mono audio to the detector. The audio block is not modified, it only updates the - // prediction. Replace the silence below with your own audio. - // - // When enhancement and VAD run together, feed the VAD the original input audio rather than - // the enhanced output of Processor.process(). - const audioBlock = new Float32Array(blockSize); - vad.process(audioBlock); - - console.log("Speech detected:", vadContext.isSpeechDetected()); - console.log("Raw VAD probability:", vadContext.rawVadProbability()); - - // Clear the prediction and all internal state, e.g. when the stream is interrupted - vadContext.reset(); - - // End the telemetry session on demand instead of waiting for the VAD to be destroyed. - // The VAD can no longer process audio after this call. - vad.terminateSession(); - console.log("Telemetry session terminated"); -} catch (error) { - console.error("Failed to run VAD:", error.message); - process.exit(1); + const context = vad.getContext() + + // How readily speech is detected, and how the decision is stabilized either side of a + // transition. All three can be changed while audio is being processed. + context.setParameter(VadParameter.Sensitivity, 0.8) + context.setParameter(VadParameter.MinimumSpeechDuration, 0.1) + context.setParameter(VadParameter.SpeechHoldDuration, 0.2) + + // The hold and minimum durations are rounded to the model's window length, so reads can + // differ from writes. + console.log('Sensitivity:', context.getParameter(VadParameter.Sensitivity)) + console.log('Minimum speech duration:', context.getParameter(VadParameter.MinimumSpeechDuration), 's') + console.log('Speech hold duration:', context.getParameter(VadParameter.SpeechHoldDuration), 's') + + // The decision lags its input by this many samples. It is not applied to the audio, so + // use it to align speech decisions with the input audio. + console.log('Prediction delay:', context.getPredictionDelay(), 'samples') + + // Process silence as sample input. Replace this with audio from your source. + const audio = new Float32Array(blockSize) + for (let block = 0; block < 10; block += 1) { + // Reads the block without modifying it, so the same buffer can go on to a processor. + vad.process(audio) + } + + console.log('\nSpeech detected:', context.isSpeechDetected()) + // The model's raw output, before speech-hold and thresholding. + console.log('Raw probability:', context.getRawVadProbability().toFixed(4)) + + // Clear internal state, including the published prediction, on a discontinuity or seek. + context.reset() + + vad.terminateSession() + console.log('\nVAD example completed successfully') } + +main().catch((error) => { + console.error('Error:', error.message) + process.exit(1) +}) diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..305c824 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,727 @@ +/* auto-generated by NAPI-RS */ +/* eslint-disable */ +/** + * Analyzes audio quality using an analysis model, such as Tyto. + * + * Call {@link Analyzer#initialize}, then {@link Analyzer#buffer} to collect mono audio. + * The model determines how much audio is retained; older samples are discarded as new + * audio arrives. + * + * Run {@link Analyzer#analyzeAsync} to analyze the buffered audio on a libuv worker + * thread, or {@link Analyzer#analyze} to run it on the calling thread. Analysis is + * computationally expensive and should not run in audio processing callbacks. + * Buffering remains synchronous and can continue while async analysis is running. + */ +export declare class Analyzer { + /** + * Creates an analyzer from an analysis model. + * + * Construction allocates memory. Call {@link Analyzer#initialize} before buffering audio. + * + * @param model - Analysis model. Other model types are rejected. + * @param licenseKey - SDK license key from . + */ + constructor(model: Model, licenseKey: string) + /** + * Destroys the native collector and analyzer without waiting for garbage collection. + * + * After disposal, all methods except `dispose()` fail. Repeated disposal has no effect. + * This call blocks the calling thread while a worker holds the analyzer lock. + * Queued analysis that acquires the lock after disposal rejects its promise. + */ + dispose(): void + /** + * Configures the analyzer's audio collector. + * + * Call this before buffering audio. Use {@link Model#getOptimalSampleRate} and + * {@link Model#getOptimalBlockSize} to avoid internal resampling and rebuffering. + * This method allocates memory; avoid calling it from audio processing callbacks. + * + * @param sampleRate - Audio sample rate in Hz. + * @param blockSize - Number of mono samples per block. + * @param variableBlockSize - Allow blocks shorter than `blockSize`. Defaults to `false`. + * Blocks larger than `blockSize` are always rejected. + */ + initialize(sampleRate: number, blockSize: number, variableBlockSize?: boolean | undefined | null): void + /** + * Buffers a mono audio block for later analysis without modifying the input. + * + * Call {@link Analyzer#initialize} first. The block must contain exactly `blockSize` + * samples, or at most `blockSize` if `variableBlockSize` is enabled. + * Buffering does not acquire the analyzer lock and can run during async analysis. + */ + buffer(audio: Float32Array): void + /** + * Analyzes buffered audio on the calling thread and returns the scores. + * + * The model analyzes a fixed duration of audio. If less audio has been buffered, the + * remaining input is padded with silence. + * + * This method blocks the calling thread. Use {@link Analyzer#analyzeAsync} to keep the + * event loop available. For multichannel audio, mix down to mono or use one analyzer + * per channel. + */ + analyze(): AnalysisResult + /** + * Analyzes buffered audio on a libuv worker thread and returns a promise for the scores. + * + * Uses the same analysis and silence padding as {@link Analyzer#analyze}. + * {@link Analyzer#buffer} can continue collecting audio while analysis runs. + * + * Synchronous calls to `analyze`, `reset`, `updateBearerToken`, `terminateSession` and + * `dispose` wait for the analyzer lock and may block while analysis is running. + */ + analyzeAsync(): Promise + /** Clears buffered audio and internal state while preserving the configured audio settings. */ + reset(): void + /** + * Replaces the bearer token on the running analyzer. + * + * Use this to refresh a JWT without recreating the instance. Both the original license + * key and the new token must be JWTs. If this call fails, the previous token remains active. + * + * A successful call validates the token's format and applies it immediately. Backend + * acceptance is checked later. If the backend rejects the token, the SDK retries with + * backoff; analysis may be rejected if no accepted token arrives in time. + * Supply a valid token to recover the session. + * + * This method allocates memory and takes a mutex. Avoid calling it from audio processing callbacks. + */ + updateBearerToken(token: string): void + /** + * Terminates the telemetry session associated with this analyzer. + * + * Once termination is handled, the analyzer can no longer analyze buffered audio. + * The session also ends when the native analyzer is destroyed. This method may block; + * avoid calling it from audio processing callbacks. If another session is still active, + * termination can complete asynchronously. + */ + terminateSession(): void +} + +/** + * A loaded ai-coustics model. + * + * The same model can be used to create multiple independent instances of a compatible + * processor, VAD or analyzer. Each instance retains a reference to the model data, + * so the `Model` handle can be disposed or garbage collected first. + */ +export declare class Model { + /** + * Loads a model from a `.aicmodel` file. + * + * The SDK memory-maps the file. Do not modify or delete it while the model or any + * processor, VAD or analyzer created from it is still alive. + * + * Download models with {@link Model.download}. Available model IDs are listed at + * . + * + * @param path - Path to the model file. + * @throws If the file cannot be loaded or its format is incompatible with this SDK. + */ + static fromFile(path: string): Model + /** + * Releases this handle's reference to the native model without waiting for garbage collection. + * + * Instances created from this model remain usable. The file mapping is released when + * its last reference is destroyed. + * + * After disposal, all methods except `dispose()` throw. Repeated disposal has no effect. + */ + dispose(): void + /** + * Downloads a model from the ai-coustics CDN and returns a promise for its file path. + * + * Each call fetches the manifest to select the latest compatible model version. + * An existing file is reused if its checksum matches; otherwise it is replaced. + * The download runs on Node's libuv thread pool. + * + * @param modelId - Model ID listed at . + * @param downloadDir - Directory in which to store the model. + * @returns A promise for the downloaded or cached model's path. + */ + static download(modelId: string, downloadDir: string): Promise + /** Returns the model identifier, including its build and format version suffixes. */ + getId(): string + /** + * Returns the sample rate in Hz for which the model was trained. + * + * The SDK resamples audio at other supported rates internally. Enhancement is limited + * to frequencies below half the model's sample rate. + */ + getOptimalSampleRate(): number + /** + * Returns the optimal block size in samples for the given sample rate. + * + * Use this value to avoid additional buffering latency. The block size depends on the + * sample rate because each model processes a fixed duration of audio. + * + * @param sampleRate - Audio sample rate in Hz. + */ + getOptimalBlockSize(sampleRate: number): number +} + +/** + * Processes mono audio with an enhancement or bypass model. + * + * Call {@link Processor#initialize} before processing audio. Each processor maintains + * its own state; create one instance per audio stream. Multiple processors can share + * the same {@link Model}. + * + * Use {@link ProcessorAsync} to run processing on Node's libuv thread pool. + */ +export declare class Processor { + /** + * Creates a new speech enhancement processor. + * + * Construction is synchronous and throws if creation fails. Call + * {@link Processor#initialize} before processing audio. + * + * @param model - Enhancement or bypass model. Other model types are rejected. + * @param licenseKey - SDK license key from . + * @param otelConfig - Optional telemetry configuration. When omitted, telemetry follows + * the runtime environment. + */ + constructor(model: Model, licenseKey: string, otelConfig?: OtelConfig | undefined | null) + /** + * Destroys the native processor and releases its telemetry session. + * + * Use this for cleanup at a specific point instead of waiting for garbage collection. + * After disposal, all methods except `dispose()` fail. Repeated disposal has no effect. + */ + dispose(): void + /** + * Configures the processor for the given audio format. + * + * Call this method before processing audio. Use {@link Model#getOptimalSampleRate} and + * {@link Model#getOptimalBlockSize} for the lowest delay. + * This method allocates memory; avoid calling it from audio processing callbacks. + * + * @param sampleRate - Audio sample rate in Hz. + * @param blockSize - Number of mono samples per block. + * @param variableBlockSize - Allow blocks shorter than `blockSize`. Defaults to `false`. + * Variable block sizes can add buffering latency. Larger blocks are always rejected. + */ + initialize(sampleRate: number, blockSize: number, variableBlockSize?: boolean | undefined | null): void + /** + * Enhances a mono audio block in place. + * + * Call {@link Processor#initialize} first. The block must contain exactly `blockSize` + * samples, or at most `blockSize` if `variableBlockSize` is enabled. + * If the input uses a SharedArrayBuffer, prevent other workers from accessing it during + * this call. + */ + process(audio: Float32Array): void + /** + * Creates a handle for reading and writing this processor's parameters and state. + * + * Each call returns an independent handle to the same processor. + */ + getContext(): ProcessorContext + /** + * Terminates the telemetry session associated with this processor. + * + * Once termination is handled, the processor can no longer process audio. + * The session also ends when the native object is destroyed. Use this method when + * termination must be requested at a specific lifecycle event. + * + * This method may block. Avoid calling it from audio processing callbacks. + * If another session is still active, termination can complete asynchronously. + */ + terminateSession(): void +} + +/** + * Speech enhancement for use in async applications. + * + * Initialization, processing and context creation run on Node's libuv thread pool and + * return promises. Construction and disposal are synchronous. + * + * Use {@link Processor} when processing should run on the calling thread. + * + * ### Threading + * + * Use one instance per stream and await each operation before submitting the next. + * Concurrent calls on one instance are not guaranteed to execute in submission order. + * Use separate instances to process multiple streams concurrently. + * + * The libuv pool defaults to four threads and is shared with filesystem, DNS and crypto + * work. Set `UV_THREADPOOL_SIZE` before starting Node to change its size. + * `AIC_NUM_THREADS` does not apply to these bindings. + */ +export declare class ProcessorAsync { + /** + * Creates a new async speech enhancement processor. + * + * Construction is synchronous and throws if creation fails. Await + * {@link ProcessorAsync#initialize} or {@link ProcessorAsync#withConfig} before processing audio. + * + * @param model - Enhancement or bypass model. Other model types are rejected. + * @param licenseKey - SDK license key from . + * @param otelConfig - Optional telemetry configuration. When omitted, telemetry follows + * the runtime environment. + */ + constructor(model: Model, licenseKey: string, otelConfig?: OtelConfig | undefined | null) + /** + * Destroys the native processor and releases its telemetry session. + * + * Use this for cleanup at a specific point instead of waiting for garbage collection. + * After disposal, all methods except `dispose()` fail. Repeated disposal has no effect. + * + * This call blocks the calling thread while a worker holds the instance lock. + * Queued work that acquires the lock after disposal rejects its promise. + */ + dispose(): void + /** + * Initializes the processor and returns a promise for a handle to the initialized instance. + * + * Uses the same configuration as {@link ProcessorAsync#initialize}. The returned handle and + * this object share the same native instance; disposing either invalidates both. + * + * ```javascript + * const processor = await new ProcessorAsync(model, licenseKey).withConfig(sampleRate, blockSize) + * ``` + */ + withConfig(sampleRate: number, blockSize: number, variableBlockSize?: boolean | undefined | null): Promise + /** + * Configures the processor for the given audio format. + * + * Await this method before processing audio. Use {@link Model#getOptimalSampleRate} and + * {@link Model#getOptimalBlockSize} for the lowest delay. + * Initialization allocates memory and runs on a libuv worker thread. + * + * @param sampleRate - Audio sample rate in Hz. + * @param blockSize - Number of mono samples per block. + * @param variableBlockSize - Allow blocks shorter than `blockSize`. Defaults to `false`. + * Variable block sizes can add buffering latency. Larger blocks are always rejected. + */ + initialize(sampleRate: number, blockSize: number, variableBlockSize?: boolean | undefined | null): Promise + /** + * Enhances a mono audio block and returns a promise for the enhanced samples. + * + * The input is copied before work is queued and remains unmodified. The promise + * resolves to a new `Float32Array` containing the enhanced samples. + * + * The instance must be initialized first. The block must contain exactly `blockSize` + * samples, or at most `blockSize` if `variableBlockSize` is enabled. + * Await each call before submitting the next block. + * + * ```javascript + * const enhanced = await processor.process(block) + * ``` + */ + process(audio: Float32Array): Promise> + /** + * Returns a promise for a {@link ProcessorContext} to control this processor. + * + * Context creation runs on a worker thread because it may wait for processing to + * release the instance lock. The returned context's methods are synchronous and can + * be called while audio is being processed. + */ + getContext(): Promise + /** + * Terminates the telemetry session associated with this processor. + * + * Once termination is handled, the processor can no longer process audio. + * The session also ends when the native object is destroyed. Use this method when + * termination must be requested at a specific lifecycle event. + * + * Termination runs on a libuv worker thread because it may block. + * If another session is still active, termination can complete asynchronously. + */ + terminateSession(): Promise +} + +/** + * Control handle for a {@link Processor}. + * + * Every method may be called while audio is being processed. Handle and processor have + * independent lifetimes: releasing the handle does not destroy the processor, and the + * handle stays valid after its processor is disposed or garbage-collected, though its + * calls then no longer reach a live processor. + */ +export declare class ProcessorContext { + /** Sets an enhancement parameter. Throws if the value is out of range. */ + setParameter(parameter: ProcessorParameter, value: number): void + /** Returns the current value of an enhancement parameter. */ + getParameter(parameter: ProcessorParameter): number + /** + * Returns the audio delay in samples at the configured sample rate. + * + * Covers algorithmic delay plus any buffering from a non-optimal block size. Before + * initialization it reports the base delay at the model's optimal settings. + */ + getAudioDelay(): number + /** + * Clears internal state and buffers while preserving the configured audio settings. + * + * Call this when the stream is interrupted or when seeking to prevent previous audio + * from affecting the output. + */ + reset(): void + /** + * Replaces the bearer token on the running processor. + * + * Use this to refresh a JWT without recreating the instance. Both the original license + * key and the new token must be JWTs. If this call fails, the previous token remains active. + * + * A successful call validates the token's format and applies it immediately. Backend + * acceptance is checked later. If the backend rejects the token, the SDK retries with + * backoff; processing is eventually disabled if no accepted token arrives in time. + * Supply a valid token to recover the session. + * + * This method allocates memory and takes a mutex. Avoid calling it from audio processing callbacks. + */ + updateBearerToken(token: string): void +} + +/** + * Detects speech using a dedicated VAD model. + * + * Call {@link Vad#initialize}, then pass mono audio to {@link Vad#process}. + * Processing leaves the audio unmodified and updates the prediction, which can be read + * through a {@link VadContext}. + * + * When using enhancement and detection together, pass the original input to the VAD + * before calling {@link Processor#process}. Enhanced audio changes the signal seen by + * the VAD and adds the processor's audio delay to the prediction delay. + */ +export declare class Vad { + /** + * Creates a new voice activity detector. + * + * Construction is synchronous and throws if creation fails. Call + * {@link Vad#initialize} before processing audio. + * + * @param model - Dedicated VAD model. Other model types are rejected. + * @param licenseKey - SDK license key from . + * @param otelConfig - Optional telemetry configuration. When omitted, telemetry follows + * the runtime environment. + */ + constructor(model: Model, licenseKey: string, otelConfig?: OtelConfig | undefined | null) + /** + * Destroys the native VAD and releases its telemetry session. + * + * Use this for cleanup at a specific point instead of waiting for garbage collection. + * After disposal, all methods except `dispose()` fail. Repeated disposal has no effect. + */ + dispose(): void + /** + * Configures the VAD for the given audio format. + * + * Call this method before processing audio. Use {@link Model#getOptimalSampleRate} and + * {@link Model#getOptimalBlockSize} for the most frequent prediction updates. + * This method allocates memory; avoid calling it from audio processing callbacks. + * + * @param sampleRate - Audio sample rate in Hz. + * @param blockSize - Number of mono samples per block. + * @param variableBlockSize - Allow blocks shorter than `blockSize`. Defaults to `false`. + * Variable block sizes can add buffering latency. Larger blocks are always rejected. + */ + initialize(sampleRate: number, blockSize: number, variableBlockSize?: boolean | undefined | null): void + /** + * Processes a mono audio block and updates the VAD prediction without modifying the input. + * + * Call {@link Vad#initialize} first. The block must contain exactly `blockSize` samples, + * or at most `blockSize` if `variableBlockSize` is enabled. + */ + process(audio: Float32Array): void + /** + * Creates a handle for reading predictions and controlling this VAD. + * + * Each call returns an independent handle to the same VAD. + */ + getContext(): VadContext + /** + * Terminates the telemetry session associated with this VAD. + * + * Once termination is handled, the VAD can no longer process audio. + * The session also ends when the native object is destroyed. Use this method when + * termination must be requested at a specific lifecycle event. + * + * This method may block. Avoid calling it from audio processing callbacks. + * If another session is still active, termination can complete asynchronously. + */ + terminateSession(): void +} + +/** + * Voice activity detection for use in async applications. + * + * Initialization, processing and context creation run on Node's libuv thread pool and + * return promises. Construction and disposal are synchronous. + * + * Read predictions through a {@link VadContext}. Pass the original input audio to the + * VAD before enhancement. + * + * ### Threading + * + * Use one instance per stream and await each operation before submitting the next. + * Concurrent calls on one instance are not guaranteed to execute in submission order. + * Use separate instances to process multiple streams concurrently. + * + * The libuv pool defaults to four threads and is shared with filesystem, DNS and crypto + * work. Set `UV_THREADPOOL_SIZE` before starting Node to change its size. + * `AIC_NUM_THREADS` does not apply to these bindings. + */ +export declare class VadAsync { + /** + * Creates a new async voice activity detector. + * + * Construction is synchronous and throws if creation fails. Await + * {@link VadAsync#initialize} or {@link VadAsync#withConfig} before processing audio. + * + * @param model - Dedicated VAD model. Other model types are rejected. + * @param licenseKey - SDK license key from . + * @param otelConfig - Optional telemetry configuration. When omitted, telemetry follows + * the runtime environment. + */ + constructor(model: Model, licenseKey: string, otelConfig?: OtelConfig | undefined | null) + /** + * Destroys the native VAD and releases its telemetry session. + * + * Use this for cleanup at a specific point instead of waiting for garbage collection. + * After disposal, all methods except `dispose()` fail. Repeated disposal has no effect. + * + * This call blocks the calling thread while a worker holds the instance lock. + * Queued work that acquires the lock after disposal rejects its promise. + */ + dispose(): void + /** + * Initializes the VAD and returns a promise for a handle to the initialized instance. + * + * Uses the same configuration as {@link VadAsync#initialize}. The returned handle and + * this object share the same native instance; disposing either invalidates both. + * + * ```javascript + * const vad = await new VadAsync(model, licenseKey).withConfig(sampleRate, blockSize) + * ``` + */ + withConfig(sampleRate: number, blockSize: number, variableBlockSize?: boolean | undefined | null): Promise + /** + * Configures the VAD for the given audio format. + * + * Await this method before processing audio. Use {@link Model#getOptimalSampleRate} and + * {@link Model#getOptimalBlockSize} for the most frequent prediction updates. + * Initialization allocates memory and runs on a libuv worker thread. + * + * @param sampleRate - Audio sample rate in Hz. + * @param blockSize - Number of mono samples per block. + * @param variableBlockSize - Allow blocks shorter than `blockSize`. Defaults to `false`. + * Variable block sizes can add buffering latency. Larger blocks are always rejected. + */ + initialize(sampleRate: number, blockSize: number, variableBlockSize?: boolean | undefined | null): Promise + /** + * Updates the VAD prediction and returns a promise for the original mono audio samples. + * + * The input is copied before work is queued and remains unmodified. The promise + * resolves to a new `Float32Array` containing the original samples. + * + * The instance must be initialized first. The block must contain exactly `blockSize` + * samples, or at most `blockSize` if `variableBlockSize` is enabled. + * Await each call before submitting the next block. + * + * ```javascript + * const audio = await vad.process(block) + * ``` + */ + process(audio: Float32Array): Promise> + /** + * Returns a promise for a {@link VadContext} to control this VAD and read predictions. + * + * Context creation runs on a worker thread because it may wait for processing to + * release the instance lock. The returned context's methods are synchronous and can + * be called while audio is being processed. + */ + getContext(): Promise + /** + * Terminates the telemetry session associated with this VAD. + * + * Once termination is handled, the VAD can no longer process audio. + * The session also ends when the native object is destroyed. Use this method when + * termination must be requested at a specific lifecycle event. + * + * Termination runs on a libuv worker thread because it may block. + * If another session is still active, termination can complete asynchronously. + */ + terminateSession(): Promise +} + +/** + * Control handle for a {@link Vad}. + * + * Every method may be called while audio is being processed. If the backing VAD is + * released the prediction stops updating, but this handle stays valid. + */ +export declare class VadContext { + /** Sets a VAD parameter. Throws if the value is out of range. */ + setParameter(parameter: VadParameter, value: number): void + /** Returns the current value of a VAD parameter. */ + getParameter(parameter: VadParameter): number + /** + * Returns whether speech is currently detected. + * + * The decision lags its input by {@link VadContext#getPredictionDelay} samples, and + * stops updating if the backing VAD stops being processed. + */ + isSpeechDetected(): boolean + /** + * Returns the model's speech probability in the range 0.0 to 1.0. + * + * This value excludes speech hold, sensitivity thresholding and minimum speech duration. + * Use it to implement custom detection logic. The prediction delay reported by + * {@link VadContext#getPredictionDelay} also applies to this value. + */ + getRawVadProbability(): number + /** + * Returns the prediction delay in samples at the configured sample rate. + * + * Includes input buffering, STFT and model processing. Non-optimal or variable block + * sizes can add buffering latency. Convert to milliseconds with + * `delaySamples * 1000 / sampleRate`. + * + * Use this delay to align speech decisions with the input audio. It is independent of + * a processor's audio delay; VAD processing does not delay or modify the audio. + */ + getPredictionDelay(): number + /** + * Clears internal state and buffers, including the published speech decision and probability. + * + * Call this when the stream is interrupted or when seeking to prevent predictions + * from using previous audio. The VAD remains initialized with its configured settings. + */ + reset(): void + /** + * Replaces the bearer token on the running VAD. + * + * Use this to refresh a JWT without recreating the instance. Both the original license + * key and the new token must be JWTs. If this call fails, the previous token remains active. + * + * A successful call validates the token's format and applies it immediately. Backend + * acceptance is checked later. If the backend rejects the token, the SDK retries with + * backoff; processing is eventually disabled if no accepted token arrives in time. + * Supply a valid token to recover the session. + * + * This method allocates memory and takes a mutex. Avoid calling it from audio processing callbacks. + */ + updateBearerToken(token: string): void +} + +/** + * Overrides the telemetry wrapper ID. Internal only, for ai-coustics wrappers embedding + * this package (e.g. the LiveKit plugin): call before constructing any `Processor`, `Vad` + * or `Analyzer`, whose constructors otherwise claim the ID for this SDK. The ID can only + * be set once per process; later writes are silently discarded. + */ +export declare function _setSdkId(id: number): void + +/** + * Results of analyzing an audio signal with {@link Analyzer}. + * + * Scores range from 0.0 to 1.0. For every field except `speakerLoudness`, lower values + * indicate less problematic audio. + */ +export interface AnalysisResult { + /** + * Predicts the likelihood of failure in downstream models, including speech-to-text, + * voice activity detection, turn-taking and speech-to-speech models. + */ + riskScore: number + /** Measure of speaker distance and reverberation. */ + speakerReverb: number + /** Measure of speaker loudness. */ + speakerLoudness: number + /** Measure of interfering speech from sources other than the main speaker. */ + interferingSpeech: number + /** Measure of ambient or environmental noise. */ + noise: number + /** Measure of artifacts from lossy speech codecs, such as low bitrate or narrowband codecs. */ + codecDegradation: number + /** + * Measure of audio dropouts and discontinuities from packet loss, frame erasure, jitter or CPU + * overload. + */ + packetLoss: number +} + +/** Returns the model file format version supported by this SDK. */ +export declare function getCompatibleModelVersion(): number + +/** + * Returns the version of the underlying native SDK. + * + * This may differ from the Node.js package version. + */ +export declare function getVersion(): string + +/** + * OpenTelemetry configuration for a processor or VAD instance. + * + * Overrides the SDK's environment-based telemetry settings, such as + * `AIC_SDK_OTEL_ENABLE`, for the instance receiving this configuration. + */ +export interface OtelConfig { + /** Whether to export telemetry. */ + enable: boolean + /** Session ID to report. A random one is generated when omitted. */ + sessionId?: string + /** Metric export interval in milliseconds. Omit or pass 0 for the SDK default of 60000. */ + exportIntervalMs?: number +} + +/** Configurable speech enhancement parameters. Values can be changed during processing. */ +export declare const enum ProcessorParameter { + /** + * Bypasses enhancement while preserving the processing delay. + * + * This allows enhancement to be enabled or disabled without clicks or timing changes. + * + * Range: 0.0 to 1.0. At 0.0 enhancement is active; at 1.0 audio passes through with + * latency compensation. Default: 0.0. + */ + Bypass = 0, + /** + * Controls enhancement strength. + * + * Quail models apply stronger noise suppression at higher values, including suppression + * of competing speech for Voice Focus models. Rook models adjust the mix of original + * and enhanced audio. + * + * Range: 0.0 to 1.0. + */ + EnhancementLevel = 1 +} + +/** Configurable voice activity detection parameters. Values can be changed during processing. */ +export declare const enum VadParameter { + /** + * Controls how long the VAD continues reporting speech after speech stops. + * + * Speech is reported if at least half the blocks processed in the last + * `speechHoldDuration * 2` seconds contained speech. Additional speech during this + * period extends the detection period. + * + * The duration is rounded to the nearest model window length, so the value read back + * may differ from the value set. + * + * Range: 0.0 to 300 times the model window length, in seconds. Default: model-specific. + */ + SpeechHoldDuration = 0, + /** + * Sets the probability threshold for detecting speech in an audio block. + * + * A model probability above this threshold counts as speech. + * + * Range: 0.0 to 1.0. Default: model-specific. + */ + Sensitivity = 1, + /** + * Controls how long speech must be present before the VAD reports speech. + * + * The duration is rounded to the nearest model window length, so the value read back + * may differ from the value set. + * + * Range: 0.0 to 1.0 seconds. Default: model-specific. + */ + MinimumSpeechDuration = 2 +} diff --git a/index.js b/index.js index c4ea5db..22b3ed6 100644 --- a/index.js +++ b/index.js @@ -1,1025 +1,572 @@ -// Platform-specific binary loader -let native; -try { - const platform = process.platform; - const arch = process.arch; - - const platformPackages = { - "linux-x64": "@ai-coustics/aic-sdk-linux-x64-gnu", - "linux-arm64": "@ai-coustics/aic-sdk-linux-arm64-gnu", - "darwin-x64": "@ai-coustics/aic-sdk-darwin-x64", - "darwin-arm64": "@ai-coustics/aic-sdk-darwin-arm64", - "win32-x64": "@ai-coustics/aic-sdk-win32-x64-msvc", - "win32-arm64": "@ai-coustics/aic-sdk-win32-arm64-msvc", - }; - - const platformKey = `${platform}-${arch}`; - const platformPackage = platformPackages[platformKey]; - - if (platformPackage) { - try { - native = require(platformPackage); - } catch (e) { - native = require("./index.node"); +// prettier-ignore +/* eslint-disable */ +// @ts-nocheck +/* auto-generated by NAPI-RS */ + +const { createRequire } = require('node:module') +require = createRequire(__filename) + +const { readFileSync } = require('node:fs') +let nativeBinding = null +const loadErrors = [] + +const isMusl = () => { + let musl = false + if (process.platform === 'linux') { + musl = isMuslFromFilesystem() + if (musl === null) { + musl = isMuslFromReport() + } + if (musl === null) { + musl = isMuslFromChildProcess() } - } else { - native = require("./index.node"); } -} catch (e) { - throw new Error( - `Failed to load native binary for platform ${process.platform}-${process.arch}. ` + - `Supported platforms: Linux (x64/ARM64, GNU libc), macOS (x64/ARM64), Windows (x64/ARM64, MSVC). ` + - `Error: ${e.message}`, - ); + return musl } -/** - * Configurable parameters for audio enhancement. - * @enum {number} - */ -const ProcessorParameter = { - /** - * Controls whether audio processing is bypassed while preserving algorithmic delay. - * - * When enabled, the input audio passes through unmodified, but the output is still - * delayed by the same amount as during normal processing. This ensures seamless - * transitions when toggling enhancement on/off without audible clicks or timing shifts. - * - * Range: 0.0 to 1.0 - * - 0.0: Enhancement active (normal processing) - * - 1.0: Bypass enabled (latency-compensated passthrough) - * - * Default: 0.0 - */ - Bypass: native.PROCESSOR_PARAM_BYPASS, - - /** - * A tunable parameter to optimize for specific STT engines, deployment environments, and user experience requirements. - * - * The exact behavior depends on the active model: - * - * - Quail Models: Controls how aggressively the model suppresses noise. When used with Quail - * Voice Focus, it also suppresses background and competing speech. - * - Rook Models: Controls the mixback and therefore the intensity of the enhancement. - * - * Range: 0.0 to 1.0 - */ - EnhancementLevel: native.PROCESSOR_PARAM_ENHANCEMENT_LEVEL, -}; - -/** - * Configurable parameters for Voice Activity Detection. - * @enum {number} - */ -const VadParameter = { - /** - * Controls for how long the VAD continues to detect speech after the audio signal - * no longer contains speech. - * - * This affects the stability of speech detected -> not detected transitions. - * - * The VAD reports speech detected if the audio signal contained speech in at least 50% - * of the blocks processed in the last `speech_hold_duration * 2` seconds. - * - * For example, if `speech_hold_duration` is set to 0.5 seconds and the VAD stops detecting speech - * in the audio signal, the VAD will continue to report speech for 0.5 seconds assuming the - * VAD does not detect speech again during that period. If a few blocks of speech are detected - * during that period, those blocks will be included in the 50% calculation, which will extend - * the speech detection period until the 50% threshold is no longer met. - * - * NOTE: The VAD returns a value per processed audio block, so this duration is rounded - * to the closest model window length. For example, if the model has a processing window - * length of 10 ms, the VAD will round up/down to the closest multiple of 10 ms. - * Because of this, this parameter may return a different value than the one it was last set to. - * - * **Range:** 0.0 to 300x model window length (value in seconds) - * - * **Default:** model-specific - */ - SpeechHoldDuration: native.VAD_PARAM_SPEECH_HOLD_DURATION, - - /** - * Controls the sensitivity of the VAD. - * - * VAD models output a probability of speech presence for each processed audio block, - * 1.0 being the model is certain speech is present and 0.0 being the model is certain - * speech is not present. The probability is compared against the sensitivity threshold - * to determine if speech is detected. - * - * A value above the threshold will trigger a "speech detected" decision. - * - * **Range:** 0.0 to 1.0 - * - * **Default:** model-specific - */ - Sensitivity: native.VAD_PARAM_SENSITIVITY, - - /** - * Controls for how long speech needs to be present in the audio signal before - * the VAD considers it speech. - * - * This affects the stability of speech not detected -> detected transitions. - * - * NOTE: The VAD returns a value per processed audio block, so this duration is rounded - * to the closest model window length. For example, if the model has a processing window - * length of 10 ms, the VAD will round up/down to the closest multiple of 10 ms. - * Because of this, this parameter may return a different value than the one it was last set to. - * - * **Range:** 0.0 to 1.0 (value in seconds) - * - * **Default:** model-specific - */ - MinimumSpeechDuration: native.VAD_PARAM_MINIMUM_SPEECH_DURATION, -}; - -/** - * Context for managing processor state and parameters. - * Created via {@link Processor#getContext}. - */ -class ProcessorContext { - constructor(nativeContext) { - this._context = nativeContext; - } - - /** - * Clears all internal processing state, including any internally stored audio samples. - * - * Call this when the audio stream is interrupted or when seeking - * to prevent artifacts from previous audio content. - * - * The processor stays initialized to the configured settings. VAD state is separate and - * must be reset through {@link VadContext#reset}. - * - * Thread Safety: Real-time safe. Can be called from audio processing threads. - */ - reset() { - native.processorContextReset(this._context); - } +const isFileMusl = (f) => f.includes('libc.musl-') || f.includes('ld-musl-') - /** - * Modifies a processor parameter. - * - * All parameters can be changed during audio processing. - * This function can be called from any thread. - * - * @param {ProcessorParameter} parameter - Parameter to modify - * @param {number} value - New parameter value. See parameter documentation for ranges - * @throws {Error} If the parameter value is out of range. - * - * @example - * processorContext.setParameter(ProcessorParameter.EnhancementLevel, 0.8); - */ - setParameter(parameter, value) { - native.processorContextSetParameter(this._context, parameter, value); - } - - /** - * Retrieves the current value of a parameter. - * - * This function can be called from any thread. - * - * @param {ProcessorParameter} parameter - Parameter to query - * @returns {number} The current parameter value. - * - * @example - * const level = processorContext.getParameter(ProcessorParameter.EnhancementLevel); - */ - getParameter(parameter) { - return native.processorContextGetParameter(this._context, parameter); - } - - /** - * Returns the delay applied to the audio in samples for the current audio configuration. - * - * This function provides the complete end-to-end latency introduced by the processor, - * which includes both algorithmic processing delay and any buffering overhead. - * The processed audio leaves {@link Processor#process} this many samples behind its input. - * Use this value to synchronize enhanced audio with other streams or to implement - * delay compensation in your application. - * - * Delay behavior: - * - Before initialization: Returns the base processing delay using the model's - * optimal block size at its native sample rate - * - After initialization: Returns the actual delay for your specific configuration, - * including any additional buffering introduced by a non-optimal block size - * - * Important: The delay value is always expressed in samples at the sample rate - * you configured during initialize(). To convert to time units: - * delay_ms = (delay_samples * 1000) / sample_rate - * - * Note: Using a block size different from the optimal value returned by - * Model.getOptimalBlockSize() will increase the delay beyond the model's base latency. - * - * @returns {number} The delay in samples. - * - * @example - * const delay = processorContext.getAudioDelay(); - * console.log(`Audio delay: ${delay} samples`); - */ - getAudioDelay() { - return native.processorContextGetAudioDelay(this._context); - } - - /** - * Swaps in a renewed JWT bearer token while audio processing continues - * uninterrupted. - * - * Only valid when the processor was created with a JWT license. If either the - * originally configured key or the new token is not a JWT, an error is thrown - * and the existing token stays in use. - * - * This function can be called from any thread. - * - * @param {string} token - The renewed JWT bearer token. - * @throws {Error} If token update is unsupported for the configured license. - * - * @example - * processorContext.updateBearerToken(renewedJwt); - */ - updateBearerToken(token) { - native.processorContextUpdateBearerToken(this._context, token); +const isMuslFromFilesystem = () => { + try { + return readFileSync('/usr/bin/ldd', 'utf-8').includes('musl') + } catch { + return null } } -/** - * Thread-safe control handle for a {@link Vad}. - * - * Created via {@link Vad#getContext}. Every method on this type maps to an SDK function that - * can be called from any thread, so a context can be used to read the prediction, read and - * write parameters, query the prediction delay, or reset the VAD while audio is being - * processed elsewhere. - * - * All contexts created from one VAD reference the same detector instance. If the backing VAD - * is destroyed, it stops producing new data. Dropping a context does not destroy the VAD. - */ -class VadContext { - constructor(nativeContext) { - this._context = nativeContext; - } - - /** - * Clears all internal detection state, including internally stored audio samples, the - * published speech decision, and the raw probability. The VAD stays initialized. - * - * Call this when the audio stream is interrupted or when seeking. - * - * @throws {Error} If the reset fails. - */ - reset() { - native.vadContextReset(this._context); - } - - /** - * Returns the VAD's prediction. - * - * The prediction lags its input by the number of samples returned by - * {@link VadContext#getPredictionDelay}. Align speech decisions to the input timeline - * using that delay. If the backing VAD stops being processed, the prediction does not - * update. - * - * @returns {boolean} True if speech is detected, false otherwise. - */ - isSpeechDetected() { - return native.vadContextIsSpeechDetected(this._context); +const isMuslFromReport = () => { + let report = null + if (typeof process.report?.getReport === 'function') { + process.report.excludeNetwork = true + report = process.report.getReport() } - - /** - * Returns the raw prediction of the VAD, without any processing. - * - * In contrast to the output of {@link VadContext#isSpeechDetected}, this is the model's - * direct prediction without going through the SDK's VAD post-processing (i.e. speech hold - * duration, sensitivity thresholding, etc.). Use it to build other abstractions on top of - * this data. - * - * The prediction lags its input by the number of samples returned by - * {@link VadContext#getPredictionDelay}. - * - * @returns {number} The raw VAD probability in the range 0.0 to 1.0. - */ - rawVadProbability() { - return native.vadContextRawVadProbability(this._context); + if (!report) { + return null } - - /** - * Modifies a VAD parameter. Parameters can be changed while audio is processed. - * - * @param {VadParameter} parameter - Parameter to modify - * @param {number} value - New parameter value. See parameter documentation for ranges - * @throws {Error} If the parameter value is out of range. - * - * @example - * vadContext.setParameter(VadParameter.SpeechHoldDuration, 0.08); - * vadContext.setParameter(VadParameter.Sensitivity, 0.5); - */ - setParameter(parameter, value) { - native.vadContextSetParameter(this._context, parameter, value); - } - - /** - * Retrieves the current value of a VAD parameter. - * - * @param {VadParameter} parameter - Parameter to query - * @returns {number} The current parameter value. - */ - getParameter(parameter) { - return native.vadContextGetParameter(this._context, parameter); + if (report.header && report.header.glibcVersionRuntime) { + return false } - - /** - * Returns the total VAD prediction delay in samples for the current audio configuration. - * - * This function provides the complete end-to-end latency of the VAD prediction, which - * includes input reblocking, STFT, and model processing delay. Use this value to line up - * VAD decisions with the input timeline. - * - * This delay is not applied to the audio: {@link Vad#process} leaves its input buffer - * untouched. The value only describes how far behind its input the published prediction is. - * - * When enhancement and VAD run together, feed the VAD the original input audio rather than - * the processor's output. This value is then the prediction's delay relative to that input, - * and it is independent of {@link ProcessorContext#getAudioDelay}. - * - * Delay behavior: - * - Before initialization: Returns the base processing delay using the model's - * optimal block size at its native sample rate - * - After initialization: Returns the end-to-end VAD prediction delay at the initialized - * sample rate, including the input-buffering latency of the configured block size - * - * Important: The delay value is always expressed in samples at the sample rate - * you configured during initialize(). To convert to time units: - * delay_ms = (delay_samples * 1000) / sample_rate - * - * Note: Using a block size different from the optimal value returned by - * Model.getOptimalBlockSize(), or enabling variable block sizes, can add input-buffering - * latency before a new VAD prediction is published. That latency is included in the - * reported delay. - * - * @returns {number} The delay in samples. - * - * @example - * const delay = vadContext.getPredictionDelay(); - * console.log(`VAD prediction delay: ${delay} samples`); - */ - getPredictionDelay() { - return native.vadContextGetPredictionDelay(this._context); - } - - /** - * Swaps in a renewed JWT bearer token while VAD processing continues uninterrupted. - * - * @param {string} token - The renewed JWT bearer token. - * @throws {Error} If token update is unsupported for the configured license. - */ - updateBearerToken(token) { - native.vadContextUpdateBearerToken(this._context, token); - } -} - -/** - * The result of analyzing an audio signal with an {@link Analyzer} or {@link FileAnalyzer}. - * - * Scores are in the range 0.0 to 1.0. For all fields except speakerLoudness, - * lower values indicate less problematic audio. - * - * @typedef {Object} AnalysisResult - * @property {number} riskScore - Headline audio score. Predicts likelihood of failure of - * downstream models including speech-to-text, voice activity detection, turn-taking or - * speech-to-speech models. Lower indicates less problematic audio. Range: 0.0 to 1.0. - * @property {number} speakerReverb - Measure of speaker distance and reverberance. - * Lower indicates less problematic audio. Range: 0.0 to 1.0. - * @property {number} speakerLoudness - Measure of speaker loudness. Range: 0.0 to 1.0. - * @property {number} interferingSpeech - Measure of interfering speech from sources other than - * the main speaker. Lower indicates less problematic audio. Range: 0.0 to 1.0. - * @property {number} noise - Measure of ambient or environmental noise. - * Lower indicates less problematic audio. Range: 0.0 to 1.0. - * @property {number} codecDegradation - Measure of artifacts introduced by lossy speech codecs, - * e.g. from a low bitrate or a narrowband codec. Lower indicates less problematic audio. - * Range: 0.0 to 1.0. - * @property {number} packetLoss - Measure of audio dropouts or discontinuities in the stream, - * e.g. from packet loss, frame erasure, jitter or CPU overload. - * Lower indicates less problematic audio. Range: 0.0 to 1.0. - */ - -/** - * Collects audio blocks for later analysis by an {@link Analyzer}. - * - * Pass one mono audio block at a time to the collector (for example on an audio thread). The - * Analyzer analyzes the collected audio later. - * - * Created via {@link analyzerPair}. - */ -class Collector { - constructor(nativeCollector) { - this._collector = nativeCollector; - } - - /** - * Configures the collector for specific audio settings. - * - * This must be called before passing audio blocks to the collector. To avoid internal - * resampling and block adaptation, use the sample rate and block size returned by - * Model.getOptimalSampleRate() and Model.getOptimalBlockSize(). - * - * Warning: Do not call from audio processing threads as this allocates memory. - * - * @param {number} sampleRate - Sample rate in Hz - * @param {number} blockSize - Maximum samples in each audio block passed to buffer() - * @param {boolean} [variableBlockSize=false] - Allow variable block sizes (adds latency) - * @throws {Error} If the audio configuration is unsupported. - */ - initialize(sampleRate, blockSize, variableBlockSize = false) { - native.collectorInitialize( - this._collector, - sampleRate, - blockSize, - variableBlockSize, - ); - } - - /** - * Adds one mono audio block to the collector. - * - * @param {Float32Array} samples - Mono audio block of size blockSize - * @throws {Error} If collection fails (collector not initialized, invalid block size, etc.) - */ - buffer(samples) { - native.collectorBuffer(this._collector, samples); + if (Array.isArray(report.sharedObjects)) { + if (report.sharedObjects.some(isFileMusl)) { + return true + } } + return false } -/** - * Runs an analysis model over the audio collected by a {@link Collector}. - * - * Analysis models are computationally expensive and should be run off the audio thread. - * - * Created via {@link analyzerPair}. - */ -class Analyzer { - constructor(nativeAnalyzer) { - this._analyzer = nativeAnalyzer; - } - - /** - * Clears the analyzer state and all audio collected by its collector. - * - * Call this when the audio stream is interrupted or when seeking to prevent mispredictions - * from previous audio content. The collector stays initialized to the configured settings. - * - * Thread Safety: Real-time safe. Can be called from audio processing threads. - * - * @throws {Error} If the reset fails. - */ - reset() { - native.analyzerReset(this._analyzer); +const isMuslFromChildProcess = () => { + try { + return require('child_process').execSync('ldd --version', { encoding: 'utf8' }).includes('musl') + } catch (e) { + // If we reach this case, we don't know if the system is musl or not, so is better to just fallback to false + return false } - - /** - * Analyzes the collected signal. - * - * Runs a forward pass of the analysis model over a fixed length of audio, determined by the - * model. If called before the collector has collected that length of audio, the tail of the - * input is analyzed as silence (zeros). - * - * Note: This function is not real-time safe. Avoid calling it from audio threads. - * - * @returns {AnalysisResult} The analysis result. - * @throws {Error} If analysis fails. - */ - analyzeBuffered() { - return native.analyzerAnalyzeBuffered(this._analyzer); - } - - /** - * Terminates this analyzer's telemetry session before the analyzer is destroyed. - * - * After this call, the analyzer can no longer analyze collected audio. This operation may - * block and should not be called from an audio thread. - * - * @throws {Error} If session termination cannot be requested. - */ - terminateSession() { - native.analyzerTerminateSession(this._analyzer); - } - - /** - * Swaps in a renewed JWT bearer token while analysis continues uninterrupted. - * - * Only valid when the analyzer was created with a JWT license. If either the originally - * configured key or the new token is not a JWT, an error is thrown and the existing token - * stays in use. - * - * @param {string} token - The renewed JWT bearer token. - * @throws {Error} If token update is unsupported for the configured license. - */ - updateBearerToken(token) { - native.analyzerUpdateBearerToken(this._analyzer, token); - } -} - -/** - * Creates a collector/analyzer pair for non-real-time analysis. - * - * Pass audio blocks to the {@link Collector} (for example on an audio thread), then use the - * {@link Analyzer} to analyze the collected audio later, off the audio thread. The collector - * retains a span of audio determined by the analysis model; as more samples are collected, - * old audio is discarded. - * - * For analyzing a complete mono signal already in memory, prefer {@link FileAnalyzer}. - * - * @param {Model} model - The loaded model instance - * @param {string} licenseKey - License key for the ai-coustics SDK - * (generate your key at https://developers.ai-coustics.com/) - * @returns {{ collector: Collector, analyzer: Analyzer }} The collector/analyzer pair. - * @throws {Error} If the pair cannot be created. - * - * @example - * const { collector, analyzer } = analyzerPair(model, licenseKey); - * const sampleRate = model.getOptimalSampleRate(); - * const blockSize = model.getOptimalBlockSize(sampleRate); - * collector.initialize(sampleRate, blockSize, false); - */ -function analyzerPair(model, licenseKey) { - const pair = native.analyzerPair(model._model, licenseKey); - return { - collector: new Collector(pair.collector), - analyzer: new Analyzer(pair.analyzer), - }; } -/** - * Analyzes complete mono audio signals. - * - * FileAnalyzer is a convenience wrapper around a {@link Collector} and {@link Analyzer} pair - * for non-real-time analysis of audio that is already loaded in memory. The windowing, - * zero-padding and reset logic is performed by the underlying SDK. - * - * Each call to analyze() configures the analyzer for mono input with the model's optimal block - * size. It analyzes independent five-second windows, advancing the start of each window by - * stepSamples. - * - * For streaming analysis, use {@link analyzerPair} directly. - * - * @example - * const analyzer = new FileAnalyzer(model, licenseKey); - * const sampleRate = 16000; - * const audio = new Float32Array(8000); - * const results = analyzer.analyze(audio, sampleRate); - */ -class FileAnalyzer { - /** - * Creates a new file analyzer. - * - * @param {Model} model - The loaded model instance - * @param {string} licenseKey - License key for the ai-coustics SDK - * (generate your key at https://developers.ai-coustics.com/) - * @throws {Error} If the analyzer cannot be created. - */ - constructor(model, licenseKey) { - // Keep a reference to the model so it stays alive for the analyzer's lifetime: the native - // analyzer borrows the model's weights and must not outlive the model. - this._model = model; - this._analyzer = native.fileAnalyzerNew(model._model, licenseKey); - } - - /** - * Analyzes a complete mono audio signal held in memory. - * - * The input must contain mono f32 samples at sampleRate. No channel mixing or resampling is - * performed. - * - * The analyzer evaluates five-second windows. FileAnalyzer analyzes a window starting at sample - * 0, then repeats with a window starting stepSamples later. - * - * If audio is shorter than or equal to five seconds, it is padded with silence and only one - * result is returned. For longer signals, only complete five-second windows are analyzed after - * the first window. - * - * Note: This function is not real-time safe. Avoid calling it from audio threads. - * - * @param {Float32Array} audio - Mono audio samples to analyze - * @param {number} sampleRate - Sample rate of audio in Hz - * @param {number|null} [stepSamples=null] - Number of samples to advance between analysis - * results. Defaults to the analysis window size (no overlap) when null. - * @returns {AnalysisResult[]} A list of analysis results. - * @throws {Error} If analysis fails. - */ - analyze(audio, sampleRate, stepSamples = null) { - if (!(audio instanceof Float32Array)) { - audio = Float32Array.from(audio); +function requireNative() { + if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) { + try { + return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH); + } catch (err) { + loadErrors.push(err) } - - if (!(sampleRate > 0)) { - throw new Error("sampleRate must be greater than 0"); + } else if (process.platform === 'android') { + if (process.arch === 'arm64') { + try { + return require('./aic-sdk.android-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-android-arm64') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-android-arm64/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm') { + try { + return require('./aic-sdk.android-arm-eabi.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-android-arm-eabi') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-android-arm-eabi/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Android ${process.arch}`)) } - - if (stepSamples != null && !(stepSamples > 0)) { - throw new Error("stepSamples must be greater than 0"); + } else if (process.platform === 'win32') { + if (process.arch === 'x64') { + try { + return require('./aic-sdk.win32-x64-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-win32-x64-msvc') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-win32-x64-msvc/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'ia32') { + try { + return require('./aic-sdk.win32-ia32-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-win32-ia32-msvc') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-win32-ia32-msvc/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./aic-sdk.win32-arm64-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-win32-arm64-msvc') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-win32-arm64-msvc/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Windows: ${process.arch}`)) } - - return native.fileAnalyzerAnalyze( - this._analyzer, - audio, - sampleRate, - stepSamples, - ); - } -} - -/** - * OpenTelemetry configuration for a {@link Processor} or {@link Vad}. - * - * Pass an instance as the third constructor argument to override AIC_SDK_OTEL_ENABLE for - * that processor or VAD only. - */ -class OtelConfig { - /** - * Creates an OpenTelemetry configuration for a processor or VAD. - * - * Pass an instance as the third constructor argument to override AIC_SDK_OTEL_ENABLE for - * that processor or VAD only. - * - * @param {boolean} enable - Whether OpenTelemetry telemetry is enabled - * @param {string|null} [sessionId=null] - Optional telemetry session ID - * @param {number} [exportIntervalMs=0] - Metric export interval in milliseconds. - * Set to 0 to use the SDK default of 60000 ms. - */ - constructor(enable, sessionId = null, exportIntervalMs = 0) { - this.enable = Boolean(enable); - this.sessionId = sessionId == null ? null : String(sessionId); - this.exportIntervalMs = Number(exportIntervalMs) || 0; - } - - /** - * Creates a config with OpenTelemetry disabled. - * - * @returns {OtelConfig} - */ - static disabled() { - return new OtelConfig(false); - } - - /** - * Creates a config with OpenTelemetry enabled and a generated session ID. - * - * @returns {OtelConfig} - */ - static enabled() { - return new OtelConfig(true); - } - - /** - * Creates a config with OpenTelemetry enabled and the provided session ID. - * - * @param {string} sessionId - Telemetry session ID - * @returns {OtelConfig} - */ - static withSessionId(sessionId) { - return new OtelConfig(true, sessionId); - } -} - -/** - * High-level wrapper for an ai-coustics model. - * - * This class provides a safe, JavaScript-friendly interface to the underlying native library. - * It handles memory management automatically. - * - * @example - * const model = Model.fromFile("/path/to/model.aicmodel"); - * const processor = new Processor(model, licenseKey); - * const sampleRate = model.getOptimalSampleRate(); - * processor.initialize(sampleRate, model.getOptimalBlockSize(sampleRate), false); - */ -class Model { - constructor(nativeModel) { - this._model = nativeModel; - } - - /** - * Creates a new model instance from a file. - * - * A single model instance can be used to create multiple processors, VADs or analyzers, - * according to the model type. - * - * The model data is memory-mapped from the file, not copied into the process. Make sure - * the file is not modified or deleted while the model, or any object created from it, is - * alive. - * - * @param {string} path - Path to the model file (.aicmodel). You can download models manually - * from https://artifacts.ai-coustics.io or use Model.download() to fetch them programmatically. - * @returns {Model} A new Model instance. - * @throws {Error} If model creation fails. - * - * @see https://artifacts.ai-coustics.io for available model IDs and downloads. - * - * @example - * const model = Model.fromFile("/path/to/model.aicmodel"); - */ - static fromFile(path) { - const nativeModel = native.modelFromFile(path); - return new Model(nativeModel); - } - - /** - * Downloads a model file from the ai-coustics artifact CDN. - * - * This method fetches the model manifest, verifies that the requested model exists in a - * version compatible with this library, and downloads the model file into the provided - * directory. If the model file already exists it is not re-downloaded. If the existing - * file's checksum does not match, the model is downloaded and the existing file replaced. - * - * The manifest is never cached and is downloaded on every call, so the latest model - * versions are always used. - * - * Note: This is a blocking operation that performs network I/O. - * - * @param {string} modelId - The model identifier as listed in the manifest (e.g. "quail-l-16khz"). - * Find available model IDs at https://artifacts.ai-coustics.io - * @param {string} downloadDir - Directory where the downloaded model file should be stored - * @returns {string} The full path to the downloaded model file. - * @throws {Error} If the download operation fails. - * - * @see https://artifacts.ai-coustics.io for available model IDs. - * - * @example - * const path = Model.download("quail-vf-2.2-s-16khz", "/tmp/models"); - * const model = Model.fromFile(path); - */ - static download(modelId, downloadDir) { - return native.modelDownload(modelId, downloadDir); - } - - /** - * Returns the model identifier string. - * - * @returns {string} The model ID string. - */ - getId() { - return native.modelGetId(this._model); - } - - /** - * Retrieves the native sample rate of the model. - * - * Each model is optimized for a specific sample rate. Using that rate avoids internal - * resampling and provides the model's intended frequency range and behavior. Other supported - * sample rates can still be configured when needed. - * - * @returns {number} The model's native sample rate in Hz. - * - * @example - * const optimalRate = model.getOptimalSampleRate(); - * console.log(`Optimal sample rate: ${optimalRate} Hz`); - */ - getOptimalSampleRate() { - return native.modelGetOptimalSampleRate(this._model); - } - - /** - * Retrieves the optimal block size for the model at a given sample rate. - * - * Using the optimal block size minimizes latency by avoiding internal buffering. Using a - * different block size makes the processor introduce additional buffering latency on top of - * its base processing delay. - * - * The optimal block size varies with the sample rate. Each model operates on a fixed time - * window length, so the required number of samples changes with the sample rate. For - * example, a model designed for 10 ms processing windows requires 480 samples at 48 kHz, - * but only 160 samples at 16 kHz to capture the same duration of audio. - * - * Call this with your intended sample rate before initializing a Processor, Vad or - * Collector to determine the best block size for minimal latency. - * - * @param {number} sampleRate - Sample rate in Hz - * @returns {number} The optimal block size in samples. - * - * @example - * const sampleRate = model.getOptimalSampleRate(); - * const blockSize = model.getOptimalBlockSize(sampleRate); - */ - getOptimalBlockSize(sampleRate) { - return native.modelGetOptimalBlockSize(this._model, sampleRate); - } -} - -/** - * High-level wrapper for ai-coustics audio enhancement. - * - * A Processor accepts enhancement and bypass models. Use {@link Vad} with a dedicated VAD - * model for voice activity detection. - * - * @example - * const model = Model.fromFile("/path/to/model.aicmodel"); - * const processor = new Processor(model, licenseKey); - * const sampleRate = model.getOptimalSampleRate(); - * const blockSize = model.getOptimalBlockSize(sampleRate); - * processor.initialize(sampleRate, blockSize, false); - * const audio = new Float32Array(blockSize); - * processor.process(audio); - */ -class Processor { - /** - * Creates an audio enhancement processor from an enhancement or bypass model. - * - * @param {Model} model - Enhancement or bypass model - * @param {string} licenseKey - License key for the ai-coustics SDK - * (generate your key at https://developers.ai-coustics.com/) - * @param {OtelConfig|null} [otelConfig=null] - Optional per-processor OpenTelemetry config. - * @throws {Error} If creation fails or the model type is unsupported. - */ - constructor(model, licenseKey, otelConfig = null) { - this._processor = native.processorNew(model._model, licenseKey, otelConfig); - } - - /** - * Configures the processor for specific audio settings. - * - * This must be called before processing any audio. For the lowest delay, use the model's - * optimal sample rate and block size. - * Do not call this from an audio processing thread because it allocates memory. - * - * @param {number} sampleRate - Sample rate in Hz (8000 - 192000) - * @param {number} blockSize - Samples provided to each processing call - * @param {boolean} [variableBlockSize=false] - Allow variable block sizes (adds latency) - * @throws {Error} If the audio configuration is unsupported. - */ - initialize(sampleRate, blockSize, variableBlockSize = false) { - native.processorInitialize( - this._processor, - sampleRate, - blockSize, - variableBlockSize, - ); - } - - /** - * Processes mono audio, enhancing speech in the provided audio block in-place. - * - * @param {Float32Array} samples - Mono audio block of size blockSize. If variableBlockSize - * was enabled, it may be shorter than blockSize - * @throws {Error} If processing fails. - */ - process(samples) { - native.processorProcess(this._processor, samples); - } - - /** - * Creates a context for controlling processor parameters and state. - * - * @returns {ProcessorContext} A new context for this processor. - */ - getContext() { - return new ProcessorContext(native.processorGetContext(this._processor)); - } - - /** - * Terminates this processor's telemetry session before the processor is destroyed. - * - * After this call, the processor can no longer process audio. This operation may block - * and should not be called from an audio thread. - * - * @throws {Error} If session termination cannot be requested. - */ - terminateSession() { - native.processorTerminateSession(this._processor); + } else if (process.platform === 'darwin') { + try { + return require('./aic-sdk.darwin-universal.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-darwin-universal') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-darwin-universal/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + if (process.arch === 'x64') { + try { + return require('./aic-sdk.darwin-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-darwin-x64') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-darwin-x64/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./aic-sdk.darwin-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-darwin-arm64') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-darwin-arm64/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on macOS: ${process.arch}`)) + } + } else if (process.platform === 'freebsd') { + if (process.arch === 'x64') { + try { + return require('./aic-sdk.freebsd-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-freebsd-x64') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-freebsd-x64/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./aic-sdk.freebsd-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-freebsd-arm64') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-freebsd-arm64/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on FreeBSD: ${process.arch}`)) + } + } else if (process.platform === 'linux') { + if (process.arch === 'x64') { + if (isMusl()) { + try { + return require('./aic-sdk.linux-x64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-linux-x64-musl') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-linux-x64-musl/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./aic-sdk.linux-x64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-linux-x64-gnu') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-linux-x64-gnu/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'arm64') { + if (isMusl()) { + try { + return require('./aic-sdk.linux-arm64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-linux-arm64-musl') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-linux-arm64-musl/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./aic-sdk.linux-arm64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-linux-arm64-gnu') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-linux-arm64-gnu/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'arm') { + if (isMusl()) { + try { + return require('./aic-sdk.linux-arm-musleabihf.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-linux-arm-musleabihf') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-linux-arm-musleabihf/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./aic-sdk.linux-arm-gnueabihf.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-linux-arm-gnueabihf') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-linux-arm-gnueabihf/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'loong64') { + if (isMusl()) { + try { + return require('./aic-sdk.linux-loong64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-linux-loong64-musl') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-linux-loong64-musl/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./aic-sdk.linux-loong64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-linux-loong64-gnu') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-linux-loong64-gnu/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'riscv64') { + if (isMusl()) { + try { + return require('./aic-sdk.linux-riscv64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-linux-riscv64-musl') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-linux-riscv64-musl/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./aic-sdk.linux-riscv64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-linux-riscv64-gnu') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-linux-riscv64-gnu/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'ppc64') { + try { + return require('./aic-sdk.linux-ppc64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-linux-ppc64-gnu') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-linux-ppc64-gnu/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 's390x') { + try { + return require('./aic-sdk.linux-s390x-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-linux-s390x-gnu') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-linux-s390x-gnu/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Linux: ${process.arch}`)) + } + } else if (process.platform === 'openharmony') { + if (process.arch === 'arm64') { + try { + return require('./aic-sdk.openharmony-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-openharmony-arm64') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-openharmony-arm64/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'x64') { + try { + return require('./aic-sdk.openharmony-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-openharmony-x64') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-openharmony-x64/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm') { + try { + return require('./aic-sdk.openharmony-arm.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@ai-coustics/aic-sdk-openharmony-arm') + const bindingPackageVersion = require('@ai-coustics/aic-sdk-openharmony-arm/package.json').version + if (bindingPackageVersion !== '0.24.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.24.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on OpenHarmony: ${process.arch}`)) + } + } else { + loadErrors.push(new Error(`Unsupported OS: ${process.platform}, architecture: ${process.arch}`)) } } -/** - * High-level wrapper for ai-coustics voice activity detection. - * - * A Vad is created from a dedicated VAD model such as `vad-2.1-xxs-16khz`. - * Enhancement models are not supported; use {@link Processor} for those. - * - * @example - * const model = Model.fromFile("/path/to/vad-model.aicmodel"); - * const vad = new Vad(model, licenseKey); - * const sampleRate = model.getOptimalSampleRate(); - * const blockSize = model.getOptimalBlockSize(sampleRate); - * vad.initialize(sampleRate, blockSize); - * const vadContext = vad.getContext(); - * vad.process(new Float32Array(blockSize)); - * console.log(vadContext.isSpeechDetected()); - */ -class Vad { - /** - * Creates a voice activity detector from a dedicated VAD model. - * - * @param {Model} model - Dedicated VAD model - * @param {string} licenseKey - License key for the ai-coustics SDK - * @param {OtelConfig|null} [otelConfig=null] - Optional per-VAD OpenTelemetry config. - * @throws {Error} If creation fails or the model type is unsupported. - */ - constructor(model, licenseKey, otelConfig = null) { - this._vad = native.vadNew(model._model, licenseKey, otelConfig); - } - - /** - * Configures the VAD for specific audio settings. - * - * This must be called before processing any audio. For the most frequent prediction - * updates, use the model's optimal sample rate and block size. - * Do not call this from an audio processing thread because it allocates memory. - * - * @param {number} sampleRate - Sample rate in Hz (8000 - 192000) - * @param {number} blockSize - Samples provided to each processing call - * @param {boolean} [variableBlockSize=false] - Allow variable block sizes (adds latency) - * @throws {Error} If the audio configuration is unsupported. - */ - initialize(sampleRate, blockSize, variableBlockSize = false) { - native.vadInitialize(this._vad, sampleRate, blockSize, variableBlockSize); - } - - /** - * Processes mono audio and updates the VAD prediction. - * - * This method does not modify the input audio buffer, it only reads from it. Read the - * prediction through a {@link VadContext}. - * - * Recommendation: when enhancement and VAD run together, pass the original input audio - * here, not the output of {@link Processor#process}. Enhancement is designed to change the - * signal, so running the VAD on its output means detecting speech in audio that no longer - * matches what the VAD model expects, and it stacks the processor's audio delay on top of - * the VAD's prediction delay. Because this method does not modify its input, calling it on - * the same buffer before Processor.process() is enough: - * - * @param {Float32Array} samples - Mono audio block of size blockSize. If variableBlockSize - * was enabled, it may be shorter than blockSize - * @throws {Error} If processing fails. - * - * @example - * vad.process(audioBlock); // reads the block, does not modify it - * processor.process(audioBlock); // enhances the block in-place - */ - process(samples) { - native.vadProcess(this._vad, samples); +nativeBinding = requireNative() + +if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { + let wasiBinding = null + let wasiBindingError = null + try { + wasiBinding = require('./aic-sdk.wasi.cjs') + nativeBinding = wasiBinding + } catch (err) { + if (process.env.NAPI_RS_FORCE_WASI) { + wasiBindingError = err + } } - - /** - * Creates a context for reading the prediction and controlling VAD state. - * - * @returns {VadContext} A new context for this VAD. - */ - getContext() { - return new VadContext(native.vadGetContext(this._vad)); + if (!nativeBinding) { + try { + wasiBinding = require('@ai-coustics/aic-sdk-wasm32-wasi') + nativeBinding = wasiBinding + } catch (err) { + if (process.env.NAPI_RS_FORCE_WASI) { + wasiBindingError.cause = err + loadErrors.push(err) + } + } } - - /** - * Terminates this VAD's telemetry session before the VAD is destroyed. - * - * After this call, the VAD can no longer process audio. This operation may block and - * should not be called from an audio thread. - * - * @throws {Error} If session termination cannot be requested. - */ - terminateSession() { - native.vadTerminateSession(this._vad); + if (process.env.NAPI_RS_FORCE_WASI === 'error' && !wasiBinding) { + const error = new Error('WASI binding not found and NAPI_RS_FORCE_WASI is set to error') + error.cause = wasiBindingError + throw error } } -/** - * Returns the version of the ai-coustics core SDK library used by this package. - * - * Note: This is not necessarily the same as this package's version. - * - * @returns {string} The library version as a string. - * - * @example - * const version = getVersion(); - * console.log(`ai-coustics SDK version: ${version}`); - */ -function getVersion() { - return native.getVersion(); -} - -/** - * Returns the model version number compatible with this SDK build. - * - * @returns {number} The compatible model version number. - */ -function getCompatibleModelVersion() { - return native.getCompatibleModelVersion(); -} - -// Internal only -function _setSdkId(id) { - native.setSdkId(id); +if (!nativeBinding) { + if (loadErrors.length > 0) { + throw new Error( + `Cannot find native binding. ` + + `npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` + + 'Please try `npm i` again after removing both package-lock.json and node_modules directory.', + { + cause: loadErrors.reduce((err, cur) => { + cur.cause = err + return cur + }), + }, + ) + } + throw new Error(`Failed to load native binding`) } -module.exports = { - Model, - OtelConfig, - Processor, - ProcessorContext, - Vad, - VadContext, - Collector, - Analyzer, - FileAnalyzer, - analyzerPair, - ProcessorParameter, - VadParameter, - getVersion, - getCompatibleModelVersion, - _setSdkId, -}; +module.exports = nativeBinding +module.exports.Analyzer = nativeBinding.Analyzer +module.exports.Model = nativeBinding.Model +module.exports.Processor = nativeBinding.Processor +module.exports.ProcessorAsync = nativeBinding.ProcessorAsync +module.exports.ProcessorContext = nativeBinding.ProcessorContext +module.exports.Vad = nativeBinding.Vad +module.exports.VadAsync = nativeBinding.VadAsync +module.exports.VadContext = nativeBinding.VadContext +module.exports._setSdkId = nativeBinding._setSdkId +module.exports.getCompatibleModelVersion = nativeBinding.getCompatibleModelVersion +module.exports.getVersion = nativeBinding.getVersion +module.exports.ProcessorParameter = nativeBinding.ProcessorParameter +module.exports.VadParameter = nativeBinding.VadParameter diff --git a/npm/darwin-arm64/README.md b/npm/darwin-arm64/README.md deleted file mode 100644 index 2062426..0000000 --- a/npm/darwin-arm64/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# @ai-coustics/aic-sdk-darwin-arm64 - -Native binary for [@ai-coustics/aic-sdk](https://www.npmjs.com/package/@ai-coustics/aic-sdk) on macOS ARM64. - -This package is automatically installed as an optional dependency. Do not install directly. diff --git a/npm/darwin-arm64/package.json b/npm/darwin-arm64/package.json deleted file mode 100644 index 4a4294f..0000000 --- a/npm/darwin-arm64/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@ai-coustics/aic-sdk-darwin-arm64", - "version": "0.23.1", - "description": "ai-coustics SDK native bindings for macOS ARM64 (Apple Silicon)", - "main": "index.node", - "files": [ - "index.node", - "README.md" - ], - "author": "ai-coustics GmbH", - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "git+https://github.com/ai-coustics/aic-sdk-node.git" - }, - "os": [ - "darwin" - ], - "cpu": [ - "arm64" - ] -} diff --git a/npm/darwin-x64/README.md b/npm/darwin-x64/README.md deleted file mode 100644 index 4eb2e73..0000000 --- a/npm/darwin-x64/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# @ai-coustics/aic-sdk-darwin-x64 - -Native binary for [@ai-coustics/aic-sdk](https://www.npmjs.com/package/@ai-coustics/aic-sdk) on macOS x64. - -This package is automatically installed as an optional dependency. Do not install directly. diff --git a/npm/darwin-x64/package.json b/npm/darwin-x64/package.json deleted file mode 100644 index c4a3325..0000000 --- a/npm/darwin-x64/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@ai-coustics/aic-sdk-darwin-x64", - "version": "0.23.1", - "description": "ai-coustics SDK native bindings for macOS x64", - "main": "index.node", - "files": [ - "index.node", - "README.md" - ], - "author": "ai-coustics GmbH", - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "git+https://github.com/ai-coustics/aic-sdk-node.git" - }, - "os": [ - "darwin" - ], - "cpu": [ - "x64" - ] -} diff --git a/npm/linux-arm64-gnu/README.md b/npm/linux-arm64-gnu/README.md deleted file mode 100644 index 2d69943..0000000 --- a/npm/linux-arm64-gnu/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# @ai-coustics/aic-sdk-linux-arm64-gnu - -Native binary for [@ai-coustics/aic-sdk](https://www.npmjs.com/package/@ai-coustics/aic-sdk) on Linux ARM64 (GNU libc). - -This package is automatically installed as an optional dependency. Do not install directly. diff --git a/npm/linux-arm64-gnu/package.json b/npm/linux-arm64-gnu/package.json deleted file mode 100644 index 7b7b31a..0000000 --- a/npm/linux-arm64-gnu/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@ai-coustics/aic-sdk-linux-arm64-gnu", - "version": "0.23.1", - "description": "ai-coustics SDK native bindings for Linux ARM64 (GNU)", - "main": "index.node", - "files": [ - "index.node", - "README.md" - ], - "author": "ai-coustics GmbH", - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "git+https://github.com/ai-coustics/aic-sdk-node.git" - }, - "os": [ - "linux" - ], - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ] -} diff --git a/npm/linux-x64-gnu/README.md b/npm/linux-x64-gnu/README.md deleted file mode 100644 index 01ddb1e..0000000 --- a/npm/linux-x64-gnu/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# @ai-coustics/aic-sdk-linux-x64-gnu - -Native binary for [@ai-coustics/aic-sdk](https://www.npmjs.com/package/@ai-coustics/aic-sdk) on Linux x64 (GNU libc). - -This package is automatically installed as an optional dependency. Do not install directly. diff --git a/npm/linux-x64-gnu/package.json b/npm/linux-x64-gnu/package.json deleted file mode 100644 index e77757e..0000000 --- a/npm/linux-x64-gnu/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@ai-coustics/aic-sdk-linux-x64-gnu", - "version": "0.23.1", - "description": "ai-coustics SDK native bindings for Linux x64 (GNU)", - "main": "index.node", - "files": [ - "index.node", - "README.md" - ], - "author": "ai-coustics GmbH", - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "git+https://github.com/ai-coustics/aic-sdk-node.git" - }, - "os": [ - "linux" - ], - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ] -} diff --git a/npm/win32-arm64-msvc/README.md b/npm/win32-arm64-msvc/README.md deleted file mode 100644 index 8e4e261..0000000 --- a/npm/win32-arm64-msvc/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# @ai-coustics/aic-sdk-win32-arm64-msvc - -Native binary for [@ai-coustics/aic-sdk](https://www.npmjs.com/package/@ai-coustics/aic-sdk) on Windows ARM64 (MSVC). - -This package is automatically installed as an optional dependency. Do not install directly. diff --git a/npm/win32-arm64-msvc/package.json b/npm/win32-arm64-msvc/package.json deleted file mode 100644 index 85e8ccc..0000000 --- a/npm/win32-arm64-msvc/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@ai-coustics/aic-sdk-win32-arm64-msvc", - "version": "0.23.1", - "description": "ai-coustics SDK native bindings for Windows ARM64 (MSVC)", - "main": "index.node", - "files": [ - "index.node", - "README.md" - ], - "author": "ai-coustics GmbH", - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "git+https://github.com/ai-coustics/aic-sdk-node.git" - }, - "os": [ - "win32" - ], - "cpu": [ - "arm64" - ] -} diff --git a/npm/win32-x64-msvc/README.md b/npm/win32-x64-msvc/README.md deleted file mode 100644 index d652543..0000000 --- a/npm/win32-x64-msvc/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# @ai-coustics/aic-sdk-win32-x64-msvc - -Native binary for [@ai-coustics/aic-sdk](https://www.npmjs.com/package/@ai-coustics/aic-sdk) on Windows x64 (MSVC). - -This package is automatically installed as an optional dependency. Do not install directly. diff --git a/npm/win32-x64-msvc/package.json b/npm/win32-x64-msvc/package.json deleted file mode 100644 index cdc94c5..0000000 --- a/npm/win32-x64-msvc/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@ai-coustics/aic-sdk-win32-x64-msvc", - "version": "0.23.1", - "description": "ai-coustics SDK native bindings for Windows x64 (MSVC)", - "main": "index.node", - "files": [ - "index.node", - "README.md" - ], - "author": "ai-coustics GmbH", - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "git+https://github.com/ai-coustics/aic-sdk-node.git" - }, - "os": [ - "win32" - ], - "cpu": [ - "x64" - ] -} diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 565194d..0000000 --- a/package-lock.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@ai-coustics/aic-sdk", - "version": "0.23.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@ai-coustics/aic-sdk", - "version": "0.23.1", - "license": "Apache-2.0", - "devDependencies": { - "wavefile": "^11.0.0" - }, - "optionalDependencies": { - "@ai-coustics/aic-sdk-darwin-arm64": "0.23.1", - "@ai-coustics/aic-sdk-darwin-x64": "0.23.1", - "@ai-coustics/aic-sdk-linux-arm64-gnu": "0.23.1", - "@ai-coustics/aic-sdk-linux-x64-gnu": "0.23.1", - "@ai-coustics/aic-sdk-win32-arm64-msvc": "0.23.1", - "@ai-coustics/aic-sdk-win32-x64-msvc": "0.23.1" - } - }, - "node_modules/wavefile": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/wavefile/-/wavefile-11.0.0.tgz", - "integrity": "sha512-/OBiAALgWU24IG7sC84cDO/KfFuvajWc5Uec0oV2zrpOOZZDgGdOwHwgEzOrwh8jkubBk7PtZfQBIcI1OaE5Ng==", - "dev": true, - "license": "MIT", - "bin": { - "wavefile": "bin/wavefile.js" - }, - "engines": { - "node": ">=8" - } - } - } -} diff --git a/package.json b/package.json index 9b5dea8..230ea40 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,20 @@ { "name": "@ai-coustics/aic-sdk", - "version": "0.23.1", + "version": "0.24.0", "description": "Node.js package of ai-coustics SDK", "main": "index.js", - "scripts": { - "test": "node tests/end2end.test.js", - "build": "bash scripts/build-target.sh index.node", - "debug": "bash scripts/build-target.sh index.node '' debug", - "version:update": "bash scripts/update-version.sh", - "prepublishOnly": "echo 'Use GitHub Actions to publish. See PUBLISHING.md for details.' && exit 1" - }, + "types": "index.d.ts", + "type": "commonjs", "author": "ai-coustics GmbH", "license": "Apache-2.0", + "homepage": "https://github.com/ai-coustics/aic-sdk-node#readme", + "bugs": { + "url": "https://github.com/ai-coustics/aic-sdk-node/issues" + }, + "repository": { + "url": "git+https://github.com/ai-coustics/aic-sdk-node.git", + "type": "git" + }, "keywords": [ "audio", "speech", @@ -19,30 +22,99 @@ "agent", "ai-coustics" ], - "homepage": "https://github.com/ai-coustics/aic-sdk-node#readme", - "bugs": { - "url": "https://github.com/ai-coustics/aic-sdk-node/issues" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/ai-coustics/aic-sdk-node.git" - }, - "type": "commonjs", "files": [ + "index.d.ts", "index.js", - "index.node", - "README.md", - "LICENSE" + "LICENSE", + "LICENSE.AIC_SDK", + "README.md" ], - "optionalDependencies": { - "@ai-coustics/aic-sdk-darwin-arm64": "0.23.1", - "@ai-coustics/aic-sdk-darwin-x64": "0.23.1", - "@ai-coustics/aic-sdk-linux-arm64-gnu": "0.23.1", - "@ai-coustics/aic-sdk-linux-x64-gnu": "0.23.1", - "@ai-coustics/aic-sdk-win32-arm64-msvc": "0.23.1", - "@ai-coustics/aic-sdk-win32-x64-msvc": "0.23.1" + "napi": { + "binaryName": "aic-sdk", + "targets": [ + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc" + ] + }, + "engines": { + "node": ">= 18" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "scripts": { + "artifacts": "napi artifacts", + "bench": "node --import @oxc-node/core/register benchmark/bench.ts", + "build": "napi build --platform --release", + "build:debug": "napi build --platform", + "format": "run-p format:prettier format:rs format:toml", + "format:prettier": "prettier . -w", + "format:prettier:check": "prettier . --check", + "format:toml": "taplo format", + "format:rs": "cargo fmt", + "lint": "oxlint", + "typecheck": "run-p typecheck:*", + "typecheck:root": "tsc --noEmit", + "typecheck:test": "tsc --noEmit -p __test__/tsconfig.json", + "typecheck:bench": "tsc --noEmit -p benchmark/tsconfig.json", + "typecheck:examples": "tsc --noEmit -p examples/tsconfig.json", + "prepublishOnly": "napi prepublish -t npm", + "pretest": "node --import @oxc-node/core/register scripts/fetch-test-models.mjs", + "test": "ava", + "version": "napi version" }, "devDependencies": { + "@napi-rs/cli": "^3.2.0", + "@oxc-node/core": "^0.1.0", + "@taplo/cli": "^0.7.0", + "@types/node": "^26.2.0", + "ava": "^8.0.0", + "chalk": "^5.6.2", + "husky": "^9.1.7", + "lint-staged": "^17.0.0", + "npm-run-all2": "^9.0.0", + "oxlint": "^1.14.0", + "prettier": "^3.6.2", + "tinybench": "^6.0.0", + "typescript": "^7.0.0", "wavefile": "^11.0.0" - } + }, + "lint-staged": { + "*.@(js|ts|tsx)": [ + "oxlint --fix" + ], + "*.@(js|ts|tsx|yml|yaml|md|json)": [ + "prettier --write" + ], + "*.toml": [ + "taplo format" + ] + }, + "ava": { + "extensions": [ + "ts" + ], + "timeout": "2m", + "workerThreads": false, + "environmentVariables": { + "TS_NODE_PROJECT": "./tsconfig.json" + }, + "nodeArguments": [ + "--import", + "@oxc-node/core/register" + ] + }, + "prettier": { + "printWidth": 120, + "semi": false, + "trailingComma": "all", + "singleQuote": true, + "arrowParens": "always" + }, + "packageManager": "pnpm@11.17.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..765e3d2 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3337 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@napi-rs/cli': + specifier: ^3.2.0 + version: 3.2.0(@emnapi/runtime@1.5.0)(@types/node@26.2.0)(emnapi@1.5.0) + '@oxc-node/core': + specifier: ^0.1.0 + version: 0.1.0 + '@taplo/cli': + specifier: ^0.7.0 + version: 0.7.0 + '@types/node': + specifier: ^26.2.0 + version: 26.2.0 + ava: + specifier: ^8.0.0 + version: 8.0.0 + chalk: + specifier: ^5.6.2 + version: 5.6.2 + husky: + specifier: ^9.1.7 + version: 9.1.7 + lint-staged: + specifier: ^17.0.0 + version: 17.0.0 + npm-run-all2: + specifier: ^9.0.0 + version: 9.0.0 + oxlint: + specifier: ^1.14.0 + version: 1.14.0 + prettier: + specifier: ^3.6.2 + version: 3.6.2 + tinybench: + specifier: ^6.0.0 + version: 6.0.0 + typescript: + specifier: ^7.0.0 + version: 7.0.2 + wavefile: + specifier: ^11.0.0 + version: 11.0.0 + +packages: + + '@emnapi/core@1.5.0': + resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} + + '@emnapi/core@1.9.1': + resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + + '@emnapi/runtime@1.5.0': + resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} + + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + + '@inquirer/checkbox@4.2.2': + resolution: {integrity: sha512-E+KExNurKcUJJdxmjglTl141EwxWyAHplvsYJQgSwXf8qiNWkTxTuCCqmhFEmbIXd4zLaGMfQFJ6WrZ7fSeV3g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.16': + resolution: {integrity: sha512-j1a5VstaK5KQy8Mu8cHmuQvN1Zc62TbLhjJxwHvKPPKEoowSF6h/0UdOpA9DNdWZ+9Inq73+puRq1df6OJ8Sag==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.2.0': + resolution: {integrity: sha512-NyDSjPqhSvpZEMZrLCYUquWNl+XC/moEcVFqS55IEYIYsY0a1cUCevSqk7ctOlnm/RaSBU5psFryNlxcmGrjaA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.18': + resolution: {integrity: sha512-yeQN3AXjCm7+Hmq5L6Dm2wEDeBRdAZuyZ4I7tWSSanbxDzqM0KqzoDbKM7p4ebllAYdoQuPJS6N71/3L281i6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.18': + resolution: {integrity: sha512-xUjteYtavH7HwDMzq4Cn2X4Qsh5NozoDHCJTdoXg9HfZ4w3R6mxV1B9tL7DGJX2eq/zqtsFjhm0/RJIMGlh3ag==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.1': + resolution: {integrity: sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.13': + resolution: {integrity: sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==} + engines: {node: '>=18'} + + '@inquirer/input@4.2.2': + resolution: {integrity: sha512-hqOvBZj/MhQCpHUuD3MVq18SSoDNHy7wEnQ8mtvs71K8OPZVXJinOzcvQna33dNYLYE4LkA9BlhAhK6MJcsVbw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.18': + resolution: {integrity: sha512-7exgBm52WXZRczsydCVftozFTrrwbG5ySE0GqUd2zLNSBXyIucs2Wnm7ZKLe/aUu6NUg9dg7Q80QIHCdZJiY4A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.18': + resolution: {integrity: sha512-zXvzAGxPQTNk/SbT3carAD4Iqi6A2JS2qtcqQjsL22uvD+JfQzUrDEtPjLL7PLn8zlSNyPdY02IiQjzoL9TStA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.8.4': + resolution: {integrity: sha512-MuxVZ1en1g5oGamXV3DWP89GEkdD54alcfhHd7InUW5BifAdKQEK9SLFa/5hlWbvuhMPlobF0WAx7Okq988Jxg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.6': + resolution: {integrity: sha512-KOZqa3QNr3f0pMnufzL7K+nweFFCCBs6LCXZzXDrVGTyssjLeudn5ySktZYv1XiSqobyHRYYK0c6QsOxJEhXKA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.1.1': + resolution: {integrity: sha512-TkMUY+A2p2EYVY3GCTItYGvqT6LiLzHBnqsU1rJbrpXUijFfM6zvUx0R4civofVwFCmJZcKqOVwwWAjplKkhxA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.3.2': + resolution: {integrity: sha512-nwous24r31M+WyDEHV+qckXkepvihxhnyIaod2MG7eCE6G0Zm/HUF6jgN8GXgf4U7AU6SLseKdanY195cwvU6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.8': + resolution: {integrity: sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@mapbox/node-pre-gyp@2.0.0': + resolution: {integrity: sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg==} + engines: {node: '>=18'} + hasBin: true + + '@napi-rs/cli@3.2.0': + resolution: {integrity: sha512-heyXt/9OBPv/WrTFW2+PxIMzH6MCeqP9ZsvOg0LN6pLngBnszcxFsdhCAh5I6sddzQsvru53zj59GUzvmpWk8Q==} + engines: {node: '>= 16'} + hasBin: true + peerDependencies: + '@emnapi/runtime': ^1.1.0 + emnapi: ^1.1.0 + peerDependenciesMeta: + '@emnapi/runtime': + optional: true + emnapi: + optional: true + + '@napi-rs/cross-toolchain@1.0.3': + resolution: {integrity: sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg==} + peerDependencies: + '@napi-rs/cross-toolchain-arm64-target-aarch64': ^1.0.3 + '@napi-rs/cross-toolchain-arm64-target-armv7': ^1.0.3 + '@napi-rs/cross-toolchain-arm64-target-ppc64le': ^1.0.3 + '@napi-rs/cross-toolchain-arm64-target-s390x': ^1.0.3 + '@napi-rs/cross-toolchain-arm64-target-x86_64': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-aarch64': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-armv7': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-ppc64le': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-s390x': ^1.0.3 + '@napi-rs/cross-toolchain-x64-target-x86_64': ^1.0.3 + peerDependenciesMeta: + '@napi-rs/cross-toolchain-arm64-target-aarch64': + optional: true + '@napi-rs/cross-toolchain-arm64-target-armv7': + optional: true + '@napi-rs/cross-toolchain-arm64-target-ppc64le': + optional: true + '@napi-rs/cross-toolchain-arm64-target-s390x': + optional: true + '@napi-rs/cross-toolchain-arm64-target-x86_64': + optional: true + '@napi-rs/cross-toolchain-x64-target-aarch64': + optional: true + '@napi-rs/cross-toolchain-x64-target-armv7': + optional: true + '@napi-rs/cross-toolchain-x64-target-ppc64le': + optional: true + '@napi-rs/cross-toolchain-x64-target-s390x': + optional: true + '@napi-rs/cross-toolchain-x64-target-x86_64': + optional: true + + '@napi-rs/lzma-android-arm-eabi@1.4.5': + resolution: {integrity: sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/lzma-android-arm64@1.4.5': + resolution: {integrity: sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/lzma-darwin-arm64@1.4.5': + resolution: {integrity: sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/lzma-darwin-x64@1.4.5': + resolution: {integrity: sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/lzma-freebsd-x64@1.4.5': + resolution: {integrity: sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/lzma-linux-arm-gnueabihf@1.4.5': + resolution: {integrity: sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/lzma-linux-arm64-gnu@1.4.5': + resolution: {integrity: sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-arm64-musl@1.4.5': + resolution: {integrity: sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/lzma-linux-ppc64-gnu@1.4.5': + resolution: {integrity: sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-riscv64-gnu@1.4.5': + resolution: {integrity: sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-s390x-gnu@1.4.5': + resolution: {integrity: sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-x64-gnu@1.4.5': + resolution: {integrity: sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/lzma-linux-x64-musl@1.4.5': + resolution: {integrity: sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/lzma-wasm32-wasi@1.4.5': + resolution: {integrity: sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@napi-rs/lzma-win32-arm64-msvc@1.4.5': + resolution: {integrity: sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/lzma-win32-ia32-msvc@1.4.5': + resolution: {integrity: sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/lzma-win32-x64-msvc@1.4.5': + resolution: {integrity: sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/lzma@1.4.5': + resolution: {integrity: sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg==} + engines: {node: '>= 10'} + + '@napi-rs/tar-android-arm-eabi@1.1.0': + resolution: {integrity: sha512-h2Ryndraj/YiKgMV/r5by1cDusluYIRT0CaE0/PekQ4u+Wpy2iUVqvzVU98ZPnhXaNeYxEvVJHNGafpOfaD0TA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/tar-android-arm64@1.1.0': + resolution: {integrity: sha512-DJFyQHr1ZxNZorm/gzc1qBNLF/FcKzcH0V0Vwan5P+o0aE2keQIGEjJ09FudkF9v6uOuJjHCVDdK6S6uHtShAw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/tar-darwin-arm64@1.1.0': + resolution: {integrity: sha512-Zz2sXRzjIX4e532zD6xm2SjXEym6MkvfCvL2RMpG2+UwNVDVscHNcz3d47Pf3sysP2e2af7fBB3TIoK2f6trPw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/tar-darwin-x64@1.1.0': + resolution: {integrity: sha512-EI+CptIMNweT0ms9S3mkP/q+J6FNZ1Q6pvpJOEcWglRfyfQpLqjlC0O+dptruTPE8VamKYuqdjxfqD8hifZDOA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/tar-freebsd-x64@1.1.0': + resolution: {integrity: sha512-J0PIqX+pl6lBIAckL/c87gpodLbjZB1OtIK+RDscKC9NLdpVv6VGOxzUV/fYev/hctcE8EfkLbgFOfpmVQPg2g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/tar-linux-arm-gnueabihf@1.1.0': + resolution: {integrity: sha512-SLgIQo3f3EjkZ82ZwvrEgFvMdDAhsxCYjyoSuWfHCz0U16qx3SuGCp8+FYOPYCECHN3ZlGjXnoAIt9ERd0dEUg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/tar-linux-arm64-gnu@1.1.0': + resolution: {integrity: sha512-d014cdle52EGaH6GpYTQOP9Py7glMO1zz/+ynJPjjzYFSxvdYx0byrjumZk2UQdIyGZiJO2MEFpCkEEKFSgPYA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/tar-linux-arm64-musl@1.1.0': + resolution: {integrity: sha512-L/y1/26q9L/uBqiW/JdOb/Dc94egFvNALUZV2WCGKQXc6UByPBMgdiEyW2dtoYxYYYYc+AKD+jr+wQPcvX2vrQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/tar-linux-ppc64-gnu@1.1.0': + resolution: {integrity: sha512-EPE1K/80RQvPbLRJDJs1QmCIcH+7WRi0F73+oTe1582y9RtfGRuzAkzeBuAGRXAQEjRQw/RjtNqr6UTJ+8UuWQ==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@napi-rs/tar-linux-s390x-gnu@1.1.0': + resolution: {integrity: sha512-B2jhWiB1ffw1nQBqLUP1h4+J1ovAxBOoe5N2IqDMOc63fsPZKNqF1PvO/dIem8z7LL4U4bsfmhy3gBfu547oNQ==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@napi-rs/tar-linux-x64-gnu@1.1.0': + resolution: {integrity: sha512-tbZDHnb9617lTnsDMGo/eAMZxnsQFnaRe+MszRqHguKfMwkisc9CCJnks/r1o84u5fECI+J/HOrKXgczq/3Oww==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/tar-linux-x64-musl@1.1.0': + resolution: {integrity: sha512-dV6cODlzbO8u6Anmv2N/ilQHq/AWz0xyltuXoLU3yUyXbZcnWYZuB2rL8OBGPmqNcD+x9NdScBNXh7vWN0naSQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/tar-wasm32-wasi@1.1.0': + resolution: {integrity: sha512-jIa9nb2HzOrfH0F8QQ9g3WE4aMH5vSI5/1NYVNm9ysCmNjCCtMXCAhlI3WKCdm/DwHf0zLqdrrtDFXODcNaqMw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@napi-rs/tar-win32-arm64-msvc@1.1.0': + resolution: {integrity: sha512-vfpG71OB0ijtjemp3WTdmBKJm9R70KM8vsSExMsIQtV0lVzP07oM1CW6JbNRPXNLhRoue9ofYLiUDk8bE0Hckg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/tar-win32-ia32-msvc@1.1.0': + resolution: {integrity: sha512-hGPyPW60YSpOSgzfy68DLBHgi6HxkAM+L59ZZZPMQ0TOXjQg+p2EW87+TjZfJOkSpbYiEkULwa/f4a2hcVjsqQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/tar-win32-x64-msvc@1.1.0': + resolution: {integrity: sha512-L6Ed1DxXK9YSCMyvpR8MiNAyKNkQLjsHsHK9E0qnHa8NzLFqzDKhvs5LfnWxM2kJ+F7m/e5n9zPm24kHb3LsVw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/tar@1.1.0': + resolution: {integrity: sha512-7cmzIu+Vbupriudo7UudoMRH2OA3cTw67vva8MxeoAe5S7vPFI7z0vp0pMXiA25S8IUJefImQ90FeJjl8fjEaQ==} + engines: {node: '>= 10'} + + '@napi-rs/wasm-runtime@1.0.7': + resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} + + '@napi-rs/wasm-runtime@1.1.2': + resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@napi-rs/wasm-tools-android-arm-eabi@1.0.1': + resolution: {integrity: sha512-lr07E/l571Gft5v4aA1dI8koJEmF1F0UigBbsqg9OWNzg80H3lDPO+auv85y3T/NHE3GirDk7x/D3sLO57vayw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/wasm-tools-android-arm64@1.0.1': + resolution: {integrity: sha512-WDR7S+aRLV6LtBJAg5fmjKkTZIdrEnnQxgdsb7Cf8pYiMWBHLU+LC49OUVppQ2YSPY0+GeYm9yuZWW3kLjJ7Bg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/wasm-tools-darwin-arm64@1.0.1': + resolution: {integrity: sha512-qWTI+EEkiN0oIn/N2gQo7+TVYil+AJ20jjuzD2vATS6uIjVz+Updeqmszi7zq7rdFTLp6Ea3/z4kDKIfZwmR9g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/wasm-tools-darwin-x64@1.0.1': + resolution: {integrity: sha512-bA6hubqtHROR5UI3tToAF/c6TDmaAgF0SWgo4rADHtQ4wdn0JeogvOk50gs2TYVhKPE2ZD2+qqt7oBKB+sxW3A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/wasm-tools-freebsd-x64@1.0.1': + resolution: {integrity: sha512-90+KLBkD9hZEjPQW1MDfwSt5J1L46EUKacpCZWyRuL6iIEO5CgWU0V/JnEgFsDOGyyYtiTvHc5bUdUTWd4I9Vg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/wasm-tools-linux-arm64-gnu@1.0.1': + resolution: {integrity: sha512-rG0QlS65x9K/u3HrKafDf8cFKj5wV2JHGfl8abWgKew0GVPyp6vfsDweOwHbWAjcHtp2LHi6JHoW80/MTHm52Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/wasm-tools-linux-arm64-musl@1.0.1': + resolution: {integrity: sha512-jAasbIvjZXCgX0TCuEFQr+4D6Lla/3AAVx2LmDuMjgG4xoIXzjKWl7c4chuaD+TI+prWT0X6LJcdzFT+ROKGHQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/wasm-tools-linux-x64-gnu@1.0.1': + resolution: {integrity: sha512-Plgk5rPqqK2nocBGajkMVbGm010Z7dnUgq0wtnYRZbzWWxwWcXfZMPa8EYxrK4eE8SzpI7VlZP1tdVsdjgGwMw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/wasm-tools-linux-x64-musl@1.0.1': + resolution: {integrity: sha512-GW7AzGuWxtQkyHknHWYFdR0CHmW6is8rG2Rf4V6GNmMpmwtXt/ItWYWtBe4zqJWycMNazpfZKSw/BpT7/MVCXQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/wasm-tools-wasm32-wasi@1.0.1': + resolution: {integrity: sha512-/nQVSTrqSsn7YdAc2R7Ips/tnw5SPUcl3D7QrXCNGPqjbatIspnaexvaOYNyKMU6xPu+pc0BTnKVmqhlJJCPLA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@napi-rs/wasm-tools-win32-arm64-msvc@1.0.1': + resolution: {integrity: sha512-PFi7oJIBu5w7Qzh3dwFea3sHRO3pojMsaEnUIy22QvsW+UJfNQwJCryVrpoUt8m4QyZXI+saEq/0r4GwdoHYFQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/wasm-tools-win32-ia32-msvc@1.0.1': + resolution: {integrity: sha512-gXkuYzxQsgkj05Zaq+KQTkHIN83dFAwMcTKa2aQcpYPRImFm2AQzEyLtpXmyCWzJ0F9ZYAOmbSyrNew8/us6bw==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/wasm-tools-win32-x64-msvc@1.0.1': + resolution: {integrity: sha512-rEAf05nol3e3eei2sRButmgXP+6ATgm0/38MKhz9Isne82T4rPIMYsCIFj0kOisaGeVwoi2fnm7O9oWp5YVnYQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/wasm-tools@1.0.1': + resolution: {integrity: sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ==} + engines: {node: '>= 10'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + + '@octokit/core@7.0.3': + resolution: {integrity: sha512-oNXsh2ywth5aowwIa7RKtawnkdH6LgU1ztfP9AIUCQCvzysB+WeU8o2kyyosDPwBZutPpjZDKPQGIzzrfTWweQ==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.0': + resolution: {integrity: sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ==} + engines: {node: '>= 20'} + + '@octokit/graphql@9.0.1': + resolution: {integrity: sha512-j1nQNU1ZxNFx2ZtKmL4sMrs4egy5h65OMDmSbVyuCzjOcwsHq6EaYjOTGXPQxgfiN8dJ4CriYHk6zF050WEULg==} + engines: {node: '>= 20'} + + '@octokit/openapi-types@25.1.0': + resolution: {integrity: sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==} + + '@octokit/plugin-paginate-rest@13.1.1': + resolution: {integrity: sha512-q9iQGlZlxAVNRN2jDNskJW/Cafy7/XE52wjZ5TTvyhyOD904Cvx//DNyoO3J/MXJ0ve3rPoNWKEg5iZrisQSuw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-request-log@6.0.0': + resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-rest-endpoint-methods@16.0.0': + resolution: {integrity: sha512-kJVUQk6/dx/gRNLWUnAWKFs1kVPn5O5CYZyssyEoNYaFedqZxsfYs7DwI3d67hGz4qOwaJ1dpm07hOAD1BXx6g==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/request-error@7.0.0': + resolution: {integrity: sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.3': + resolution: {integrity: sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA==} + engines: {node: '>= 20'} + + '@octokit/rest@22.0.0': + resolution: {integrity: sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA==} + engines: {node: '>= 20'} + + '@octokit/types@14.1.0': + resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} + + '@oxc-node/core-android-arm-eabi@0.1.0': + resolution: {integrity: sha512-+ycNqMBKBz3EWpQKm7HgUMRLGKfFZsZ/JxN9ctx12CwGy0PTtjX3TB+1WEbiJrgWiZM0axBjuwe4MEqS6j1kgQ==} + cpu: [arm] + os: [android] + + '@oxc-node/core-android-arm64@0.1.0': + resolution: {integrity: sha512-sJZGDgQwlawrGnLPu1ueAoM/0sUKtCUZr0y4IljarPCVbVBHimcxKcyNlzucIjyQwwiptZourCbUNHONvm4i1g==} + cpu: [arm64] + os: [android] + + '@oxc-node/core-darwin-arm64@0.1.0': + resolution: {integrity: sha512-6hsKxbYCzAg390Y/URpCCDPDM4HSHl+Cxodsh2lw6GjX68FDFgLbEwOU2ivXfnXEmJMEMLSjv/0tPTBzDPIJJg==} + cpu: [arm64] + os: [darwin] + + '@oxc-node/core-darwin-x64@0.1.0': + resolution: {integrity: sha512-VCygvTqrquI3u25B0D6LjO1GnAVMyuAXae4PxRwmEwNuWgWaErG3zHaaU6+hBlXysiyZWKhSXAoAAJmrwe17tA==} + cpu: [x64] + os: [darwin] + + '@oxc-node/core-freebsd-x64@0.1.0': + resolution: {integrity: sha512-UQ0hCpwTOUpg1Oh/H/I0BRQmt8XCjCQArk3Cp0P1qc3/xcZ2IcTihxwIZAKLr/lkl+7Ya5rBn9zuY1fe28HaqA==} + cpu: [x64] + os: [freebsd] + + '@oxc-node/core-linux-arm-gnueabihf@0.1.0': + resolution: {integrity: sha512-Z+Il9u39MIhusioOQyRfZFMRY+e6QtePxRH44bUHXj7r+eTcHXTJyaDqqCr1HRvtPc9K/re1zH2hV9yEZtHUsg==} + cpu: [arm] + os: [linux] + + '@oxc-node/core-linux-arm64-gnu@0.1.0': + resolution: {integrity: sha512-nt2IT6MCZApyWsaEjky2znYZIII/BphuqD5mtnbGrFeF3dBpO6U2JiXHCQK/FZ/yrVLlCmcPgcOEL6yMYu8Tiw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-node/core-linux-arm64-musl@0.1.0': + resolution: {integrity: sha512-0CYLp49qV4KIZmgckqiNcHiLROcb9J1sQSejIKJwbgcaoBLRw5olRCUE9cOi424jRxzXq50zsEv4ihheSA71pQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-node/core-linux-ppc64-gnu@0.1.0': + resolution: {integrity: sha512-eRarcfNvV0NorkUx5oywdUlC+E35dQCwZzI9BRe6ePywyeCBGJw6D5NnMdPfhCI1olPTkpPdCHXjY/gcl8XH3Q==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-node/core-linux-s390x-gnu@0.1.0': + resolution: {integrity: sha512-PZx57NdmM9jq5jWXV1uk/PfHuDbodQ71KIwkNtLqLTdgJZS69m9Xi9j0vo6yyMBGJMD+aYK79eO0KxPU/5TgSA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-node/core-linux-x64-gnu@0.1.0': + resolution: {integrity: sha512-N9lTK2chPpsXx0Ur6nUTW7OFE0d0wK4qpbb7WJAyJ48mU9bC22xuCi2yGDB748n16Yly3+mCJ+LiU7r3aBLZhA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-node/core-linux-x64-musl@0.1.0': + resolution: {integrity: sha512-xs/ObZhHfN6AcAcjYQQkaeXBR1khm7ZlF6uOpmEhdAiG80P1aVw9ndEYz0LQHp2iVhPaRL9UfYuVKLqKah1TAA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-node/core-openharmony-arm64@0.1.0': + resolution: {integrity: sha512-B7ixIkc5G7pIqZmWM8oqi/qDdcRdo//mTNM4ChFNU1oou2Hy/FlidAKppMLFVRc4ddRrp6uEvYrT1LgZnjzn6g==} + cpu: [arm64] + os: [openharmony] + + '@oxc-node/core-wasm32-wasi@0.1.0': + resolution: {integrity: sha512-W4jM3S4XRgtpCKDGNGitCCfinIlyRAKoiQHi1nM+Nul3+Xf2RZqFOgBzdHxT+CahrT8kDi+p7gDGQN3uziwM1A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-node/core-win32-arm64-msvc@0.1.0': + resolution: {integrity: sha512-3Qi2tdV+JSqK5Cfcpg3FX54zR78dBEFk+/usFXcF+bhMiBy8YmXGd+yDudl+wwEYf9Xz+Hqx74u8YCXrWDAUTg==} + cpu: [arm64] + os: [win32] + + '@oxc-node/core-win32-ia32-msvc@0.1.0': + resolution: {integrity: sha512-j66qK1qu5FcmfmIYdv1bp7gIuOi1LCDhzFOu7UdWZyFrinZHh7gHnLHngpSibM/M2Zp/mVLLWD2Ojlsy+8VtNA==} + cpu: [ia32] + os: [win32] + + '@oxc-node/core-win32-x64-msvc@0.1.0': + resolution: {integrity: sha512-S3qWlUQ7ZrOSLG1IwNiqQEovJU8MZ11Vc9k+NUzNTO20zFPEt9Jq1wFElusxJZBqBsd7AUXIq08n9dXC/ialbg==} + cpu: [x64] + os: [win32] + + '@oxc-node/core@0.1.0': + resolution: {integrity: sha512-Spk/ey3zg1CpBU1eUHBPbAbfFddntutZPPsweh+kNh9M9Ksc8j9OCujralW9HrVyi6nNWek1PnMfSZ7NPLLCKA==} + + '@oxlint/darwin-arm64@1.14.0': + resolution: {integrity: sha512-rcTw0QWeOc6IeVp+Up7WtcwdS9l4j7TOq4tihF0Ud/fl+VUVdvDCPuZ9QTnLXJhwMXiyQRWdxRyI6XBwf80ncQ==} + cpu: [arm64] + os: [darwin] + + '@oxlint/darwin-x64@1.14.0': + resolution: {integrity: sha512-TWFSEmyl2/DN4HoXNwQl0y/y3EXFJDctfv5MiDtVOV1GJKX80cGSIxMxXb08Q3CCWqteqEijmfSMo5TG8X1H/A==} + cpu: [x64] + os: [darwin] + + '@oxlint/linux-arm64-gnu@1.14.0': + resolution: {integrity: sha512-N1FqdKfwhVWPpMElv8qlGqdEefTbDYaRVhdGWOjs/2f7FESa5vX0cvA7ToqzkoXyXZI5DqByWiPML33njK30Kg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/linux-arm64-musl@1.14.0': + resolution: {integrity: sha512-v/BPuiateLBb7Gz1STb69EWjkgKdlPQ1NM56z+QQur21ly2hiMkBX2n0zEhqfu9PQVRUizu6AlsYuzcPY/zsIQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/linux-x64-gnu@1.14.0': + resolution: {integrity: sha512-gUTp8KIrSYt97dn+tRRC3LKnH4xlHKCwrPwiDcGmLbCxojuN9/H5mnIhPKEfwNuZNdoKGS/ABuq3neVyvRCRtQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/linux-x64-musl@1.14.0': + resolution: {integrity: sha512-DpN6cW2HPjYXeENG0JBbmubO8LtfKt6qJqEMBw9gUevbyBaX+k+Jn7sYgh6S23wGOkzmTNphBsf/7ulj4nIVYA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/win32-arm64@1.14.0': + resolution: {integrity: sha512-oXxJksnUTUMgJ0NvjKS1mrCXAy1ttPgIVacRSlxQ+1XHy+aJDMM7I8fsCtoKoEcTIpPaD98eqUqlLYs0H2MGjA==} + cpu: [arm64] + os: [win32] + + '@oxlint/win32-x64@1.14.0': + resolution: {integrity: sha512-iRYy2rhTQKFztyx0jtNMRBnFpzsRwFdjWQ7sKKzJpmbijA3Tw3DCqlGT7QRgoVRF0+X/ccNGvvsrgMohPVfLeQ==} + cpu: [x64] + os: [win32] + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@rollup/pluginutils@5.1.4': + resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@taplo/cli@0.7.0': + resolution: {integrity: sha512-Ck3zFhQhIhi02Hl6T4ZmJsXdnJE+wXcJz5f8klxd4keRYgenMnip3JDPMGDRLbnC/2iGd8P0sBIQqI3KxfVjBg==} + hasBin: true + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/estree@1.0.6': + resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vercel/nft@1.5.0': + resolution: {integrity: sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==} + engines: {node: '>=20'} + hasBin: true + + abbrev@3.0.0: + resolution: {integrity: sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA==} + engines: {node: ^18.17.0 || >=20.5.0} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.3: + resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} + engines: {node: '>= 14'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@7.0.0: + resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-find-index@1.0.2: + resolution: {integrity: sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==} + engines: {node: '>=0.10.0'} + + arrgv@1.0.2: + resolution: {integrity: sha512-a4eg4yhp7mmruZDQFqVMlxNRFGi/i1r87pt8SDHy0/I8PqSXoUTlWZRdAZo0VXgvEARcujbtTk8kiZRi1uDGRw==} + engines: {node: '>=8.0.0'} + + arrify@3.0.0: + resolution: {integrity: sha512-tLkvA81vQG/XqE2mjDkGQHoOINtMHtysSnemrmoGe6PydDPMRbVugqyk4A6V/WDWEfm3l+0d8anA9r8cv/5Jaw==} + engines: {node: '>=12'} + + async-sema@3.1.1: + resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} + + ava@8.0.0: + resolution: {integrity: sha512-KMUyT3LcnZ/5ipDAqnTGn6PQUvr1Jk4dex3v7ieQrGaMlAblfIpMLwK56HfPcObvJEzyhCWXfZjbM98FeAy0oQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + hasBin: true + peerDependencies: + '@ava/typescript': '*' + peerDependenciesMeta: + '@ava/typescript': + optional: true + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + blueimp-md5@2.19.0: + resolution: {integrity: sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + callsites@4.2.0: + resolution: {integrity: sha512-kfzR4zzQtAE9PC7CzZsjl3aBNbXWuXiSeOCdLcPpBfGW8YuCqQHcRPFDbr/BPVmd3EEPVpuFzLyuT/cUhPr4OQ==} + engines: {node: '>=12.20'} + + cbor@10.0.12: + resolution: {integrity: sha512-exQDevYd7ZQLP4moMQcZkKCVZsXLAtUSflObr3xTh4xzFIv/xBCdvCd6L259kQOUP2kcTC0jvC6PpZIf/WmRXA==} + engines: {node: '>=20'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@2.1.0: + resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + chunkd@2.0.1: + resolution: {integrity: sha512-7d58XsFmOq0j6el67Ug9mHf9ELUXsQXYJBkyxhH/k+6Ke0qXRnv0kbemx+Twc6fRJ07C49lcbdgm9FL1Ei/6SQ==} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + ci-parallel-vars@1.0.1: + resolution: {integrity: sha512-uvzpYrpmidaoxvIQHM+rKSrigjOe9feHYbw4uOI2gdfe1C3xIlxO+kVXq83WQWNniTf8bAxVpy+cQeFQsMERKg==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + + cli-truncate@6.0.0: + resolution: {integrity: sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==} + engines: {node: '>=22'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + clipanion@4.0.0-rc.4: + resolution: {integrity: sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==} + peerDependencies: + typanion: '*' + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + common-path-prefix@3.0.0: + resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==} + + concordance@5.0.4: + resolution: {integrity: sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw==} + engines: {node: '>=10.18.0 <11 || >=12.14.0 <13 || >=14'} + + consola@3.4.0: + resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==} + engines: {node: ^14.18.0 || >=16.10.0} + + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + currently-unhandled@0.4.1: + resolution: {integrity: sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==} + engines: {node: '>=0.10.0'} + + date-time@3.1.0: + resolution: {integrity: sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg==} + engines: {node: '>=6'} + + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emittery@2.0.0: + resolution: {integrity: sha512-FLtgn/CGBXiX3ZtPAm5q4LWWepHChOt55J9u01WFu3dyap2U7IwptlrqoE1COR/kxwdy/DOxIBALSxIW449I1g==} + engines: {node: '>=22'} + + emnapi@1.5.0: + resolution: {integrity: sha512-adAaiwTxMnHbq1u2LUf+AfDR5MYrxDVBtezGspxwk5e/Zb6KHkGNdfuMU4JBIVm6ASY06K8KalhOPUht92MsnA==} + peerDependencies: + node-addon-api: '>= 6.1.0' + peerDependenciesMeta: + node-addon-api: + optional: true + + emoji-regex@10.3.0: + resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + es-toolkit@1.39.10: + resolution: {integrity: sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==} + + escalade@3.1.1: + resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + engines: {node: '>=6'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + fast-content-type-parse@3.0.0: + resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.16.0: + resolution: {integrity: sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up-simple@1.0.0: + resolution: {integrity: sha512-q7Us7kcjj2VMePAa02hDAF6d+MzsdsAWEwYyOpwUtlerRBkOEPBCRZrAV4XfcSN8fHAgaD0hP7miwoay6DCprw==} + engines: {node: '>=18'} + + find-up@7.0.0: + resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} + engines: {node: '>=18'} + + foreground-child@3.3.0: + resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} + engines: {node: '>=14'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + globby@16.2.0: + resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} + engines: {node: '>=20'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore-by-default@2.1.0: + resolution: {integrity: sha512-yiWd4GVmJp0Q6ghmM2B/V3oZGRmjrKLXvHR3TE1nfoXsmoggllfZUQe74EN0fJdPFZu2NIvNdrMMLm3OsV7Ohw==} + engines: {node: '>=10 <11 || >=12 <13 || >=14'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + irregular-plurals@4.2.0: + resolution: {integrity: sha512-bW9UXHL7bnUcNtTo+9ccSngbxc+V40H32IgvdVin0Xs8gbo+AVYD5g/72ce/54Kjfhq66vcZr8H8TKEvsifeOw==} + engines: {node: '>=18.20'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-unicode-supported@2.0.0: + resolution: {integrity: sha512-FRdAyx5lusK1iHG0TWpVtk9+1i+GjrzRffhDg4ovQ7mcidMQ6mj+MhKPmvh7Xwyv5gIS06ns49CA7Sqg7lC22Q==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + js-string-escape@1.0.1: + resolution: {integrity: sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==} + engines: {node: '>= 0.8'} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json-parse-even-better-errors@6.0.0: + resolution: {integrity: sha512-2/8adwnK1/+Fdjyts4r6wSpfANWw8zdNhU9U/Llk59c6O+DjSisPWPykwoL8gZmocP9Dy64S7oie2g+Mia123A==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + lint-staged@17.0.0: + resolution: {integrity: sha512-286BsrsEp/7taRKY839AAU8E78uModiwmU7/cbuHbGfNA4mQVtLXA2Au9pLgWI2v4/M+PHq+s/rH+LJlVKucwA==} + engines: {node: '>=22.22.1'} + hasBin: true + + listr2@10.2.1: + resolution: {integrity: sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==} + engines: {node: '>=22.13.0'} + + load-json-file@7.0.1: + resolution: {integrity: sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + + matcher@6.0.0: + resolution: {integrity: sha512-TzDerdcNtI79w7Av4GT57bLdElPA/VAkjqdMZv8yhuc8geU2z0ljW9anXbX/55aHEMTpYypZb1lxsA/46r9oOQ==} + engines: {node: '>=20'} + + md5-hex@3.0.1: + resolution: {integrity: sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw==} + engines: {node: '>=8'} + + memoize@11.0.0: + resolution: {integrity: sha512-cjsfZaC9b1clqPeIVMbb5dLHSXgdgGWGxdAU3oTUUkHiwWTKTBNnSmcqWJncNjYtBi3S8Rp0c5GIiyGztR8TRA==} + engines: {node: '>=22'} + + memorystream@0.3.1: + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} + engines: {node: '>= 0.10.0'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.0.1: + resolution: {integrity: sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==} + engines: {node: '>= 18'} + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build@4.7.1: + resolution: {integrity: sha512-wTSrZ+8lsRRa3I3H8Xr65dLWSgCvY2l4AOnaeKdPA9TB/WYMPaTcrzf3rXvFoVvjKNVnu0CcWSx54qq9GKRUYg==} + hasBin: true + + nofilter@3.1.0: + resolution: {integrity: sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==} + engines: {node: '>=12.19'} + + nopt@8.1.0: + resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + npm-normalize-package-bin@6.0.0: + resolution: {integrity: sha512-tdt4aFn9QamlhdN3HV2D2ccpBwO5/fyjjbXUxYA6uBjyekMZcZvDq0aSj9t5Jo+tih6AYFnt/cuIRn9013e0Uw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + npm-run-all2@9.0.0: + resolution: {integrity: sha512-NMHaiMWl+kotdoAzVtwElvEh4PLdjAGsdmCJXOGv0rdM4d19FGIa0z0ISFuMklmYgVgQzS4h+jNlowz+q1aojw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0, npm: '>= 10'} + hasBin: true + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + oxlint@1.14.0: + resolution: {integrity: sha512-oo0nq3zF9hmgATGc9esoMahLuEESOodUxEDeHDA2K7tbYcSfcmReE9G2QNppnq9rOSQHLTwlMtzGAjjttYaufQ==} + engines: {node: '>=8.*'} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.1.5' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-map@7.0.4: + resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} + engines: {node: '>=18'} + + package-config@5.0.0: + resolution: {integrity: sha512-GYTTew2slBcYdvRHqjhwaaydVMvn/qrGC323+nKclYioNSLTDUM/lGgtGTgyHVtYcozb+XkE8CNhwcraOmZ9Mg==} + engines: {node: '>=18'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + plur@6.0.0: + resolution: {integrity: sha512-Y9wXQivjRX0REtwpA9+n0bYYypWESn3cWtW2vazymw711qn+AQXxzZjRqhANYGBLIMC1UzVdpwe/1hHQwHfwng==} + engines: {node: '>=20'} + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + read-package-json-fast@6.0.0: + resolution: {integrity: sha512-PNaGjoCnw9DBA2Kl8D+8po957z778q/HOPuY2u3Bkw/JO3eC8MDx7jn/PgMtSgpcBbs+6UOjDbwReGpXmRvs0g==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.1: + resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slice-ansi@7.1.0: + resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + + slice-ansi@9.0.0: + resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} + engines: {node: '>=22'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + supertap@3.0.1: + resolution: {integrity: sha512-u1ZpIBCawJnO+0QePsEiOknOfCRq0yERxiAchT0i4li0WHNUJbf0evXXSXOcCAR4M8iMDoajXYmstm/qO81Isw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + tar@7.4.3: + resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} + engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + temp-dir@3.0.0: + resolution: {integrity: sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==} + engines: {node: '>=14.16'} + + time-zone@1.0.0: + resolution: {integrity: sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA==} + engines: {node: '>=4'} + + tinybench@6.0.0: + resolution: {integrity: sha512-BWlWpVbbZXaYjRV0twGLNQO00Zj4HA/sjLOQP2IvzQqGwRGp+2kh7UU3ijyJ3ywFRogYDRbiHDMrUOfaMnN56g==} + engines: {node: '>=20.0.0'} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tslib@2.6.3: + resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} + + typanion@3.14.0: + resolution: {integrity: sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==} + + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} + + universal-user-agent@7.0.2: + resolution: {integrity: sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==} + + wavefile@11.0.0: + resolution: {integrity: sha512-/OBiAALgWU24IG7sC84cDO/KfFuvajWc5Uec0oV2zrpOOZZDgGdOwHwgEzOrwh8jkubBk7PtZfQBIcI1OaE5Ng==} + engines: {node: '>=8'} + hasBin: true + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + well-known-symbols@2.0.0: + resolution: {integrity: sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==} + engines: {node: '>=6'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@7.0.0: + resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + + wrap-ansi@10.0.0: + resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} + engines: {node: '>=20'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} + engines: {node: ^20.17.0 || >=22.9.0} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml@2.8.4: + resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yocto-queue@1.2.1: + resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} + engines: {node: '>=12.20'} + + yoctocolors-cjs@2.1.2: + resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} + engines: {node: '>=18'} + +snapshots: + + '@emnapi/core@1.5.0': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.6.3 + optional: true + + '@emnapi/core@1.9.1': + dependencies: + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.6.3 + optional: true + + '@emnapi/runtime@1.5.0': + dependencies: + tslib: 2.6.3 + optional: true + + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.6.3 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.6.3 + optional: true + + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.6.3 + optional: true + + '@inquirer/checkbox@4.2.2(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.2.0(@types/node@26.2.0) + '@inquirer/figures': 1.0.13 + '@inquirer/type': 3.0.8(@types/node@26.2.0) + ansi-escapes: 4.3.2 + yoctocolors-cjs: 2.1.2 + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/confirm@5.1.16(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.2.0(@types/node@26.2.0) + '@inquirer/type': 3.0.8(@types/node@26.2.0) + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/core@10.2.0(@types/node@26.2.0)': + dependencies: + '@inquirer/figures': 1.0.13 + '@inquirer/type': 3.0.8(@types/node@26.2.0) + ansi-escapes: 4.3.2 + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.2 + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/editor@4.2.18(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.2.0(@types/node@26.2.0) + '@inquirer/external-editor': 1.0.1(@types/node@26.2.0) + '@inquirer/type': 3.0.8(@types/node@26.2.0) + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/expand@4.0.18(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.2.0(@types/node@26.2.0) + '@inquirer/type': 3.0.8(@types/node@26.2.0) + yoctocolors-cjs: 2.1.2 + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/external-editor@1.0.1(@types/node@26.2.0)': + dependencies: + chardet: 2.1.0 + iconv-lite: 0.6.3 + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/figures@1.0.13': {} + + '@inquirer/input@4.2.2(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.2.0(@types/node@26.2.0) + '@inquirer/type': 3.0.8(@types/node@26.2.0) + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/number@3.0.18(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.2.0(@types/node@26.2.0) + '@inquirer/type': 3.0.8(@types/node@26.2.0) + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/password@4.0.18(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.2.0(@types/node@26.2.0) + '@inquirer/type': 3.0.8(@types/node@26.2.0) + ansi-escapes: 4.3.2 + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/prompts@7.8.4(@types/node@26.2.0)': + dependencies: + '@inquirer/checkbox': 4.2.2(@types/node@26.2.0) + '@inquirer/confirm': 5.1.16(@types/node@26.2.0) + '@inquirer/editor': 4.2.18(@types/node@26.2.0) + '@inquirer/expand': 4.0.18(@types/node@26.2.0) + '@inquirer/input': 4.2.2(@types/node@26.2.0) + '@inquirer/number': 3.0.18(@types/node@26.2.0) + '@inquirer/password': 4.0.18(@types/node@26.2.0) + '@inquirer/rawlist': 4.1.6(@types/node@26.2.0) + '@inquirer/search': 3.1.1(@types/node@26.2.0) + '@inquirer/select': 4.3.2(@types/node@26.2.0) + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/rawlist@4.1.6(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.2.0(@types/node@26.2.0) + '@inquirer/type': 3.0.8(@types/node@26.2.0) + yoctocolors-cjs: 2.1.2 + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/search@3.1.1(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.2.0(@types/node@26.2.0) + '@inquirer/figures': 1.0.13 + '@inquirer/type': 3.0.8(@types/node@26.2.0) + yoctocolors-cjs: 2.1.2 + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/select@4.3.2(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.2.0(@types/node@26.2.0) + '@inquirer/figures': 1.0.13 + '@inquirer/type': 3.0.8(@types/node@26.2.0) + ansi-escapes: 4.3.2 + yoctocolors-cjs: 2.1.2 + optionalDependencies: + '@types/node': 26.2.0 + + '@inquirer/type@3.0.8(@types/node@26.2.0)': + optionalDependencies: + '@types/node': 26.2.0 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@mapbox/node-pre-gyp@2.0.0': + dependencies: + consola: 3.4.0 + detect-libc: 2.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 2.7.0 + nopt: 8.1.0 + semver: 7.7.2 + tar: 7.4.3 + transitivePeerDependencies: + - encoding + - supports-color + + '@napi-rs/cli@3.2.0(@emnapi/runtime@1.5.0)(@types/node@26.2.0)(emnapi@1.5.0)': + dependencies: + '@inquirer/prompts': 7.8.4(@types/node@26.2.0) + '@napi-rs/cross-toolchain': 1.0.3 + '@napi-rs/wasm-tools': 1.0.1 + '@octokit/rest': 22.0.0 + clipanion: 4.0.0-rc.4(typanion@3.14.0) + colorette: 2.0.20 + debug: 4.4.1 + es-toolkit: 1.39.10 + find-up: 7.0.0 + js-yaml: 4.1.0 + semver: 7.7.2 + typanion: 3.14.0 + optionalDependencies: + '@emnapi/runtime': 1.5.0 + emnapi: 1.5.0 + transitivePeerDependencies: + - '@napi-rs/cross-toolchain-arm64-target-aarch64' + - '@napi-rs/cross-toolchain-arm64-target-armv7' + - '@napi-rs/cross-toolchain-arm64-target-ppc64le' + - '@napi-rs/cross-toolchain-arm64-target-s390x' + - '@napi-rs/cross-toolchain-arm64-target-x86_64' + - '@napi-rs/cross-toolchain-x64-target-aarch64' + - '@napi-rs/cross-toolchain-x64-target-armv7' + - '@napi-rs/cross-toolchain-x64-target-ppc64le' + - '@napi-rs/cross-toolchain-x64-target-s390x' + - '@napi-rs/cross-toolchain-x64-target-x86_64' + - '@types/node' + - supports-color + + '@napi-rs/cross-toolchain@1.0.3': + dependencies: + '@napi-rs/lzma': 1.4.5 + '@napi-rs/tar': 1.1.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@napi-rs/lzma-android-arm-eabi@1.4.5': + optional: true + + '@napi-rs/lzma-android-arm64@1.4.5': + optional: true + + '@napi-rs/lzma-darwin-arm64@1.4.5': + optional: true + + '@napi-rs/lzma-darwin-x64@1.4.5': + optional: true + + '@napi-rs/lzma-freebsd-x64@1.4.5': + optional: true + + '@napi-rs/lzma-linux-arm-gnueabihf@1.4.5': + optional: true + + '@napi-rs/lzma-linux-arm64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-arm64-musl@1.4.5': + optional: true + + '@napi-rs/lzma-linux-ppc64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-riscv64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-s390x-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-x64-musl@1.4.5': + optional: true + + '@napi-rs/lzma-wasm32-wasi@1.4.5': + dependencies: + '@napi-rs/wasm-runtime': 1.0.7 + optional: true + + '@napi-rs/lzma-win32-arm64-msvc@1.4.5': + optional: true + + '@napi-rs/lzma-win32-ia32-msvc@1.4.5': + optional: true + + '@napi-rs/lzma-win32-x64-msvc@1.4.5': + optional: true + + '@napi-rs/lzma@1.4.5': + optionalDependencies: + '@napi-rs/lzma-android-arm-eabi': 1.4.5 + '@napi-rs/lzma-android-arm64': 1.4.5 + '@napi-rs/lzma-darwin-arm64': 1.4.5 + '@napi-rs/lzma-darwin-x64': 1.4.5 + '@napi-rs/lzma-freebsd-x64': 1.4.5 + '@napi-rs/lzma-linux-arm-gnueabihf': 1.4.5 + '@napi-rs/lzma-linux-arm64-gnu': 1.4.5 + '@napi-rs/lzma-linux-arm64-musl': 1.4.5 + '@napi-rs/lzma-linux-ppc64-gnu': 1.4.5 + '@napi-rs/lzma-linux-riscv64-gnu': 1.4.5 + '@napi-rs/lzma-linux-s390x-gnu': 1.4.5 + '@napi-rs/lzma-linux-x64-gnu': 1.4.5 + '@napi-rs/lzma-linux-x64-musl': 1.4.5 + '@napi-rs/lzma-wasm32-wasi': 1.4.5 + '@napi-rs/lzma-win32-arm64-msvc': 1.4.5 + '@napi-rs/lzma-win32-ia32-msvc': 1.4.5 + '@napi-rs/lzma-win32-x64-msvc': 1.4.5 + + '@napi-rs/tar-android-arm-eabi@1.1.0': + optional: true + + '@napi-rs/tar-android-arm64@1.1.0': + optional: true + + '@napi-rs/tar-darwin-arm64@1.1.0': + optional: true + + '@napi-rs/tar-darwin-x64@1.1.0': + optional: true + + '@napi-rs/tar-freebsd-x64@1.1.0': + optional: true + + '@napi-rs/tar-linux-arm-gnueabihf@1.1.0': + optional: true + + '@napi-rs/tar-linux-arm64-gnu@1.1.0': + optional: true + + '@napi-rs/tar-linux-arm64-musl@1.1.0': + optional: true + + '@napi-rs/tar-linux-ppc64-gnu@1.1.0': + optional: true + + '@napi-rs/tar-linux-s390x-gnu@1.1.0': + optional: true + + '@napi-rs/tar-linux-x64-gnu@1.1.0': + optional: true + + '@napi-rs/tar-linux-x64-musl@1.1.0': + optional: true + + '@napi-rs/tar-wasm32-wasi@1.1.0': + dependencies: + '@napi-rs/wasm-runtime': 1.0.7 + optional: true + + '@napi-rs/tar-win32-arm64-msvc@1.1.0': + optional: true + + '@napi-rs/tar-win32-ia32-msvc@1.1.0': + optional: true + + '@napi-rs/tar-win32-x64-msvc@1.1.0': + optional: true + + '@napi-rs/tar@1.1.0': + optionalDependencies: + '@napi-rs/tar-android-arm-eabi': 1.1.0 + '@napi-rs/tar-android-arm64': 1.1.0 + '@napi-rs/tar-darwin-arm64': 1.1.0 + '@napi-rs/tar-darwin-x64': 1.1.0 + '@napi-rs/tar-freebsd-x64': 1.1.0 + '@napi-rs/tar-linux-arm-gnueabihf': 1.1.0 + '@napi-rs/tar-linux-arm64-gnu': 1.1.0 + '@napi-rs/tar-linux-arm64-musl': 1.1.0 + '@napi-rs/tar-linux-ppc64-gnu': 1.1.0 + '@napi-rs/tar-linux-s390x-gnu': 1.1.0 + '@napi-rs/tar-linux-x64-gnu': 1.1.0 + '@napi-rs/tar-linux-x64-musl': 1.1.0 + '@napi-rs/tar-wasm32-wasi': 1.1.0 + '@napi-rs/tar-win32-arm64-msvc': 1.1.0 + '@napi-rs/tar-win32-ia32-msvc': 1.1.0 + '@napi-rs/tar-win32-x64-msvc': 1.1.0 + + '@napi-rs/wasm-runtime@1.0.7': + dependencies: + '@emnapi/core': 1.5.0 + '@emnapi/runtime': 1.5.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@napi-rs/wasm-tools-android-arm-eabi@1.0.1': + optional: true + + '@napi-rs/wasm-tools-android-arm64@1.0.1': + optional: true + + '@napi-rs/wasm-tools-darwin-arm64@1.0.1': + optional: true + + '@napi-rs/wasm-tools-darwin-x64@1.0.1': + optional: true + + '@napi-rs/wasm-tools-freebsd-x64@1.0.1': + optional: true + + '@napi-rs/wasm-tools-linux-arm64-gnu@1.0.1': + optional: true + + '@napi-rs/wasm-tools-linux-arm64-musl@1.0.1': + optional: true + + '@napi-rs/wasm-tools-linux-x64-gnu@1.0.1': + optional: true + + '@napi-rs/wasm-tools-linux-x64-musl@1.0.1': + optional: true + + '@napi-rs/wasm-tools-wasm32-wasi@1.0.1': + dependencies: + '@napi-rs/wasm-runtime': 1.0.7 + optional: true + + '@napi-rs/wasm-tools-win32-arm64-msvc@1.0.1': + optional: true + + '@napi-rs/wasm-tools-win32-ia32-msvc@1.0.1': + optional: true + + '@napi-rs/wasm-tools-win32-x64-msvc@1.0.1': + optional: true + + '@napi-rs/wasm-tools@1.0.1': + optionalDependencies: + '@napi-rs/wasm-tools-android-arm-eabi': 1.0.1 + '@napi-rs/wasm-tools-android-arm64': 1.0.1 + '@napi-rs/wasm-tools-darwin-arm64': 1.0.1 + '@napi-rs/wasm-tools-darwin-x64': 1.0.1 + '@napi-rs/wasm-tools-freebsd-x64': 1.0.1 + '@napi-rs/wasm-tools-linux-arm64-gnu': 1.0.1 + '@napi-rs/wasm-tools-linux-arm64-musl': 1.0.1 + '@napi-rs/wasm-tools-linux-x64-gnu': 1.0.1 + '@napi-rs/wasm-tools-linux-x64-musl': 1.0.1 + '@napi-rs/wasm-tools-wasm32-wasi': 1.0.1 + '@napi-rs/wasm-tools-win32-arm64-msvc': 1.0.1 + '@napi-rs/wasm-tools-win32-ia32-msvc': 1.0.1 + '@napi-rs/wasm-tools-win32-x64-msvc': 1.0.1 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.16.0 + + '@octokit/auth-token@6.0.0': {} + + '@octokit/core@7.0.3': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.1 + '@octokit/request': 10.0.3 + '@octokit/request-error': 7.0.0 + '@octokit/types': 14.1.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.2 + + '@octokit/endpoint@11.0.0': + dependencies: + '@octokit/types': 14.1.0 + universal-user-agent: 7.0.2 + + '@octokit/graphql@9.0.1': + dependencies: + '@octokit/request': 10.0.3 + '@octokit/types': 14.1.0 + universal-user-agent: 7.0.2 + + '@octokit/openapi-types@25.1.0': {} + + '@octokit/plugin-paginate-rest@13.1.1(@octokit/core@7.0.3)': + dependencies: + '@octokit/core': 7.0.3 + '@octokit/types': 14.1.0 + + '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.3)': + dependencies: + '@octokit/core': 7.0.3 + + '@octokit/plugin-rest-endpoint-methods@16.0.0(@octokit/core@7.0.3)': + dependencies: + '@octokit/core': 7.0.3 + '@octokit/types': 14.1.0 + + '@octokit/request-error@7.0.0': + dependencies: + '@octokit/types': 14.1.0 + + '@octokit/request@10.0.3': + dependencies: + '@octokit/endpoint': 11.0.0 + '@octokit/request-error': 7.0.0 + '@octokit/types': 14.1.0 + fast-content-type-parse: 3.0.0 + universal-user-agent: 7.0.2 + + '@octokit/rest@22.0.0': + dependencies: + '@octokit/core': 7.0.3 + '@octokit/plugin-paginate-rest': 13.1.1(@octokit/core@7.0.3) + '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.3) + '@octokit/plugin-rest-endpoint-methods': 16.0.0(@octokit/core@7.0.3) + + '@octokit/types@14.1.0': + dependencies: + '@octokit/openapi-types': 25.1.0 + + '@oxc-node/core-android-arm-eabi@0.1.0': + optional: true + + '@oxc-node/core-android-arm64@0.1.0': + optional: true + + '@oxc-node/core-darwin-arm64@0.1.0': + optional: true + + '@oxc-node/core-darwin-x64@0.1.0': + optional: true + + '@oxc-node/core-freebsd-x64@0.1.0': + optional: true + + '@oxc-node/core-linux-arm-gnueabihf@0.1.0': + optional: true + + '@oxc-node/core-linux-arm64-gnu@0.1.0': + optional: true + + '@oxc-node/core-linux-arm64-musl@0.1.0': + optional: true + + '@oxc-node/core-linux-ppc64-gnu@0.1.0': + optional: true + + '@oxc-node/core-linux-s390x-gnu@0.1.0': + optional: true + + '@oxc-node/core-linux-x64-gnu@0.1.0': + optional: true + + '@oxc-node/core-linux-x64-musl@0.1.0': + optional: true + + '@oxc-node/core-openharmony-arm64@0.1.0': + optional: true + + '@oxc-node/core-wasm32-wasi@0.1.0': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + optional: true + + '@oxc-node/core-win32-arm64-msvc@0.1.0': + optional: true + + '@oxc-node/core-win32-ia32-msvc@0.1.0': + optional: true + + '@oxc-node/core-win32-x64-msvc@0.1.0': + optional: true + + '@oxc-node/core@0.1.0': + dependencies: + pirates: 4.0.7 + optionalDependencies: + '@oxc-node/core-android-arm-eabi': 0.1.0 + '@oxc-node/core-android-arm64': 0.1.0 + '@oxc-node/core-darwin-arm64': 0.1.0 + '@oxc-node/core-darwin-x64': 0.1.0 + '@oxc-node/core-freebsd-x64': 0.1.0 + '@oxc-node/core-linux-arm-gnueabihf': 0.1.0 + '@oxc-node/core-linux-arm64-gnu': 0.1.0 + '@oxc-node/core-linux-arm64-musl': 0.1.0 + '@oxc-node/core-linux-ppc64-gnu': 0.1.0 + '@oxc-node/core-linux-s390x-gnu': 0.1.0 + '@oxc-node/core-linux-x64-gnu': 0.1.0 + '@oxc-node/core-linux-x64-musl': 0.1.0 + '@oxc-node/core-openharmony-arm64': 0.1.0 + '@oxc-node/core-wasm32-wasi': 0.1.0 + '@oxc-node/core-win32-arm64-msvc': 0.1.0 + '@oxc-node/core-win32-ia32-msvc': 0.1.0 + '@oxc-node/core-win32-x64-msvc': 0.1.0 + + '@oxlint/darwin-arm64@1.14.0': + optional: true + + '@oxlint/darwin-x64@1.14.0': + optional: true + + '@oxlint/linux-arm64-gnu@1.14.0': + optional: true + + '@oxlint/linux-arm64-musl@1.14.0': + optional: true + + '@oxlint/linux-x64-gnu@1.14.0': + optional: true + + '@oxlint/linux-x64-musl@1.14.0': + optional: true + + '@oxlint/win32-arm64@1.14.0': + optional: true + + '@oxlint/win32-x64@1.14.0': + optional: true + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@rollup/pluginutils@5.1.4': + dependencies: + '@types/estree': 1.0.6 + estree-walker: 2.0.2 + picomatch: 4.0.4 + + '@sindresorhus/merge-streams@4.0.0': {} + + '@taplo/cli@0.7.0': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.6.3 + optional: true + + '@types/estree@1.0.6': {} + + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vercel/nft@1.5.0': + dependencies: + '@mapbox/node-pre-gyp': 2.0.0 + '@rollup/pluginutils': 5.1.4 + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + async-sema: 3.1.1 + bindings: 1.5.0 + estree-walker: 2.0.2 + glob: 13.0.6 + graceful-fs: 4.2.11 + node-gyp-build: 4.7.1 + picomatch: 4.0.4 + resolve-from: 5.0.0 + transitivePeerDependencies: + - encoding + - rollup + - supports-color + + abbrev@3.0.0: {} + + acorn-import-attributes@1.9.5(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@7.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@7.0.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-find-index@1.0.2: {} + + arrgv@1.0.2: {} + + arrify@3.0.0: {} + + async-sema@3.1.1: {} + + ava@8.0.0: + dependencies: + '@vercel/nft': 1.5.0 + acorn: 8.16.0 + acorn-walk: 8.3.5 + ansi-styles: 6.2.3 + arrgv: 1.0.2 + arrify: 3.0.0 + callsites: 4.2.0 + cbor: 10.0.12 + chalk: 5.6.2 + chunkd: 2.0.1 + ci-info: 4.4.0 + ci-parallel-vars: 1.0.1 + cli-truncate: 6.0.0 + code-excerpt: 4.0.0 + common-path-prefix: 3.0.0 + concordance: 5.0.4 + currently-unhandled: 0.4.1 + debug: 4.4.3 + emittery: 2.0.0 + figures: 6.1.0 + globby: 16.2.0 + ignore-by-default: 2.1.0 + indent-string: 5.0.0 + is-plain-object: 5.0.0 + is-promise: 4.0.0 + matcher: 6.0.0 + memoize: 11.0.0 + ms: 2.1.3 + p-map: 7.0.4 + package-config: 5.0.0 + picomatch: 4.0.4 + plur: 6.0.0 + pretty-ms: 9.3.0 + resolve-cwd: 3.0.0 + slash: 5.1.0 + stack-utils: 2.0.6 + supertap: 3.0.1 + temp-dir: 3.0.0 + write-file-atomic: 7.0.1 + yargs: 18.0.0 + transitivePeerDependencies: + - encoding + - rollup + - supports-color + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + before-after-hook@4.0.0: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + blueimp-md5@2.19.0: {} + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + callsites@4.2.0: {} + + cbor@10.0.12: + dependencies: + nofilter: 3.1.0 + + chalk@5.6.2: {} + + chardet@2.1.0: {} + + chownr@3.0.0: {} + + chunkd@2.0.1: {} + + ci-info@4.4.0: {} + + ci-parallel-vars@1.0.1: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.0 + + cli-truncate@6.0.0: + dependencies: + slice-ansi: 9.0.0 + string-width: 8.2.0 + + cli-width@4.1.0: {} + + clipanion@4.0.0-rc.4(typanion@3.14.0): + dependencies: + typanion: 3.14.0 + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.0 + + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + common-path-prefix@3.0.0: {} + + concordance@5.0.4: + dependencies: + date-time: 3.1.0 + esutils: 2.0.3 + fast-diff: 1.3.0 + js-string-escape: 1.0.1 + lodash: 4.17.21 + md5-hex: 3.0.1 + semver: 7.7.2 + well-known-symbols: 2.0.0 + + consola@3.4.0: {} + + convert-to-spaces@2.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + currently-unhandled@0.4.1: + dependencies: + array-find-index: 1.0.2 + + date-time@3.1.0: + dependencies: + time-zone: 1.0.0 + + debug@4.4.1: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.0.2: {} + + eastasianwidth@0.2.0: {} + + emittery@2.0.0: {} + + emnapi@1.5.0: + optional: true + + emoji-regex@10.3.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + environment@1.1.0: {} + + es-toolkit@1.39.10: {} + + escalade@3.1.1: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@5.0.0: {} + + esprima@4.0.1: {} + + estree-walker@2.0.2: {} + + esutils@2.0.3: {} + + eventemitter3@5.0.4: {} + + fast-content-type-parse@3.0.0: {} + + fast-diff@1.3.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.16.0: + dependencies: + reusify: 1.0.4 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.0.0 + + file-uri-to-path@1.0.0: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up-simple@1.0.0: {} + + find-up@7.0.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + unicorn-magic: 0.1.0 + + foreground-child@3.3.0: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.5.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@10.4.5: + dependencies: + foreground-child: 3.3.0 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@13.0.6: + dependencies: + minimatch: 10.2.4 + minipass: 7.1.3 + path-scurry: 2.0.2 + + globby@16.2.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + is-path-inside: 4.0.0 + slash: 5.1.0 + unicorn-magic: 0.4.0 + + graceful-fs@4.2.11: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + husky@9.1.7: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore-by-default@2.1.0: {} + + ignore@7.0.5: {} + + indent-string@5.0.0: {} + + irregular-plurals@4.2.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.5.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-path-inside@4.0.0: {} + + is-plain-object@5.0.0: {} + + is-promise@4.0.0: {} + + is-unicode-supported@2.0.0: {} + + isexe@2.0.0: {} + + isexe@4.0.0: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + js-string-escape@1.0.1: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json-parse-even-better-errors@6.0.0: {} + + lint-staged@17.0.0: + dependencies: + listr2: 10.2.1 + picomatch: 4.0.4 + string-argv: 0.3.2 + tinyexec: 1.1.2 + optionalDependencies: + yaml: 2.8.4 + + listr2@10.2.1: + dependencies: + cli-truncate: 5.2.0 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 10.0.0 + + load-json-file@7.0.1: {} + + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + + lodash@4.17.21: {} + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.0.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.0 + + lru-cache@10.4.3: {} + + lru-cache@11.2.6: {} + + matcher@6.0.0: + dependencies: + escape-string-regexp: 5.0.0 + + md5-hex@3.0.1: + dependencies: + blueimp-md5: 2.19.0 + + memoize@11.0.0: + dependencies: + mimic-function: 5.0.1 + + memorystream@0.3.1: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mimic-function@5.0.1: {} + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + minipass@7.1.3: {} + + minizlib@3.0.1: + dependencies: + minipass: 7.1.3 + rimraf: 5.0.10 + + mkdirp@3.0.1: {} + + ms@2.1.3: {} + + mute-stream@2.0.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build@4.7.1: {} + + nofilter@3.1.0: {} + + nopt@8.1.0: + dependencies: + abbrev: 3.0.0 + + npm-normalize-package-bin@6.0.0: {} + + npm-run-all2@9.0.0: + dependencies: + ansi-styles: 6.2.3 + cross-spawn: 7.0.6 + memorystream: 0.3.1 + picomatch: 4.0.4 + pidtree: 0.6.0 + read-package-json-fast: 6.0.0 + shell-quote: 1.8.1 + which: 7.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + oxlint@1.14.0: + optionalDependencies: + '@oxlint/darwin-arm64': 1.14.0 + '@oxlint/darwin-x64': 1.14.0 + '@oxlint/linux-arm64-gnu': 1.14.0 + '@oxlint/linux-arm64-musl': 1.14.0 + '@oxlint/linux-x64-gnu': 1.14.0 + '@oxlint/linux-x64-musl': 1.14.0 + '@oxlint/win32-arm64': 1.14.0 + '@oxlint/win32-x64': 1.14.0 + + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.1 + + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + + p-map@7.0.4: {} + + package-config@5.0.0: + dependencies: + find-up-simple: 1.0.0 + load-json-file: 7.0.1 + + package-json-from-dist@1.0.1: {} + + parse-ms@4.0.0: {} + + path-exists@5.0.0: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.6 + minipass: 7.1.3 + + picomatch@2.3.1: {} + + picomatch@4.0.4: {} + + pidtree@0.6.0: {} + + pirates@4.0.7: {} + + plur@6.0.0: + dependencies: + irregular-plurals: 4.2.0 + + prettier@3.6.2: {} + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + queue-microtask@1.2.3: {} + + read-package-json-fast@6.0.0: + dependencies: + json-parse-even-better-errors: 6.0.0 + npm-normalize-package-bin: 6.0.0 + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@5.0.0: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.0.4: {} + + rfdc@1.4.1: {} + + rimraf@5.0.10: + dependencies: + glob: 10.4.5 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safer-buffer@2.1.2: {} + + semver@7.7.2: {} + + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.1: {} + + signal-exit@4.1.0: {} + + slash@5.1.0: {} + + slice-ansi@7.1.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + sprintf-js@1.0.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.3.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + supertap@3.0.1: + dependencies: + indent-string: 5.0.0 + js-yaml: 3.14.1 + serialize-error: 7.0.1 + strip-ansi: 7.2.0 + + tar@7.4.3: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.0.1 + mkdirp: 3.0.1 + yallist: 5.0.0 + + temp-dir@3.0.0: {} + + time-zone@1.0.0: {} + + tinybench@6.0.0: {} + + tinyexec@1.1.2: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tr46@0.0.3: {} + + tslib@2.6.3: + optional: true + + typanion@3.14.0: {} + + type-fest@0.13.1: {} + + type-fest@0.21.3: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.3.0: {} + + unicorn-magic@0.1.0: {} + + unicorn-magic@0.4.0: {} + + universal-user-agent@7.0.2: {} + + wavefile@11.0.0: {} + + webidl-conversions@3.0.1: {} + + well-known-symbols@2.0.0: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@7.0.0: + dependencies: + isexe: 4.0.0 + + wrap-ansi@10.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.0 + strip-ansi: 7.2.0 + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + write-file-atomic@7.0.1: + dependencies: + signal-exit: 4.1.0 + + y18n@5.0.8: {} + + yallist@5.0.0: {} + + yaml@2.8.4: + optional: true + + yargs-parser@22.0.0: {} + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.1.1 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yocto-queue@1.2.1: {} + + yoctocolors-cjs@2.1.2: {} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..b196eaa --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +tab_spaces = 2 diff --git a/scripts/build-target.sh b/scripts/build-target.sh deleted file mode 100755 index 588333f..0000000 --- a/scripts/build-target.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash -# Builds the native module for one target and places it at the given destination. -# -# Usage: scripts/build-target.sh [cargo-target] [profile] -# -# The release matrix in .github/workflows/build.yml runs this once per target, on that -# target's native runner. `npm run build` and `npm run debug` run it for the host. -set -euo pipefail - -destination="${1:?Usage: scripts/build-target.sh [cargo-target] [profile]}" -target="${2:-$(rustc -vV | sed -n 's/^host: //p')}" -profile="${3:-release}" - -profile_flag=() -if [ "$profile" = "release" ]; then - profile_flag=(--release) -fi - -# Let cargo report which file it produced rather than reconstructing the per-platform name -# (libaic_sdk_node.so, libaic_sdk_node.dylib, aic_sdk_node.dll). -messages="$(mktemp)" -trap 'rm -f "$messages"' EXIT - -cargo build "${profile_flag[@]}" --locked --target "$target" \ - --message-format=json-render-diagnostics >"$messages" - -binary="$(node -e ' -const fs = require("fs"); -let found; -for (const line of fs.readFileSync(process.argv[1], "utf8").split("\n")) { - if (!line) continue; - const message = JSON.parse(line); - if (message.reason !== "compiler-artifact") continue; - if (!message.target.kind.includes("cdylib")) continue; - found = message.filenames.find((name) => /\.(so|dylib|dll)$/.test(name)); -} -if (!found) { - console.error("No cdylib artifact in cargo output. Is crate-type still cdylib?"); - process.exit(1); -} -process.stdout.write(found); -' "$messages")" - -mkdir -p "$(dirname "$destination")" -cp "$binary" "$destination" -echo "Built $target ($profile) -> $destination" diff --git a/scripts/fetch-test-models.mjs b/scripts/fetch-test-models.mjs new file mode 100644 index 0000000..f915fe6 --- /dev/null +++ b/scripts/fetch-test-models.mjs @@ -0,0 +1,33 @@ +/** + * Downloads the `.aicmodel` fixtures the end-to-end tests need, via the SDK's own + * `Model.download()`. Requires a built addon, so run `pnpm build` first (CI downloads + * the `.node` artifact before `pnpm test`). + * + * The manifest is re-fetched on every call, so the newest compatible model version is + * always used. Files that already exist with a matching checksum are left untouched. + * + * `Model.download()` picks the filename from the manifest (build hash + version), so the + * resolved paths are written to `paths.json` for `modelPath()` to read back at test time. + */ +import fs from 'node:fs' +import path from 'node:path' + +import { Model } from '../index.js' +import { TEST_DATA_DIR, TEST_MODELS } from '../__test__/models.ts' + +async function main() { + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }) + + const paths = {} + for (const [kind, model] of Object.entries(TEST_MODELS)) { + console.log(`Downloading ${model.id}`) + paths[kind] = await Model.download(model.id, TEST_DATA_DIR) + } + + fs.writeFileSync(path.join(TEST_DATA_DIR, 'paths.json'), JSON.stringify(paths, null, 2)) +} + +main().catch((error) => { + console.error(`Failed to fetch test models: ${error.message}`) + process.exit(1) +}) diff --git a/scripts/update-version.sh b/scripts/update-version.sh deleted file mode 100755 index 5d7b225..0000000 --- a/scripts/update-version.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash -set -e - -if [ -z "$1" ]; then - echo "Usage: ./scripts/update-version.sh " - exit 1 -fi - -VERSION="$1" - -# Update main package. `npm version` fails when the version is already the target one, so -# skip it in that case to keep this script idempotent. -if [ "$(node -p "require('./package.json').version")" != "$VERSION" ]; then - npm version "$VERSION" --no-git-tag-version -else - echo "package.json already at version $VERSION" -fi - -# Update Cargo.toml -if [ -f "Cargo.toml" ]; then - sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" Cargo.toml - rm -f Cargo.toml.bak - echo "Updated Cargo.toml to version $VERSION" -fi - -# Update all platform packages -for dir in npm/*/; do - if [ -f "$dir/package.json" ]; then - cd "$dir" - if [ "$(node -p "require('./package.json').version")" != "$VERSION" ]; then - npm version "$VERSION" --no-git-tag-version - else - echo "$dir already at version $VERSION" - fi - cd ../.. - fi -done - -# Update optionalDependencies versions in root package.json -node -e " -const fs = require('fs'); -const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); -if (pkg.optionalDependencies) { - for (const dep in pkg.optionalDependencies) { - pkg.optionalDependencies[dep] = '$VERSION'; - } - fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); -} -" - -# `npm version` above rewrote package-lock.json while optionalDependencies still held the previous -# version, so the lockfile's copy of the root manifest is stale. Rewrite it from the updated -# package.json. The platform packages for this version are not published yet, so resolve nothing. -npm install --package-lock-only --ignore-scripts >/dev/null -echo "Updated package-lock.json to version $VERSION" - -echo "Updated to version $VERSION" -echo "Next: git add . && git commit -m 'chore: bump version to $VERSION' && git tag $VERSION && git push origin main --tags" diff --git a/src/analyzer.rs b/src/analyzer.rs index 34e7ca2..f5f933d 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -1,249 +1,236 @@ -use std::sync::Mutex; - -use neon::{ - object::Object, - prelude::{Context, FunctionContext}, - result::{JsResult, NeonResult}, - types::{ - Finalize, JsArray, JsBox, JsNull, JsNumber, JsObject, JsString, JsTypedArray, JsUndefined, - JsValue, buffer::TypedArray, - }, +use crate::{ + claim_sdk_id, + disposable_slot::{DisposableSlot, lock}, + error::{Result, map_err}, + mem, + model::Model, + processor::audio_config, }; -use crate::model::Model; -use crate::util::parse_processor_config; - -/// Builds a JS object with the camelCase analysis-result fields from an SDK result. -fn analysis_result_to_object<'a, C: Context<'a>>( - cx: &mut C, - result: &aic_sdk::AnalysisResult, -) -> JsResult<'a, JsObject> { - let obj = cx.empty_object(); - let fields: [(&str, f32); 7] = [ - ("riskScore", result.risk_score), - ("speakerReverb", result.speaker_reverb), - ("speakerLoudness", result.speaker_loudness), - ("interferingSpeech", result.interfering_speech), - ("noise", result.noise), - ("codecDegradation", result.codec_degradation), - ("packetLoss", result.packet_loss), - ]; - for (key, value) in fields { - let number = cx.number(value as f64); - obj.set(cx, key, number)?; - } - Ok(obj) -} +use napi::{ + Env, Task, + bindgen_prelude::ObjectFinalize, + bindgen_prelude::{AsyncTask, Float32Array}, +}; +use napi_derive::napi; +use std::sync::{Arc, Mutex}; -/// Collects audio blocks for later analysis by an [`Analyzer`]. +/// Results of analyzing an audio signal with {@link Analyzer}. /// -/// Created together with an [`Analyzer`] via `analyzerPair`. -pub struct Collector { - inner: Mutex, +/// Scores range from 0.0 to 1.0. For every field except `speakerLoudness`, lower values +/// indicate less problematic audio. +#[napi(object)] +pub struct AnalysisResult { + /// Predicts the likelihood of failure in downstream models, including speech-to-text, + /// voice activity detection, turn-taking and speech-to-speech models. + pub risk_score: f64, + /// Measure of speaker distance and reverberation. + pub speaker_reverb: f64, + /// Measure of speaker loudness. + pub speaker_loudness: f64, + /// Measure of interfering speech from sources other than the main speaker. + pub interfering_speech: f64, + /// Measure of ambient or environmental noise. + pub noise: f64, + /// Measure of artifacts from lossy speech codecs, such as low bitrate or narrowband codecs. + pub codec_degradation: f64, + /// Measure of audio dropouts and discontinuities from packet loss, frame erasure, jitter or CPU + /// overload. + pub packet_loss: f64, } -impl Finalize for Collector {} - -/// Runs an analysis model over the audio collected by a [`Collector`]. -/// -/// Created together with a [`Collector`] via `analyzerPair`. -pub struct Analyzer { - inner: Mutex>, +impl From for AnalysisResult { + fn from(result: aic_sdk::AnalysisResult) -> Self { + Self { + risk_score: result.risk_score.into(), + speaker_reverb: result.speaker_reverb.into(), + speaker_loudness: result.speaker_loudness.into(), + interfering_speech: result.interfering_speech.into(), + noise: result.noise.into(), + codec_degradation: result.codec_degradation.into(), + packet_loss: result.packet_loss.into(), + } + } } -impl Finalize for Analyzer {} - -/// Creates a collector/analyzer pair for non-real-time analysis. +/// Analyzes audio quality using an analysis model, such as Tyto. /// -/// Returns a JS object with `collector` and `analyzer` native handles. -pub fn analyzer_pair(mut cx: FunctionContext) -> JsResult { - let model = cx.argument::>(0)?; - let license_key = cx.argument::(1)?.value(&mut cx); - - // SAFETY: This function has no safety requirements. - unsafe { - aic_sdk::set_sdk_id(4); - } - - let (collector, analyzer) = aic_sdk::analyzer_pair(&model.inner, &license_key) - .or_else(|e| cx.throw_error(e.to_string()))?; - - let collector_box = cx.boxed(Collector { - inner: Mutex::new(collector), - }); - let analyzer_box = cx.boxed(Analyzer { - inner: Mutex::new(analyzer), - }); - - let result = cx.empty_object(); - result.set(&mut cx, "collector", collector_box)?; - result.set(&mut cx, "analyzer", analyzer_box)?; - - Ok(result) +/// Call {@link Analyzer#initialize}, then {@link Analyzer#buffer} to collect mono audio. +/// The model determines how much audio is retained; older samples are discarded as new +/// audio arrives. +/// +/// Run {@link Analyzer#analyzeAsync} to analyze the buffered audio on a libuv worker +/// thread, or {@link Analyzer#analyze} to run it on the calling thread. Analysis is +/// computationally expensive and should not run in audio processing callbacks. +/// Buffering remains synchronous and can continue while async analysis is running. +#[napi(custom_finalize)] +pub struct Analyzer { + // Both SDK objects own their state and retain the memory-mapped model as needed. + // Only the analyzer is shared with workers; collector access does not take its lock. + collector: DisposableSlot, + analyzer: Arc>>>, } -impl Collector { - pub fn initialize(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let config = parse_processor_config(&mut cx, 1)?; - - let mut collector = this.inner.lock().unwrap(); - - collector - .initialize(&config) - .or_else(|e| cx.throw_error(e.to_string()))?; - - Ok(cx.undefined()) - } - - pub fn buffer(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let audio_block = cx.argument::>(1)?; - let samples = audio_block.as_slice(&cx); - - let mut collector = this.inner.lock().unwrap(); - collector - .buffer(samples) - .or_else(|e| cx.throw_error(e.to_string()))?; - - Ok(cx.undefined()) +impl ObjectFinalize for Analyzer { + fn finalize(mut self, env: Env) -> Result<()> { + // A pending task can retain the analyzer after this finalizer; see `DisposableSlot`. + // The SDK permits the collector and analyzer to be destroyed independently, so the + // collector can be released during analysis. Both releases are idempotent. + self.collector.release(env); + if Arc::strong_count(&self.analyzer) == 1 { + lock(&self.analyzer).release(env); } + Ok(()) + } } +#[napi] impl Analyzer { - pub fn reset(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let analyzer = this.inner.lock().unwrap(); - analyzer - .reset() - .or_else(|e| cx.throw_error(e.to_string()))?; - Ok(cx.undefined()) - } - - pub fn analyze_buffered(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - - let result = { - let mut analyzer = this.inner.lock().unwrap(); - analyzer - .analyze_buffered() - .or_else(|e| cx.throw_error(e.to_string()))? - }; - - analysis_result_to_object(&mut cx, &result) - } - - pub fn terminate_session(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let mut analyzer = this.inner.lock().unwrap(); - - analyzer - .terminate_session() - .or_else(|e| cx.throw_error(e.to_string()))?; - - Ok(cx.undefined()) - } - - pub fn update_bearer_token(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let token = cx.argument::(1)?.value(&mut cx); - - let analyzer = this.inner.lock().unwrap(); - analyzer - .update_bearer_token(&token) - .or_else(|e| cx.throw_error(e.to_string()))?; - - Ok(cx.undefined()) - } + /// Creates an analyzer from an analysis model. + /// + /// Construction allocates memory. Call {@link Analyzer#initialize} before buffering audio. + /// + /// @param model - Analysis model. Other model types are rejected. + /// @param licenseKey - SDK license key from . + #[napi(constructor)] + pub fn new(env: Env, model: &Model, license_key: String) -> Result { + let model_inner = model.inner()?; + claim_sdk_id(); + let (collector, analyzer) = map_err(aic_sdk::analyzer_pair(model_inner, &license_key))?; + + Ok(Self { + collector: DisposableSlot::new(env, collector, "Analyzer", mem::COLLECTOR_BYTES), + analyzer: Arc::new(Mutex::new(DisposableSlot::new( + env, + analyzer, + "Analyzer", + mem::ANALYZER_BYTES, + ))), + }) + } + + /// Destroys the native collector and analyzer without waiting for garbage collection. + /// + /// After disposal, all methods except `dispose()` fail. Repeated disposal has no effect. + /// This call blocks the calling thread while a worker holds the analyzer lock. + /// Queued analysis that acquires the lock after disposal rejects its promise. + #[napi] + pub fn dispose(&mut self, env: Env) { + // The SDK allows collector destruction during analysis. Releasing the analyzer + // requires its lock and waits if a worker currently holds it. + self.collector.release(env); + lock(&self.analyzer).release(env); + } + + /// Configures the analyzer's audio collector. + /// + /// Call this before buffering audio. Use {@link Model#getOptimalSampleRate} and + /// {@link Model#getOptimalBlockSize} to avoid internal resampling and rebuffering. + /// This method allocates memory; avoid calling it from audio processing callbacks. + /// + /// @param sampleRate - Audio sample rate in Hz. + /// @param blockSize - Number of mono samples per block. + /// @param variableBlockSize - Allow blocks shorter than `blockSize`. Defaults to `false`. + /// Blocks larger than `blockSize` are always rejected. + #[napi] + pub fn initialize( + &mut self, + sample_rate: u32, + block_size: u32, + variable_block_size: Option, + ) -> Result<()> { + map_err(self.collector.get_mut()?.initialize(&audio_config( + sample_rate, + block_size, + variable_block_size, + ))) + } + + /// Buffers a mono audio block for later analysis without modifying the input. + /// + /// Call {@link Analyzer#initialize} first. The block must contain exactly `blockSize` + /// samples, or at most `blockSize` if `variableBlockSize` is enabled. + /// Buffering does not acquire the analyzer lock and can run during async analysis. + #[napi] + pub fn buffer(&mut self, audio: Float32Array) -> Result<()> { + map_err(self.collector.get_mut()?.buffer(&audio)) + } + + /// Analyzes buffered audio on the calling thread and returns the scores. + /// + /// The model analyzes a fixed duration of audio. If less audio has been buffered, the + /// remaining input is padded with silence. + /// + /// This method blocks the calling thread. Use {@link Analyzer#analyzeAsync} to keep the + /// event loop available. For multichannel audio, mix down to mono or use one analyzer + /// per channel. + #[napi] + pub fn analyze(&self) -> Result { + map_err(lock(&self.analyzer).get_mut()?.analyze_buffered()).map(AnalysisResult::from) + } + + /// Analyzes buffered audio on a libuv worker thread and returns a promise for the scores. + /// + /// Uses the same analysis and silence padding as {@link Analyzer#analyze}. + /// {@link Analyzer#buffer} can continue collecting audio while analysis runs. + /// + /// Synchronous calls to `analyze`, `reset`, `updateBearerToken`, `terminateSession` and + /// `dispose` wait for the analyzer lock and may block while analysis is running. + #[napi(ts_return_type = "Promise")] + pub fn analyze_async(&self) -> AsyncTask { + AsyncTask::new(AnalyzeTask { + analyzer: self.analyzer.clone(), + }) + } + + /// Clears buffered audio and internal state while preserving the configured audio settings. + #[napi] + pub fn reset(&self) -> Result<()> { + map_err(lock(&self.analyzer).get_mut()?.reset()) + } + + /// Replaces the bearer token on the running analyzer. + /// + /// Use this to refresh a JWT without recreating the instance. Both the original license + /// key and the new token must be JWTs. If this call fails, the previous token remains active. + /// + /// A successful call validates the token's format and applies it immediately. Backend + /// acceptance is checked later. If the backend rejects the token, the SDK retries with + /// backoff; analysis may be rejected if no accepted token arrives in time. + /// Supply a valid token to recover the session. + /// + /// This method allocates memory and takes a mutex. Avoid calling it from audio processing callbacks. + #[napi] + pub fn update_bearer_token(&self, token: String) -> Result<()> { + map_err(lock(&self.analyzer).get_mut()?.update_bearer_token(&token)) + } + + /// Terminates the telemetry session associated with this analyzer. + /// + /// Once termination is handled, the analyzer can no longer analyze buffered audio. + /// The session also ends when the native analyzer is destroyed. This method may block; + /// avoid calling it from audio processing callbacks. If another session is still active, + /// termination can complete asynchronously. + #[napi] + pub fn terminate_session(&self) -> Result<()> { + map_err(lock(&self.analyzer).get_mut()?.terminate_session()) + } } -/// Analyzes complete mono audio signals held in memory. -/// -/// Wraps the SDK [`aic_sdk::FileAnalyzer`], which owns a [`Collector`]/[`Analyzer`] pair and -/// performs the windowing, zero-padding and reset logic itself. Created via `fileAnalyzerNew`. -pub struct FileAnalyzer { - inner: Mutex>, +/// Runs analysis on a worker while the collector remains accessible on the JavaScript thread. +pub struct AnalyzeTask { + analyzer: Arc>>>, } -impl Finalize for FileAnalyzer {} - -impl FileAnalyzer { - pub fn new(mut cx: FunctionContext) -> JsResult> { - let model = cx.argument::>(0)?; - let license_key = cx.argument::(1)?.value(&mut cx); +impl Task for AnalyzeTask { + type Output = aic_sdk::AnalysisResult; + type JsValue = AnalysisResult; - // SAFETY: This function has no safety requirements. - unsafe { - aic_sdk::set_sdk_id(4); - } + fn compute(&mut self) -> Result { + map_err(lock(&self.analyzer).get_mut()?.analyze_buffered()) + } - // SAFETY: aic_sdk::FileAnalyzer borrows the model for the analyzer's lifetime so the native - // analyzer can keep reading the model's weights. The JS FileAnalyzer wrapper retains the - // Model object (this._model = model), keeping the boxed Model alive for at least as long as - // this FileAnalyzer, so extending the borrow to 'static is sound. This mirrors the 'static - // coercion the rest of the binding already relies on (Model<'static>, Analyzer<'static>). - let model_ref: &'static aic_sdk::Model<'static> = - unsafe { std::mem::transmute(&model.inner) }; - - let file_analyzer = aic_sdk::FileAnalyzer::new(model_ref, &license_key) - .or_else(|e| cx.throw_error(e.to_string()))?; - - Ok(cx.boxed(FileAnalyzer { - inner: Mutex::new(file_analyzer), - })) - } - - pub fn analyze(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let audio = cx.argument::>(1)?; - let sample_rate = cx.argument::(2)?.value(&mut cx) as u32; - - // The step argument is optional: null or undefined means "no overlap" (the SDK defaults - // it to the analysis window size). - let step_arg = cx.argument::(3)?; - let step_samples: Option = - if step_arg.is_a::(&mut cx) || step_arg.is_a::(&mut cx) { - None - } else { - Some( - step_arg - .downcast_or_throw::(&mut cx)? - .value(&mut cx) as usize, - ) - }; - - // Borrow the audio without copying and run the analysis. The slice borrows the context - // immutably, so this block must end before we build the JS result with `&mut cx`. - let results = { - let samples = audio.as_slice(&cx); - let mut analyzer = this.inner.lock().unwrap(); - analyzer.analyze(samples, sample_rate, step_samples) - }; - let results = results.or_else(|e| cx.throw_error(e.to_string()))?; - - let array = cx.empty_array(); - for (index, result) in results.iter().enumerate() { - let obj = analysis_result_to_object(&mut cx, result)?; - array.set(&mut cx, index as u32, obj)?; - } - - Ok(array) - } -} - -pub fn register_exports(cx: &mut neon::prelude::ModuleContext) -> NeonResult<()> { - cx.export_function("analyzerPair", analyzer_pair)?; - - cx.export_function("fileAnalyzerNew", FileAnalyzer::new)?; - cx.export_function("fileAnalyzerAnalyze", FileAnalyzer::analyze)?; - - cx.export_function("collectorInitialize", Collector::initialize)?; - cx.export_function("collectorBuffer", Collector::buffer)?; - - cx.export_function("analyzerReset", Analyzer::reset)?; - cx.export_function("analyzerAnalyzeBuffered", Analyzer::analyze_buffered)?; - cx.export_function("analyzerTerminateSession", Analyzer::terminate_session)?; - cx.export_function("analyzerUpdateBearerToken", Analyzer::update_bearer_token)?; - - Ok(()) + fn resolve(&mut self, _env: Env, result: aic_sdk::AnalysisResult) -> Result { + Ok(result.into()) + } } diff --git a/src/disposable_slot.rs b/src/disposable_slot.rs new file mode 100644 index 0000000..3b0aec5 --- /dev/null +++ b/src/disposable_slot.rs @@ -0,0 +1,77 @@ +//! Owns a native SDK object and tracks its estimated memory usage in V8. +//! +//! All binding classes use this slot for disposal checks and memory reporting. +//! Classes shared with libuv tasks wrap it in `Arc>`. + +use std::sync::{Mutex, MutexGuard}; + +use napi::Env; + +use crate::{ + error::{Result, disposed_error}, + mem::adjust, +}; + +/// A slot holding a native SDK object until it is disposed, after which every access +/// fails with the disposed error. +/// +/// [`new`](Self::new) reports the object's footprint to V8 and [`release`](Self::release) +/// withdraws it. `Option::take` makes `release` idempotent, so `dispose()` and a class +/// finalizer cannot withdraw it twice. +/// +/// A slot dropped without `release` having run still destroys the object, but its bytes +/// stay reported: withdrawing them needs an `Env`, which `Drop` does not have. This +/// happens when the last JS handle to a shared object is finalized while a task still +/// holds a clone, and leaves V8 over-reported for the rest of the process. +pub(crate) struct DisposableSlot { + inner: Option, + /// The JS class name, for the disposed error message. + class: &'static str, + /// The footprint reported to V8 while `inner` is live. + bytes: i64, +} + +impl DisposableSlot { + /// Takes ownership of `inner` and reports its `bytes` of native footprint to V8. + pub(crate) fn new(env: Env, inner: T, class: &'static str, bytes: i64) -> Self { + adjust(env, bytes); + + Self { + inner: Some(inner), + class, + bytes, + } + } + + /// The native object, or the disposed error once it is gone. + pub(crate) fn get(&self) -> Result<&T> { + self + .inner + .as_ref() + .ok_or_else(|| disposed_error(self.class)) + } + + /// The native object as `&mut`, or the disposed error once it is gone. + pub(crate) fn get_mut(&mut self) -> Result<&mut T> { + self + .inner + .as_mut() + .ok_or_else(|| disposed_error(self.class)) + } + + /// Destroys the native object, if it is still live, and withdraws its footprint report. + /// Idempotent, since `dispose()` and the class finalizer both call it. + pub(crate) fn release(&mut self, env: Env) { + if self.inner.take().is_some() { + adjust(env, -self.bytes); + } + } +} + +/// Locks a shared slot, recovering the guard if the mutex is poisoned. +/// +/// Recovery allows disposal after a task panics. The slot's `Option` still records +/// whether the native object has been released. +pub(crate) fn lock(slot: &Mutex) -> MutexGuard<'_, T> { + slot.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..2b805dd --- /dev/null +++ b/src/error.rs @@ -0,0 +1,36 @@ +use aic_sdk::AicError; + +/// Local wrapper for converting an SDK error into a JavaScript exception. +/// +/// Rust's orphan rules require a local type because both `AicError` and `napi::Error` +/// are defined in dependencies. +pub struct JsAicError(pub AicError); + +impl From for JsAicError { + fn from(error: AicError) -> Self { + Self(error) + } +} + +impl From for napi::Error { + fn from(error: JsAicError) -> Self { + // Preserve the SDK's error message. All SDK errors become plain JavaScript Errors. + napi::Error::new(napi::Status::GenericFailure, error.0.to_string()) + } +} + +/// Shorthand for the `Result` every binding method returns. +pub type Result = napi::Result; + +/// Converts an SDK result into a JS-throwing result. +pub fn map_err(result: std::result::Result) -> Result { + result.map_err(|error| JsAicError(error).into()) +} + +/// The error thrown by any method called after `dispose()`. +pub fn disposed_error(class: &str) -> napi::Error { + napi::Error::new( + napi::Status::GenericFailure, + format!("{class} has been disposed"), + ) +} diff --git a/src/lib.rs b/src/lib.rs index 79d03a3..ecda4b9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,61 +1,63 @@ -use neon::prelude::*; +#![deny(clippy::all)] + +//! Node.js bindings for the ai-coustics SDK. +//! +//! The napi-rs classes wrap the Rust SDK's model, processing, VAD and analysis types. +//! Native objects are released through explicit disposal or JavaScript finalization. + +use napi_derive::napi; mod analyzer; +mod disposable_slot; +mod error; +mod mem; mod model; mod processor; -mod processor_context; -mod util; +mod processor_async; mod vad; -mod vad_context; - -fn get_sdk_version(mut cx: FunctionContext) -> JsResult { - let version = aic_sdk::get_sdk_version(); - Ok(cx.string(version)) +mod vad_async; + +pub use analyzer::*; +pub use model::*; +pub use processor::*; +pub use processor_async::*; +pub use vad::*; +pub use vad_async::*; + +/// Telemetry ID assigned to this binding. Must match `SdkWrapper::NodeJs` in +/// `aic-sdk-telemetry`. +const SDK_WRAPPER_ID_NODE: u32 = 4; + +/// Sets the Node telemetry ID unless an embedding wrapper has already set one. +/// +/// The upstream `OnceLock` accepts only the first write. Call this before delegating to +/// an SDK constructor, which would otherwise set the Rust wrapper ID (2). +/// Delaying this until construction lets embedders call [`set_sdk_id`] after module load. +pub(crate) fn claim_sdk_id() { + // SAFETY: `4` is the wrapper ID assigned to this binding by ai-coustics. + unsafe { aic_sdk::set_sdk_id(SDK_WRAPPER_ID_NODE) }; } -fn get_compatible_model_version(mut cx: FunctionContext) -> JsResult { - let model_version = aic_sdk::get_compatible_model_version(); - Ok(cx.number(model_version)) +/// Overrides the telemetry wrapper ID. Internal only, for ai-coustics wrappers embedding +/// this package (e.g. the LiveKit plugin): call before constructing any `Processor`, `Vad` +/// or `Analyzer`, whose constructors otherwise claim the ID for this SDK. The ID can only +/// be set once per process; later writes are silently discarded. +#[napi(js_name = "_setSdkId")] +pub fn set_sdk_id(id: u32) { + // SAFETY: This function has no safety requirements. + unsafe { aic_sdk::set_sdk_id(id) }; } -// Internal only, not part of the public API. Used by ai-coustics to identify wrapper -// SDKs/plugins that embed this package (e.g. a LiveKit plugin built on top of this SDK). -// The underlying ID can only be set once per process, so callers must invoke this before -// constructing any Processor/Vad/Analyzer/FileAnalyzer, whose constructors otherwise claim -// the ID for this SDK first. -fn set_sdk_id(mut cx: FunctionContext) -> JsResult { - let id = cx.argument::(0)?.value(&mut cx) as u32; - - // SAFETY: This function has no safety requirements. - unsafe { - aic_sdk::set_sdk_id(id); - } - - Ok(cx.undefined()) +/// Returns the version of the underlying native SDK. +/// +/// This may differ from the Node.js package version. +#[napi] +pub fn get_version() -> String { + aic_sdk::get_sdk_version().to_owned() } -#[neon::main] -fn main(mut cx: ModuleContext) -> NeonResult<()> { - // Free functions - cx.export_function("getVersion", get_sdk_version)?; - cx.export_function("getCompatibleModelVersion", get_compatible_model_version)?; - cx.export_function("setSdkId", set_sdk_id)?; - - // Model - model::register_exports(&mut cx)?; - - // Processor - processor::register_exports(&mut cx)?; - - // ProcessorContext - processor_context::register_exports(&mut cx)?; - - // Vad / VadContext - vad::register_exports(&mut cx)?; - vad_context::register_exports(&mut cx)?; - - // Analyzer / Collector - analyzer::register_exports(&mut cx)?; - - Ok(()) +/// Returns the model file format version supported by this SDK. +#[napi] +pub fn get_compatible_model_version() -> u32 { + aic_sdk::get_compatible_model_version() } diff --git a/src/mem.rs b/src/mem.rs new file mode 100644 index 0000000..66dfd6d --- /dev/null +++ b/src/mem.rs @@ -0,0 +1,65 @@ +//! Reports estimated native memory usage to V8's garbage collector. +//! +//! V8 cannot infer native allocations from the small JavaScript wrapper objects. +//! `DisposableSlot` adds an estimate on construction and subtracts it on explicit +//! release or finalization. This helps GC account for models and processing state. +//! +//! The SDK has no per-instance memory query. The constants below are rounded-up +//! measurements; model estimates use the file size. + +use std::path::Path; + +use napi::Env; + +const MIB: i64 = 1024 * 1024; +const KIB: i64 = 1024; + +/// A `Processor` or `Vad` instance. Measured as the RSS delta per instance, at initialize +/// plus one `process` call: +/// +/// - `quail-vf-2.2-s` (5 MiB model): ~190 KiB +/// - `quail-vf-2.2-l` (20 MiB model): ~462 KiB +/// - `vad-2.1-xxs` (0.6 MiB model): ~197 KiB +/// +/// The weights stay file-backed under `Model`, so the workspace scales only weakly with +/// model size. 512 KiB covers the largest measured model with headroom and stays within +/// ~3x of the smallest. +pub(crate) const PROCESSOR_BYTES: i64 = 512 * KIB; + +/// The analyzer half of an `Analyzer`, which holds the model workspace. Measured ~8.2 MiB +/// for `tyto-1.1-l` at construction, so 14 MiB leaves ~1.7x headroom for larger analysis +/// models. +/// +/// Reported separately from [`COLLECTOR_BYTES`] because the two halves are destroyed +/// independently: the collector can be destroyed during analysis, so a single +/// report for the pair would be released too early. +pub(crate) const ANALYZER_BYTES: i64 = 14 * MIB; + +/// The collector half of an `Analyzer`, which holds the buffered audio. Measured as the +/// ~0.7 MiB that `tyto-1.1-l` grows by once the collector is initialized and holding its +/// 5 s span, so 2 MiB leaves ~3x headroom. +pub(crate) const COLLECTOR_BYTES: i64 = 2 * MIB; + +/// Fallback footprint for a `Model` when file metadata is unavailable. The weights are +/// memory-mapped, so the resident share approaches the file size as pages are touched. +const MODEL_FALLBACK_BYTES: i64 = 64 * MIB; + +/// Tells V8 that `delta_bytes` of external (native) memory changed. +/// +/// Positive when an object is created, negative (the same value) when it is finalized. +/// This is only a GC hint, so a failed adjustment is ignored. +pub(crate) fn adjust(env: Env, delta_bytes: i64) { + if delta_bytes == 0 { + return; + } + + let _ = env.adjust_external_memory(delta_bytes); +} + +/// The footprint to report for a model loaded from `path`. The weights are memory-mapped, +/// so this is the file size, or [`MODEL_FALLBACK_BYTES`] when file metadata is unavailable. +pub(crate) fn model_bytes(path: &Path) -> i64 { + std::fs::metadata(path) + .map(|meta| meta.len() as i64) + .unwrap_or(MODEL_FALLBACK_BYTES) +} diff --git a/src/model.rs b/src/model.rs index 42b886b..d63b01c 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,56 +1,137 @@ -use neon::{ - prelude::{Context, FunctionContext}, - result::{JsResult, NeonResult}, - types::{Finalize, JsBox, JsNumber, JsString}, +use crate::{ + disposable_slot::DisposableSlot, + error::{JsAicError, Result, map_err}, + mem, }; +use napi::{Env, Task, bindgen_prelude::AsyncTask, bindgen_prelude::ObjectFinalize}; +use napi_derive::napi; + +/// A loaded ai-coustics model. +/// +/// The same model can be used to create multiple independent instances of a compatible +/// processor, VAD or analyzer. Each instance retains a reference to the model data, +/// so the `Model` handle can be disposed or garbage collected first. +#[napi(custom_finalize)] pub struct Model { - pub(crate) inner: aic_sdk::Model<'static>, + // The memory-mapped model owns its data and has a `'static` lifetime. + // Report the file size as this instance's native memory estimate. + slot: DisposableSlot>, } -impl Finalize for Model {} +impl ObjectFinalize for Model { + fn finalize(mut self, env: Env) -> Result<()> { + // A no-op if `dispose()` already ran. + self.slot.release(env); + Ok(()) + } +} impl Model { - pub fn from_file(mut cx: FunctionContext) -> JsResult> { - let path = cx.argument::(0)?.value(&mut cx); - let inner = aic_sdk::Model::from_file(path).or_else(|e| cx.throw_error(e.to_string()))?; - Ok(cx.boxed(Model { inner })) - } - - pub fn download(mut cx: FunctionContext) -> JsResult { - let model_id = cx.argument::(0)?.value(&mut cx); - let download_dir = cx.argument::(1)?.value(&mut cx); - let path = aic_sdk::Model::download(&model_id, download_dir) - .or_else(|e| cx.throw_error(e.to_string()))?; - Ok(cx.string(path.to_str().expect("Path can be converted to string"))) - } - - pub fn get_id(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let id = this.inner.id(); - Ok(cx.string(id)) - } - - pub fn get_optimal_sample_rate(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let sample_rate = this.inner.optimal_sample_rate(); - Ok(cx.number(sample_rate)) - } - - pub fn get_optimal_block_size(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let sample_rate = cx.argument::(1)?.value(&mut cx) as u32; - let block_size = this.inner.optimal_block_size(sample_rate); - Ok(cx.number(block_size as f64)) - } + /// The inner SDK model, or the disposed error once `dispose()` ran. + pub(crate) fn inner(&self) -> Result<&aic_sdk::Model<'static>> { + self.slot.get() + } } -pub fn register_exports(cx: &mut neon::prelude::ModuleContext) -> NeonResult<()> { - cx.export_function("modelFromFile", Model::from_file)?; - cx.export_function("modelDownload", Model::download)?; - cx.export_function("modelGetId", Model::get_id)?; - cx.export_function("modelGetOptimalSampleRate", Model::get_optimal_sample_rate)?; - cx.export_function("modelGetOptimalBlockSize", Model::get_optimal_block_size)?; +#[napi] +impl Model { + /// Loads a model from a `.aicmodel` file. + /// + /// The SDK memory-maps the file. Do not modify or delete it while the model or any + /// processor, VAD or analyzer created from it is still alive. + /// + /// Download models with {@link Model.download}. Available model IDs are listed at + /// . + /// + /// @param path - Path to the model file. + /// @throws If the file cannot be loaded or its format is incompatible with this SDK. + #[napi(factory)] + pub fn from_file(env: Env, path: String) -> Result { + let inner = map_err(aic_sdk::Model::from_file(&path))?; + let bytes = mem::model_bytes(std::path::Path::new(&path)); - Ok(()) + Ok(Self { + slot: DisposableSlot::new(env, inner, "Model", bytes), + }) + } + + /// Releases this handle's reference to the native model without waiting for garbage collection. + /// + /// Instances created from this model remain usable. The file mapping is released when + /// its last reference is destroyed. + /// + /// After disposal, all methods except `dispose()` throw. Repeated disposal has no effect. + #[napi] + pub fn dispose(&mut self, env: Env) { + self.slot.release(env); + } + + /// Downloads a model from the ai-coustics CDN and returns a promise for its file path. + /// + /// Each call fetches the manifest to select the latest compatible model version. + /// An existing file is reused if its checksum matches; otherwise it is replaced. + /// The download runs on Node's libuv thread pool. + /// + /// @param modelId - Model ID listed at . + /// @param downloadDir - Directory in which to store the model. + /// @returns A promise for the downloaded or cached model's path. + // napi cannot infer an `AsyncTask`'s resolved type; without the annotation the + // generated d.ts says `Promise`. + #[napi(ts_return_type = "Promise")] + pub fn download(model_id: String, download_dir: String) -> AsyncTask { + AsyncTask::new(DownloadTask { + model_id, + download_dir, + }) + } + + /// Returns the model identifier, including its build and format version suffixes. + #[napi] + pub fn get_id(&self) -> Result { + Ok(self.inner()?.id().to_owned()) + } + + /// Returns the sample rate in Hz for which the model was trained. + /// + /// The SDK resamples audio at other supported rates internally. Enhancement is limited + /// to frequencies below half the model's sample rate. + #[napi] + pub fn get_optimal_sample_rate(&self) -> Result { + Ok(self.inner()?.optimal_sample_rate()) + } + + /// Returns the optimal block size in samples for the given sample rate. + /// + /// Use this value to avoid additional buffering latency. The block size depends on the + /// sample rate because each model processes a fixed duration of audio. + /// + /// @param sampleRate - Audio sample rate in Hz. + #[napi] + pub fn get_optimal_block_size(&self, sample_rate: u32) -> Result { + // Use `u32` so block sizes are exposed as JavaScript numbers for typed-array lengths. + Ok(self.inner()?.optimal_block_size(sample_rate) as u32) + } +} + +/// Runs the blocking model download on the libuv thread pool. +pub struct DownloadTask { + model_id: String, + download_dir: String, +} + +impl Task for DownloadTask { + type Output = String; + type JsValue = String; + + fn compute(&mut self) -> Result { + let path = aic_sdk::Model::download(&self.model_id, &self.download_dir) + .map_err(|error| napi::Error::from(JsAicError(error)))?; + + Ok(path.to_string_lossy().into_owned()) + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { + Ok(output) + } } diff --git a/src/processor.rs b/src/processor.rs index 6778428..4e1967e 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -1,103 +1,265 @@ -use std::sync::Mutex; - -use neon::{ - prelude::{Context, FunctionContext}, - result::{JsResult, NeonResult}, - types::{Finalize, JsBox, JsString, JsTypedArray, JsUndefined, buffer::TypedArray}, +use crate::{ + claim_sdk_id, + disposable_slot::DisposableSlot, + error::{Result, map_err}, + mem, + model::Model, }; -use crate::model::Model; -use crate::processor_context::ProcessorContext; -use crate::util::{parse_otel_config, parse_processor_config}; +use napi::{Env, bindgen_prelude::Float32Array, bindgen_prelude::ObjectFinalize}; +use napi_derive::napi; -pub struct Processor { - inner: Mutex>, +/// Configurable speech enhancement parameters. Values can be changed during processing. +#[napi] +pub enum ProcessorParameter { + /// Bypasses enhancement while preserving the processing delay. + /// + /// This allows enhancement to be enabled or disabled without clicks or timing changes. + /// + /// Range: 0.0 to 1.0. At 0.0 enhancement is active; at 1.0 audio passes through with + /// latency compensation. Default: 0.0. + Bypass = 0, + /// Controls enhancement strength. + /// + /// Quail models apply stronger noise suppression at higher values, including suppression + /// of competing speech for Voice Focus models. Rook models adjust the mix of original + /// and enhanced audio. + /// + /// Range: 0.0 to 1.0. + EnhancementLevel = 1, } -impl Finalize for Processor {} - -impl Processor { - pub fn new(mut cx: FunctionContext) -> JsResult> { - let model = cx.argument::>(0)?; - let license_key = cx.argument::(1)?.value(&mut cx); - let otel_config = match cx.argument_opt(2) { - Some(value) => parse_otel_config(&mut cx, value)?, - None => None, - }; - - // SAFETY: This function has no safety requirements. - unsafe { - aic_sdk::set_sdk_id(4); - } - - let processor = match &otel_config { - Some(otel_config) => { - aic_sdk::Processor::with_otel_config(&model.inner, &license_key, otel_config) - } - None => aic_sdk::Processor::new(&model.inner, &license_key), - } - .or_else(|e| cx.throw_error(e.to_string()))?; - - Ok(cx.boxed(Processor { - inner: Mutex::new(processor), - })) +impl From for aic_sdk::ProcessorParameter { + fn from(parameter: ProcessorParameter) -> Self { + match parameter { + ProcessorParameter::Bypass => Self::Bypass, + ProcessorParameter::EnhancementLevel => Self::EnhancementLevel, } + } +} - pub fn initialize(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let config = parse_processor_config(&mut cx, 1)?; +/// OpenTelemetry configuration for a processor or VAD instance. +/// +/// Overrides the SDK's environment-based telemetry settings, such as +/// `AIC_SDK_OTEL_ENABLE`, for the instance receiving this configuration. +#[napi(object)] +pub struct OtelConfig { + /// Whether to export telemetry. + pub enable: bool, + /// Session ID to report. A random one is generated when omitted. + pub session_id: Option, + /// Metric export interval in milliseconds. Omit or pass 0 for the SDK default of 60000. + pub export_interval_ms: Option, +} - let mut processor = this.inner.lock().unwrap(); +impl From for aic_sdk::OtelConfig { + fn from(config: OtelConfig) -> Self { + Self { + enable: config.enable, + session_id: config.session_id, + export_interval_ms: config.export_interval_ms.unwrap_or(0), + } + } +} - processor - .initialize(&config) - .or_else(|e| cx.throw_error(e.to_string()))?; +/// Converts JavaScript audio settings into the SDK configuration. Block sizes use JS numbers. +pub(crate) fn audio_config( + sample_rate: u32, + block_size: u32, + variable_block_size: Option, +) -> aic_sdk::ProcessorConfig { + aic_sdk::ProcessorConfig { + sample_rate, + block_size: block_size as usize, + variable_block_size: variable_block_size.unwrap_or(false), + } +} - Ok(cx.undefined()) - } +/// Processes mono audio with an enhancement or bypass model. +/// +/// Call {@link Processor#initialize} before processing audio. Each processor maintains +/// its own state; create one instance per audio stream. Multiple processors can share +/// the same {@link Model}. +/// +/// Use {@link ProcessorAsync} to run processing on Node's libuv thread pool. +#[napi(custom_finalize)] +pub struct Processor { + // Only the JavaScript thread accesses this slot. Async classes use a shared, locked slot. + slot: DisposableSlot>, +} - pub fn process(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let mut audio_block = cx.argument::>(1)?; +impl ObjectFinalize for Processor { + fn finalize(mut self, env: Env) -> Result<()> { + // A no-op if `dispose()` already ran. + self.slot.release(env); + Ok(()) + } +} - let mut processor = this.inner.lock().unwrap(); +#[napi] +impl Processor { + /// Creates a new speech enhancement processor. + /// + /// Construction is synchronous and throws if creation fails. Call + /// {@link Processor#initialize} before processing audio. + /// + /// @param model - Enhancement or bypass model. Other model types are rejected. + /// @param licenseKey - SDK license key from . + /// @param otelConfig - Optional telemetry configuration. When omitted, telemetry follows + /// the runtime environment. + #[napi(constructor)] + pub fn new( + env: Env, + model: &Model, + license_key: String, + otel_config: Option, + ) -> Result { + let model_inner = model.inner()?; + claim_sdk_id(); + let inner = match otel_config { + Some(config) => { + aic_sdk::Processor::with_otel_config(model_inner, &license_key, &config.into()) + } + None => aic_sdk::Processor::new(model_inner, &license_key), + }; + let inner = map_err(inner)?; - let samples = audio_block.as_mut_slice(&mut cx); + Ok(Self { + slot: DisposableSlot::new(env, inner, "Processor", mem::PROCESSOR_BYTES), + }) + } - processor - .process(samples) - .or_else(|e| cx.throw_error(e.to_string()))?; + /// Destroys the native processor and releases its telemetry session. + /// + /// Use this for cleanup at a specific point instead of waiting for garbage collection. + /// After disposal, all methods except `dispose()` fail. Repeated disposal has no effect. + #[napi] + pub fn dispose(&mut self, env: Env) { + self.slot.release(env); + } - Ok(cx.undefined()) - } + /// Configures the processor for the given audio format. + /// + /// Call this method before processing audio. Use {@link Model#getOptimalSampleRate} and + /// {@link Model#getOptimalBlockSize} for the lowest delay. + /// This method allocates memory; avoid calling it from audio processing callbacks. + /// + /// @param sampleRate - Audio sample rate in Hz. + /// @param blockSize - Number of mono samples per block. + /// @param variableBlockSize - Allow blocks shorter than `blockSize`. Defaults to `false`. + /// Variable block sizes can add buffering latency. Larger blocks are always rejected. + #[napi] + pub fn initialize( + &mut self, + sample_rate: u32, + block_size: u32, + variable_block_size: Option, + ) -> Result<()> { + map_err(self.slot.get_mut()?.initialize(&audio_config( + sample_rate, + block_size, + variable_block_size, + ))) + } - pub fn get_context(mut cx: FunctionContext) -> JsResult> { - let this = cx.argument::>(0)?; - let processor = this.inner.lock().unwrap(); + /// Enhances a mono audio block in place. + /// + /// Call {@link Processor#initialize} first. The block must contain exactly `blockSize` + /// samples, or at most `blockSize` if `variableBlockSize` is enabled. + /// If the input uses a SharedArrayBuffer, prevent other workers from accessing it during + /// this call. + #[napi] + pub fn process(&mut self, mut audio: Float32Array) -> Result<()> { + // `Float32Array` references the caller's ArrayBuffer; processing writes into it directly. + // + // SAFETY: The call is synchronous and the mutable slice does not escape this function. + // JavaScript in this isolate cannot access the buffer during the call. A caller using + // SharedArrayBuffer must prevent concurrent access from other workers. + let samples = unsafe { audio.as_mut() }; - Ok(cx.boxed(ProcessorContext { - inner: processor.context(), - })) - } + map_err(self.slot.get_mut()?.process(samples)) + } - pub fn terminate_session(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let mut processor = this.inner.lock().unwrap(); + /// Creates a handle for reading and writing this processor's parameters and state. + /// + /// Each call returns an independent handle to the same processor. + #[napi] + pub fn get_context(&self) -> Result { + Ok(ProcessorContext { + inner: self.slot.get()?.context(), + }) + } - processor - .terminate_session() - .or_else(|e| cx.throw_error(e.to_string()))?; + /// Terminates the telemetry session associated with this processor. + /// + /// Once termination is handled, the processor can no longer process audio. + /// The session also ends when the native object is destroyed. Use this method when + /// termination must be requested at a specific lifecycle event. + /// + /// This method may block. Avoid calling it from audio processing callbacks. + /// If another session is still active, termination can complete asynchronously. + #[napi] + pub fn terminate_session(&mut self) -> Result<()> { + map_err(self.slot.get_mut()?.terminate_session()) + } +} - Ok(cx.undefined()) - } +/// Control handle for a {@link Processor}. +/// +/// Every method may be called while audio is being processed. Handle and processor have +/// independent lifetimes: releasing the handle does not destroy the processor, and the +/// handle stays valid after its processor is disposed or garbage-collected, though its +/// calls then no longer reach a live processor. +#[napi] +pub struct ProcessorContext { + pub(crate) inner: aic_sdk::ProcessorContext, } -pub fn register_exports(cx: &mut neon::prelude::ModuleContext) -> NeonResult<()> { - cx.export_function("processorNew", Processor::new)?; - cx.export_function("processorInitialize", Processor::initialize)?; - cx.export_function("processorProcess", Processor::process)?; - cx.export_function("processorGetContext", Processor::get_context)?; - cx.export_function("processorTerminateSession", Processor::terminate_session)?; +#[napi] +impl ProcessorContext { + /// Sets an enhancement parameter. Throws if the value is out of range. + #[napi] + pub fn set_parameter(&self, parameter: ProcessorParameter, value: f64) -> Result<()> { + map_err(self.inner.set_parameter(parameter.into(), value as f32)) + } - Ok(()) + /// Returns the current value of an enhancement parameter. + #[napi] + pub fn get_parameter(&self, parameter: ProcessorParameter) -> Result { + map_err(self.inner.parameter(parameter.into())).map(f64::from) + } + + /// Returns the audio delay in samples at the configured sample rate. + /// + /// Covers algorithmic delay plus any buffering from a non-optimal block size. Before + /// initialization it reports the base delay at the model's optimal settings. + #[napi] + pub fn get_audio_delay(&self) -> u32 { + self.inner.audio_delay() as u32 + } + + /// Clears internal state and buffers while preserving the configured audio settings. + /// + /// Call this when the stream is interrupted or when seeking to prevent previous audio + /// from affecting the output. + #[napi] + pub fn reset(&self) -> Result<()> { + map_err(self.inner.reset()) + } + + /// Replaces the bearer token on the running processor. + /// + /// Use this to refresh a JWT without recreating the instance. Both the original license + /// key and the new token must be JWTs. If this call fails, the previous token remains active. + /// + /// A successful call validates the token's format and applies it immediately. Backend + /// acceptance is checked later. If the backend rejects the token, the SDK retries with + /// backoff; processing is eventually disabled if no accepted token arrives in time. + /// Supply a valid token to recover the session. + /// + /// This method allocates memory and takes a mutex. Avoid calling it from audio processing callbacks. + #[napi] + pub fn update_bearer_token(&self, token: String) -> Result<()> { + map_err(self.inner.update_bearer_token(&token)) + } } diff --git a/src/processor_async.rs b/src/processor_async.rs new file mode 100644 index 0000000..0e8d66e --- /dev/null +++ b/src/processor_async.rs @@ -0,0 +1,290 @@ +use crate::{ + claim_sdk_id, + disposable_slot::{DisposableSlot, lock}, + error::{Result, map_err}, + mem, + model::Model, + processor::{OtelConfig, ProcessorContext, audio_config}, +}; + +use napi::{ + Env, Task, + bindgen_prelude::{AsyncTask, Float32Array, ObjectFinalize}, +}; +use napi_derive::napi; +use std::sync::{Arc, Mutex}; + +/// Speech enhancement for use in async applications. +/// +/// Initialization, processing and context creation run on Node's libuv thread pool and +/// return promises. Construction and disposal are synchronous. +/// +/// Use {@link Processor} when processing should run on the calling thread. +/// +/// ### Threading +/// +/// Use one instance per stream and await each operation before submitting the next. +/// Concurrent calls on one instance are not guaranteed to execute in submission order. +/// Use separate instances to process multiple streams concurrently. +/// +/// The libuv pool defaults to four threads and is shared with filesystem, DNS and crypto +/// work. Set `UV_THREADPOOL_SIZE` before starting Node to change its size. +/// `AIC_NUM_THREADS` does not apply to these bindings. +#[napi(custom_finalize)] +pub struct ProcessorAsync { + slot: Arc>>>, +} + +impl ObjectFinalize for ProcessorAsync { + fn finalize(self, env: Env) -> Result<()> { + // Release the object and V8 memory estimate only for the last shared handle. + // A pending task can retain the slot beyond finalization; see `DisposableSlot`. + // `release` has no effect if the object was already disposed. + if Arc::strong_count(&self.slot) == 1 { + lock(&self.slot).release(env); + } + Ok(()) + } +} + +#[napi] +impl ProcessorAsync { + /// Creates a new async speech enhancement processor. + /// + /// Construction is synchronous and throws if creation fails. Await + /// {@link ProcessorAsync#initialize} or {@link ProcessorAsync#withConfig} before processing audio. + /// + /// @param model - Enhancement or bypass model. Other model types are rejected. + /// @param licenseKey - SDK license key from . + /// @param otelConfig - Optional telemetry configuration. When omitted, telemetry follows + /// the runtime environment. + #[napi(constructor)] + pub fn new( + env: Env, + model: &Model, + license_key: String, + otel_config: Option, + ) -> Result { + let model_inner = model.inner()?; + claim_sdk_id(); + let inner = match otel_config { + Some(config) => { + aic_sdk::Processor::with_otel_config(model_inner, &license_key, &config.into()) + } + None => aic_sdk::Processor::new(model_inner, &license_key), + }; + let inner = map_err(inner)?; + + Ok(Self { + slot: Arc::new(Mutex::new(DisposableSlot::new( + env, + inner, + "ProcessorAsync", + mem::PROCESSOR_BYTES, + ))), + }) + } + + /// Destroys the native processor and releases its telemetry session. + /// + /// Use this for cleanup at a specific point instead of waiting for garbage collection. + /// After disposal, all methods except `dispose()` fail. Repeated disposal has no effect. + /// + /// This call blocks the calling thread while a worker holds the instance lock. + /// Queued work that acquires the lock after disposal rejects its promise. + #[napi] + pub fn dispose(&self, env: Env) { + lock(&self.slot).release(env); + } + + /// Initializes the processor and returns a promise for a handle to the initialized instance. + /// + /// Uses the same configuration as {@link ProcessorAsync#initialize}. The returned handle and + /// this object share the same native instance; disposing either invalidates both. + /// + /// ```javascript + /// const processor = await new ProcessorAsync(model, licenseKey).withConfig(sampleRate, blockSize) + /// ``` + #[napi(ts_return_type = "Promise")] + pub fn with_config( + &self, + sample_rate: u32, + block_size: u32, + variable_block_size: Option, + ) -> AsyncTask { + AsyncTask::new(ProcessorWithConfigTask { + slot: self.slot.clone(), + config: audio_config(sample_rate, block_size, variable_block_size), + }) + } + + /// Configures the processor for the given audio format. + /// + /// Await this method before processing audio. Use {@link Model#getOptimalSampleRate} and + /// {@link Model#getOptimalBlockSize} for the lowest delay. + /// Initialization allocates memory and runs on a libuv worker thread. + /// + /// @param sampleRate - Audio sample rate in Hz. + /// @param blockSize - Number of mono samples per block. + /// @param variableBlockSize - Allow blocks shorter than `blockSize`. Defaults to `false`. + /// Variable block sizes can add buffering latency. Larger blocks are always rejected. + #[napi(ts_return_type = "Promise")] + pub fn initialize( + &self, + sample_rate: u32, + block_size: u32, + variable_block_size: Option, + ) -> AsyncTask { + AsyncTask::new(ProcessorInitializeTask { + slot: self.slot.clone(), + config: audio_config(sample_rate, block_size, variable_block_size), + }) + } + + /// Enhances a mono audio block and returns a promise for the enhanced samples. + /// + /// The input is copied before work is queued and remains unmodified. The promise + /// resolves to a new `Float32Array` containing the enhanced samples. + /// + /// The instance must be initialized first. The block must contain exactly `blockSize` + /// samples, or at most `blockSize` if `variableBlockSize` is enabled. + /// Await each call before submitting the next block. + /// + /// ```javascript + /// const enhanced = await processor.process(block) + /// ``` + // Specify ArrayBuffer so the result is assignable to a variable inferred from + // `new Float32Array(n)`. The returned buffer is never a SharedArrayBuffer. + #[napi(ts_return_type = "Promise>")] + pub fn process(&self, audio: Float32Array) -> AsyncTask { + AsyncTask::new(ProcessorProcessTask { + slot: self.slot.clone(), + // Copy on the JavaScript thread so the worker owns its input. + audio: audio.to_vec(), + }) + } + + /// Returns a promise for a {@link ProcessorContext} to control this processor. + /// + /// Context creation runs on a worker thread because it may wait for processing to + /// release the instance lock. The returned context's methods are synchronous and can + /// be called while audio is being processed. + #[napi(ts_return_type = "Promise")] + pub fn get_context(&self) -> AsyncTask { + AsyncTask::new(ProcessorContextTask { + slot: self.slot.clone(), + }) + } + + /// Terminates the telemetry session associated with this processor. + /// + /// Once termination is handled, the processor can no longer process audio. + /// The session also ends when the native object is destroyed. Use this method when + /// termination must be requested at a specific lifecycle event. + /// + /// Termination runs on a libuv worker thread because it may block. + /// If another session is still active, termination can complete asynchronously. + #[napi(ts_return_type = "Promise")] + pub fn terminate_session(&self) -> AsyncTask { + AsyncTask::new(ProcessorTerminateTask { + slot: self.slot.clone(), + }) + } +} + +pub struct ProcessorWithConfigTask { + slot: Arc>>>, + config: aic_sdk::ProcessorConfig, +} + +impl Task for ProcessorWithConfigTask { + type Output = (); + type JsValue = ProcessorAsync; + + fn compute(&mut self) -> Result<()> { + map_err(lock(&self.slot).get_mut()?.initialize(&self.config)) + } + + fn resolve(&mut self, _env: Env, _: ()) -> Result { + // The returned handle shares the native instance and its existing memory report. + // If disposal occurred during initialization, this handle is also disposed. + Ok(ProcessorAsync { + slot: self.slot.clone(), + }) + } +} + +pub struct ProcessorInitializeTask { + slot: Arc>>>, + config: aic_sdk::ProcessorConfig, +} + +impl Task for ProcessorInitializeTask { + type Output = (); + type JsValue = (); + + fn compute(&mut self) -> Result<()> { + map_err(lock(&self.slot).get_mut()?.initialize(&self.config)) + } + + fn resolve(&mut self, _env: Env, _: ()) -> Result<()> { + Ok(()) + } +} + +pub struct ProcessorProcessTask { + slot: Arc>>>, + audio: Vec, +} + +impl Task for ProcessorProcessTask { + type Output = Vec; + type JsValue = Float32Array; + + fn compute(&mut self) -> Result> { + // Transfer the task buffer to `resolve` without another allocation. + let mut audio = std::mem::take(&mut self.audio); + map_err(lock(&self.slot).get_mut()?.process(&mut audio))?; + + Ok(audio) + } + + fn resolve(&mut self, _env: Env, audio: Vec) -> Result { + // Transfer the allocation to V8 as an external ArrayBuffer without copying. + Ok(Float32Array::new(audio)) + } +} + +pub struct ProcessorContextTask { + slot: Arc>>>, +} + +impl Task for ProcessorContextTask { + type Output = aic_sdk::ProcessorContext; + type JsValue = ProcessorContext; + + fn compute(&mut self) -> Result { + Ok(lock(&self.slot).get()?.context()) + } + + fn resolve(&mut self, _env: Env, context: aic_sdk::ProcessorContext) -> Result { + Ok(ProcessorContext { inner: context }) + } +} + +pub struct ProcessorTerminateTask { + slot: Arc>>>, +} + +impl Task for ProcessorTerminateTask { + type Output = (); + type JsValue = (); + + fn compute(&mut self) -> Result<()> { + map_err(lock(&self.slot).get_mut()?.terminate_session()) + } + + fn resolve(&mut self, _env: Env, _: ()) -> Result<()> { + Ok(()) + } +} diff --git a/src/processor_context.rs b/src/processor_context.rs deleted file mode 100644 index 11c2f00..0000000 --- a/src/processor_context.rs +++ /dev/null @@ -1,110 +0,0 @@ -use neon::{ - handle::Handle, - prelude::{Context, FunctionContext}, - result::{JsResult, NeonResult}, - types::{Finalize, JsBox, JsNumber, JsString, JsUndefined, JsValue}, -}; - -// Processor parameter constants -pub const PROCESSOR_PARAM_BYPASS: i32 = 0; -pub const PROCESSOR_PARAM_ENHANCEMENT_LEVEL: i32 = 1; - -pub fn parse_processor_parameter( - cx: &mut FunctionContext, - value: Handle, -) -> NeonResult { - let param_num = value.downcast_or_throw::(cx)?.value(cx) as i32; - - match param_num { - PROCESSOR_PARAM_BYPASS => Ok(aic_sdk::ProcessorParameter::Bypass), - PROCESSOR_PARAM_ENHANCEMENT_LEVEL => Ok(aic_sdk::ProcessorParameter::EnhancementLevel), - _ => cx.throw_error(format!("Invalid processor parameter: {}", param_num)), - } -} - -pub struct ProcessorContext { - pub(crate) inner: aic_sdk::ProcessorContext, -} - -impl Finalize for ProcessorContext {} - -impl ProcessorContext { - pub fn reset(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - this.inner - .reset() - .or_else(|e| cx.throw_error(e.to_string()))?; - Ok(cx.undefined()) - } - - pub fn set_parameter(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let parameter_arg = cx.argument::(1)?; - let parameter = parse_processor_parameter(&mut cx, parameter_arg)?; - let value = cx.argument::(2)?.value(&mut cx) as f32; - - this.inner - .set_parameter(parameter, value) - .or_else(|e| cx.throw_error(e.to_string()))?; - - Ok(cx.undefined()) - } - - pub fn get_parameter(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let parameter_arg = cx.argument::(1)?; - let parameter = parse_processor_parameter(&mut cx, parameter_arg)?; - - let value = this - .inner - .parameter(parameter) - .or_else(|e| cx.throw_error(e.to_string()))?; - - Ok(cx.number(value as f64)) - } - - pub fn get_audio_delay(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let delay = this.inner.audio_delay(); - Ok(cx.number(delay as f64)) - } - - pub fn update_bearer_token(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let token = cx.argument::(1)?.value(&mut cx); - - this.inner - .update_bearer_token(&token) - .or_else(|e| cx.throw_error(e.to_string()))?; - - Ok(cx.undefined()) - } -} - -pub fn register_exports(cx: &mut neon::prelude::ModuleContext) -> NeonResult<()> { - cx.export_function("processorContextReset", ProcessorContext::reset)?; - cx.export_function( - "processorContextSetParameter", - ProcessorContext::set_parameter, - )?; - cx.export_function( - "processorContextGetParameter", - ProcessorContext::get_parameter, - )?; - cx.export_function( - "processorContextGetAudioDelay", - ProcessorContext::get_audio_delay, - )?; - cx.export_function( - "processorContextUpdateBearerToken", - ProcessorContext::update_bearer_token, - )?; - - // Export processor parameter constants - let bypass = cx.number(PROCESSOR_PARAM_BYPASS); - cx.export_value("PROCESSOR_PARAM_BYPASS", bypass)?; - let enhancement_level = cx.number(PROCESSOR_PARAM_ENHANCEMENT_LEVEL); - cx.export_value("PROCESSOR_PARAM_ENHANCEMENT_LEVEL", enhancement_level)?; - - Ok(()) -} diff --git a/src/util.rs b/src/util.rs deleted file mode 100644 index 6fad847..0000000 --- a/src/util.rs +++ /dev/null @@ -1,71 +0,0 @@ -use neon::{ - handle::Handle, - object::Object, - prelude::FunctionContext, - result::NeonResult, - types::{JsBoolean, JsNull, JsNumber, JsObject, JsString, JsUndefined, JsValue}, -}; - -/// Reads a [`aic_sdk::ProcessorConfig`] from three consecutive arguments -/// (`sampleRate`, `blockSize`, `variableBlockSize`) starting at `first_arg`. -pub fn parse_processor_config( - cx: &mut FunctionContext, - first_arg: usize, -) -> NeonResult { - let sample_rate = cx.argument::(first_arg)?.value(cx) as u32; - let block_size = cx.argument::(first_arg + 1)?.value(cx) as usize; - let variable_block_size = cx.argument::(first_arg + 2)?.value(cx); - - Ok(aic_sdk::ProcessorConfig { - sample_rate, - block_size, - variable_block_size, - }) -} - -/// Reads an optional [`aic_sdk::OtelConfig`] from a JS value. -/// -/// `null` and `undefined` map to `None`, which leaves the SDK's environment-based -/// telemetry defaults in place. -pub fn parse_otel_config( - cx: &mut FunctionContext, - value: Handle, -) -> NeonResult> { - if is_nullish(cx, value) { - return Ok(None); - } - - let object = value.downcast_or_throw::(cx)?; - let enable = object.get::(cx, "enable")?.value(cx); - - let session_id_value = object.get::(cx, "sessionId")?; - let session_id = if is_nullish(cx, session_id_value) { - None - } else { - Some( - session_id_value - .downcast_or_throw::(cx)? - .value(cx), - ) - }; - - let export_interval_value = object.get::(cx, "exportIntervalMs")?; - let export_interval_ms = if is_nullish(cx, export_interval_value) { - 0 - } else { - export_interval_value - .downcast_or_throw::(cx)? - .value(cx) as u32 - }; - - Ok(Some(aic_sdk::OtelConfig { - enable, - session_id, - export_interval_ms, - })) -} - -/// Returns `true` if the value is `null` or `undefined`. -fn is_nullish(cx: &mut FunctionContext, value: Handle) -> bool { - value.is_a::(cx) || value.is_a::(cx) -} diff --git a/src/vad.rs b/src/vad.rs index 0e71aaf..5f355f9 100644 --- a/src/vad.rs +++ b/src/vad.rs @@ -1,105 +1,252 @@ -use std::sync::Mutex; - -use neon::{ - prelude::{Context, FunctionContext}, - result::{JsResult, NeonResult}, - types::{Finalize, JsBox, JsString, JsTypedArray, JsUndefined, buffer::TypedArray}, -}; - use crate::{ - model::Model, - util::{parse_otel_config, parse_processor_config}, - vad_context::VadContext, + claim_sdk_id, + disposable_slot::DisposableSlot, + error::{Result, map_err}, + mem, + model::Model, + processor::{OtelConfig, audio_config}, }; -pub struct Vad { - inner: Mutex>, +use napi::{Env, bindgen_prelude::Float32Array, bindgen_prelude::ObjectFinalize}; +use napi_derive::napi; + +/// Configurable voice activity detection parameters. Values can be changed during processing. +#[napi] +pub enum VadParameter { + /// Controls how long the VAD continues reporting speech after speech stops. + /// + /// Speech is reported if at least half the blocks processed in the last + /// `speechHoldDuration * 2` seconds contained speech. Additional speech during this + /// period extends the detection period. + /// + /// The duration is rounded to the nearest model window length, so the value read back + /// may differ from the value set. + /// + /// Range: 0.0 to 300 times the model window length, in seconds. Default: model-specific. + SpeechHoldDuration = 0, + /// Sets the probability threshold for detecting speech in an audio block. + /// + /// A model probability above this threshold counts as speech. + /// + /// Range: 0.0 to 1.0. Default: model-specific. + Sensitivity = 1, + /// Controls how long speech must be present before the VAD reports speech. + /// + /// The duration is rounded to the nearest model window length, so the value read back + /// may differ from the value set. + /// + /// Range: 0.0 to 1.0 seconds. Default: model-specific. + MinimumSpeechDuration = 2, } -impl Finalize for Vad {} - -impl Vad { - pub fn new(mut cx: FunctionContext) -> JsResult> { - let model = cx.argument::>(0)?; - let license_key = cx.argument::(1)?.value(&mut cx); - let otel_config = match cx.argument_opt(2) { - Some(value) => parse_otel_config(&mut cx, value)?, - None => None, - }; - - // SAFETY: This function has no safety requirements. - unsafe { - aic_sdk::set_sdk_id(4); - } - - let vad = match &otel_config { - Some(otel_config) => { - aic_sdk::Vad::with_otel_config(&model.inner, &license_key, otel_config) - } - None => aic_sdk::Vad::new(&model.inner, &license_key), - } - .or_else(|e| cx.throw_error(e.to_string()))?; - - Ok(cx.boxed(Vad { - inner: Mutex::new(vad), - })) - } - - pub fn initialize(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let config = parse_processor_config(&mut cx, 1)?; - - this.inner - .lock() - .unwrap() - .initialize(&config) - .or_else(|e| cx.throw_error(e.to_string()))?; - - Ok(cx.undefined()) - } - - pub fn process(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let audio_block = cx.argument::>(1)?; - let samples = audio_block.as_slice(&cx); - - this.inner - .lock() - .unwrap() - .process(samples) - .or_else(|e| cx.throw_error(e.to_string()))?; - - Ok(cx.undefined()) - } - - pub fn get_context(mut cx: FunctionContext) -> JsResult> { - let this = cx.argument::>(0)?; - let vad = this.inner.lock().unwrap(); - - Ok(cx.boxed(VadContext { - inner: vad.context(), - })) +impl From for aic_sdk::VadParameter { + fn from(parameter: VadParameter) -> Self { + match parameter { + VadParameter::SpeechHoldDuration => Self::SpeechHoldDuration, + VadParameter::Sensitivity => Self::Sensitivity, + VadParameter::MinimumSpeechDuration => Self::MinimumSpeechDuration, } + } +} - pub fn terminate_session(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; +/// Detects speech using a dedicated VAD model. +/// +/// Call {@link Vad#initialize}, then pass mono audio to {@link Vad#process}. +/// Processing leaves the audio unmodified and updates the prediction, which can be read +/// through a {@link VadContext}. +/// +/// When using enhancement and detection together, pass the original input to the VAD +/// before calling {@link Processor#process}. Enhanced audio changes the signal seen by +/// the VAD and adds the processor's audio delay to the prediction delay. +#[napi(custom_finalize)] +pub struct Vad { + // Only the JavaScript thread accesses this slot. Async classes use a shared, locked slot. + slot: DisposableSlot>, +} - this.inner - .lock() - .unwrap() - .terminate_session() - .or_else(|e| cx.throw_error(e.to_string()))?; +impl ObjectFinalize for Vad { + fn finalize(mut self, env: Env) -> Result<()> { + // A no-op if `dispose()` already ran. + self.slot.release(env); + Ok(()) + } +} - Ok(cx.undefined()) - } +#[napi] +impl Vad { + /// Creates a new voice activity detector. + /// + /// Construction is synchronous and throws if creation fails. Call + /// {@link Vad#initialize} before processing audio. + /// + /// @param model - Dedicated VAD model. Other model types are rejected. + /// @param licenseKey - SDK license key from . + /// @param otelConfig - Optional telemetry configuration. When omitted, telemetry follows + /// the runtime environment. + #[napi(constructor)] + pub fn new( + env: Env, + model: &Model, + license_key: String, + otel_config: Option, + ) -> Result { + let model_inner = model.inner()?; + claim_sdk_id(); + let inner = match otel_config { + Some(config) => aic_sdk::Vad::with_otel_config(model_inner, &license_key, &config.into()), + None => aic_sdk::Vad::new(model_inner, &license_key), + }; + let inner = map_err(inner)?; + + Ok(Self { + slot: DisposableSlot::new(env, inner, "Vad", mem::PROCESSOR_BYTES), + }) + } + + /// Destroys the native VAD and releases its telemetry session. + /// + /// Use this for cleanup at a specific point instead of waiting for garbage collection. + /// After disposal, all methods except `dispose()` fail. Repeated disposal has no effect. + #[napi] + pub fn dispose(&mut self, env: Env) { + self.slot.release(env); + } + + /// Configures the VAD for the given audio format. + /// + /// Call this method before processing audio. Use {@link Model#getOptimalSampleRate} and + /// {@link Model#getOptimalBlockSize} for the most frequent prediction updates. + /// This method allocates memory; avoid calling it from audio processing callbacks. + /// + /// @param sampleRate - Audio sample rate in Hz. + /// @param blockSize - Number of mono samples per block. + /// @param variableBlockSize - Allow blocks shorter than `blockSize`. Defaults to `false`. + /// Variable block sizes can add buffering latency. Larger blocks are always rejected. + #[napi] + pub fn initialize( + &mut self, + sample_rate: u32, + block_size: u32, + variable_block_size: Option, + ) -> Result<()> { + map_err(self.slot.get_mut()?.initialize(&audio_config( + sample_rate, + block_size, + variable_block_size, + ))) + } + + /// Processes a mono audio block and updates the VAD prediction without modifying the input. + /// + /// Call {@link Vad#initialize} first. The block must contain exactly `blockSize` samples, + /// or at most `blockSize` if `variableBlockSize` is enabled. + #[napi] + pub fn process(&mut self, audio: Float32Array) -> Result<()> { + // Borrow the typed-array contents without copying or modifying them. + map_err(self.slot.get_mut()?.process(&audio)) + } + + /// Creates a handle for reading predictions and controlling this VAD. + /// + /// Each call returns an independent handle to the same VAD. + #[napi] + pub fn get_context(&self) -> Result { + Ok(VadContext { + inner: self.slot.get()?.context(), + }) + } + + /// Terminates the telemetry session associated with this VAD. + /// + /// Once termination is handled, the VAD can no longer process audio. + /// The session also ends when the native object is destroyed. Use this method when + /// termination must be requested at a specific lifecycle event. + /// + /// This method may block. Avoid calling it from audio processing callbacks. + /// If another session is still active, termination can complete asynchronously. + #[napi] + pub fn terminate_session(&mut self) -> Result<()> { + map_err(self.slot.get_mut()?.terminate_session()) + } } -pub fn register_exports(cx: &mut neon::prelude::ModuleContext) -> NeonResult<()> { - cx.export_function("vadNew", Vad::new)?; - cx.export_function("vadInitialize", Vad::initialize)?; - cx.export_function("vadProcess", Vad::process)?; - cx.export_function("vadGetContext", Vad::get_context)?; - cx.export_function("vadTerminateSession", Vad::terminate_session)?; +/// Control handle for a {@link Vad}. +/// +/// Every method may be called while audio is being processed. If the backing VAD is +/// released the prediction stops updating, but this handle stays valid. +#[napi] +pub struct VadContext { + pub(crate) inner: aic_sdk::VadContext, +} - Ok(()) +#[napi] +impl VadContext { + /// Sets a VAD parameter. Throws if the value is out of range. + #[napi] + pub fn set_parameter(&self, parameter: VadParameter, value: f64) -> Result<()> { + map_err(self.inner.set_parameter(parameter.into(), value as f32)) + } + + /// Returns the current value of a VAD parameter. + #[napi] + pub fn get_parameter(&self, parameter: VadParameter) -> Result { + map_err(self.inner.parameter(parameter.into())).map(f64::from) + } + + /// Returns whether speech is currently detected. + /// + /// The decision lags its input by {@link VadContext#getPredictionDelay} samples, and + /// stops updating if the backing VAD stops being processed. + #[napi] + pub fn is_speech_detected(&self) -> bool { + self.inner.is_speech_detected() + } + + /// Returns the model's speech probability in the range 0.0 to 1.0. + /// + /// This value excludes speech hold, sensitivity thresholding and minimum speech duration. + /// Use it to implement custom detection logic. The prediction delay reported by + /// {@link VadContext#getPredictionDelay} also applies to this value. + #[napi] + pub fn get_raw_vad_probability(&self) -> f64 { + self.inner.raw_vad_probability().into() + } + + /// Returns the prediction delay in samples at the configured sample rate. + /// + /// Includes input buffering, STFT and model processing. Non-optimal or variable block + /// sizes can add buffering latency. Convert to milliseconds with + /// `delaySamples * 1000 / sampleRate`. + /// + /// Use this delay to align speech decisions with the input audio. It is independent of + /// a processor's audio delay; VAD processing does not delay or modify the audio. + #[napi] + pub fn get_prediction_delay(&self) -> u32 { + self.inner.prediction_delay() as u32 + } + + /// Clears internal state and buffers, including the published speech decision and probability. + /// + /// Call this when the stream is interrupted or when seeking to prevent predictions + /// from using previous audio. The VAD remains initialized with its configured settings. + #[napi] + pub fn reset(&self) -> Result<()> { + map_err(self.inner.reset()) + } + + /// Replaces the bearer token on the running VAD. + /// + /// Use this to refresh a JWT without recreating the instance. Both the original license + /// key and the new token must be JWTs. If this call fails, the previous token remains active. + /// + /// A successful call validates the token's format and applies it immediately. Backend + /// acceptance is checked later. If the backend rejects the token, the SDK retries with + /// backoff; processing is eventually disabled if no accepted token arrives in time. + /// Supply a valid token to recover the session. + /// + /// This method allocates memory and takes a mutex. Avoid calling it from audio processing callbacks. + #[napi] + pub fn update_bearer_token(&self, token: String) -> Result<()> { + map_err(self.inner.update_bearer_token(&token)) + } } diff --git a/src/vad_async.rs b/src/vad_async.rs new file mode 100644 index 0000000..3bdedcc --- /dev/null +++ b/src/vad_async.rs @@ -0,0 +1,289 @@ +use crate::{ + claim_sdk_id, + disposable_slot::{DisposableSlot, lock}, + error::{Result, map_err}, + mem, + model::Model, + processor::{OtelConfig, audio_config}, + vad::VadContext, +}; + +use napi::{ + Env, Task, + bindgen_prelude::{AsyncTask, Float32Array, ObjectFinalize}, +}; +use napi_derive::napi; +use std::sync::{Arc, Mutex}; + +/// Voice activity detection for use in async applications. +/// +/// Initialization, processing and context creation run on Node's libuv thread pool and +/// return promises. Construction and disposal are synchronous. +/// +/// Read predictions through a {@link VadContext}. Pass the original input audio to the +/// VAD before enhancement. +/// +/// ### Threading +/// +/// Use one instance per stream and await each operation before submitting the next. +/// Concurrent calls on one instance are not guaranteed to execute in submission order. +/// Use separate instances to process multiple streams concurrently. +/// +/// The libuv pool defaults to four threads and is shared with filesystem, DNS and crypto +/// work. Set `UV_THREADPOOL_SIZE` before starting Node to change its size. +/// `AIC_NUM_THREADS` does not apply to these bindings. +#[napi(custom_finalize)] +pub struct VadAsync { + slot: Arc>>>, +} + +impl ObjectFinalize for VadAsync { + fn finalize(self, env: Env) -> Result<()> { + // Release the object and V8 memory estimate only for the last shared handle. + // A pending task can retain the slot beyond finalization; see `DisposableSlot`. + // `release` has no effect if the object was already disposed. + if Arc::strong_count(&self.slot) == 1 { + lock(&self.slot).release(env); + } + Ok(()) + } +} + +#[napi] +impl VadAsync { + /// Creates a new async voice activity detector. + /// + /// Construction is synchronous and throws if creation fails. Await + /// {@link VadAsync#initialize} or {@link VadAsync#withConfig} before processing audio. + /// + /// @param model - Dedicated VAD model. Other model types are rejected. + /// @param licenseKey - SDK license key from . + /// @param otelConfig - Optional telemetry configuration. When omitted, telemetry follows + /// the runtime environment. + #[napi(constructor)] + pub fn new( + env: Env, + model: &Model, + license_key: String, + otel_config: Option, + ) -> Result { + let model_inner = model.inner()?; + claim_sdk_id(); + let inner = match otel_config { + Some(config) => aic_sdk::Vad::with_otel_config(model_inner, &license_key, &config.into()), + None => aic_sdk::Vad::new(model_inner, &license_key), + }; + let inner = map_err(inner)?; + + Ok(Self { + slot: Arc::new(Mutex::new(DisposableSlot::new( + env, + inner, + "VadAsync", + mem::PROCESSOR_BYTES, + ))), + }) + } + + /// Destroys the native VAD and releases its telemetry session. + /// + /// Use this for cleanup at a specific point instead of waiting for garbage collection. + /// After disposal, all methods except `dispose()` fail. Repeated disposal has no effect. + /// + /// This call blocks the calling thread while a worker holds the instance lock. + /// Queued work that acquires the lock after disposal rejects its promise. + #[napi] + pub fn dispose(&self, env: Env) { + lock(&self.slot).release(env); + } + + /// Initializes the VAD and returns a promise for a handle to the initialized instance. + /// + /// Uses the same configuration as {@link VadAsync#initialize}. The returned handle and + /// this object share the same native instance; disposing either invalidates both. + /// + /// ```javascript + /// const vad = await new VadAsync(model, licenseKey).withConfig(sampleRate, blockSize) + /// ``` + #[napi(ts_return_type = "Promise")] + pub fn with_config( + &self, + sample_rate: u32, + block_size: u32, + variable_block_size: Option, + ) -> AsyncTask { + AsyncTask::new(VadWithConfigTask { + slot: self.slot.clone(), + config: audio_config(sample_rate, block_size, variable_block_size), + }) + } + + /// Configures the VAD for the given audio format. + /// + /// Await this method before processing audio. Use {@link Model#getOptimalSampleRate} and + /// {@link Model#getOptimalBlockSize} for the most frequent prediction updates. + /// Initialization allocates memory and runs on a libuv worker thread. + /// + /// @param sampleRate - Audio sample rate in Hz. + /// @param blockSize - Number of mono samples per block. + /// @param variableBlockSize - Allow blocks shorter than `blockSize`. Defaults to `false`. + /// Variable block sizes can add buffering latency. Larger blocks are always rejected. + #[napi(ts_return_type = "Promise")] + pub fn initialize( + &self, + sample_rate: u32, + block_size: u32, + variable_block_size: Option, + ) -> AsyncTask { + AsyncTask::new(VadInitializeTask { + slot: self.slot.clone(), + config: audio_config(sample_rate, block_size, variable_block_size), + }) + } + + /// Updates the VAD prediction and returns a promise for the original mono audio samples. + /// + /// The input is copied before work is queued and remains unmodified. The promise + /// resolves to a new `Float32Array` containing the original samples. + /// + /// The instance must be initialized first. The block must contain exactly `blockSize` + /// samples, or at most `blockSize` if `variableBlockSize` is enabled. + /// Await each call before submitting the next block. + /// + /// ```javascript + /// const audio = await vad.process(block) + /// ``` + // See the note on {@link ProcessorAsync#process} for why the buffer type is spelled out. + #[napi(ts_return_type = "Promise>")] + pub fn process(&self, audio: Float32Array) -> AsyncTask { + AsyncTask::new(VadProcessTask { + slot: self.slot.clone(), + // Copy on the JavaScript thread so the worker owns its input. + audio: audio.to_vec(), + }) + } + + /// Returns a promise for a {@link VadContext} to control this VAD and read predictions. + /// + /// Context creation runs on a worker thread because it may wait for processing to + /// release the instance lock. The returned context's methods are synchronous and can + /// be called while audio is being processed. + #[napi(ts_return_type = "Promise")] + pub fn get_context(&self) -> AsyncTask { + AsyncTask::new(VadContextTask { + slot: self.slot.clone(), + }) + } + + /// Terminates the telemetry session associated with this VAD. + /// + /// Once termination is handled, the VAD can no longer process audio. + /// The session also ends when the native object is destroyed. Use this method when + /// termination must be requested at a specific lifecycle event. + /// + /// Termination runs on a libuv worker thread because it may block. + /// If another session is still active, termination can complete asynchronously. + #[napi(ts_return_type = "Promise")] + pub fn terminate_session(&self) -> AsyncTask { + AsyncTask::new(VadTerminateTask { + slot: self.slot.clone(), + }) + } +} + +pub struct VadWithConfigTask { + slot: Arc>>>, + config: aic_sdk::ProcessorConfig, +} + +impl Task for VadWithConfigTask { + type Output = (); + type JsValue = VadAsync; + + fn compute(&mut self) -> Result<()> { + map_err(lock(&self.slot).get_mut()?.initialize(&self.config)) + } + + fn resolve(&mut self, _env: Env, _: ()) -> Result { + // The returned handle shares the native instance and its existing memory report. + // If disposal occurred during initialization, this handle is also disposed. + Ok(VadAsync { + slot: self.slot.clone(), + }) + } +} + +pub struct VadInitializeTask { + slot: Arc>>>, + config: aic_sdk::ProcessorConfig, +} + +impl Task for VadInitializeTask { + type Output = (); + type JsValue = (); + + fn compute(&mut self) -> Result<()> { + map_err(lock(&self.slot).get_mut()?.initialize(&self.config)) + } + + fn resolve(&mut self, _env: Env, _: ()) -> Result<()> { + Ok(()) + } +} + +pub struct VadProcessTask { + slot: Arc>>>, + audio: Vec, +} + +impl Task for VadProcessTask { + type Output = Vec; + type JsValue = Float32Array; + + fn compute(&mut self) -> Result> { + // Transfer the task buffer to `resolve` without another allocation. + let audio = std::mem::take(&mut self.audio); + map_err(lock(&self.slot).get_mut()?.process(&audio))?; + + Ok(audio) + } + + fn resolve(&mut self, _env: Env, audio: Vec) -> Result { + // Transfer the allocation to V8 as an external ArrayBuffer without copying. + Ok(Float32Array::new(audio)) + } +} + +pub struct VadContextTask { + slot: Arc>>>, +} + +impl Task for VadContextTask { + type Output = aic_sdk::VadContext; + type JsValue = VadContext; + + fn compute(&mut self) -> Result { + Ok(lock(&self.slot).get()?.context()) + } + + fn resolve(&mut self, _env: Env, context: aic_sdk::VadContext) -> Result { + Ok(VadContext { inner: context }) + } +} + +pub struct VadTerminateTask { + slot: Arc>>>, +} + +impl Task for VadTerminateTask { + type Output = (); + type JsValue = (); + + fn compute(&mut self) -> Result<()> { + map_err(lock(&self.slot).get_mut()?.terminate_session()) + } + + fn resolve(&mut self, _env: Env, _: ()) -> Result<()> { + Ok(()) + } +} diff --git a/src/vad_context.rs b/src/vad_context.rs deleted file mode 100644 index 7f89cb8..0000000 --- a/src/vad_context.rs +++ /dev/null @@ -1,124 +0,0 @@ -use neon::{ - handle::Handle, - prelude::{Context, FunctionContext}, - result::{JsResult, NeonResult}, - types::{Finalize, JsBoolean, JsBox, JsNumber, JsUndefined, JsValue}, -}; - -// VAD parameter constants -pub const VAD_PARAM_SPEECH_HOLD_DURATION: i32 = 0; -pub const VAD_PARAM_SENSITIVITY: i32 = 1; -pub const VAD_PARAM_MINIMUM_SPEECH_DURATION: i32 = 2; - -pub fn parse_vad_parameter( - cx: &mut FunctionContext, - value: Handle, -) -> NeonResult { - let param_num = value.downcast_or_throw::(cx)?.value(cx) as i32; - - match param_num { - VAD_PARAM_SPEECH_HOLD_DURATION => Ok(aic_sdk::VadParameter::SpeechHoldDuration), - VAD_PARAM_SENSITIVITY => Ok(aic_sdk::VadParameter::Sensitivity), - VAD_PARAM_MINIMUM_SPEECH_DURATION => Ok(aic_sdk::VadParameter::MinimumSpeechDuration), - _ => cx.throw_error(format!("Invalid VAD parameter: {}", param_num)), - } -} - -pub struct VadContext { - pub(crate) inner: aic_sdk::VadContext, -} - -impl Finalize for VadContext {} - -impl VadContext { - pub fn reset(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - this.inner - .reset() - .or_else(|e| cx.throw_error(e.to_string()))?; - Ok(cx.undefined()) - } - - pub fn is_speech_detected(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let detected = this.inner.is_speech_detected(); - Ok(cx.boolean(detected)) - } - - pub fn raw_vad_probability(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let probability = this.inner.raw_vad_probability(); - Ok(cx.number(probability as f64)) - } - - pub fn get_prediction_delay(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - Ok(cx.number(this.inner.prediction_delay() as f64)) - } - - pub fn update_bearer_token(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let token = cx.argument::(1)?.value(&mut cx); - - this.inner - .update_bearer_token(&token) - .or_else(|e| cx.throw_error(e.to_string()))?; - - Ok(cx.undefined()) - } - - pub fn set_parameter(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let parameter_arg = cx.argument::(1)?; - let parameter = parse_vad_parameter(&mut cx, parameter_arg)?; - let value = cx.argument::(2)?.value(&mut cx) as f32; - - this.inner - .set_parameter(parameter, value) - .or_else(|e| cx.throw_error(e.to_string()))?; - - Ok(cx.undefined()) - } - - pub fn get_parameter(mut cx: FunctionContext) -> JsResult { - let this = cx.argument::>(0)?; - let parameter_arg = cx.argument::(1)?; - let parameter = parse_vad_parameter(&mut cx, parameter_arg)?; - - let value = this - .inner - .parameter(parameter) - .or_else(|e| cx.throw_error(e.to_string()))?; - - Ok(cx.number(value as f64)) - } -} - -pub fn register_exports(cx: &mut neon::prelude::ModuleContext) -> NeonResult<()> { - cx.export_function("vadContextReset", VadContext::reset)?; - cx.export_function("vadContextIsSpeechDetected", VadContext::is_speech_detected)?; - cx.export_function( - "vadContextRawVadProbability", - VadContext::raw_vad_probability, - )?; - cx.export_function("vadContextSetParameter", VadContext::set_parameter)?; - cx.export_function("vadContextGetParameter", VadContext::get_parameter)?; - cx.export_function( - "vadContextGetPredictionDelay", - VadContext::get_prediction_delay, - )?; - cx.export_function( - "vadContextUpdateBearerToken", - VadContext::update_bearer_token, - )?; - - // Export VAD parameter constants - let speech_hold_duration = cx.number(VAD_PARAM_SPEECH_HOLD_DURATION); - cx.export_value("VAD_PARAM_SPEECH_HOLD_DURATION", speech_hold_duration)?; - let sensitivity = cx.number(VAD_PARAM_SENSITIVITY); - cx.export_value("VAD_PARAM_SENSITIVITY", sensitivity)?; - let min_speech_duration = cx.number(VAD_PARAM_MINIMUM_SPEECH_DURATION); - cx.export_value("VAD_PARAM_MINIMUM_SPEECH_DURATION", min_speech_duration)?; - - Ok(()) -} diff --git a/taplo.toml b/taplo.toml deleted file mode 100644 index e01cb40..0000000 --- a/taplo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[formatting] -column_width = 100 -reorder_arrays = true -reorder_keys = true - -[[rule]] -formatting = { reorder_keys = true } -keys = ["package.*"] diff --git a/tests/common.js b/tests/common.js deleted file mode 100644 index a908b5f..0000000 --- a/tests/common.js +++ /dev/null @@ -1,179 +0,0 @@ -const fs = require("fs"); -const path = require("path"); -const WaveFile = require("wavefile").WaveFile; - -const { Model } = require(".."); - -const TEST_AUDIO_PATH = path.join(__dirname, "data", "test_signal.wav"); -const TEST_AUDIO_ENHANCED_PATH = path.join( - __dirname, - "data", - "test_signal_enhanced.wav", -); -const VAD_RESULTS_PATH = path.join(__dirname, "data", "vad_results.json"); - -/** Enhancement model used for the audio enhancement tests. */ -const ENHANCEMENT_MODEL_ID = "quail-vf-2.2-s-16khz"; -/** - * Dedicated VAD model used for the voice activity detection tests. Enhancement models cannot - * be used for voice activity detection. - */ -const VAD_MODEL_ID = "vad-2.1-xxs-16khz"; -/** Analysis model used for the analyzer tests. */ -const ANALYSIS_MODEL_ID = "tyto-1.1-l-16khz"; - -/** - * Finds an existing model file in the target directory that belongs to a model ID. - * @param {string} targetDir - Directory to search in - * @param {string} modelId - Model ID whose file name prefix to match - * @returns {string|null} - Path to found model or null - */ -function findExistingModel(targetDir, modelId) { - if (!fs.existsSync(targetDir)) { - return null; - } - const prefix = modelId.replace(/[-.]/g, "_"); - const entries = fs.readdirSync(targetDir); - for (const entry of entries) { - if (entry.endsWith(".aicmodel") && entry.startsWith(prefix)) { - return path.join(targetDir, entry); - } - } - return null; -} - -/** - * Downloads a model into the package's target directory, reusing an already downloaded file. - * @param {string} modelId - Model ID to resolve - * @returns {string} - Path to the model file - */ -function getModelPath(modelId) { - const targetDir = path.join(__dirname, "..", "target"); - - const existing = findExistingModel(targetDir, modelId); - if (existing) { - return existing; - } - - if (!fs.existsSync(targetDir)) { - fs.mkdirSync(targetDir, { recursive: true }); - } - - return Model.download(modelId, targetDir); -} - -/** - * Gets the path to the enhancement test model, downloading if necessary. - * @returns {string} - Path to the model file - */ -function getTestModelPath() { - return getModelPath(ENHANCEMENT_MODEL_ID); -} - -/** - * Gets the path to the dedicated VAD test model, downloading if necessary. - * @returns {string} - Path to the VAD model file - */ -function getVadModelPath() { - return getModelPath(VAD_MODEL_ID); -} - -/** - * Gets the path to the analysis test model, downloading if necessary. - * @returns {string} - Path to the analysis model file - */ -function getAnalysisModelPath() { - return getModelPath(ANALYSIS_MODEL_ID); -} - -/** - * Gets the license key from environment variable. - * @returns {string} - The license key - * @throws {Error} If AIC_SDK_LICENSE is not set - */ -function licenseKey() { - const key = process.env.AIC_SDK_LICENSE; - if (!key) { - throw new Error("AIC_SDK_LICENSE environment variable not set"); - } - return key; -} - -/** - * Audio data structure returned by loadWavAudio. - * @typedef {Object} TestAudio - * @property {number} sampleRate - Sample rate in Hz - * @property {number} sampleCount - Number of samples - * @property {Float32Array} samples - Mono audio samples - */ - -/** - * Loads a mono WAV file and returns audio data. - * Uses manual normalization to match Rust's hound library exactly (no dithering). - * @param {string} filePath - Path to the WAV file - * @returns {TestAudio} - Audio data structure - */ -function loadWavAudio(filePath) { - const fileBytes = fs.readFileSync(filePath); - const wav = new WaveFile(fileBytes); - - const sampleRate = wav.fmt.sampleRate; - const audioFormat = wav.fmt.audioFormat; - const bitsPerSample = wav.fmt.bitsPerSample; - - let samples; - - // Check if this is a 32-bit float format - const isFloat32 = - audioFormat === 3 || - (audioFormat === 65534 && - wav.fmt.subformat?.[0] === 3 && - bitsPerSample === 32); - - if (isFloat32) { - // View the raw sample bytes directly as float32 values (wavefile misinterprets them). - const sampleBytes = wav.data.samples; - samples = new Float32Array( - sampleBytes.buffer, - sampleBytes.byteOffset, - sampleBytes.length / 4, - ); - } else { - // Integer format: normalize manually (divide by 2^(bits-1), no dithering) - const rawSamples = wav.getSamples(true); - const maxValue = 1 << (bitsPerSample - 1); - samples = new Float32Array(rawSamples.length); - for (let i = 0; i < rawSamples.length; i++) { - samples[i] = rawSamples[i] / maxValue; - } - } - - return { - sampleRate, - sampleCount: samples.length, - samples, - }; -} - -/** - * Checks if two floating point numbers are approximately equal. - * @param {number} a - First number - * @param {number} b - Second number - * @param {number} epsilon - Maximum allowed difference - * @returns {boolean} - True if approximately equal - */ -function approxEqual(a, b, epsilon = 1e-6) { - return Math.abs(a - b) <= epsilon; -} - -module.exports = { - TEST_AUDIO_PATH, - TEST_AUDIO_ENHANCED_PATH, - VAD_RESULTS_PATH, - getTestModelPath, - getVadModelPath, - getAnalysisModelPath, - licenseKey, - loadWavAudio, - approxEqual, -}; diff --git a/tests/data/test_signal.wav b/tests/data/test_signal.wav deleted file mode 100644 index f531154..0000000 Binary files a/tests/data/test_signal.wav and /dev/null differ diff --git a/tests/data/test_signal_enhanced.wav b/tests/data/test_signal_enhanced.wav deleted file mode 100644 index e0d8287..0000000 Binary files a/tests/data/test_signal_enhanced.wav and /dev/null differ diff --git a/tests/data/vad_results.json b/tests/data/vad_results.json deleted file mode 100644 index fb555d4..0000000 --- a/tests/data/vad_results.json +++ /dev/null @@ -1,935 +0,0 @@ -[ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - false, - false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false -] \ No newline at end of file diff --git a/tests/end2end.test.js b/tests/end2end.test.js deleted file mode 100644 index 7932c44..0000000 --- a/tests/end2end.test.js +++ /dev/null @@ -1,474 +0,0 @@ -const fs = require("fs"); -const assert = require("assert"); - -const { - Model, - OtelConfig, - Processor, - ProcessorParameter, - Vad, - VadParameter, - FileAnalyzer, - analyzerPair, -} = require(".."); -const { - TEST_AUDIO_PATH, - TEST_AUDIO_ENHANCED_PATH, - VAD_RESULTS_PATH, - getTestModelPath, - getVadModelPath, - getAnalysisModelPath, - licenseKey, - loadWavAudio, - approxEqual, -} = require("./common"); - -/** - * Tests audio enhancement by processing an entire mono file containing voice in a single pass. - * Uses a non-optimal block size (full file length) to verify the internal block adapter handles - * arbitrary input sizes correctly. Uses a reduced enhancement level (0.9) to exercise non-default - * parameter paths. Compares output against a pre-generated reference file. - */ -function testProcessFullFile() { - console.log("Running: testProcessFullFile"); - - const audio = loadWavAudio(TEST_AUDIO_PATH); - const model = Model.fromFile(getTestModelPath()); - - const processor = new Processor(model, licenseKey()); - processor.initialize(audio.sampleRate, audio.sampleCount, false); - - const procCtx = processor.getContext(); - procCtx.setParameter(ProcessorParameter.EnhancementLevel, 0.9); - - const samples = new Float32Array(audio.samples); - processor.process(samples); - - const expectedOutput = loadWavAudio(TEST_AUDIO_ENHANCED_PATH); - - let mismatchCount = 0; - for (let i = 0; i < samples.length; i++) { - if (!approxEqual(samples[i], expectedOutput.samples[i], 1e-6)) { - mismatchCount++; - if (mismatchCount <= 5) { - console.log( - ` Sample mismatch at index ${i}: got ${samples[i]}, expected ${expectedOutput.samples[i]}`, - ); - } - } - } - - assert.strictEqual( - mismatchCount, - 0, - `${mismatchCount} samples did not match expected output`, - ); - processor.terminateSession(); - console.log(" PASSED"); -} - -/** - * Tests dedicated VAD processing in optimal-size blocks against a deterministic reference. - */ -function testProcessBlocksWithVad() { - console.log("Running: testProcessBlocksWithVad"); - - const audio = loadWavAudio(TEST_AUDIO_PATH); - const model = Model.fromFile(getVadModelPath()); - const blockSize = model.getOptimalBlockSize(audio.sampleRate); - - const vad = new Vad(model, licenseKey()); - vad.initialize(audio.sampleRate, blockSize, false); - const vadContext = vad.getContext(); - - assert( - vadContext.getPredictionDelay() > 0, - "VAD should report a prediction delay", - ); - - const samples = new Float32Array(audio.samples); - const speechDetectedResults = []; - const rawVadProbabilities = []; - - for (let offset = 0; offset + blockSize <= samples.length; offset += blockSize) { - vad.process(samples.subarray(offset, offset + blockSize)); - speechDetectedResults.push(vadContext.isSpeechDetected()); - rawVadProbabilities.push(vadContext.rawVadProbability()); - } - - assert( - rawVadProbabilities.every( - (probability) => probability >= 0.0 && probability <= 1.0, - ), - "Raw VAD probabilities must be in the range 0.0 to 1.0", - ); - - const expectedResults = JSON.parse( - fs.readFileSync(VAD_RESULTS_PATH, "utf8"), - ); - assert.deepStrictEqual( - speechDetectedResults, - expectedResults, - "VAD results do not match expected", - ); - vad.terminateSession(); - console.log(" PASSED"); -} - -/** - * Tests that resetting the VAD immediately clears its published prediction. - */ -function testVadResetClearsPublishedPrediction() { - console.log("Running: testVadResetClearsPublishedPrediction"); - - const audio = loadWavAudio(TEST_AUDIO_PATH); - const model = Model.fromFile(getVadModelPath()); - const blockSize = model.getOptimalBlockSize(audio.sampleRate); - - const vad = new Vad(model, licenseKey()); - vad.initialize(audio.sampleRate, blockSize, false); - const vadContext = vad.getContext(); - - let speechWasDetected = false; - for (let offset = 0; offset + blockSize <= audio.samples.length; offset += blockSize) { - vad.process(audio.samples.subarray(offset, offset + blockSize)); - if (vadContext.isSpeechDetected()) { - speechWasDetected = true; - break; - } - } - - assert(speechWasDetected, "The test signal should contain detectable speech"); - - vadContext.reset(); - assert.strictEqual(vadContext.isSpeechDetected(), false); - assert.strictEqual(vadContext.rawVadProbability(), 0.0); - console.log(" PASSED"); -} - -/** - * Tests that processing before VAD initialization is rejected. - */ -function testVadRejectsProcessingBeforeInitialize() { - console.log("Running: testVadRejectsProcessingBeforeInitialize"); - - const model = Model.fromFile(getVadModelPath()); - const vad = new Vad(model, licenseKey()); - - assert.throws( - () => vad.process(new Float32Array(160)), - /must be initialized/, - ); - console.log(" PASSED"); -} - -/** - * Tests VAD parameter round-tripping and the dedicated-model sensitivity range. - */ -function testVadParameters() { - console.log("Running: testVadParameters"); - - const model = Model.fromFile(getVadModelPath()); - const vad = new Vad(model, licenseKey(), OtelConfig.disabled()); - const vadContext = vad.getContext(); - - vadContext.setParameter(VadParameter.Sensitivity, 0.5); - assert.strictEqual( - vadContext.getParameter(VadParameter.Sensitivity), - 0.5, - ); - assert.throws( - () => vadContext.setParameter(VadParameter.Sensitivity, 7.0), - /outside the acceptable range/, - ); - - // This succeeds for JWT licenses and returns TokenUpdateUnsupported for other license types. - try { - vadContext.updateBearerToken(licenseKey()); - } catch (error) { - assert.match(error.message, /token|JWT/i); - } - console.log(" PASSED"); -} - -/** - * Tests that enhancement models cannot be used to create a dedicated VAD. - */ -function testVadRejectsEnhancementModel() { - console.log("Running: testVadRejectsEnhancementModel"); - - const model = Model.fromFile(getTestModelPath()); - assert.throws( - () => new Vad(model, licenseKey()), - /not supported by the requested API/, - ); - console.log(" PASSED"); -} - -/** - * Asserts that an analysis result has all expected fields in the valid 0.0 to 1.0 range. - */ -function assertValidAnalysisResult(result) { - const fields = [ - "riskScore", - "speakerReverb", - "speakerLoudness", - "interferingSpeech", - "noise", - "codecDegradation", - "packetLoss", - ]; - for (const field of fields) { - assert.strictEqual( - typeof result[field], - "number", - `Field ${field} should be a number`, - ); - assert.ok( - result[field] >= 0.0 && result[field] <= 1.0, - `Field ${field} should be in range 0.0 to 1.0, got ${result[field]}`, - ); - } -} - -/** - * Tests that FileAnalyzer returns a single padded result for audio shorter than the - * five-second analysis window. - */ -function testFileAnalyzerShortAudio() { - console.log("Running: testFileAnalyzerShortAudio"); - - const model = Model.fromFile(getAnalysisModelPath()); - const analyzer = new FileAnalyzer(model, licenseKey()); - - const sampleRate = 16000; - const audio = new Float32Array(sampleRate); // 1 second, shorter than the 5s window - const results = analyzer.analyze(audio, sampleRate); - - assert.strictEqual(results.length, 1, "Short audio should yield one result"); - assertValidAnalysisResult(results[0]); - console.log(" PASSED"); -} - -/** - * Tests that FileAnalyzer produces one result per complete five-second window when stepping - * through a longer signal without overlap. - */ -function testFileAnalyzerWindowing() { - console.log("Running: testFileAnalyzerWindowing"); - - const model = Model.fromFile(getAnalysisModelPath()); - const analyzer = new FileAnalyzer(model, licenseKey()); - - const sampleRate = 16000; - // 12 seconds of low-level audio, stepping by the full 5s window (no overlap). - const audio = new Float32Array(sampleRate * 12); - for (let i = 0; i < audio.length; i++) { - audio[i] = Math.sin(i * 0.05) * 0.1; - } - - const stepSamples = sampleRate * 5; - const results = analyzer.analyze(audio, sampleRate, stepSamples); - - // Windows start at 0 and 5s. (12 - 5) / 5 = 1 followup window -> 2 results total. - assert.strictEqual(results.length, 2, "12s audio with 5s step should yield two results"); - for (const result of results) { - assertValidAnalysisResult(result); - } - console.log(" PASSED"); -} - -/** - * Tests analyzing audio through a Collector/Analyzer pair directly. - */ -function testAnalyzerPairDirect() { - console.log("Running: testAnalyzerPairDirect"); - - const model = Model.fromFile(getAnalysisModelPath()); - const { collector, analyzer } = analyzerPair(model, licenseKey()); - - const sampleRate = 16000; - const blockSize = model.getOptimalBlockSize(sampleRate); - collector.initialize(sampleRate, blockSize, false); - - // Pass five seconds of silence to the collector in optimal-size blocks, then analyze. - const audioBlock = new Float32Array(blockSize); - for ( - let collectedSamples = 0; - collectedSamples < sampleRate * 5; - collectedSamples += blockSize - ) { - collector.buffer(audioBlock); - } - - const result = analyzer.analyzeBuffered(); - assertValidAnalysisResult(result); - - // Reset should succeed and leave the collector initialized for reuse. - analyzer.reset(); - analyzer.terminateSession(); - console.log(" PASSED"); -} - -/** - * Tests that creating an analyzer pair with an enhancement model (not an analysis model) - * surfaces the ModelTypeUnsupported error. - */ -function testAnalyzerRejectsNonAnalysisModel() { - console.log("Running: testAnalyzerRejectsNonAnalysisModel"); - - const model = Model.fromFile(getTestModelPath()); - - assert.throws( - () => analyzerPair(model, licenseKey()), - /not supported by the requested API/, - "Enhancement model should be rejected for analysis", - ); - console.log(" PASSED"); -} - -/** - * Tests that passing an audio block before the collector is initialized is rejected. - */ -function testCollectorRejectsBlockBeforeInitialize() { - console.log("Running: testCollectorRejectsBlockBeforeInitialize"); - - const model = Model.fromFile(getAnalysisModelPath()); - const { collector } = analyzerPair(model, licenseKey()); - - assert.throws( - () => collector.buffer(new Float32Array(4)), - /must be initialized/, - ); - console.log(" PASSED"); -} - -/** - * Tests that the collector rejects audio blocks whose size does not match the initialized config. - */ -function testCollectorValidatesLayout() { - console.log("Running: testCollectorValidatesLayout"); - - const model = Model.fromFile(getAnalysisModelPath()); - const sampleRate = model.getOptimalSampleRate(); - const blockSize = model.getOptimalBlockSize(sampleRate); - - const { collector } = analyzerPair(model, licenseKey()); - collector.initialize(sampleRate, blockSize, false); - - // An audio block whose length differs from the initialized block size is rejected. - assert.throws( - () => collector.buffer(new Float32Array(blockSize - 1)), - /differs from the one provided/, - ); - console.log(" PASSED"); -} - -/** - * Tests that a license key containing a NUL byte is rejected when creating an analyzer pair. - */ -function testAnalyzerPairRejectsLicenseWithNul() { - console.log("Running: testAnalyzerPairRejectsLicenseWithNul"); - - const model = Model.fromFile(getAnalysisModelPath()); - - assert.throws( - () => analyzerPair(model, "invalid\0license"), - /format is invalid/, - ); - console.log(" PASSED"); -} - -/** - * Tests that variable block sizes are accepted when enabled and rejected when disabled. - */ -function testCollectorVariableBlockSize() { - console.log("Running: testCollectorVariableBlockSize"); - - const model = Model.fromFile(getAnalysisModelPath()); - const sampleRate = model.getOptimalSampleRate(); - const blockSize = model.getOptimalBlockSize(sampleRate); - const fullBlock = new Float32Array(blockSize); - const shortBlock = new Float32Array(20); - - // Disabled: a short audio block after a full one is rejected. - const disabled = analyzerPair(model, licenseKey()); - disabled.collector.initialize(sampleRate, blockSize, false); - disabled.collector.buffer(fullBlock); - assert.throws( - () => disabled.collector.buffer(shortBlock), - /differs from the one provided/, - ); - - // Enabled: a short audio block is accepted. - const enabled = analyzerPair(model, licenseKey()); - enabled.collector.initialize(sampleRate, blockSize, true); - enabled.collector.buffer(fullBlock); - enabled.collector.buffer(shortBlock); // should not throw - console.log(" PASSED"); -} - -/** - * Tests that resetting the analyzer leaves the collector initialized for continued collection. - */ -function testAnalyzerResetKeepsCollectorInitialized() { - console.log("Running: testAnalyzerResetKeepsCollectorInitialized"); - - const model = Model.fromFile(getAnalysisModelPath()); - const sampleRate = model.getOptimalSampleRate(); - const blockSize = model.getOptimalBlockSize(sampleRate); - - const { collector, analyzer } = analyzerPair(model, licenseKey()); - collector.initialize(sampleRate, blockSize, false); - - analyzer.reset(); - - // Collecting another audio block works after reset because the collector stays initialized. - collector.buffer(new Float32Array(blockSize)); - assertValidAnalysisResult(analyzer.analyzeBuffered()); - console.log(" PASSED"); -} - -// Run all tests -function runAllTests() { - console.log("Running end-to-end tests...\n"); - - const tests = [ - testProcessFullFile, - testProcessBlocksWithVad, - testVadResetClearsPublishedPrediction, - testVadRejectsProcessingBeforeInitialize, - testVadParameters, - testVadRejectsEnhancementModel, - testFileAnalyzerShortAudio, - testFileAnalyzerWindowing, - testAnalyzerPairDirect, - testAnalyzerRejectsNonAnalysisModel, - testCollectorRejectsBlockBeforeInitialize, - testCollectorValidatesLayout, - testAnalyzerPairRejectsLicenseWithNul, - testCollectorVariableBlockSize, - testAnalyzerResetKeepsCollectorInitialized, - ]; - - let passed = 0; - let failed = 0; - - for (const test of tests) { - try { - test(); - passed++; - } catch (error) { - console.log(` FAILED: ${error.message}`); - failed++; - } - } - - console.log(`\nResults: ${passed} passed, ${failed} failed`); - - if (failed > 0) { - process.exit(1); - } -} - -runAllTests(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6462ffa --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "strict": true, + // `node10` resolution was removed in TypeScript 7. `nodenext` honours the package's + // "type": "commonjs", so the emitted module kind is unchanged. + "moduleResolution": "nodenext", + "module": "nodenext", + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + // Named explicitly: the tests and benchmark use node builtins, and these are not picked + // up automatically under this layout. + "types": ["node"] + }, + "include": ["."], + "exclude": ["node_modules", "benchmark", "__test__"] +}