diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3e3f6c49..8c6c199f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -352,6 +352,124 @@ jobs: # a deploy key secret. Until then, the asset + checksum above are produced # but not wired into a resolvable SwiftPM tag. + # ---- npm (TypeScript) -------------------------------------------------------- + # Mirrors the Python/Rust path: a dispatch REHEARSAL that builds + installs the + # shipped tarballs and transcribes the canary (the §4 shipped-artifact gate), + # and a tag-gated RELEASE that turns the native bundles into per-platform npm + # packages and publishes them with the pure-TS API package. + + ts-rehearsal: + # Pack the API + a platform package, install from the tarballs into a clean + # dir, and transcribe the canary — exactly the clean-machine path, no + # TRANSCRIBE_LIBRARY (the install stands on the platform package alone). + if: github.event_name == 'workflow_dispatch' + runs-on: [self-hosted, macOS, ARM64] + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: astral-sh/setup-uv@v8.2.0 + - run: command -v ninja >/dev/null || brew install ninja + - name: Build + install a shared libtranscribe (the native-bundle twin) + run: | + cmake -B build-shared -G Ninja -DTRANSCRIBE_BUILD_SHARED=ON \ + -DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON + cmake --build build-shared --target transcribe + cmake --install build-shared --prefix "$PWD/ts-native" + - name: Pack API + platform tarballs + working-directory: bindings/typescript + run: | + npm install --no-audit --no-fund + npm run build + ver="$(node -p "require('./package.json').version")" + node scripts/pack-platform.mjs --lib-dir "$GITHUB_WORKSPACE/ts-native/lib" \ + --tuple darwin-arm64-metal --version "$ver" --out /tmp/ts-platform + rm -rf /tmp/ts-tarballs && mkdir -p /tmp/ts-tarballs + npm pack --pack-destination /tmp/ts-tarballs + (cd /tmp/ts-platform/darwin-arm64-metal && npm pack --pack-destination /tmp/ts-tarballs) + - uses: ./.github/actions/fetch-canary + with: + hf-token: ${{ secrets.HF_TOKEN }} + - name: Install from tarballs (clean dir) and transcribe the canary + run: | + rm -rf /tmp/ts-consumer && mkdir -p /tmp/ts-consumer + (cd /tmp/ts-consumer && npm init -y >/dev/null && \ + npm install --no-audit --no-fund /tmp/ts-tarballs/*.tgz) + TS_PKG_ENTRY=/tmp/ts-consumer/node_modules/transcribe-cpp/dist/index.js \ + TRANSCRIBE_SMOKE_AUDIO="$GITHUB_WORKSPACE/samples/jfk.wav" \ + node scripts/ci/ts_packed_smoke.mjs + + ts-release: + # Tags only. Build each @transcribe-cpp/ package from its native + # bundle (the exact bytes the wheels shipped) and publish them + the pure-TS + # API package to npm. The `npm` environment carries the approval gate. + if: startsWith(github.ref, 'refs/tags/v') + needs: [create-release, wheels] + runs-on: blacksmith-2vcpu-ubuntu-2404 + environment: npm + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: "https://registry.npmjs.org" + - uses: actions/download-artifact@v8 + with: + pattern: native-* + merge-multiple: true + path: native-bundles + - name: Build platform packages from the native bundles + working-directory: bindings/typescript + run: | + set -e + npm install --no-audit --no-fund + npm run build + ver="$(node -p "require('./package.json').version")" + # Sync the API package's @transcribe-cpp/* platform pins to the version + # we publish, so the tarball pins the exact bytes we ship (the static + # gate in check_version_sync.py enforces the base match; this nails the + # exact pin). Without this the pins go stale on the first version bump + # and the install resolves a mismatched native -> load-time AbiError. + node -e ' + const fs = require("node:fs"); + const v = process.argv[1]; + const p = JSON.parse(fs.readFileSync("package.json", "utf8")); + for (const k of Object.keys(p.optionalDependencies || {})) p.optionalDependencies[k] = v; + fs.writeFileSync("package.json", JSON.stringify(p, null, 2) + "\n"); + ' "$ver" + out=/tmp/ts-platform + # build-tuple (bundle) -> npm tuple (Node platform-arch convention) + map() { case "$1" in + macos-arm64-metal) echo darwin-arm64-metal ;; + macos-x86_64-cpu) echo darwin-x64-cpu ;; + linux-x86_64-cpu-vulkan) echo linux-x64-cpu-vulkan ;; + linux-aarch64-cpu-vulkan) echo linux-arm64-cpu-vulkan ;; + windows-x86_64-cpu-vulkan) echo win32-x64-cpu-vulkan ;; + *) echo "" ;; esac; } + for f in "$GITHUB_WORKSPACE"/native-bundles/transcribe-native-*.tar.gz; do + base="$(basename "$f" .tar.gz)"; build_tuple="${base#transcribe-native-}" + npm_tuple="$(map "$build_tuple")" + [ -n "$npm_tuple" ] || { echo "::warning::no npm tuple for $build_tuple"; continue; } + ex="/tmp/extract/$build_tuple"; rm -rf "$ex"; mkdir -p "$ex"; tar xzf "$f" -C "$ex" + libdir="$(dirname "$(find "$ex" \( -name 'libtranscribe.*' -o -name 'transcribe.dll' \) | head -1)")" + node scripts/pack-platform.mjs --lib-dir "$libdir" --tuple "$npm_tuple" --version "$ver" --out "$out" + done + echo "TS_VER=$ver" >> "$GITHUB_ENV" + - name: Publish platform packages, then the API package + working-directory: bindings/typescript + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -e + for d in /tmp/ts-platform/*/; do + echo "publishing $(node -p "require('${d}package.json').name")" + npm publish "$d" --access public + done + npm publish --access public # the API package (transcribe-cpp) + finalize-release: # Tags only. Publish the draft GitHub Release only after its hosted assets # and the core PyPI publish have completed. crates.io has its own approval diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 0134046f..6a88a462 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -25,6 +25,15 @@ name: rust-ci # `return` early (a clean skip) otherwise. One `cargo test` invocation runs # both tiers; the model tier self-skips when the canary env is absent. # +# Posture cost split: the model tier (canary fetch + the model-gated tests + the +# examples) runs ONLY on the representative *-static leg of each OS. The *-shared +# and *-dynamic-backends legs are LINK-posture proofs, not marshalling proofs +# (the marshalling code is byte-identical across postures), so they run build + +# the -sys FFI smoke + the always-on no-model tier (which still covers the +# dynamic-backends module load) and skip the canary fetch — dropping the +# duplicated model-tier cost (chiefly the moonshine streaming conformance) from +# the legs that can't catch a marshalling regression anyway. +# # Windows joins here: the first cargo-driven MSVC build of this tree (static # posture). cl.exe comes from msvc-dev-cmd, zlib from vcpkg as a static lib # against the dynamic CRT (`x64-windows-static-md`, matching Rust's default /MD @@ -105,9 +114,11 @@ jobs: - label: linux-static runner: blacksmith-2vcpu-ubuntu-2404 features: "" + model_tier: true # representative leg for linux: full model tier - label: linux-shared runner: blacksmith-2vcpu-ubuntu-2404 features: "--features shared" + model_tier: false # link-posture proof only (build + -sys smoke + no-model) # Loadable per-ISA backend modules (the multi-ISA CPU provider posture) # exercised through Rust: build.rs turns on BACKEND_DL + CPU_ALL_VARIANTS # on x86, the modules install into the -sys prefix, and the safe crate's @@ -116,15 +127,18 @@ jobs: - label: linux-dynamic-backends runner: blacksmith-2vcpu-ubuntu-2404 features: "--features dynamic-backends" + model_tier: false # no-model tier still covers the DL module load # macOS arm64 on the bare-metal M4 mini (all macOS arm64 CI on owned # hardware; the source build turns Metal on, and real hardware is the # trustworthy place to exercise it). Same posture as native-ci. - label: macos-arm64-static runner: [self-hosted, macOS, ARM64] features: "" + model_tier: true # representative leg for macOS: full model tier - label: macos-arm64-shared runner: [self-hosted, macOS, ARM64] features: "--features shared" + model_tier: false # link-posture proof only # Windows / MSVC (Blacksmith windows-2025, same image family as the # wheel lane). First cargo-driven MSVC build of this tree; no ccache # here (MSVC + ccache is fiddly), so every run is a cold ggml build — @@ -134,9 +148,11 @@ jobs: - label: windows-static runner: blacksmith-2vcpu-windows-2025 features: "" + model_tier: true # representative leg for windows: full model tier - label: windows-shared runner: blacksmith-2vcpu-windows-2025 features: "--features shared" + model_tier: false # link-posture proof only runs-on: ${{ matrix.runner }} timeout-minutes: 60 env: @@ -215,7 +231,12 @@ jobs: # (forks have none): without it the model tests skip cleanly, leaving the # always-on no-model tier. jfk.wav ships in-repo, so only the two model # paths need exporting (fetch-canary handles that). - - uses: ./.github/actions/fetch-canary + # Model tier runs only on the representative *-static leg per OS. The + # shared / dynamic-backends legs skip the canary fetch, so the model-gated + # tests in the conformance step below self-skip and only the no-model tier + # runs there (build + link posture + FFI surface proof). + - if: matrix.model_tier + uses: ./.github/actions/fetch-canary with: hf-token: ${{ secrets.HF_TOKEN }} - name: Safe-crate conformance (no-model tier always; model tier when canary present) @@ -225,6 +246,7 @@ jobs: # the TRANSCRIBE_SMOKE_* env fetch-canary exports (or skips cleanly + exits # 0). shell:bash so the loop is identical on the windows runner (Git Bash). - name: Examples (CI-executed; §6 Rosetta set) + if: matrix.model_tier # examples need the canary models; only the model-tier legs have them shell: bash run: | for ex in transcribe-file streaming batch backend-select error-handling; do diff --git a/.github/workflows/typescript-ci.yml b/.github/workflows/typescript-ci.yml new file mode 100644 index 00000000..a04c3caa --- /dev/null +++ b/.github/workflows/typescript-ci.yml @@ -0,0 +1,206 @@ +name: typescript-ci + +# Every-PR gates for the TypeScript/Node binding (npm package transcribe-cpp). +# Thin per-binding workflow on the shared rails: the binding-agnostic C +# contracts are certified in native-ci.yml; this file adds only what the koffi +# FFI layer introduces. +# +# - ts-gates: the generated-FFI drift gate (generate.py --check, which covers +# _generated.py + transcribe.abihash + the TS _generated.ts), +# version-sync, and the TypeScript typecheck (tsc). No native +# build — fast, runs everywhere including forks. +# - ts-build: build a shared libtranscribe, then run the node:test suite and +# the 5 canonical examples against it on linux, macos, and windows. +# +# Two test tiers (requirements §4): the no-model tests (version/ABI/error +# mapping/device discovery) always run, including on forks. The model-gated +# tests (transcription, streaming, cancel, family extensions) un-skip only when +# the canary GGUFs are fetched — same fetch-canary action + HF_TOKEN gate the +# other bindings use — and skip cleanly otherwise. +# +# Scope: Node only for v1 (koffi is N-API-based; Bun/Deno are a documented +# follow-up). Path filters follow native-ci.yml's shape: the binding's own tree +# plus the native paths it loads from. Branch protection is OFF (project +# decision, 2026-06-13). + +on: + push: + branches: [main] + paths: &paths + - "bindings/typescript/**" + - "src/**" + - "include/**" + - "ggml/**" + - "cmake/**" + - "CMakeLists.txt" + - "CMakePresets.json" + - "bindings/python/_generate/generate.py" + - "bindings/python/_generate/check_version_sync.py" + - ".github/workflows/typescript-ci.yml" + - ".github/actions/**" + pull_request: + paths: *paths + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ts-gates: + # Cheap, native-build-free gates. generate.py --check needs libclang; users + # never do, because the FFI surface (_generated.ts) is committed. + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 # for the python gate scripts + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Install libclang (FFI generator) + run: sudo apt-get update && sudo apt-get install -y libclang-dev clang + - name: FFI drift gate (_generated.py + transcribe.abihash + _generated.ts) + run: uv run --no-project --with 'libclang==18.1.1' bindings/python/_generate/generate.py --check + - name: Version sync (header <-> every active manifest) + run: uv run --no-project python bindings/python/_generate/check_version_sync.py + - name: Install + typecheck + working-directory: bindings/typescript + run: | + npm install --no-audit --no-fund + npm run build + + ts-build: + name: ts-build (${{ matrix.label }}) + strategy: + fail-fast: false + matrix: + include: + - label: linux-x64 + runner: blacksmith-2vcpu-ubuntu-2404 + lib: libtranscribe.so + lib_subdir: src # .so is a LIBRARY artifact (build-shared/src) + cmake_flags: "" + # macOS arm64 on owned hardware; Metal on, embedded library (matches + # native-ci / the shipped macOS wheel posture). + - label: macos-arm64 + runner: [self-hosted, macOS, ARM64] + lib: libtranscribe.dylib + lib_subdir: src + cmake_flags: "-DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON" + # Windows / MSVC (Blacksmith windows-2025, the wheel + rust-ci image). + # The first koffi load of this tree on Windows: it proves transcribe.dll + # and its co-located ggml DLLs resolve. koffi's first LoadLibraryW fails + # to find the sibling DLLs (Windows does not search a DLL's own dir for + # its deps), then retries with LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR, which + # does — so the package-local layout the loader/pack-platform ship is + # exactly what this exercises. transcribe.dll is a RUNTIME artifact + # (build-shared/bin, not src). MSVC + Ninja + vcpkg zlib mirror rust-ci's + # Windows leg; no ccache there, so it's a cold ggml build every run. + - label: windows-x64 + runner: blacksmith-2vcpu-windows-2025 + lib: transcribe.dll + lib_subdir: bin # DLL is a RUNTIME artifact (build-shared/bin) + cmake_flags: "" + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 # headroom for the cold MSVC ggml build on Windows + env: + # Gates the model-test tier: present on this repo's branches, absent on + # forks (where fetch-canary skips and the model tests skip cleanly). + HF_TOKEN: ${{ secrets.HF_TOKEN }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: denoland/setup-deno@v2 + if: runner.os != 'Windows' # the Deno smoke below is non-Windows only + with: + deno-version: v2.x + - uses: astral-sh/setup-uv@v8.2.0 # fetch-canary fetches via uvx + - name: Install build deps (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y cmake ninja-build ccache + # Windows toolchain mirrors rust-ci's Windows leg: cl.exe ambient for the + # Ninja generator (else CMake silently falls back to the image's MinGW gcc), + # and static zlib against the dynamic CRT resolved via CMAKE_PREFIX_PATH. + - name: Set up MSVC (cl.exe on PATH for the Ninja generator) + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + - name: Install build deps (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + choco install ninja --no-progress -y + vcpkg install zlib:x64-windows-static-md + $zlibPrefix = "$env:VCPKG_INSTALLATION_ROOT/installed/x64-windows-static-md" -replace '\\','/' + Add-Content $env:GITHUB_ENV "CMAKE_PREFIX_PATH=$zlibPrefix" + - name: Enable ccache launcher + if: runner.os == 'Linux' + run: | + echo "CMAKE_C_COMPILER_LAUNCHER=ccache" >> "$GITHUB_ENV" + echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache" >> "$GITHUB_ENV" + - name: CPU ISA signature (ccache key) + if: runner.os == 'Linux' + run: echo "CPU_SIG=$(grep -m1 '^flags' /proc/cpuinfo | sha256sum | cut -c1-8)" >> "$GITHUB_ENV" + - name: ccache (native source build) + if: runner.os == 'Linux' + uses: actions/cache@v5 + with: + path: ~/.cache/ccache + key: ccache-ts-build-${{ matrix.label }}-${{ env.CPU_SIG }}-${{ github.sha }} + restore-keys: ccache-ts-build-${{ matrix.label }}-${{ env.CPU_SIG }}- + - name: Build shared libtranscribe + # bash on every OS (Git Bash on the Windows runner) so the body and the + # GITHUB_ENV append are identical. $GITHUB_WORKSPACE, not $PWD: on Windows + # bash, $PWD is an MSYS /d/... path koffi/node can't open; the workspace + # var is a native path. lib_subdir selects bin (Windows RUNTIME) vs src. + shell: bash + run: | + cmake -B build-shared -G Ninja -DTRANSCRIBE_BUILD_SHARED=ON ${{ matrix.cmake_flags }} + cmake --build build-shared --target transcribe + echo "TRANSCRIBE_LIBRARY=$GITHUB_WORKSPACE/build-shared/${{ matrix.lib_subdir }}/${{ matrix.lib }}" >> "$GITHUB_ENV" + - name: Install + build the binding + working-directory: bindings/typescript + run: | + npm install --no-audit --no-fund + npm run build + # The canary GGUFs upgrade the suite from no-model gates to real + # transcription/streaming/cancel/extension coverage. jfk.wav ships in-repo; + # only the model paths need exporting (fetch-canary handles that). + # + # Windows is no-model-only for now: that is the koffi DLL-resolution proof + # this leg was added for, and it is unaffected. The model tier is deferred + # because loading a model spins up the ggml CPU threadpool, and on MSVC with + # OpenMP (vcomp) the runtime crashes (0xC0000005) at process exit under + # node/koffi, after the run itself succeeds. Skipping the canary makes the + # model-gated tests and the (model-loading) examples self-skip cleanly. Drop + # this guard to re-enable the Windows model tier once that teardown crash in + # the MSVC OpenMP runtime is fixed. + - uses: ./.github/actions/fetch-canary + if: runner.os != 'Windows' + with: + hf-token: ${{ secrets.HF_TOKEN }} + - name: Conformance (no-model tier always; model tier when canary present) + working-directory: bindings/typescript + run: npm test + - name: Examples (CI-executed; §6 Rosetta set) + working-directory: bindings/typescript + shell: bash # the loop is bash syntax; Git Bash makes it identical on Windows + run: | + for ex in transcribe-file streaming batch backend-select error-handling; do + echo "=== example: $ex ===" + node "examples/$ex.mjs" + done + # Deno runs the SAME unmodified package via N-API (no adapter). Proves the + # binding is engine-portable; self-skips when the canary is absent. Non- + # Windows: the Windows leg's job is the Node + koffi DLL-resolution proof, + # and Deno-on-Windows N-API quirks shouldn't gate that signal. + - name: Deno smoke (run the package as-is under deno) + if: runner.os != 'Windows' + working-directory: bindings/typescript + run: deno run --allow-ffi --allow-read --allow-env --allow-sys examples/transcribe-file.mjs + - name: ccache stats + if: runner.os == 'Linux' + run: ccache -s | head -8 diff --git a/bindings/python/_generate/check_version_sync.py b/bindings/python/_generate/check_version_sync.py index d33d359c..519d0d94 100644 --- a/bindings/python/_generate/check_version_sync.py +++ b/bindings/python/_generate/check_version_sync.py @@ -30,6 +30,7 @@ HEADER = REPO / "include" / "transcribe.h" PYPROJECT = REPO / "bindings" / "python" / "pyproject.toml" INIT = REPO / "bindings" / "python" / "src" / "transcribe_cpp" / "__init__.py" +TS_PACKAGE_JSON = REPO / "bindings" / "typescript" / "package.json" # Binding package manifests (requirements doc §2: every manifest is derived # from or gated against the header). Gated by the `active` flag: a 0.0.0 @@ -46,7 +47,7 @@ # bindings/rust/transcribe-cpp/. ("Cargo.toml", "cargo", True), ("bindings/rust/transcribe-cpp/Cargo.toml", "cargo", True), - ("bindings/typescript/package.json", "npm", False), + ("bindings/typescript/package.json", "npm", True), ] @@ -107,6 +108,20 @@ def native_pin_versions(text: str) -> "dict[str, str | None]": return {f"pyproject.toml ({name} pin)": version for name, version in pins} +def npm_optional_pins(text: str) -> "dict[str, str | None]": + # The npm analog of native_pin_versions: the API package + # (bindings/typescript/package.json) pins each @transcribe-cpp/ + # provider in optionalDependencies at an exact version. Pre-1.0 they must + # share the base version with everything else, exactly as the Python native + # pins do. The release job (ts-release) re-syncs them to the published + # version; this is the static counterpart so a forgotten bump fails CI. + block = re.search(r'"optionalDependencies"\s*:\s*\{([^}]*)\}', text, re.S) + pins = re.findall(r'"(@transcribe-cpp/[^"]+)"\s*:\s*"([^"]+)"', block.group(1)) if block else [] + if not pins: + return {"package.json (optionalDependencies)": None} + return {f"package.json ({name} pin)": version for name, version in pins} + + def main() -> int: pyproject_text = PYPROJECT.read_text() sources = { @@ -115,6 +130,8 @@ def main() -> int: "__init__.__version__": init_version(INIT.read_text()), } sources.update(native_pin_versions(pyproject_text)) + if TS_PACKAGE_JSON.exists(): + sources.update(npm_optional_pins(TS_PACKAGE_JSON.read_text())) # Binding manifests: active ones join the equality set; inactive ones # must merely exist and parse (placeholder versions are reported, not diff --git a/bindings/python/_generate/generate.py b/bindings/python/_generate/generate.py index 93644448..a262110d 100644 --- a/bindings/python/_generate/generate.py +++ b/bindings/python/_generate/generate.py @@ -32,6 +32,12 @@ INCLUDE = REPO / "include" HEADER = INCLUDE / "transcribe" / "extensions.h" OUTPUT = REPO / "bindings" / "python" / "src" / "transcribe_cpp" / "_generated.py" +# The TypeScript (koffi) FFI surface. Same parsed IR, emitted for the Node +# binding: constants, koffi struct types, the captured layout + ABI ids for the +# load-time check, and the function signatures (for drift visibility). The +# committed file embeds the SAME PUBLIC_HEADER_HASH the Python oracle computes, +# so a header ABI change turns the TS --check red exactly like Python's. +OUTPUT_TS = REPO / "bindings" / "typescript" / "src" / "_generated.ts" # The binding-neutral home of the ABI digest (PUBLIC_HEADER_HASH). This # generator is the oracle that computes it, but the checked-in file lives with # the headers it digests so every consumer — CMake's provider-contract stamp, @@ -62,6 +68,16 @@ TypeKind.FLOAT: "c_float", TypeKind.DOUBLE: "c_double", } +# ctypes scalar name -> koffi builtin type name (for the TS emitter). +_KOFFI_SCALAR = { + "c_bool": "bool", "c_char": "char", "c_ubyte": "uchar", + "c_short": "short", "c_ushort": "ushort", + "c_int": "int", "c_uint": "uint", + "c_long": "long", "c_ulong": "ulong", + "c_longlong": "longlong", "c_ulonglong": "ulonglong", + "c_float": "float", "c_double": "double", +} + def clang_args() -> list[str]: args = ["-x", "c", "-std=c11", f"-I{INCLUDE}"] @@ -326,6 +342,126 @@ def render(s: Surface, libclang_version: str) -> tuple[str, str]: return "\n".join(out), digest +def koffi_type(t, structs: dict) -> str: + """Return a TS expression for a koffi field type. + + Scalars/char*/void* become quoted koffi type-name strings; a by-value + nested struct becomes a reference into the locally built `T` map; a + constant array becomes a koffi.array(...) call. Every pointer that is not + char* collapses to 'void *' (struct pointers, function pointers, double + pointers) — the hand-written engine adapter gives those their precise + in/out/proto treatment at call sites. + """ + spelling = t.spelling.replace("const ", "").replace("volatile ", "").strip() + k = t.kind + if k == TypeKind.POINTER: + cp = t.get_pointee().get_canonical() + if cp.kind in (TypeKind.CHAR_S, TypeKind.CHAR_U, TypeKind.SCHAR, TypeKind.UCHAR): + return "'char *'" + return "'void *'" + if spelling in _SPELLING_INT: + return f"'{spelling}'" # koffi understands the stdint names verbatim + if k == TypeKind.ENUM: + return "'int'" + if k in (TypeKind.TYPEDEF, TypeKind.ELABORATED): + return koffi_type(t.get_canonical(), structs) + if k == TypeKind.RECORD: + name = t.get_declaration().spelling + return f"T[{name!r}]" if name in structs else "'void *'" + if k == TypeKind.CONSTANTARRAY: + return f"koffi.array({koffi_type(t.element_type, structs)}, {t.element_count})" + if k in _KIND_SCALAR: + v = _KIND_SCALAR[k] + return f"'{_KOFFI_SCALAR.get(v, v)}'" if v else "'void *'" + raise SystemExit(f"unmapped koffi type: {t.spelling!r} kind={k}") + + +def render_ts(s: Surface, digest: str, libclang_version: str) -> str: + struct_names = set(s.structs) + out: list[str] = [] + w = out.append + w("// Low-level koffi FFI surface for transcribe.cpp — AUTOGENERATED. DO NOT EDIT.") + w("//") + w("// Regenerate with:") + w("// uv run --no-project --with 'libclang==18.1.1' \\") + w("// bindings/python/_generate/generate.py") + w("//") + w("// Source: include/transcribe/extensions.h") + w(f"// libclang: {libclang_version}") + w("/* eslint-disable */") + w("") + w("// Stable digest of the ABI surface (structs, enums, macros, layout,") + w("// prototypes), computed by the Python oracle and pinned here so a header") + w("// ABI change turns this binding's drift check red for conscious review.") + w(f'export const PUBLIC_HEADER_HASH = "{digest}";') + w("") + + w("// === enum constants ===") + for name, value in s.enum_constants: + w(f"export const {name} = {value};") + w("") + w("// === macro constants (integer object-like macros) ===") + for name, value in s.macros: + w(f"export const {name} = {value};") + w("") + + # Captured C-compiler layout + native ABI ids for the load-time check. + layout = {} + for name in order_by_value_deps(s.structs, s.struct_order): + cursor = s.structs[name] + offsets = {f.spelling: cursor.type.get_offset(f.spelling) // 8 + for f in struct_fields(cursor)} + layout[name] = { + "size": cursor.type.get_size(), + "align": cursor.type.get_align(), + "offsets": offsets, + } + w("export interface StructLayout { size: number; align: number; " + "offsets: Record; }") + w("export const STRUCT_LAYOUT: Record = {") + for name in s.struct_order: + lo = layout[name] + w(f" {name!r}: {{ size: {lo['size']}, align: {lo['align']}, " + f"offsets: {lo['offsets']!r} }},") + w("};") + w("") + w("export const ABI_STRUCT_IDS: Record = {") + for name in s.struct_order: + if name in s.abi_ids: + w(f" {name!r}: {s.abi_ids[name]},") + w("};") + w("") + + # Build the koffi struct types in by-value dependency order so a nested + # member's type object exists before it is referenced. + w("// Build koffi struct types; returns a name -> koffi.IKoffiCType map.") + w("export function defineTypes(koffi: any): Record {") + w(" const T: Record = {};") + for name in order_by_value_deps(s.structs, s.struct_order): + cursor = s.structs[name] + fields = ", ".join( + f"{f.spelling}: {koffi_type(f.type, struct_names)}" + for f in struct_fields(cursor)) + w(f" T[{name!r}] = koffi.struct({{ {fields} }});") + w(" return T;") + w("}") + w("") + + # Function signatures as data (not used at call time — the engine adapter + # binds functions by hand — but committed so a signature change shows as a + # concrete diff here, not only as a hash bump.) + w("export interface FnSig { ret: string; args: string[]; }") + w("export const FUNCTION_SIGNATURES: Record = {") + for name in sorted(s.functions): + fn = s.functions[name] + ret = fn.result_type.spelling + args = [a.type.spelling for a in fn.get_arguments()] + w(f" {name!r}: {{ ret: {ret!r}, args: {args!r} }},") + w("};") + w("") + return "\n".join(out) + + def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--check", action="store_true", @@ -349,7 +485,9 @@ def main() -> int: print(f"clang error: {d.spelling} at {d.location}", file=sys.stderr) return 2 - text, digest = render(collect(tu), libclang_version) + surface = collect(tu) + text, digest = render(surface, libclang_version) + ts_text = render_ts(surface, digest, libclang_version) hash_text = digest + "\n" if args.check: @@ -360,18 +498,24 @@ def main() -> int: current_hash = ABIHASH.read_text() if ABIHASH.exists() else "" if current_hash != hash_text: stale.append(str(ABIHASH)) + current_ts = OUTPUT_TS.read_text() if OUTPUT_TS.exists() else "" + if current_ts != ts_text: + stale.append(str(OUTPUT_TS)) if stale: for path in stale: print(f"{path} is out of date — regenerate with " "_generate/generate.py", file=sys.stderr) return 1 - print(f"{OUTPUT.name} and {ABIHASH.name} are up to date") + print(f"{OUTPUT.name}, {ABIHASH.name} and {OUTPUT_TS.name} are up to date") return 0 OUTPUT.write_text(text) ABIHASH.write_text(hash_text) + OUTPUT_TS.parent.mkdir(parents=True, exist_ok=True) + OUTPUT_TS.write_text(ts_text) print(f"wrote {OUTPUT}") print(f"wrote {ABIHASH} ({digest})") + print(f"wrote {OUTPUT_TS}") return 0 diff --git a/bindings/python/src/transcribe_cpp/__init__.py b/bindings/python/src/transcribe_cpp/__init__.py index b8c5691b..208fbdb5 100644 --- a/bindings/python/src/transcribe_cpp/__init__.py +++ b/bindings/python/src/transcribe_cpp/__init__.py @@ -1229,6 +1229,18 @@ def state(self) -> str: def revision(self) -> int: return _lib.transcribe_stream_revision(self._h) + @property + def last_status(self) -> TranscribeError | None: + """The stream's recorded terminal failure, or None while it is healthy. + + Set after a ``feed()`` / ``finalize()`` transitioned the stream to + ``"failed"``; reset by ``begin`` / ``reset``. Inspect it when + :attr:`state` is ``"failed"`` to learn why.""" + status = _lib.transcribe_stream_last_status(self._h) + if status == _generated.TRANSCRIBE_OK: + return None + return exception_for_status(status, _status_string(status), "stream") + def reset(self) -> None: """Return the session to idle, discarding stream state. Idempotent.""" if self._active: diff --git a/bindings/python/tests/test_streaming.py b/bindings/python/tests/test_streaming.py index 6d0b09b1..e6081844 100644 --- a/bindings/python/tests/test_streaming.py +++ b/bindings/python/tests/test_streaming.py @@ -30,9 +30,11 @@ def test_streaming_real(streaming_model_path, audio_pcm): update = stream.finalize() committed = stream.text().committed revision, state = stream.revision, stream.state + last_status = stream.last_status assert update.is_final, update assert state == "finished", state assert revision >= 1 + assert last_status is None, last_status assert "country" in committed.lower(), committed diff --git a/bindings/rust/transcribe-cpp/src/session.rs b/bindings/rust/transcribe-cpp/src/session.rs index 617b9190..a1647458 100644 --- a/bindings/rust/transcribe-cpp/src/session.rs +++ b/bindings/rust/transcribe-cpp/src/session.rs @@ -581,6 +581,20 @@ impl Stream<'_> { unsafe { sys::transcribe_stream_revision(self.session.ptr) } } + /// The stream's recorded terminal failure, or `None` while it is healthy. + /// + /// Set after a [`feed`](Self::feed) / [`finalize`](Self::finalize) + /// transitioned the stream to [`StreamState::Failed`]; reset by a new + /// stream. Inspect it when [`state`](Self::state) is `Failed` to learn why. + pub fn last_status(&self) -> Option { + let status = unsafe { sys::transcribe_stream_last_status(self.session.ptr) }; + if status == sys::transcribe_status::TRANSCRIBE_OK { + None + } else { + Some(error_for_status(status, "stream")) + } + } + /// Whether the stream was ended by cancellation. pub fn was_aborted(&self) -> bool { self.session.was_aborted() diff --git a/bindings/rust/transcribe-cpp/tests/streaming.rs b/bindings/rust/transcribe-cpp/tests/streaming.rs index 91f965ab..bbf16d64 100644 --- a/bindings/rust/transcribe-cpp/tests/streaming.rs +++ b/bindings/rust/transcribe-cpp/tests/streaming.rs @@ -6,6 +6,18 @@ mod common; use transcribe_cpp::{Model, RunOptions, StreamExtension, StreamOptions, StreamState}; use transcribe_cpp::{MoonshineStreamingOptions, Stream}; +/// Partial-decode cadence for the mechanics tests that only assert the +/// post-finalize text. Aligned to the model, not an arbitrary number: the +/// streaming encoder emits one frame per 20 ms (frame_len 80 × 4 subsampling), +/// and the default cadence is one 12-frame cumulative right-context window +/// (240 ms). This is 4 such windows — 48 encoder frames — so a partial decode +/// fires ~once/second instead of ~4×/second. Each partial decode re-decodes the +/// transcript from BOS, so quartering their count is a ~4× speedup; the +/// post-finalize assertions are unaffected (finalize still does the full +/// decode). `streams_jfk_committed_text` deliberately keeps the 240 ms default +/// so the realistic cadence + the default-resolution path stay covered. +const COARSE_DECODE_INTERVAL_MS: i32 = 960; + fn feed_in_chunks(stream: &mut Stream<'_>, pcm: &[f32], chunk: usize) { for frame in pcm.chunks(chunk) { stream.feed(frame).expect("feed"); @@ -35,6 +47,11 @@ fn streams_jfk_committed_text() { let update = stream.finalize().unwrap(); assert!(update.is_final); assert_eq!(stream.state(), StreamState::Finished); + assert!( + stream.last_status().is_none(), + "a healthy finished stream has no failure: {:?}", + stream.last_status() + ); let text = stream.text(); // `full` is the authoritative raw hypothesis. (committed_text is a @@ -58,6 +75,11 @@ fn on_finalize_policy_commits_full_text() { let mut session = Model::load(&model_path).unwrap().session().unwrap(); let opts = StreamOptions { commit_policy: transcribe_cpp::CommitPolicy::OnFinalize, + family: Some(StreamExtension::MoonshineStreaming( + MoonshineStreamingOptions { + min_decode_interval_ms: Some(COARSE_DECODE_INTERVAL_MS), + }, + )), ..Default::default() }; let mut stream = session.stream(&RunOptions::default(), &opts).unwrap(); @@ -78,9 +100,15 @@ fn stream_revision_advances() { return; }; let mut session = Model::load(&model_path).unwrap().session().unwrap(); - let mut stream = session - .stream(&RunOptions::default(), &StreamOptions::default()) - .unwrap(); + let opts = StreamOptions { + family: Some(StreamExtension::MoonshineStreaming( + MoonshineStreamingOptions { + min_decode_interval_ms: Some(COARSE_DECODE_INTERVAL_MS), + }, + )), + ..Default::default() + }; + let mut stream = session.stream(&RunOptions::default(), &opts).unwrap(); let r0 = stream.revision(); feed_in_chunks(&mut stream, &pcm, 1600); stream.finalize().unwrap(); diff --git a/bindings/swift/Sources/TranscribeCpp/Streaming.swift b/bindings/swift/Sources/TranscribeCpp/Streaming.swift index afa670b7..98a1314d 100644 --- a/bindings/swift/Sources/TranscribeCpp/Streaming.swift +++ b/bindings/swift/Sources/TranscribeCpp/Streaming.swift @@ -161,8 +161,13 @@ public final class Stream { public var state: StreamState { StreamState(transcribe_stream_get_state(session.ptr)) } public var revision: Int32 { transcribe_stream_revision(session.ptr) } - public var lastStatus: Int32 { - Int32(bitPattern: transcribe_stream_last_status(session.ptr).rawValue) + + /// The stream's recorded terminal failure, or `nil` while it is healthy. + /// Set after a `feed`/`finalize` transitioned the stream to `.failed`; reset + /// by a new stream. Inspect it when `state == .failed` to learn why. + public var lastStatus: TranscribeError? { + let status = transcribe_stream_last_status(session.ptr) + return status == TRANSCRIBE_OK ? nil : TranscribeError.make(status, context: "stream") } /// The UI-stable text snapshot (committed / tentative / full). diff --git a/bindings/swift/Tests/TranscribeCppTests/StreamingTests.swift b/bindings/swift/Tests/TranscribeCppTests/StreamingTests.swift index 1d224c2c..d80a9d78 100644 --- a/bindings/swift/Tests/TranscribeCppTests/StreamingTests.swift +++ b/bindings/swift/Tests/TranscribeCppTests/StreamingTests.swift @@ -35,6 +35,7 @@ final class StreamingTests: XCTestCase { let stream = try session.stream() try Fixtures.drive(stream, pcm: pcm) XCTAssertGreaterThan(stream.revision, 0) + XCTAssertNil(stream.lastStatus, "a healthy stream has no failure status") } func testStreamResetReturnsToIdleAndIsReusable() throws { diff --git a/bindings/typescript/.gitignore b/bindings/typescript/.gitignore index 69694e4b..336bca8b 100644 --- a/bindings/typescript/.gitignore +++ b/bindings/typescript/.gitignore @@ -1,3 +1,5 @@ node_modules dist *.tgz +prebuilds +build-from-source diff --git a/bindings/typescript/README.md b/bindings/typescript/README.md index 4252e8a0..e7721be8 100644 --- a/bindings/typescript/README.md +++ b/bindings/typescript/README.md @@ -1,17 +1,215 @@ # transcribe-cpp TypeScript/Node.js bindings for [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp), -a C/C++ speech-to-text library built on ggml. +a C/C++ speech-to-text library built on ggml — Whisper, Parakeet, Moonshine, +Voxtral, and more, on CPU, Metal, CUDA, and Vulkan. -> **Status: placeholder.** This release reserves the `transcribe-cpp` name on -> npm while the first-party bindings are developed. It ships no functionality -> yet. Watch the repository for the first functional release. +```sh +npm install transcribe-cpp +``` + +A matching prebuilt native package is selected automatically for your +platform (`@transcribe-cpp/`); there is nothing to compile and no +environment variables to set. + +## Quickstart + +```ts +import { TranscribeModel } from "transcribe-cpp"; + +const model = await TranscribeModel.load("whisper-tiny-Q5_K_M.gguf"); + +// 16 kHz mono float32 PCM (bring your own decoder). +const result = await model.transcribe(pcm, { timestamps: "segment" }); + +console.log(result.text); +console.log(result.language); // detected or requested +for (const seg of result.segments) { + console.log(`[${seg.t0Ms}–${seg.t1Ms}ms] ${seg.text}`); +} + +model.dispose(); +``` + +### Streaming + +```ts +const session = model.createSession(); +const stream = await session.stream({ commitPolicy: "stable_prefix" }); + +for (const chunk of pcmChunks) { + await stream.feed(chunk); + const { committed, tentative } = stream.text; // committed is append-only/stable + render(committed, tentative); +} +await stream.finalize(); +stream.reset(); +``` + +### Batch + +```ts +const items = await session.runBatch([pcmA, pcmB]); +for (const item of items) { + if (item.ok) console.log(item.result.text); + else console.error(item.error.message); // per-utterance failure +} +``` + +### Cancellation + +```ts +const ac = new AbortController(); +setTimeout(() => ac.abort(), 5000); +try { + await model.transcribe(pcm, { signal: ac.signal }); +} catch (e) { + if (e instanceof Aborted) console.log("partial:", e.partialResult?.text); +} +``` + +### Family extensions + +Per-family options are a typed, discriminated union passed as `family`: + +```ts +// Whisper is a run-slot extension: +await model.transcribe(pcm, { family: { kind: "whisper", initialPrompt: "…" } }); + +// Streaming families (moonshine, parakeet, voxtral) are stream-slot: +const stream = await session.stream({ family: { kind: "moonshine" } }); + +model.accepts({ kind: "whisper" }); // does this model take that extension? +``` + +### Resource management + +`TranscribeModel`, `Session`, and `Stream` all implement `Symbol.dispose`, so +`using` works (TypeScript 5.2+ / Node 22+): ```ts -import { version } from "transcribe-cpp"; +using model = await TranscribeModel.load("model.gguf"); +using session = model.createSession(); +// disposed automatically at block exit +``` + +Disposing a model disposes its sessions; disposing a stream resets it (releasing +the model lease). Disposal is idempotent and order-independent. + +## Backend selection + +```ts +import { getAvailableBackends, backendAvailable } from "transcribe-cpp"; + +getAvailableBackends(); // [{ kind: "metal", name: "MTL0", description: "…" }, …] +backendAvailable("cuda"); // boolean — never throws + +const model = await TranscribeModel.load("model.gguf", { backend: "metal" }); +``` + +`backend` defaults to `"auto"` (best accelerator, else CPU). A missing Vulkan +loader/driver degrades silently to CPU; an explicit backend that cannot be +satisfied raises a `BackendError`. + +## Thread-safety + +Your JavaScript runs on one thread, but `run`/`feed`/`finalize` dispatch the +native compute to a **libuv worker thread** (via koffi's async calls), so the +event loop stays responsive while inference runs. + +The C library allows **one compute in flight per model** — a `run`, a `runBatch`, +or an *active stream* — across all of its sessions. The binding enforces this: +every compute call serializes through an internal model-wide mutex, and an active +stream holds a model-wide lease for its whole lifetime. While a stream is active +(after `stream()`, before `finalize()`/`reset()`), a `run`/`runBatch`/`stream` on +any session of that model is refused with a `Busy` error rather than allowed to +race. So to parallelize, load one model per worker; to share a model, finalize or +reset the stream first. A single `Session` is single-use-at-a-time — don't call +`run`/`feed` on the same session concurrently. + +Hand-offs are ordered, not racy: `finalize()`/`reset()`/`dispose()` release the +lease only after the native teardown runs on the shared queue, so the slot is +never freed early. Issue the teardown before the next `stream()`/`run()` and it +is correctly serialized — `stream.reset(); const next = await session.stream()` +works without awaiting the (void) `reset()`. The reverse order — beginning before +the teardown — is refused with `Busy`, by design. + +Because the compute is genuinely on another thread, **do not touch a session +while a call against it is in flight** — it is single-threaded in the C library: + +- Reading a stream's `text`/`state`/`revision`/`lastStatus`, or a session's + `limits`/`wasAborted`, during an un-awaited `feed`/`finalize`/`run` **throws**. +- `reset()` and `dispose()` are safe to call any time: the native teardown is + deferred behind any in-flight call, so it never frees a session mid-compute. +- Disposing a `Session` or `TranscribeModel` while a stream is still active + releases the lease and invalidates the stream — its later calls throw rather + than touch the freed handle. +- The **input PCM is borrowed, not copied**: `run`/`runBatch`/`feed` hand the + buffer to native code that reads it on the worker thread, so do not mutate it + (e.g. reuse a scratch/capture buffer) until the returned promise resolves. + Pass a fresh buffer per call, or `await` before overwriting. + +The normal pattern is safe — `await` first, then read: -console.log(version); +```ts +await stream.feed(chunk); +const { committed, tentative } = stream.text; // ✓ feed has completed + +const p = stream.feed(chunk); +stream.text; // ✗ throws: read while feed in flight +await p; +``` + +Since JavaScript has no destructors, a stream holds the model's lease until you +`finalize()`, `reset()`, or let `using` dispose it — drop the reference without +one and the model stays `Busy` until you dispose the session or model. Always +finalize/reset (or use `using`). + +Result strings are copied at the boundary; the objects you get back are fully +owned and outlive the model. + +## Runtime support + +The binding is one native artifact (koffi, an N-API addon) plus plain ESM, so any +runtime with N-API support runs the same package — there is no per-runtime build. + +- **Node ≥ 22** — the primary, fully tested target. +- **Deno ≥ 2** — runs the package unmodified via `deno run` (use `--allow-ffi + --allow-read --allow-env`, or `-A`). Note: `deno compile` does not bundle native + addons, so the compiled-binary path is unsupported. +- **Bun** — not yet. Bun loads the addon and runs, but currently crashes in its + own N-API finalizer (`napi_reference_unref`); this is an upstream Bun bug, fixed + on Bun's side, not here. + +## Building from source + +The universal fallback when no prebuilt package exists for your platform (a new +arch, a custom backend, or a single self-contained library): + +```sh +# from a transcribe.cpp checkout: +npm run build:native -- --source /path/to/transcribe.cpp +# add --self-contained to link ggml into one libtranscribe + +# from a consumer project (the script ships in the package): +node node_modules/transcribe-cpp/scripts/build-from-source.mjs --source /path/to/transcribe.cpp ``` -- Package: `transcribe-cpp` -- License: MIT +This drives CMake directly (the binding is FFI over a shared library, not an +N-API addon, so `cmake-js` does not apply) and installs into `prebuilds//`, +which the loader finds automatically. Requires `cmake` and a C/C++ toolchain. +You can also point `TRANSCRIBE_LIBRARY` at any `libtranscribe` you built. + +## Waived requirements + +- **Per-field ABI layout is not waived** (unlike Rust/Swift): TypeScript has no + compiler reading the C headers, so — like the Python ctypes binding — the + generated struct layout is verified at load against both the captured + C-compiler layout and the live library (`transcribe_abi_struct_size/_align`). +- **Buffer model load** awaits `transcribe_model_load_buffer` in the C API; only + file load is exposed today. + +## License + +MIT. Bundled native packages include third-party license texts (ggml and any +bundled backend runtimes). diff --git a/bindings/typescript/bun.lock b/bindings/typescript/bun.lock deleted file mode 100644 index 4ccd0fe8..00000000 --- a/bindings/typescript/bun.lock +++ /dev/null @@ -1,15 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "transcribe-cpp", - "devDependencies": { - "typescript": "^5.6.0", - }, - }, - }, - "packages": { - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - } -} diff --git a/bindings/typescript/examples/_support.mjs b/bindings/typescript/examples/_support.mjs new file mode 100644 index 00000000..65a96ff2 --- /dev/null +++ b/bindings/typescript/examples/_support.mjs @@ -0,0 +1,44 @@ +// Shared example plumbing (the analog of Rust's examples/common). Each example +// resolves its model/audio from argv or the TRANSCRIBE_SMOKE_* env vars, and +// skips cleanly (exit 0) when a required canary is absent — so the whole set is +// CI-executable headless under the same skip rules as the model test tier. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +export const DEFAULT_AUDIO = path.resolve(HERE, "../../../samples/jfk.wav"); + +export function model(envName) { + return process.argv[2] || process.env[envName] || ""; +} + +export function audio() { + return process.argv[3] || process.env.TRANSCRIBE_SMOKE_AUDIO || DEFAULT_AUDIO; +} + +export function skip(msg) { + console.log(`skip: ${msg}`); + process.exit(0); +} + +export function readWav(file) { + const b = fs.readFileSync(file); + let o = 12, + fmt = null, + d = null; + while (o + 8 <= b.length) { + const id = b.toString("ascii", o, o + 4); + const s = b.readUInt32LE(o + 4); + if (id === "fmt ") fmt = { ch: b.readUInt16LE(o + 10) }; + else if (id === "data") d = b.subarray(o + 8, o + 8 + s); + o += 8 + s + (s & 1); + } + if (!fmt || !d) throw new Error("bad WAV"); + const ch = fmt.ch; + const n = Math.floor(d.length / 2 / ch); + const p = new Float32Array(n); + for (let i = 0; i < n; i++) p[i] = d.readInt16LE(i * 2 * ch) / 32768; + return p; +} diff --git a/bindings/typescript/examples/backend-select.mjs b/bindings/typescript/examples/backend-select.mjs new file mode 100644 index 00000000..3d73f232 --- /dev/null +++ b/bindings/typescript/examples/backend-select.mjs @@ -0,0 +1,34 @@ +// backend-select — discover devices, request an explicit backend, fall back. +// +// node examples/backend-select.mjs [model.gguf] +// +// Device discovery needs no model; the explicit-backend load is skipped when no +// model is given. + +import { getAvailableBackends, backendAvailable, TranscribeModel } from "../dist/index.js"; +import { model, skip } from "./_support.mjs"; + +console.log("discovered devices:"); +for (const d of getAvailableBackends()) { + console.log(` ${d.kind.padEnd(7)} ${d.name} — ${d.description}`); +} +console.log("\nbackend availability:"); +for (const b of ["cpu", "metal", "vulkan", "cuda"]) { + console.log(` ${b.padEnd(7)} ${backendAvailable(b)}`); +} + +const path = model("TRANSCRIBE_SMOKE_MODEL"); +if (!path) skip("\nno model — device discovery only (set TRANSCRIBE_SMOKE_MODEL to load)"); + +// Prefer an accelerator, fall back to CPU on a clean failure. +const preferred = backendAvailable("metal") ? "metal" : backendAvailable("cuda") ? "cuda" : "cpu"; +console.log(`\nrequesting backend: ${preferred}`); +let m; +try { + m = await TranscribeModel.load(path, { backend: preferred }); +} catch (e) { + console.log(` ${preferred} unavailable (${e.constructor.name}); retrying on cpu`); + m = await TranscribeModel.load(path, { backend: "cpu" }); +} +console.log(`loaded on backend: ${m.backend}`); +m.dispose(); diff --git a/bindings/typescript/examples/batch.mjs b/bindings/typescript/examples/batch.mjs new file mode 100644 index 00000000..8a23c1cb --- /dev/null +++ b/bindings/typescript/examples/batch.mjs @@ -0,0 +1,25 @@ +// batch — transcribe multiple utterances in one call. +// +// node examples/batch.mjs +// +// or set TRANSCRIBE_SMOKE_MODEL. Skips cleanly when absent. + +import { TranscribeModel } from "../dist/index.js"; +import { model, audio, readWav, skip } from "./_support.mjs"; + +const path = model("TRANSCRIBE_SMOKE_MODEL"); +if (!path) skip("no model (set TRANSCRIBE_SMOKE_MODEL)"); + +const m = await TranscribeModel.load(path); +const session = m.createSession(); + +const pcm = readWav(audio()); +const items = await session.runBatch([pcm, pcm.subarray(0, pcm.length / 2)]); + +items.forEach((item, i) => { + if (item.ok) console.log(`[${i}] ${item.result.text.trim()}`); + else console.log(`[${i}] ERROR (${item.error.constructor.name}): ${item.error.message}`); +}); + +session.dispose(); +m.dispose(); diff --git a/bindings/typescript/examples/error-handling.mjs b/bindings/typescript/examples/error-handling.mjs new file mode 100644 index 00000000..7fede822 --- /dev/null +++ b/bindings/typescript/examples/error-handling.mjs @@ -0,0 +1,38 @@ +// error-handling — typed errors, cooperative cancellation, cleanup. +// +// node examples/error-handling.mjs [model.gguf] [audio.wav] +// +// The typed-error demo needs no model; cancellation runs when a model is given. + +import { TranscribeModel, ModelFileNotFound, Aborted } from "../dist/index.js"; +import { model, audio, readWav, skip } from "./_support.mjs"; + +// 1) A missing file is a typed, catchable error — not a crash. +try { + await TranscribeModel.load("/no/such/model.gguf"); +} catch (e) { + console.log(`missing file -> ${e.constructor.name} (status ${e.status}): ${e.message}`); + console.log(` instanceof ModelFileNotFound: ${e instanceof ModelFileNotFound}`); +} + +const path = model("TRANSCRIBE_SMOKE_MODEL"); +if (!path) skip("\nno model — typed-error demo only (set TRANSCRIBE_SMOKE_MODEL for cancellation)"); + +const m = await TranscribeModel.load(path); + +// 2) Cooperative cancellation via AbortSignal; the partial transcript survives. +const controller = new AbortController(); +controller.abort(); // abort up front for a deterministic demo +try { + await m.transcribe(readWav(audio()), { signal: controller.signal }); +} catch (e) { + if (e instanceof Aborted) { + console.log(`\ncancelled -> Aborted; partial text: "${(e.partialResult?.text ?? "").trim()}"`); + } else { + throw e; + } +} + +// 3) Deterministic cleanup. +m.dispose(); +console.log("disposed cleanly"); diff --git a/bindings/typescript/examples/streaming.mjs b/bindings/typescript/examples/streaming.mjs new file mode 100644 index 00000000..951474f1 --- /dev/null +++ b/bindings/typescript/examples/streaming.mjs @@ -0,0 +1,28 @@ +// streaming — feed chunks, show committed (stable) vs tentative (volatile) text. +// +// node examples/streaming.mjs +// +// or set TRANSCRIBE_SMOKE_STREAMING_MODEL. Skips cleanly when absent. + +import { TranscribeModel } from "../dist/index.js"; +import { model, audio, readWav, skip } from "./_support.mjs"; + +const path = model("TRANSCRIBE_SMOKE_STREAMING_MODEL"); +if (!path) skip("no streaming model (set TRANSCRIBE_SMOKE_STREAMING_MODEL)"); + +const m = await TranscribeModel.load(path); +const session = m.createSession(); +const stream = await session.stream({ commitPolicy: "stable_prefix" }); + +const pcm = readWav(audio()); +for (let i = 0; i < pcm.length; i += 16000) { + await stream.feed(pcm.subarray(i, i + 16000)); + const t = stream.text; + console.log(`rev ${stream.revision} | committed: "${t.committed.trim()}" | tentative: "${t.tentative.trim()}"`); +} +await stream.finalize(); +console.log(`\nfinal: ${stream.text.committed.trim()}`); + +stream.reset(); +session.dispose(); +m.dispose(); diff --git a/bindings/typescript/examples/transcribe-file.mjs b/bindings/typescript/examples/transcribe-file.mjs new file mode 100644 index 00000000..e14781b4 --- /dev/null +++ b/bindings/typescript/examples/transcribe-file.mjs @@ -0,0 +1,28 @@ +// transcribe-file — load a model, transcribe a WAV, print text + segments. +// +// node examples/transcribe-file.mjs +// +// or set TRANSCRIBE_SMOKE_MODEL (+ optional TRANSCRIBE_SMOKE_AUDIO). Skips +// cleanly (exit 0) when no model is given. + +import { TranscribeModel } from "../dist/index.js"; +import { model, audio, readWav, skip } from "./_support.mjs"; + +const path = model("TRANSCRIBE_SMOKE_MODEL"); +if (!path) skip("no model (pass a .gguf or set TRANSCRIBE_SMOKE_MODEL)"); + +const m = await TranscribeModel.load(path); +console.log(`model: ${m.arch}/${m.variant} on ${m.backend}`); + +const result = await m.transcribe(readWav(audio()), { timestamps: "segment" }); +console.log(`\ntext: ${result.text.trim()}`); +console.log(`language: ${result.language}`); +for (const s of result.segments) { + console.log(` [${s.t0Ms} - ${s.t1Ms} ms] ${s.text}`); +} +console.log( + `\ntimings: load ${result.timings.loadMs.toFixed(0)}ms ` + + `encode ${result.timings.encodeMs.toFixed(0)}ms decode ${result.timings.decodeMs.toFixed(0)}ms`, +); + +m.dispose(); diff --git a/bindings/typescript/package-lock.json b/bindings/typescript/package-lock.json new file mode 100644 index 00000000..a38663eb --- /dev/null +++ b/bindings/typescript/package-lock.json @@ -0,0 +1,309 @@ +{ + "name": "transcribe-cpp", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "transcribe-cpp", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "koffi": "^3.0.2" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=22" + }, + "optionalDependencies": { + "@transcribe-cpp/darwin-arm64-metal": "0.0.1", + "@transcribe-cpp/darwin-x64-cpu": "0.0.1", + "@transcribe-cpp/linux-arm64-cpu-vulkan": "0.0.1", + "@transcribe-cpp/linux-x64-cpu-vulkan": "0.0.1", + "@transcribe-cpp/win32-x64-cpu-vulkan": "0.0.1" + } + }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.0.2.tgz", + "integrity": "sha512-OaL5EViEvsdbc8Ut90zY44AKF3mq6JBqkK/xLkGaSXcLsOJUsD+XaPAZb/WOWcKVg7On31wP+4hB7MwHQPdiUg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.0.2.tgz", + "integrity": "sha512-ZkBKudZkIOy5yVcp8cUTFCrE5DSgxkcH26mUxV6m8JgUtvtps4iMIGAAZLSq5aouvQQKhbKpXtZ3YdzpcytM+g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.0.2.tgz", + "integrity": "sha512-eVHMjYL6yMc7GOdH0juDVEYFU4D9Az6cqyI7+ytWwovwAjHNtf96S17Ag/aI2qs7WVCoHceKDdMUATzs5grvMw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.0.2.tgz", + "integrity": "sha512-qiSkR5fF/oa8MIukjsXMmFPC/FWGfUtw3ee+jgURg0R+Tuhlvqlfh50W4rQ0Y0faITJcF+3wmCI6WSt7p4iCkA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.0.2.tgz", + "integrity": "sha512-RDqoD8tBiRoe1C/lZEdMWETKk2dTR298XgJ9PBXbw2ETMVpIX+ci++X+j+4e7J3kVldxHLdFCykNei6d1wmhTA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.0.2.tgz", + "integrity": "sha512-zIm5NlzFuxt4f6mDjwTDgfzu8oZngcALTmOkWbp7HSeWdjQeiy8JRaSOEpfzlIlqWoPxhRUX5Yniqkt8564VnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.0.2.tgz", + "integrity": "sha512-Le3JxJswxBhO3OeeSWA400/v2sTPwTWz/IIc6ARc4SbTGUcz2jm6jXXQviW01r5OvCQ+w2Q2T4PxP+eaRa2+fQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.0.2.tgz", + "integrity": "sha512-TiPOFy4OX0tLhKwL6oSOhDIhwd5b48x7nxCBT8u6IW+nLXMIo2j+jla1h9niQ8HVGknGOzu48k55KVl0YMsaew==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.0.2.tgz", + "integrity": "sha512-U44szVAHW4WKTV0s6K6rjeZEURJ0CFHbZC0Ik3lpzvEidbOu8dTft7OGNr4hhBvx/XaeLMCCebPLBoYZZNW2Dg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.0.2.tgz", + "integrity": "sha512-bm4qT+e59KWn4V/jK8uF1RV2k0/JJvmGPUj4qCJ3E9663H+6ZLqz1rpCdgXSEA95f5CNR3RWcgjQwxgKO9xFtQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.0.2.tgz", + "integrity": "sha512-tUu2LMSyeCSe1DbJKZrjGOKRQzDhj/RAw4rVdlHXlU/B26sdyKZjEwE3GxVlYDdEBX4FiYYmoVuG3BwGWSefCw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.0.2.tgz", + "integrity": "sha512-o4QZwsKIQvlMSIJaCXsu8d2sz4bbKEJrZhmnCeT/SKEU1VEiegmDn/I6DYrg5mOMDC15GWlUP1qP6AsVlrxqXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.0.2.tgz", + "integrity": "sha512-Yz/iP2xLaq8t3TFy5+f2Oww7rBpXgZ4YRexdYffSm2EwcnDeCtPudQSjIiKyEBdvDsU2vQetTrxyqDHStXXocw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.0.2.tgz", + "integrity": "sha512-zozoHzvSi61jch4sKqFi3ATsDC7lviFthoivzPKVk4VeJvFeSdqueKoYGHNXEzfhIXn9Y5K85l9a59nwfutkUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/koffi": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.0.2.tgz", + "integrity": "sha512-YS4sGzlVMhFNNkKbkB3tcLKJxH9WGP3jHTxi4Eyx+ieMRKVz4K2i+6rebYr9ngHN36jhIW6YXnxkzBeOQ51lsw==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-darwin-arm64": "3.0.2", + "@koromix/koffi-darwin-x64": "3.0.2", + "@koromix/koffi-freebsd-arm64": "3.0.2", + "@koromix/koffi-freebsd-ia32": "3.0.2", + "@koromix/koffi-freebsd-x64": "3.0.2", + "@koromix/koffi-linux-arm64": "3.0.2", + "@koromix/koffi-linux-ia32": "3.0.2", + "@koromix/koffi-linux-loong64": "3.0.2", + "@koromix/koffi-linux-riscv64": "3.0.2", + "@koromix/koffi-linux-x64": "3.0.2", + "@koromix/koffi-openbsd-ia32": "3.0.2", + "@koromix/koffi-openbsd-x64": "3.0.2", + "@koromix/koffi-win32-ia32": "3.0.2", + "@koromix/koffi-win32-x64": "3.0.2" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/bindings/typescript/package.json b/bindings/typescript/package.json index 18ed88bb..2c7f7aa2 100644 --- a/bindings/typescript/package.json +++ b/bindings/typescript/package.json @@ -1,7 +1,7 @@ { "name": "transcribe-cpp", - "version": "0.0.0", - "description": "TypeScript/Node.js bindings for transcribe.cpp (pre-release name reservation placeholder)", + "version": "0.0.1", + "description": "TypeScript/Node.js bindings for transcribe.cpp — a C/C++ speech-to-text library built on ggml", "type": "module", "exports": { ".": { @@ -12,7 +12,7 @@ "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", - "files": ["dist", "README.md", "LICENSE"], + "files": ["dist", "README.md", "LICENSE", "scripts/build-from-source.mjs"], "license": "MIT", "author": "The transcribe.cpp authors", "homepage": "https://github.com/handy-computer/transcribe.cpp#readme", @@ -25,13 +25,27 @@ }, "keywords": ["transcription", "speech-to-text", "asr", "stt", "ggml", "whisper", "parakeet"], "engines": { - "node": ">=18" + "node": ">=22" }, "scripts": { "build": "tsc", - "prepublishOnly": "bun run build" + "build:native": "node scripts/build-from-source.mjs", + "pretest": "npm run build", + "test": "node --test test/*.test.mjs", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "koffi": "^3.0.2" + }, + "optionalDependencies": { + "@transcribe-cpp/darwin-arm64-metal": "0.0.1", + "@transcribe-cpp/darwin-x64-cpu": "0.0.1", + "@transcribe-cpp/linux-x64-cpu-vulkan": "0.0.1", + "@transcribe-cpp/linux-arm64-cpu-vulkan": "0.0.1", + "@transcribe-cpp/win32-x64-cpu-vulkan": "0.0.1" }, "devDependencies": { + "@types/node": "^22.0.0", "typescript": "^5.6.0" } } diff --git a/bindings/typescript/scripts/build-from-source.mjs b/bindings/typescript/scripts/build-from-source.mjs new file mode 100644 index 00000000..4841bf79 --- /dev/null +++ b/bindings/typescript/scripts/build-from-source.mjs @@ -0,0 +1,90 @@ +// Build libtranscribe from source — the universal fallback when no prebuilt +// @transcribe-cpp/ package exists for the host (a new arch, a custom +// backend, a self-contained library). The result installs into +// `prebuilds//`, which the loader probes automatically — no env var. +// +// node scripts/build-from-source.mjs [--source ] [--self-contained] +// +// --source Path to a transcribe.cpp checkout (default: +// $TRANSCRIBE_SOURCE_DIR, else walk up to find the repo). +// --self-contained Link ggml statically into one libtranscribe (no sibling +// ggml libraries). Otherwise ggml ships as sibling shared +// libs alongside libtranscribe. +// +// This drives CMake directly (the binding is FFI over a shared library, not an +// N-API addon — so cmake-js, which builds .node addons, is not the right tool). +// Requires cmake + a C/C++ toolchain (Ninja recommended). On macOS Metal is +// enabled and embedded; on Linux the default CPU backend builds (add your own +// -D flags via TRANSCRIBE_CMAKE_FLAGS for CUDA/Vulkan/etc.). + +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function arg(name) { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? (process.argv[i + 1]?.startsWith("--") ? true : process.argv[i + 1] ?? true) : undefined; +} + +function tuple() { + const { platform, arch } = process; + const a = arch === "x64" ? "x64" : arch; + if (platform === "darwin") return arch === "arm64" ? "darwin-arm64-metal" : "darwin-x64-cpu"; + if (platform === "linux") return `linux-${a}-cpu-vulkan`; + if (platform === "win32") return `win32-${a}-cpu-vulkan`; + throw new Error(`unsupported platform ${platform}/${arch}`); +} + +function findSource() { + const explicit = arg("source") || process.env.TRANSCRIBE_SOURCE_DIR; + const start = typeof explicit === "string" ? explicit : PKG_ROOT; + let dir = path.resolve(start); + for (let i = 0; i < 12; i++) { + if ( + fs.existsSync(path.join(dir, "CMakeLists.txt")) && + fs.existsSync(path.join(dir, "include", "transcribe.h")) + ) { + return dir; + } + const up = path.dirname(dir); + if (up === dir) break; + dir = up; + } + throw new Error( + "could not locate a transcribe.cpp source tree. Pass --source or set " + + "TRANSCRIBE_SOURCE_DIR (the published npm package ships no C++ sources).", + ); +} + +function run(cmd, args) { + console.log(`$ ${cmd} ${args.join(" ")}`); + const r = spawnSync(cmd, args, { stdio: "inherit" }); + if (r.status !== 0) { + throw new Error(`${cmd} exited with ${r.status ?? r.signal}`); + } +} + +const src = findSource(); +const t = tuple(); +const buildDir = path.join(PKG_ROOT, "build-from-source"); +const prefix = path.join(PKG_ROOT, "prebuilds", t); +const selfContained = Boolean(arg("self-contained")); + +console.log(`building libtranscribe for ${t}`); +console.log(` source: ${src}`); +console.log(` install prefix: ${prefix}${selfContained ? " (self-contained)" : ""}`); + +const flags = ["-DTRANSCRIBE_BUILD_SHARED=ON"]; +if (process.platform === "darwin") flags.push("-DGGML_METAL=ON", "-DGGML_METAL_EMBED_LIBRARY=ON"); +if (selfContained) flags.push("-DBUILD_SHARED_LIBS=OFF"); // ggml static -> one libtranscribe +if (process.env.TRANSCRIBE_CMAKE_FLAGS) flags.push(...process.env.TRANSCRIBE_CMAKE_FLAGS.split(" ").filter(Boolean)); + +fs.rmSync(prefix, { recursive: true, force: true }); +run("cmake", ["-B", buildDir, "-S", src, "-G", "Ninja", ...flags]); +run("cmake", ["--build", buildDir, "--target", "transcribe"]); +run("cmake", ["--install", buildDir, "--prefix", prefix]); + +console.log(`\ndone. The loader will find prebuilds/${t}/lib/ automatically.`); diff --git a/bindings/typescript/scripts/pack-platform.mjs b/bindings/typescript/scripts/pack-platform.mjs new file mode 100644 index 00000000..410ed6a4 --- /dev/null +++ b/bindings/typescript/scripts/pack-platform.mjs @@ -0,0 +1,116 @@ +// Build an @transcribe-cpp/ platform package from a native lib directory +// (a `transcribe-native-` bundle, or a `cmake --install` lib/ tree). The +// package carries the shared library + its ggml siblings + contract.json + +// third-party licenses, and an os/cpu/libc-constrained package.json so npm +// installs only the one matching the host. The API package depends on all of +// these via optionalDependencies; the loader require.resolve's the matching one. +// +// node scripts/pack-platform.mjs --lib-dir --tuple \ +// --version --out [--header-hash ] [--backends a,b] +// +// Used by the release workflow (over the CI native bundles) and locally to +// validate the clean-machine install path. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + +// npm tuple (Node platform-arch convention) -> os/cpu/libc + lib name + backends. +const TUPLES = { + "darwin-arm64-metal": { os: "darwin", cpu: "arm64", lib: "libtranscribe.dylib", backends: ["metal", "cpu"] }, + "darwin-x64-cpu": { os: "darwin", cpu: "x64", lib: "libtranscribe.dylib", backends: ["cpu"] }, + "linux-x64-cpu-vulkan": { os: "linux", cpu: "x64", libc: "glibc", lib: "libtranscribe.so", backends: ["cpu", "vulkan"] }, + "linux-arm64-cpu-vulkan": { os: "linux", cpu: "arm64", libc: "glibc", lib: "libtranscribe.so", backends: ["cpu", "vulkan"] }, + "win32-x64-cpu-vulkan": { os: "win32", cpu: "x64", lib: "transcribe.dll", backends: ["cpu", "vulkan"] }, +}; + +function arg(name, fallback) { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : fallback; +} + +const libDir = arg("lib-dir"); +const tuple = arg("tuple"); +const version = arg("version"); +const outRoot = arg("out"); +if (!libDir || !tuple || !version || !outRoot) { + console.error("usage: --lib-dir --tuple --version --out "); + process.exit(2); +} +const spec = TUPLES[tuple]; +if (!spec) { + console.error(`unknown tuple ${tuple}; known: ${Object.keys(TUPLES).join(", ")}`); + process.exit(2); +} + +const pkgDir = path.join(outRoot, tuple); +fs.rmSync(pkgDir, { recursive: true, force: true }); +fs.mkdirSync(pkgDir, { recursive: true }); + +// 1) Copy the shared libraries (transcribe + ggml siblings + symlinks). +const libExt = { darwin: ".dylib", linux: ".so", win32: ".dll" }[spec.os]; +const files = []; +for (const f of fs.readdirSync(libDir)) { + if (f.includes(libExt) || f.endsWith(".so") || /\.so\.\d/.test(f)) { + // Dereference: npm pack drops symlinks, and the install-name deps reference + // the .0 / plain names, so every referenced name must be a real file. (The + // CI native bundles are already delocate/auditwheel-flattened — no symlinks + // — so this is a no-op there; only the local cmake-install tree has them.) + fs.cpSync(path.join(libDir, f), path.join(pkgDir, f), { recursive: true, dereference: true }); + files.push(f); + } +} +if (!files.includes(spec.lib)) { + console.error(`expected ${spec.lib} in ${libDir}; found: ${files.join(", ") || "(none)"}`); + process.exit(1); +} +// The link manifest helps source/compiled consumers; ship it if present. +if (fs.existsSync(path.join(libDir, "transcribe-link.json"))) { + fs.cpSync(path.join(libDir, "transcribe-link.json"), path.join(pkgDir, "transcribe-link.json")); + files.push("transcribe-link.json"); +} + +// 2) contract.json — copy the bundle's if present, else synthesize it. +const headerHash = + arg("header-hash") || fs.readFileSync(path.join(REPO, "include", "transcribe.abihash"), "utf8").trim(); +const backends = (arg("backends") || spec.backends.join(",")).split(","); +const srcContract = path.join(libDir, "contract.json"); +const contract = fs.existsSync(srcContract) + ? JSON.parse(fs.readFileSync(srcContract, "utf8")) + : { version, header_hash: headerHash, backends, lane: tuple }; +fs.writeFileSync(path.join(pkgDir, "contract.json"), JSON.stringify(contract, null, 2) + "\n"); +files.push("contract.json"); + +// 3) Third-party license texts (ggml at minimum) — required by §5. +fs.mkdirSync(path.join(pkgDir, "licenses"), { recursive: true }); +for (const [name, rel] of [ + ["LICENSE", "LICENSE"], + ["LICENSE.ggml", "ggml/LICENSE"], +]) { + const p = path.join(REPO, rel); + if (fs.existsSync(p)) fs.cpSync(p, path.join(pkgDir, "licenses", name)); +} +files.push("licenses"); + +// 4) package.json — os/cpu/libc gate so npm installs only the matching host. +const pkg = { + name: `@transcribe-cpp/${tuple}`, + version, + description: `Prebuilt transcribe.cpp native library for ${tuple}.`, + license: "MIT", + repository: { type: "git", url: "git+https://github.com/handy-computer/transcribe.cpp.git" }, + os: [spec.os], + cpu: [spec.cpu], + ...(spec.libc ? { libc: [spec.libc] } : {}), + files: [...new Set(files)].sort(), + // The bytes are the deliverable; nothing to run on install. + preferUnplugged: true, +}; +fs.writeFileSync(path.join(pkgDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n"); + +console.log(`wrote ${pkg.name}@${version} -> ${pkgDir}`); +console.log(` os=${spec.os} cpu=${spec.cpu}${spec.libc ? ` libc=${spec.libc}` : ""}`); +console.log(` backends=${contract.backends.join(",")} header_hash=${contract.header_hash}`); +console.log(` files: ${pkg.files.join(", ")}`); diff --git a/bindings/typescript/src/_generated.ts b/bindings/typescript/src/_generated.ts new file mode 100644 index 00000000..2e9352e1 --- /dev/null +++ b/bindings/typescript/src/_generated.ts @@ -0,0 +1,263 @@ +// Low-level koffi FFI surface for transcribe.cpp — AUTOGENERATED. DO NOT EDIT. +// +// Regenerate with: +// uv run --no-project --with 'libclang==18.1.1' \ +// bindings/python/_generate/generate.py +// +// Source: include/transcribe/extensions.h +// libclang: 18.1.1 +/* eslint-disable */ + +// Stable digest of the ABI surface (structs, enums, macros, layout, +// prototypes), computed by the Python oracle and pinned here so a header +// ABI change turns this binding's drift check red for conscious review. +export const PUBLIC_HEADER_HASH = "fe9ed398c408e5d9"; + +// === enum constants === +export const TRANSCRIBE_OK = 0; +export const TRANSCRIBE_ERR_INVALID_ARG = 1; +export const TRANSCRIBE_ERR_NOT_IMPLEMENTED = 2; +export const TRANSCRIBE_ERR_FILE_NOT_FOUND = 3; +export const TRANSCRIBE_ERR_GGUF = 4; +export const TRANSCRIBE_ERR_UNSUPPORTED_ARCH = 5; +export const TRANSCRIBE_ERR_UNSUPPORTED_VARIANT = 6; +export const TRANSCRIBE_ERR_OOM = 7; +export const TRANSCRIBE_ERR_BACKEND = 8; +export const TRANSCRIBE_ERR_SAMPLE_RATE = 9; +export const TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE = 10; +export const TRANSCRIBE_ERR_UNSUPPORTED_TASK = 11; +export const TRANSCRIBE_ERR_UNSUPPORTED_TIMESTAMPS = 12; +export const TRANSCRIBE_ERR_ABORTED = 13; +export const TRANSCRIBE_ERR_BAD_STRUCT_SIZE = 14; +export const TRANSCRIBE_ERR_UNSUPPORTED_PNC = 15; +export const TRANSCRIBE_ERR_UNSUPPORTED_ITN = 16; +export const TRANSCRIBE_ERR_INPUT_TOO_LONG = 17; +export const TRANSCRIBE_ERR_OUTPUT_TRUNCATED = 18; +export const TRANSCRIBE_ABI_MODEL_LOAD_PARAMS = 0; +export const TRANSCRIBE_ABI_SESSION_PARAMS = 1; +export const TRANSCRIBE_ABI_RUN_PARAMS = 2; +export const TRANSCRIBE_ABI_STREAM_PARAMS = 3; +export const TRANSCRIBE_ABI_CAPABILITIES = 4; +export const TRANSCRIBE_ABI_TIMINGS = 5; +export const TRANSCRIBE_ABI_SEGMENT = 6; +export const TRANSCRIBE_ABI_WORD = 7; +export const TRANSCRIBE_ABI_TOKEN = 8; +export const TRANSCRIBE_ABI_STREAM_UPDATE = 9; +export const TRANSCRIBE_ABI_STREAM_TEXT = 10; +export const TRANSCRIBE_ABI_SESSION_LIMITS = 11; +export const TRANSCRIBE_ABI_EXT = 12; +export const TRANSCRIBE_ABI_BACKEND_DEVICE = 13; +export const TRANSCRIBE_LOG_LEVEL_NONE = 0; +export const TRANSCRIBE_LOG_LEVEL_INFO = 1; +export const TRANSCRIBE_LOG_LEVEL_WARN = 2; +export const TRANSCRIBE_LOG_LEVEL_ERROR = 3; +export const TRANSCRIBE_LOG_LEVEL_DEBUG = 4; +export const TRANSCRIBE_LOG_LEVEL_CONT = 5; +export const TRANSCRIBE_TASK_TRANSCRIBE = 0; +export const TRANSCRIBE_TASK_TRANSLATE = 1; +export const TRANSCRIBE_TIMESTAMPS_NONE = 0; +export const TRANSCRIBE_TIMESTAMPS_AUTO = 1; +export const TRANSCRIBE_TIMESTAMPS_SEGMENT = 2; +export const TRANSCRIBE_TIMESTAMPS_WORD = 3; +export const TRANSCRIBE_TIMESTAMPS_TOKEN = 4; +export const TRANSCRIBE_KV_TYPE_AUTO = 0; +export const TRANSCRIBE_KV_TYPE_F32 = 1; +export const TRANSCRIBE_KV_TYPE_F16 = 2; +export const TRANSCRIBE_PNC_MODE_DEFAULT = 0; +export const TRANSCRIBE_PNC_MODE_OFF = 1; +export const TRANSCRIBE_PNC_MODE_ON = 2; +export const TRANSCRIBE_ITN_MODE_DEFAULT = 0; +export const TRANSCRIBE_ITN_MODE_OFF = 1; +export const TRANSCRIBE_ITN_MODE_ON = 2; +export const TRANSCRIBE_EXT_SLOT_RUN = 0; +export const TRANSCRIBE_EXT_SLOT_STREAM = 1; +export const TRANSCRIBE_BACKEND_AUTO = 0; +export const TRANSCRIBE_BACKEND_CPU = 1; +export const TRANSCRIBE_BACKEND_METAL = 2; +export const TRANSCRIBE_BACKEND_VULKAN = 3; +export const TRANSCRIBE_BACKEND_CPU_ACCEL = 4; +export const TRANSCRIBE_BACKEND_CUDA = 5; +export const TRANSCRIBE_FEATURE_INITIAL_PROMPT = 0; +export const TRANSCRIBE_FEATURE_TEMPERATURE_FALLBACK = 1; +export const TRANSCRIBE_FEATURE_LONG_FORM = 2; +export const TRANSCRIBE_FEATURE_CANCELLATION = 3; +export const TRANSCRIBE_FEATURE_PNC = 4; +export const TRANSCRIBE_FEATURE_ITN = 5; +export const TRANSCRIBE_STREAM_IDLE = 0; +export const TRANSCRIBE_STREAM_ACTIVE = 1; +export const TRANSCRIBE_STREAM_FINISHED = 2; +export const TRANSCRIBE_STREAM_FAILED = 3; +export const TRANSCRIBE_STREAM_COMMIT_AUTO = 0; +export const TRANSCRIBE_STREAM_COMMIT_ON_FINALIZE = 1; +export const TRANSCRIBE_STREAM_COMMIT_STABLE_PREFIX = 2; +export const TRANSCRIBE_WHISPER_PROMPT_FIRST_SEGMENT = 0; +export const TRANSCRIBE_WHISPER_PROMPT_ALL_SEGMENTS = 1; + +// === macro constants (integer object-like macros) === +export const TRANSCRIBE_EXT_KIND_MOONSHINE_STREAMING_STREAM = 1414746957; +export const TRANSCRIBE_EXT_KIND_PARAKEET_BUFFERED_STREAM = 1396853584; +export const TRANSCRIBE_EXT_KIND_PARAKEET_STREAM = 1414744912; +export const TRANSCRIBE_EXT_KIND_VOXTRAL_REALTIME_STREAM = 1414746710; +export const TRANSCRIBE_EXT_KIND_WHISPER_RUN = 1314015319; +export const TRANSCRIBE_VERSION_MAJOR = 0; +export const TRANSCRIBE_VERSION_MINOR = 0; +export const TRANSCRIBE_VERSION_PATCH = 1; + +export interface StructLayout { size: number; align: number; offsets: Record; } +export const STRUCT_LAYOUT: Record = { + 'transcribe_ext': { size: 16, align: 8, offsets: {'size': 0, 'kind': 8} }, + 'transcribe_backend_device': { size: 32, align: 8, offsets: {'struct_size': 0, 'name': 8, 'description': 16, 'kind': 24} }, + 'transcribe_model_load_params': { size: 16, align: 8, offsets: {'struct_size': 0, 'backend': 8, 'gpu_device': 12} }, + 'transcribe_session_params': { size: 24, align: 8, offsets: {'struct_size': 0, 'n_threads': 8, 'kv_type': 12, 'n_ctx': 16} }, + 'transcribe_run_params': { size: 64, align: 8, offsets: {'struct_size': 0, 'task': 8, 'timestamps': 12, 'pnc': 16, 'itn': 20, 'language': 24, 'target_language': 32, 'keep_special_tags': 40, 'family': 48, 'spec_k_drafts': 56} }, + 'transcribe_capabilities': { size: 40, align: 8, offsets: {'struct_size': 0, 'native_sample_rate': 8, 'n_languages': 12, 'languages': 16, 'max_timestamp_kind': 24, 'supports_language_detect': 28, 'supports_translate': 29, 'supports_streaming': 30, 'supports_spec_decode': 31, 'max_audio_ms': 32} }, + 'transcribe_session_limits': { size: 32, align: 8, offsets: {'struct_size': 0, 'effective_n_ctx': 8, 'effective_max_audio_ms': 16, 'max_kv_bytes': 24} }, + 'transcribe_stream_params': { size: 24, align: 8, offsets: {'struct_size': 0, 'family': 8, 'commit_policy': 16, 'stable_prefix_agreement_n': 20} }, + 'transcribe_stream_update': { size: 48, align: 8, offsets: {'struct_size': 0, 'result_changed': 8, 'is_final': 9, 'revision': 12, 'input_received_ms': 16, 'audio_committed_ms': 24, 'buffered_ms': 32, 'committed_changed': 40, 'tentative_changed': 41} }, + 'transcribe_stream_text': { size: 64, align: 8, offsets: {'struct_size': 0, 'full_text': 8, 'full_text_bytes': 16, 'committed_text': 24, 'committed_text_bytes': 32, 'tentative_text': 40, 'tentative_text_bytes': 48, 'raw_tentative_start_bytes': 56} }, + 'transcribe_timings': { size: 24, align: 8, offsets: {'struct_size': 0, 'load_ms': 8, 'mel_ms': 12, 'encode_ms': 16, 'decode_ms': 20} }, + 'transcribe_segment': { size: 48, align: 8, offsets: {'struct_size': 0, 't0_ms': 8, 't1_ms': 16, 'first_word': 24, 'n_words': 28, 'first_token': 32, 'n_tokens': 36, 'text': 40} }, + 'transcribe_word': { size: 48, align: 8, offsets: {'struct_size': 0, 't0_ms': 8, 't1_ms': 16, 'seg_index': 24, 'first_token': 28, 'n_tokens': 32, 'text': 40} }, + 'transcribe_token': { size: 48, align: 8, offsets: {'struct_size': 0, 'id': 8, 'p': 12, 't0_ms': 16, 't1_ms': 24, 'seg_index': 32, 'word_index': 36, 'text': 40} }, + 'transcribe_moonshine_streaming_stream_ext': { size: 24, align: 8, offsets: {'ext': 0, 'min_decode_interval_ms': 16} }, + 'transcribe_parakeet_stream_ext': { size: 24, align: 8, offsets: {'ext': 0, 'att_context_right': 16} }, + 'transcribe_parakeet_buffered_stream_ext': { size: 32, align: 8, offsets: {'ext': 0, 'left_ms': 16, 'chunk_ms': 20, 'right_ms': 24} }, + 'transcribe_voxtral_realtime_stream_ext': { size: 24, align: 8, offsets: {'ext': 0, 'num_delay_tokens': 16, 'min_decode_interval_ms': 20} }, + 'transcribe_whisper_run_ext': { size: 80, align: 8, offsets: {'ext': 0, 'initial_prompt': 16, 'prompt_tokens': 24, 'n_prompt_tokens': 32, 'prompt_condition': 40, 'condition_on_prev_tokens': 44, 'max_prev_context_tokens': 48, 'temperature': 52, 'temperature_inc': 56, 'compression_ratio_thold': 60, 'logprob_thold': 64, 'no_speech_thold': 68, 'seed': 72, 'max_initial_timestamp': 76} }, + 'transcribe_whisper_chunk_trace': { size: 48, align: 8, offsets: {'struct_size': 0, 't0_ms': 8, 't1_ms': 16, 'temperature_used': 24, 'compression_ratio': 28, 'avg_logprob': 32, 'no_speech_prob': 36, 'no_speech_triggered': 40, 'n_fallbacks': 44} }, +}; + +export const ABI_STRUCT_IDS: Record = { + 'transcribe_ext': 12, + 'transcribe_backend_device': 13, + 'transcribe_model_load_params': 0, + 'transcribe_session_params': 1, + 'transcribe_run_params': 2, + 'transcribe_capabilities': 4, + 'transcribe_session_limits': 11, + 'transcribe_stream_params': 3, + 'transcribe_stream_update': 9, + 'transcribe_stream_text': 10, + 'transcribe_timings': 5, + 'transcribe_segment': 6, + 'transcribe_word': 7, + 'transcribe_token': 8, +}; + +// Build koffi struct types; returns a name -> koffi.IKoffiCType map. +export function defineTypes(koffi: any): Record { + const T: Record = {}; + T['transcribe_ext'] = koffi.struct({ size: 'uint64_t', kind: 'uint32_t' }); + T['transcribe_backend_device'] = koffi.struct({ struct_size: 'uint64_t', name: 'char *', description: 'char *', kind: 'char *' }); + T['transcribe_model_load_params'] = koffi.struct({ struct_size: 'uint64_t', backend: 'int', gpu_device: 'int' }); + T['transcribe_session_params'] = koffi.struct({ struct_size: 'uint64_t', n_threads: 'int', kv_type: 'int', n_ctx: 'int32_t' }); + T['transcribe_run_params'] = koffi.struct({ struct_size: 'uint64_t', task: 'int', timestamps: 'int', pnc: 'int', itn: 'int', language: 'char *', target_language: 'char *', keep_special_tags: 'bool', family: 'void *', spec_k_drafts: 'int32_t' }); + T['transcribe_capabilities'] = koffi.struct({ struct_size: 'uint64_t', native_sample_rate: 'int32_t', n_languages: 'int', languages: 'void *', max_timestamp_kind: 'int', supports_language_detect: 'bool', supports_translate: 'bool', supports_streaming: 'bool', supports_spec_decode: 'bool', max_audio_ms: 'int64_t' }); + T['transcribe_session_limits'] = koffi.struct({ struct_size: 'uint64_t', effective_n_ctx: 'int32_t', effective_max_audio_ms: 'int64_t', max_kv_bytes: 'int64_t' }); + T['transcribe_stream_params'] = koffi.struct({ struct_size: 'uint64_t', family: 'void *', commit_policy: 'int', stable_prefix_agreement_n: 'uint32_t' }); + T['transcribe_stream_update'] = koffi.struct({ struct_size: 'uint64_t', result_changed: 'bool', is_final: 'bool', revision: 'int32_t', input_received_ms: 'int64_t', audio_committed_ms: 'int64_t', buffered_ms: 'int64_t', committed_changed: 'bool', tentative_changed: 'bool' }); + T['transcribe_stream_text'] = koffi.struct({ struct_size: 'uint64_t', full_text: 'char *', full_text_bytes: 'uint64_t', committed_text: 'char *', committed_text_bytes: 'uint64_t', tentative_text: 'char *', tentative_text_bytes: 'uint64_t', raw_tentative_start_bytes: 'uint64_t' }); + T['transcribe_timings'] = koffi.struct({ struct_size: 'uint64_t', load_ms: 'float', mel_ms: 'float', encode_ms: 'float', decode_ms: 'float' }); + T['transcribe_segment'] = koffi.struct({ struct_size: 'uint64_t', t0_ms: 'int64_t', t1_ms: 'int64_t', first_word: 'int', n_words: 'int', first_token: 'int', n_tokens: 'int', text: 'char *' }); + T['transcribe_word'] = koffi.struct({ struct_size: 'uint64_t', t0_ms: 'int64_t', t1_ms: 'int64_t', seg_index: 'int', first_token: 'int', n_tokens: 'int', text: 'char *' }); + T['transcribe_token'] = koffi.struct({ struct_size: 'uint64_t', id: 'int', p: 'float', t0_ms: 'int64_t', t1_ms: 'int64_t', seg_index: 'int', word_index: 'int', text: 'char *' }); + T['transcribe_moonshine_streaming_stream_ext'] = koffi.struct({ ext: T['transcribe_ext'], min_decode_interval_ms: 'int32_t' }); + T['transcribe_parakeet_stream_ext'] = koffi.struct({ ext: T['transcribe_ext'], att_context_right: 'int32_t' }); + T['transcribe_parakeet_buffered_stream_ext'] = koffi.struct({ ext: T['transcribe_ext'], left_ms: 'int32_t', chunk_ms: 'int32_t', right_ms: 'int32_t' }); + T['transcribe_voxtral_realtime_stream_ext'] = koffi.struct({ ext: T['transcribe_ext'], num_delay_tokens: 'int32_t', min_decode_interval_ms: 'int32_t' }); + T['transcribe_whisper_run_ext'] = koffi.struct({ ext: T['transcribe_ext'], initial_prompt: 'char *', prompt_tokens: 'void *', n_prompt_tokens: 'size_t', prompt_condition: 'int', condition_on_prev_tokens: 'bool', max_prev_context_tokens: 'int32_t', temperature: 'float', temperature_inc: 'float', compression_ratio_thold: 'float', logprob_thold: 'float', no_speech_thold: 'float', seed: 'uint32_t', max_initial_timestamp: 'float' }); + T['transcribe_whisper_chunk_trace'] = koffi.struct({ struct_size: 'uint64_t', t0_ms: 'int64_t', t1_ms: 'int64_t', temperature_used: 'float', compression_ratio: 'float', avg_logprob: 'float', no_speech_prob: 'float', no_speech_triggered: 'bool', n_fallbacks: 'int32_t' }); + return T; +} + +export interface FnSig { ret: string; args: string[]; } +export const FUNCTION_SIGNATURES: Record = { + 'transcribe_abi_struct_align': { ret: 'size_t', args: ['transcribe_abi_struct'] }, + 'transcribe_abi_struct_size': { ret: 'size_t', args: ['transcribe_abi_struct'] }, + 'transcribe_backend_available': { ret: '_Bool', args: ['transcribe_backend_request'] }, + 'transcribe_backend_device_count': { ret: 'int', args: [] }, + 'transcribe_backend_device_init': { ret: 'void', args: ['struct transcribe_backend_device *'] }, + 'transcribe_batch_detected_language': { ret: 'const char *', args: ['const struct transcribe_session *', 'int'] }, + 'transcribe_batch_full_text': { ret: 'const char *', args: ['const struct transcribe_session *', 'int'] }, + 'transcribe_batch_get_segment': { ret: 'transcribe_status', args: ['const struct transcribe_session *', 'int', 'int', 'struct transcribe_segment *'] }, + 'transcribe_batch_get_timings': { ret: 'transcribe_status', args: ['const struct transcribe_session *', 'int', 'struct transcribe_timings *'] }, + 'transcribe_batch_get_token': { ret: 'transcribe_status', args: ['const struct transcribe_session *', 'int', 'int', 'struct transcribe_token *'] }, + 'transcribe_batch_get_word': { ret: 'transcribe_status', args: ['const struct transcribe_session *', 'int', 'int', 'struct transcribe_word *'] }, + 'transcribe_batch_n_results': { ret: 'int', args: ['const struct transcribe_session *'] }, + 'transcribe_batch_n_segments': { ret: 'int', args: ['const struct transcribe_session *', 'int'] }, + 'transcribe_batch_n_tokens': { ret: 'int', args: ['const struct transcribe_session *', 'int'] }, + 'transcribe_batch_n_words': { ret: 'int', args: ['const struct transcribe_session *', 'int'] }, + 'transcribe_batch_returned_timestamp_kind': { ret: 'transcribe_timestamp_kind', args: ['const struct transcribe_session *', 'int'] }, + 'transcribe_batch_status': { ret: 'transcribe_status', args: ['const struct transcribe_session *', 'int'] }, + 'transcribe_capabilities_init': { ret: 'void', args: ['struct transcribe_capabilities *'] }, + 'transcribe_close': { ret: 'void', args: ['struct transcribe_session *'] }, + 'transcribe_detected_language': { ret: 'const char *', args: ['const struct transcribe_session *'] }, + 'transcribe_ext_check': { ret: 'transcribe_status', args: ['const struct transcribe_ext *', 'uint32_t', 'uint64_t'] }, + 'transcribe_full_text': { ret: 'const char *', args: ['const struct transcribe_session *'] }, + 'transcribe_get_backend_device': { ret: 'transcribe_status', args: ['int', 'struct transcribe_backend_device *'] }, + 'transcribe_get_model': { ret: 'const struct transcribe_model *', args: ['const struct transcribe_session *'] }, + 'transcribe_get_segment': { ret: 'transcribe_status', args: ['const struct transcribe_session *', 'int', 'struct transcribe_segment *'] }, + 'transcribe_get_timings': { ret: 'transcribe_status', args: ['const struct transcribe_session *', 'struct transcribe_timings *'] }, + 'transcribe_get_token': { ret: 'transcribe_status', args: ['const struct transcribe_session *', 'int', 'struct transcribe_token *'] }, + 'transcribe_get_whisper_chunk_count': { ret: 'int', args: ['const struct transcribe_session *'] }, + 'transcribe_get_whisper_chunk_trace': { ret: 'transcribe_status', args: ['const struct transcribe_session *', 'int', 'struct transcribe_whisper_chunk_trace *'] }, + 'transcribe_get_word': { ret: 'transcribe_status', args: ['const struct transcribe_session *', 'int', 'struct transcribe_word *'] }, + 'transcribe_init_backends': { ret: 'transcribe_status', args: ['const char *'] }, + 'transcribe_init_backends_default': { ret: 'transcribe_status', args: [] }, + 'transcribe_log_set': { ret: 'void', args: ['transcribe_log_callback', 'void *'] }, + 'transcribe_model_accepts_ext_kind': { ret: '_Bool', args: ['const struct transcribe_model *', 'transcribe_ext_slot', 'uint32_t'] }, + 'transcribe_model_arch_string': { ret: 'const char *', args: ['const struct transcribe_model *'] }, + 'transcribe_model_backend': { ret: 'const char *', args: ['const struct transcribe_model *'] }, + 'transcribe_model_free': { ret: 'void', args: ['struct transcribe_model *'] }, + 'transcribe_model_get_capabilities': { ret: 'transcribe_status', args: ['const struct transcribe_model *', 'struct transcribe_capabilities *'] }, + 'transcribe_model_load_file': { ret: 'transcribe_status', args: ['const char *', 'const struct transcribe_model_load_params *', 'struct transcribe_model **'] }, + 'transcribe_model_load_params_init': { ret: 'void', args: ['struct transcribe_model_load_params *'] }, + 'transcribe_model_supports': { ret: '_Bool', args: ['const struct transcribe_model *', 'transcribe_feature'] }, + 'transcribe_model_variant_string': { ret: 'const char *', args: ['const struct transcribe_model *'] }, + 'transcribe_moonshine_streaming_stream_ext_init': { ret: 'void', args: ['struct transcribe_moonshine_streaming_stream_ext *'] }, + 'transcribe_n_segments': { ret: 'int', args: ['const struct transcribe_session *'] }, + 'transcribe_n_tokens': { ret: 'int', args: ['const struct transcribe_session *'] }, + 'transcribe_n_words': { ret: 'int', args: ['const struct transcribe_session *'] }, + 'transcribe_open': { ret: 'transcribe_status', args: ['const char *', 'const struct transcribe_model_load_params *', 'const struct transcribe_session_params *', 'struct transcribe_session **'] }, + 'transcribe_parakeet_buffered_stream_ext_init': { ret: 'void', args: ['struct transcribe_parakeet_buffered_stream_ext *'] }, + 'transcribe_parakeet_stream_ext_init': { ret: 'void', args: ['struct transcribe_parakeet_stream_ext *'] }, + 'transcribe_print_timings': { ret: 'void', args: ['const struct transcribe_session *'] }, + 'transcribe_reset_timings': { ret: 'void', args: ['struct transcribe_session *'] }, + 'transcribe_returned_timestamp_kind': { ret: 'transcribe_timestamp_kind', args: ['const struct transcribe_session *'] }, + 'transcribe_run': { ret: 'transcribe_status', args: ['struct transcribe_session *', 'const float *', 'int', 'const struct transcribe_run_params *'] }, + 'transcribe_run_batch': { ret: 'transcribe_status', args: ['struct transcribe_session *', 'const float *const *', 'const int *', 'int', 'const struct transcribe_run_params *'] }, + 'transcribe_run_params_init': { ret: 'void', args: ['struct transcribe_run_params *'] }, + 'transcribe_segment_init': { ret: 'void', args: ['struct transcribe_segment *'] }, + 'transcribe_session_free': { ret: 'void', args: ['struct transcribe_session *'] }, + 'transcribe_session_get_limits': { ret: 'transcribe_status', args: ['const struct transcribe_session *', 'struct transcribe_session_limits *'] }, + 'transcribe_session_init': { ret: 'transcribe_status', args: ['struct transcribe_model *', 'const struct transcribe_session_params *', 'struct transcribe_session **'] }, + 'transcribe_session_limits_init': { ret: 'void', args: ['struct transcribe_session_limits *'] }, + 'transcribe_session_params_init': { ret: 'void', args: ['struct transcribe_session_params *'] }, + 'transcribe_set_abort_callback': { ret: 'void', args: ['struct transcribe_session *', 'transcribe_abort_callback', 'void *'] }, + 'transcribe_status_string': { ret: 'const char *', args: ['int'] }, + 'transcribe_stream_begin': { ret: 'transcribe_status', args: ['struct transcribe_session *', 'const struct transcribe_run_params *', 'const struct transcribe_stream_params *'] }, + 'transcribe_stream_feed': { ret: 'transcribe_status', args: ['struct transcribe_session *', 'const float *', 'int', 'struct transcribe_stream_update *'] }, + 'transcribe_stream_finalize': { ret: 'transcribe_status', args: ['struct transcribe_session *', 'struct transcribe_stream_update *'] }, + 'transcribe_stream_get_state': { ret: 'enum transcribe_stream_state', args: ['const struct transcribe_session *'] }, + 'transcribe_stream_get_text': { ret: 'transcribe_status', args: ['const struct transcribe_session *', 'struct transcribe_stream_text *'] }, + 'transcribe_stream_last_status': { ret: 'transcribe_status', args: ['const struct transcribe_session *'] }, + 'transcribe_stream_n_committed_segments': { ret: 'int', args: ['const struct transcribe_session *'] }, + 'transcribe_stream_n_committed_tokens': { ret: 'int', args: ['const struct transcribe_session *'] }, + 'transcribe_stream_n_committed_words': { ret: 'int', args: ['const struct transcribe_session *'] }, + 'transcribe_stream_params_init': { ret: 'void', args: ['struct transcribe_stream_params *'] }, + 'transcribe_stream_reset': { ret: 'void', args: ['struct transcribe_session *'] }, + 'transcribe_stream_revision': { ret: 'int', args: ['const struct transcribe_session *'] }, + 'transcribe_stream_text_init': { ret: 'void', args: ['struct transcribe_stream_text *'] }, + 'transcribe_stream_update_init': { ret: 'void', args: ['struct transcribe_stream_update *'] }, + 'transcribe_timings_init': { ret: 'void', args: ['struct transcribe_timings *'] }, + 'transcribe_token_init': { ret: 'void', args: ['struct transcribe_token *'] }, + 'transcribe_tokenize': { ret: 'int', args: ['const struct transcribe_model *', 'const char *', 'int32_t *', 'size_t'] }, + 'transcribe_version': { ret: 'const char *', args: [] }, + 'transcribe_version_commit': { ret: 'const char *', args: [] }, + 'transcribe_voxtral_realtime_stream_ext_init': { ret: 'void', args: ['struct transcribe_voxtral_realtime_stream_ext *'] }, + 'transcribe_was_aborted': { ret: '_Bool', args: ['const struct transcribe_session *'] }, + 'transcribe_was_truncated': { ret: '_Bool', args: ['const struct transcribe_session *'] }, + 'transcribe_whisper_chunk_trace_init': { ret: 'void', args: ['struct transcribe_whisper_chunk_trace *'] }, + 'transcribe_whisper_run_ext_init': { ret: 'void', args: ['struct transcribe_whisper_run_ext *'] }, + 'transcribe_word_init': { ret: 'void', args: ['struct transcribe_word *'] }, +}; diff --git a/bindings/typescript/src/abi.ts b/bindings/typescript/src/abi.ts new file mode 100644 index 00000000..4617706a --- /dev/null +++ b/bindings/typescript/src/abi.ts @@ -0,0 +1,58 @@ +/** + * Load-time ABI verification. TypeScript has no C compiler to check struct + * layout at build time (unlike Rust/Swift), so — like the Python ctypes + * binding — we verify the generated layout twice before constructing anything: + * + * 1. self-check: koffi's computed size/align/offsets vs the layout the C + * compiler captured at generation time (STRUCT_LAYOUT). + * 2. native-agreement: koffi's size/align vs the loaded library via + * transcribe_abi_struct_size/_align (catches a binding/library skew). + */ + +import { ABI_STRUCT_IDS, STRUCT_LAYOUT } from "./_generated.js"; +import * as g from "./_generated.js"; +import { AbiError } from "./errors.js"; +import type { Bound } from "./ffi.js"; + +export function verifyLayouts({ koffi, T, F }: Bound): void { + const mismatches: string[] = []; + + for (const [name, lo] of Object.entries(STRUCT_LAYOUT)) { + const type = T[name]; + if (!type) continue; + const size = koffi.sizeof(type); + const align = koffi.alignof(type); + if (size !== lo.size) mismatches.push(`${name}: size ${size} != captured ${lo.size}`); + if (align !== lo.align) mismatches.push(`${name}: align ${align} != captured ${lo.align}`); + for (const [field, off] of Object.entries(lo.offsets)) { + const actual = koffi.offsetof(type, field); + if (actual !== off) { + mismatches.push(`${name}.${field}: offset ${actual} != captured ${off}`); + } + } + } + + for (const [name, id] of Object.entries(ABI_STRUCT_IDS)) { + const type = T[name]; + if (!type) continue; + const nativeSize = Number(F.abiStructSize(id)); + const nativeAlign = Number(F.abiStructAlign(id)); + if (nativeSize === 0) { + mismatches.push(`${name}: native size 0 (loaded library is older than this binding)`); + continue; + } + const size = koffi.sizeof(type); + const align = koffi.alignof(type); + if (size !== nativeSize) mismatches.push(`${name}: size ${size} != native ${nativeSize}`); + if (align !== nativeAlign) mismatches.push(`${name}: align ${align} != native ${nativeAlign}`); + } + + if (mismatches.length) { + throw new AbiError( + "transcribe_cpp ABI layout check failed — this is a version/build skew between the " + + "binding and the native library:\n " + + mismatches.join("\n "), + g.TRANSCRIBE_ERR_BAD_STRUCT_SIZE, + ); + } +} diff --git a/bindings/typescript/src/errors.ts b/bindings/typescript/src/errors.ts new file mode 100644 index 00000000..41ec2e5d --- /dev/null +++ b/bindings/typescript/src/errors.ts @@ -0,0 +1,84 @@ +/** Error hierarchy mapped from `transcribe_status`. Mirrors the Python binding. */ + +import type { TranscriptionResult } from "./types.js"; +import * as g from "./_generated.js"; + +export class TranscribeError extends Error { + /** The `transcribe_status` value (0 for binding-side errors). */ + readonly status: number; + /** Set on per-utterance failures from a batch run. */ + utteranceIndex?: number; + /** Any partial transcript recovered before the error (set on Aborted / OutputTruncated). */ + partialResult?: TranscriptionResult; + + constructor(message: string, status: number = g.TRANSCRIBE_OK) { + super(message); + this.name = new.target.name; + this.status = status; + } +} + +export class InvalidArgument extends TranscribeError {} +export class NotImplementedByModel extends TranscribeError {} + +/** + * Raised when another compute already occupies the model. The C library allows + * at most one run / batch / active stream in flight per model across ALL of its + * sessions (transcribe.h); a sibling op while a stream is active is refused with + * this rather than allowed to race. Binding-side error, so `status` is 0. + */ +export class Busy extends TranscribeError {} +export class ModelFileNotFound extends TranscribeError {} +export class ModelLoadError extends TranscribeError {} +export class OutOfMemory extends TranscribeError {} +export class BackendError extends TranscribeError {} +export class UnsupportedRequest extends TranscribeError {} +export class AbiError extends TranscribeError {} +export class InputTooLong extends TranscribeError {} +export class VersionMismatch extends TranscribeError {} + +/** Raised when a run is cancelled; carries any partial transcript in `partialResult`. */ +export class Aborted extends TranscribeError {} + +/** Raised when decode hits the context/generation cap; carries the partial in `partialResult`. */ +export class OutputTruncated extends TranscribeError {} + +const STATUS_TO_EXC: Record TranscribeError> = { + [g.TRANSCRIBE_ERR_INVALID_ARG]: InvalidArgument, + [g.TRANSCRIBE_ERR_NOT_IMPLEMENTED]: NotImplementedByModel, + [g.TRANSCRIBE_ERR_FILE_NOT_FOUND]: ModelFileNotFound, + [g.TRANSCRIBE_ERR_GGUF]: ModelLoadError, + [g.TRANSCRIBE_ERR_UNSUPPORTED_ARCH]: ModelLoadError, + [g.TRANSCRIBE_ERR_UNSUPPORTED_VARIANT]: ModelLoadError, + [g.TRANSCRIBE_ERR_OOM]: OutOfMemory, + [g.TRANSCRIBE_ERR_BACKEND]: BackendError, + [g.TRANSCRIBE_ERR_SAMPLE_RATE]: InvalidArgument, + [g.TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE]: UnsupportedRequest, + [g.TRANSCRIBE_ERR_UNSUPPORTED_TASK]: UnsupportedRequest, + [g.TRANSCRIBE_ERR_UNSUPPORTED_TIMESTAMPS]: UnsupportedRequest, + [g.TRANSCRIBE_ERR_ABORTED]: Aborted, + [g.TRANSCRIBE_ERR_BAD_STRUCT_SIZE]: AbiError, + [g.TRANSCRIBE_ERR_UNSUPPORTED_PNC]: UnsupportedRequest, + [g.TRANSCRIBE_ERR_UNSUPPORTED_ITN]: UnsupportedRequest, + [g.TRANSCRIBE_ERR_INPUT_TOO_LONG]: InputTooLong, + [g.TRANSCRIBE_ERR_OUTPUT_TRUNCATED]: OutputTruncated, +}; + +/** Build (do not throw) the mapped exception for a status. */ +export function exceptionForStatus( + status: number, + statusString: string, + context = "", +): TranscribeError { + const Cls = STATUS_TO_EXC[status] ?? TranscribeError; + const msg = context + ? `${context}: ${statusString} (status ${status})` + : `${statusString} (status ${status})`; + return new Cls(msg, status); +} + +/** Throw the mapped exception unless the status is OK. */ +export function raiseForStatus(status: number, statusString: string, context = ""): void { + if (status === g.TRANSCRIBE_OK) return; + throw exceptionForStatus(status, statusString, context); +} diff --git a/bindings/typescript/src/ffi.ts b/bindings/typescript/src/ffi.ts new file mode 100644 index 00000000..03529709 --- /dev/null +++ b/bindings/typescript/src/ffi.ts @@ -0,0 +1,246 @@ +/** + * The koffi engine adapter: load the library and hand-bind the C functions + * with explicit in/out/inout direction (which a generic generator can't infer). + * Struct *layouts* come from the generated `defineTypes`; the drift gate covers + * signature changes via the pinned PUBLIC_HEADER_HASH. + * + * This is the one runtime-specific seam. A Bun/Deno adapter would re-implement + * this file against the same generated types and the same public shape. + */ + +import koffi from "koffi"; +import { defineTypes } from "./_generated.js"; + +export interface Bound { + koffi: typeof koffi; + lib: ReturnType; + T: Record; + F: Record; +} + +export function bindLibrary(libraryPath: string): Bound { + const lib = koffi.load(libraryPath); + const T = defineTypes(koffi); + + const inp = (t: any) => koffi.pointer(t); // const X * (marshalled in) + const outp = (t: any) => koffi.out(koffi.pointer(t)); // X * pure out (e.g. *_init) + // Accessors read struct_size on input then fill the rest: copy in AND back. + const iop = (t: any) => koffi.inout(koffi.pointer(t)); + const handleOut = koffi.out(koffi.pointer("void *")); // X ** out + + const F: Record = { + // identity / diagnostics + version: lib.func("transcribe_version", "str", []), + versionCommit: lib.func("transcribe_version_commit", "str", []), + statusString: lib.func("transcribe_status_string", "str", ["int"]), + abiStructSize: lib.func("transcribe_abi_struct_size", "uint64", ["int"]), + abiStructAlign: lib.func("transcribe_abi_struct_align", "uint64", ["int"]), + + // backends + initBackends: lib.func("transcribe_init_backends", "int", ["str"]), + initBackendsDefault: lib.func("transcribe_init_backends_default", "int", []), + backendDeviceCount: lib.func("transcribe_backend_device_count", "int", []), + backendDeviceInit: lib.func("transcribe_backend_device_init", "void", [ + outp(T.transcribe_backend_device), + ]), + getBackendDevice: lib.func("transcribe_get_backend_device", "int", [ + "int", + iop(T.transcribe_backend_device), + ]), + backendAvailable: lib.func("transcribe_backend_available", "bool", ["int"]), + + // logging (callback pointer comes from koffi.register -> passed as void*) + logSet: lib.func("transcribe_log_set", "void", ["void *", "void *"]), + + // model + modelLoadParamsInit: lib.func("transcribe_model_load_params_init", "void", [ + outp(T.transcribe_model_load_params), + ]), + modelLoadFile: lib.func("transcribe_model_load_file", "int", [ + "str", + inp(T.transcribe_model_load_params), + handleOut, + ]), + modelFree: lib.func("transcribe_model_free", "void", ["void *"]), + modelArch: lib.func("transcribe_model_arch_string", "str", ["void *"]), + modelVariant: lib.func("transcribe_model_variant_string", "str", ["void *"]), + modelBackend: lib.func("transcribe_model_backend", "str", ["void *"]), + modelSupports: lib.func("transcribe_model_supports", "bool", ["void *", "int"]), + tokenize: lib.func("transcribe_tokenize", "int", ["void *", "str", "int32_t *", "size_t"]), + capabilitiesInit: lib.func("transcribe_capabilities_init", "void", [ + outp(T.transcribe_capabilities), + ]), + modelGetCapabilities: lib.func("transcribe_model_get_capabilities", "int", [ + "void *", + iop(T.transcribe_capabilities), + ]), + + // session + sessionParamsInit: lib.func("transcribe_session_params_init", "void", [ + outp(T.transcribe_session_params), + ]), + sessionInit: lib.func("transcribe_session_init", "int", [ + "void *", + inp(T.transcribe_session_params), + handleOut, + ]), + sessionFree: lib.func("transcribe_session_free", "void", ["void *"]), + sessionLimitsInit: lib.func("transcribe_session_limits_init", "void", [ + outp(T.transcribe_session_limits), + ]), + sessionGetLimits: lib.func("transcribe_session_get_limits", "int", [ + "void *", + iop(T.transcribe_session_limits), + ]), + setAbortCallback: lib.func("transcribe_set_abort_callback", "void", [ + "void *", + "void *", + "void *", + ]), + wasAborted: lib.func("transcribe_was_aborted", "bool", ["void *"]), + wasTruncated: lib.func("transcribe_was_truncated", "bool", ["void *"]), + + // run (offline) + result accessors + runParamsInit: lib.func("transcribe_run_params_init", "void", [ + outp(T.transcribe_run_params), + ]), + run: lib.func("transcribe_run", "int", [ + "void *", + inp("float"), + "int", + inp(T.transcribe_run_params), + ]), + fullText: lib.func("transcribe_full_text", "str", ["void *"]), + detectedLanguage: lib.func("transcribe_detected_language", "str", ["void *"]), + returnedTimestampKind: lib.func("transcribe_returned_timestamp_kind", "int", ["void *"]), + nSegments: lib.func("transcribe_n_segments", "int", ["void *"]), + nWords: lib.func("transcribe_n_words", "int", ["void *"]), + nTokens: lib.func("transcribe_n_tokens", "int", ["void *"]), + segmentInit: lib.func("transcribe_segment_init", "void", [outp(T.transcribe_segment)]), + wordInit: lib.func("transcribe_word_init", "void", [outp(T.transcribe_word)]), + tokenInit: lib.func("transcribe_token_init", "void", [outp(T.transcribe_token)]), + getSegment: lib.func("transcribe_get_segment", "int", [ + "void *", + "int", + iop(T.transcribe_segment), + ]), + getWord: lib.func("transcribe_get_word", "int", ["void *", "int", iop(T.transcribe_word)]), + getToken: lib.func("transcribe_get_token", "int", ["void *", "int", iop(T.transcribe_token)]), + timingsInit: lib.func("transcribe_timings_init", "void", [outp(T.transcribe_timings)]), + getTimings: lib.func("transcribe_get_timings", "int", ["void *", iop(T.transcribe_timings)]), + + // family extensions + modelAcceptsExtKind: lib.func("transcribe_model_accepts_ext_kind", "bool", [ + "void *", + "int", + "uint32", + ]), + whisperRunExtInit: lib.func("transcribe_whisper_run_ext_init", "void", [ + outp(T.transcribe_whisper_run_ext), + ]), + moonshineStreamingStreamExtInit: lib.func( + "transcribe_moonshine_streaming_stream_ext_init", + "void", + [outp(T.transcribe_moonshine_streaming_stream_ext)], + ), + parakeetStreamExtInit: lib.func("transcribe_parakeet_stream_ext_init", "void", [ + outp(T.transcribe_parakeet_stream_ext), + ]), + parakeetBufferedStreamExtInit: lib.func("transcribe_parakeet_buffered_stream_ext_init", "void", [ + outp(T.transcribe_parakeet_buffered_stream_ext), + ]), + voxtralRealtimeStreamExtInit: lib.func("transcribe_voxtral_realtime_stream_ext_init", "void", [ + outp(T.transcribe_voxtral_realtime_stream_ext), + ]), + + // batch (offline) + runBatch: lib.func("transcribe_run_batch", "int", [ + "void *", + "float **", + "int *", + "int", + inp(T.transcribe_run_params), + ]), + batchNResults: lib.func("transcribe_batch_n_results", "int", ["void *"]), + batchStatus: lib.func("transcribe_batch_status", "int", ["void *", "int"]), + batchNSegments: lib.func("transcribe_batch_n_segments", "int", ["void *", "int"]), + batchGetSegment: lib.func("transcribe_batch_get_segment", "int", [ + "void *", + "int", + "int", + iop(T.transcribe_segment), + ]), + batchNWords: lib.func("transcribe_batch_n_words", "int", ["void *", "int"]), + batchGetWord: lib.func("transcribe_batch_get_word", "int", [ + "void *", + "int", + "int", + iop(T.transcribe_word), + ]), + batchNTokens: lib.func("transcribe_batch_n_tokens", "int", ["void *", "int"]), + batchGetToken: lib.func("transcribe_batch_get_token", "int", [ + "void *", + "int", + "int", + iop(T.transcribe_token), + ]), + batchFullText: lib.func("transcribe_batch_full_text", "str", ["void *", "int"]), + batchDetectedLanguage: lib.func("transcribe_batch_detected_language", "str", ["void *", "int"]), + batchReturnedTimestampKind: lib.func("transcribe_batch_returned_timestamp_kind", "int", [ + "void *", + "int", + ]), + batchGetTimings: lib.func("transcribe_batch_get_timings", "int", [ + "void *", + "int", + iop(T.transcribe_timings), + ]), + + // streaming + streamParamsInit: lib.func("transcribe_stream_params_init", "void", [ + outp(T.transcribe_stream_params), + ]), + streamUpdateInit: lib.func("transcribe_stream_update_init", "void", [ + outp(T.transcribe_stream_update), + ]), + streamTextInit: lib.func("transcribe_stream_text_init", "void", [outp(T.transcribe_stream_text)]), + streamBegin: lib.func("transcribe_stream_begin", "int", [ + "void *", + inp(T.transcribe_run_params), + inp(T.transcribe_stream_params), + ]), + streamFeed: lib.func("transcribe_stream_feed", "int", [ + "void *", + inp("float"), + "int", + iop(T.transcribe_stream_update), + ]), + streamFinalize: lib.func("transcribe_stream_finalize", "int", [ + "void *", + iop(T.transcribe_stream_update), + ]), + streamGetText: lib.func("transcribe_stream_get_text", "int", [ + "void *", + iop(T.transcribe_stream_text), + ]), + streamReset: lib.func("transcribe_stream_reset", "void", ["void *"]), + streamGetState: lib.func("transcribe_stream_get_state", "int", ["void *"]), + streamRevision: lib.func("transcribe_stream_revision", "int", ["void *"]), + streamLastStatus: lib.func("transcribe_stream_last_status", "int", ["void *"]), + }; + + return { koffi, lib, T, F }; +} + +let abortProtoType: any = null; +let logProtoType: any = null; + +/** koffi proto for the abort callback: `bool (void *userdata)`. */ +export function abortProto(): any { + return (abortProtoType ??= koffi.proto("bool TranscribeAbortCb(void *udata)")); +} + +/** koffi proto for the log callback: `void (int level, const char *msg, void *ud)`. */ +export function logProto(): any { + return (logProtoType ??= koffi.proto("void TranscribeLogCb(int level, const char *msg, void *udata)")); +} diff --git a/bindings/typescript/src/index.ts b/bindings/typescript/src/index.ts index 52db35ec..50fd20fc 100644 --- a/bindings/typescript/src/index.ts +++ b/bindings/typescript/src/index.ts @@ -1,11 +1,1142 @@ /** - * TypeScript/Node.js bindings for transcribe.cpp. + * transcribe.cpp — TypeScript/Node.js bindings. * - * This is a placeholder package that reserves the `transcribe-cpp` name on npm - * while first-party bindings are developed. It ships no functionality yet. + * A koffi FFI binding over the shared native library: offline transcription and + * batch, streaming with committed/tentative text, typed per-family extensions, + * backend discovery, log routing, and cooperative cancellation. + */ + +import { native, setLogHandler, type LogHandler, type Native } from "./native.js"; +import * as g from "./_generated.js"; +import { + Aborted, + Busy, + exceptionForStatus, + InvalidArgument, + ModelLoadError, + NotImplementedByModel, + OutputTruncated, + TranscribeError, + UnsupportedRequest, +} from "./errors.js"; +import type { + Backend, + BackendInfo, + BatchItem, + Capabilities, + CommitPolicy, + ExtSlot, + FamilyExtension, + Feature, + KvType, + ModelOptions, + PcmLike, + Segment, + SessionLimits, + SessionOptions, + StreamOptions, + StreamState, + StreamText, + StreamUpdate, + Timings, + TimestampKind, + Token, + TranscribeOptions, + TranscriptionResult, + Word, +} from "./types.js"; + +export * from "./types.js"; +export * from "./errors.js"; +export { setLogHandler, type LogHandler }; + +// ---- enum maps ------------------------------------------------------------- + +const BACKENDS: Record = { + auto: g.TRANSCRIBE_BACKEND_AUTO, + cpu: g.TRANSCRIBE_BACKEND_CPU, + metal: g.TRANSCRIBE_BACKEND_METAL, + vulkan: g.TRANSCRIBE_BACKEND_VULKAN, + cpu_accel: g.TRANSCRIBE_BACKEND_CPU_ACCEL, + cuda: g.TRANSCRIBE_BACKEND_CUDA, +}; +const KV_TYPES: Record = { + auto: g.TRANSCRIBE_KV_TYPE_AUTO, + f32: g.TRANSCRIBE_KV_TYPE_F32, + f16: g.TRANSCRIBE_KV_TYPE_F16, +}; +const TASKS = { transcribe: g.TRANSCRIBE_TASK_TRANSCRIBE, translate: g.TRANSCRIBE_TASK_TRANSLATE }; +const TIMESTAMPS: Record = { + none: g.TRANSCRIBE_TIMESTAMPS_NONE, + auto: g.TRANSCRIBE_TIMESTAMPS_AUTO, + segment: g.TRANSCRIBE_TIMESTAMPS_SEGMENT, + word: g.TRANSCRIBE_TIMESTAMPS_WORD, + token: g.TRANSCRIBE_TIMESTAMPS_TOKEN, +}; +const TIMESTAMP_NAMES: Record = Object.fromEntries( + Object.entries(TIMESTAMPS).map(([k, v]) => [v, k as TimestampKind]), +); +const FEATURES: Record = { + initial_prompt: g.TRANSCRIBE_FEATURE_INITIAL_PROMPT, + temperature_fallback: g.TRANSCRIBE_FEATURE_TEMPERATURE_FALLBACK, + long_form: g.TRANSCRIBE_FEATURE_LONG_FORM, + cancellation: g.TRANSCRIBE_FEATURE_CANCELLATION, + pnc: g.TRANSCRIBE_FEATURE_PNC, + itn: g.TRANSCRIBE_FEATURE_ITN, +}; + +// ---- helpers --------------------------------------------------------------- + +function lookup(map: Record, key: T, what: string): number { + const v = map[key]; + if (v === undefined) { + throw new TranscribeError( + `invalid ${what} ${JSON.stringify(key)}; expected one of ${Object.keys(map).join(", ")}`, + ); + } + return v; +} + +function check(n: Native, status: number, context: string): void { + if (status === g.TRANSCRIBE_OK) return; + throw exceptionForStatus(status, n.F.statusString(status), context); +} + +function busyError(op: string): Busy { + return new Busy( + `cannot ${op}: a stream is active on this model. The C library allows one ` + + `run / batch / active stream in flight per model across all sessions — ` + + `finalize or reset the stream first, or use a separate model.`, + ); +} + +/** + * Run a native free/reset behind the model lock, after any in-flight (and + * queued) worker call drains, so it never overlaps a compute on a libuv worker. + * `after` (e.g. the compute-lease release) runs in the SAME queued slot, after + * the native teardown — so an op queued earlier still observes the lease held / + * the stream un-reset until the native state has actually been torn down. + * Best-effort: errors are swallowed (a free cannot meaningfully fail) and the + * floating promise is marked intentional with `void`. + */ +function deferFree(lock: Mutex, fn: () => void, after?: () => void): void { + void lock.run(async () => { + try { + fn(); + } catch { + /* native free is infallible in practice; nothing to recover */ + } + after?.(); + }); +} + +function callAsync(fn: any, ...args: any[]): Promise { + return new Promise((resolve, reject) => + fn.async(...args, (err: Error | null, res: T) => (err ? reject(err) : resolve(res))), + ); +} + +// Coerce caller PCM to a Float32Array. A Float32Array is returned AS-IS (no +// copy): the buffer is borrowed across the async native call, which reads it on +// a worker thread, so callers must not mutate it until the promise resolves +// (documented on run/runBatch/feed). Other inputs already produce a fresh array. +function toFloat32(pcm: PcmLike): Float32Array { + let out: Float32Array; + if (pcm instanceof Float32Array) out = pcm; + else if (Array.isArray(pcm)) out = Float32Array.from(pcm); + else if (pcm instanceof ArrayBuffer) out = new Float32Array(pcm); + else if (ArrayBuffer.isView(pcm)) { + const b = pcm as Buffer; + if (b.byteLength % 4 !== 0) { + throw new TranscribeError("PCM byte length must be a multiple of 4 (float32)"); + } + out = new Float32Array(b.buffer, b.byteOffset, b.byteLength / 4); + } else { + throw new TranscribeError("PCM must be a Float32Array, number[], ArrayBuffer, or Buffer"); + } + if (out.length === 0) throw new TranscribeError("PCM is empty"); + return out; +} + +// koffi decodes int64 struct fields as bigint. We surface them as number for +// ergonomics; the fields this is used on (millisecond timestamps, kv byte caps) +// stay well under Number.MAX_SAFE_INTEGER, so the narrowing is lossless in +// practice. Revisit if a field can exceed 2^53. +function num(v: number | bigint): number { + return typeof v === "bigint" ? Number(v) : v; +} + +/** + * A FIFO async mutex plus the model-wide compute lease. One per Model. * - * @see https://github.com/handy-computer/transcribe.cpp + * `run()` serializes every native compute call (run, batch, stream + * feed/finalize) so they never overlap in time, even when koffi `.async()` puts + * work on libuv workers. But the C contract is stronger: at most one + * run/batch/*active stream* may be in flight per model across all sessions, and + * an active stream occupies the model for its whole lifetime — not just during + * a feed. `streamActive` is that lease: it is claimed at stream begin and held + * until finalize/reset, and run/batch/stream refuse (Busy) while it is set. + */ +class Mutex { + #tail: Promise = Promise.resolve(); + /** True while an active stream holds the model's single compute slot. */ + streamActive = false; + run(fn: () => Promise): Promise { + const prev = this.#tail; + let release!: () => void; + this.#tail = new Promise((r) => (release = r)); + return prev.then(fn).finally(release); + } +} + +// ---- module-level introspection ------------------------------------------- +// +// Note: these (like every public entry point) trigger the lazy native bootstrap +// on first call — they dlopen the library, verify the ABI, and init backends. +// There is no way to probe the binding without loading the native library, with +// one exception: `headerHash` below is the compile-time PUBLIC_HEADER_HASH and +// is the value the binding *expects*, not one read from the loaded library. + +export function version(): { version: string; commit: string; headerHash: string } { + const n = native(); + return { version: n.F.version(), commit: n.F.versionCommit(), headerHash: g.PUBLIC_HEADER_HASH }; +} + +export function libraryPath(): string { + return native().libraryPath; +} + +export function getAvailableBackends(): BackendInfo[] { + const n = native(); + const count = n.F.backendDeviceCount(); + const out: BackendInfo[] = []; + for (let i = 0; i < count; i++) { + const dev: any = {}; + n.F.backendDeviceInit(dev); + check(n, n.F.getBackendDevice(i, dev), `reading backend device ${i}`); + out.push({ name: dev.name ?? "", description: dev.description ?? "", kind: dev.kind ?? "" }); + } + return out; +} + +export function backendAvailable(backend: Backend): boolean { + const n = native(); + return n.F.backendAvailable(lookup(BACKENDS, backend, "backend")); +} + +// ---- result materialization ------------------------------------------------ + +/** Result accessors, parameterized so single and batch reads share the code. */ +interface Accessors { + nSegments(): number; + getSegment(j: number, out: any): number; + nWords(): number; + getWord(j: number, out: any): number; + nTokens(): number; + getToken(j: number, out: any): number; + getTimings(out: any): number; + fullText(): string; + detectedLanguage(): string; + returnedTimestampKind(): number; +} + +function singleAccessors(n: Native, h: any): Accessors { + const F = n.F; + return { + nSegments: () => F.nSegments(h), + getSegment: (j, o) => F.getSegment(h, j, o), + nWords: () => F.nWords(h), + getWord: (j, o) => F.getWord(h, j, o), + nTokens: () => F.nTokens(h), + getToken: (j, o) => F.getToken(h, j, o), + getTimings: (o) => F.getTimings(h, o), + fullText: () => F.fullText(h), + detectedLanguage: () => F.detectedLanguage(h), + returnedTimestampKind: () => F.returnedTimestampKind(h), + }; +} + +function batchAccessors(n: Native, h: any, i: number): Accessors { + const F = n.F; + return { + nSegments: () => F.batchNSegments(h, i), + getSegment: (j, o) => F.batchGetSegment(h, i, j, o), + nWords: () => F.batchNWords(h, i), + getWord: (j, o) => F.batchGetWord(h, i, j, o), + nTokens: () => F.batchNTokens(h, i), + getToken: (j, o) => F.batchGetToken(h, i, j, o), + getTimings: (o) => F.batchGetTimings(h, i, o), + fullText: () => F.batchFullText(h, i), + detectedLanguage: () => F.batchDetectedLanguage(h, i), + returnedTimestampKind: () => F.batchReturnedTimestampKind(h, i), + }; +} + +function materialize(n: Native, acc: Accessors): Omit { + const F = n.F; + + const segments: Segment[] = []; + for (let i = 0, c = acc.nSegments(); i < c; i++) { + const s: any = {}; + F.segmentInit(s); + check(n, acc.getSegment(i, s), `reading segment ${i}`); + segments.push({ + text: s.text ?? "", + t0Ms: num(s.t0_ms), + t1Ms: num(s.t1_ms), + firstWord: s.first_word, + nWords: s.n_words, + firstToken: s.first_token, + nTokens: s.n_tokens, + }); + } + + const words: Word[] = []; + for (let i = 0, c = acc.nWords(); i < c; i++) { + const w: any = {}; + F.wordInit(w); + check(n, acc.getWord(i, w), `reading word ${i}`); + words.push({ + text: w.text ?? "", + t0Ms: num(w.t0_ms), + t1Ms: num(w.t1_ms), + segIndex: w.seg_index, + firstToken: w.first_token, + nTokens: w.n_tokens, + }); + } + + const tokens: Token[] = []; + for (let i = 0, c = acc.nTokens(); i < c; i++) { + const t: any = {}; + F.tokenInit(t); + check(n, acc.getToken(i, t), `reading token ${i}`); + tokens.push({ + text: t.text ?? "", + id: t.id, + p: t.p, + t0Ms: num(t.t0_ms), + t1Ms: num(t.t1_ms), + segIndex: t.seg_index, + wordIndex: t.word_index, + }); + } + + const tm: any = {}; + F.timingsInit(tm); + check(n, acc.getTimings(tm), "reading timings"); + const timings: Timings = { + loadMs: tm.load_ms, + melMs: tm.mel_ms, + encodeMs: tm.encode_ms, + decodeMs: tm.decode_ms, + }; + + return { + text: acc.fullText() ?? "", + language: acc.detectedLanguage() ?? "", + timestampKind: TIMESTAMP_NAMES[acc.returnedTimestampKind()] ?? "none", + segments, + words, + tokens, + timings, + }; +} + +// ---- family extensions ----------------------------------------------------- + +const COMMIT_POLICIES: Record = { + auto: g.TRANSCRIBE_STREAM_COMMIT_AUTO, + on_finalize: g.TRANSCRIBE_STREAM_COMMIT_ON_FINALIZE, + stable_prefix: g.TRANSCRIBE_STREAM_COMMIT_STABLE_PREFIX, +}; +const STREAM_STATES: Record = { + [g.TRANSCRIBE_STREAM_IDLE]: "idle", + [g.TRANSCRIBE_STREAM_ACTIVE]: "active", + [g.TRANSCRIBE_STREAM_FINISHED]: "finished", + [g.TRANSCRIBE_STREAM_FAILED]: "failed", +}; +const SLOT: Record = { + run: g.TRANSCRIBE_EXT_SLOT_RUN, + stream: g.TRANSCRIBE_EXT_SLOT_STREAM, +}; + +interface FamilyReg { + slot: ExtSlot; + kind: number; + type: string; + init: string; + map: (o: any) => Record; +} + +const FAMILY: Record = { + whisper: { + slot: "run", + kind: g.TRANSCRIBE_EXT_KIND_WHISPER_RUN, + type: "transcribe_whisper_run_ext", + init: "whisperRunExtInit", + map: (o) => ({ + initial_prompt: o.initialPrompt, + condition_on_prev_tokens: o.conditionOnPrevTokens, + temperature: o.temperature, + temperature_inc: o.temperatureInc, + compression_ratio_thold: o.compressionRatioThold, + logprob_thold: o.logprobThold, + no_speech_thold: o.noSpeechThold, + max_prev_context_tokens: o.maxPrevContextTokens, + seed: o.seed, + max_initial_timestamp: o.maxInitialTimestamp, + }), + }, + moonshine: { + slot: "stream", + kind: g.TRANSCRIBE_EXT_KIND_MOONSHINE_STREAMING_STREAM, + type: "transcribe_moonshine_streaming_stream_ext", + init: "moonshineStreamingStreamExtInit", + map: (o) => ({ min_decode_interval_ms: o.minDecodeIntervalMs }), + }, + parakeet: { + slot: "stream", + kind: g.TRANSCRIBE_EXT_KIND_PARAKEET_STREAM, + type: "transcribe_parakeet_stream_ext", + init: "parakeetStreamExtInit", + map: (o) => ({ att_context_right: o.attContextRight }), + }, + parakeet_buffered: { + slot: "stream", + kind: g.TRANSCRIBE_EXT_KIND_PARAKEET_BUFFERED_STREAM, + type: "transcribe_parakeet_buffered_stream_ext", + init: "parakeetBufferedStreamExtInit", + map: (o) => ({ left_ms: o.leftMs, chunk_ms: o.chunkMs, right_ms: o.rightMs }), + }, + voxtral: { + slot: "stream", + kind: g.TRANSCRIBE_EXT_KIND_VOXTRAL_REALTIME_STREAM, + type: "transcribe_voxtral_realtime_stream_ext", + init: "voxtralRealtimeStreamExtInit", + map: (o) => ({ + num_delay_tokens: o.numDelayTokens, + min_decode_interval_ms: o.minDecodeIntervalMs, + }), + }, +}; + +/** + * Build a native ext-struct buffer for a family extension and return the koffi + * pointer to assign to `params.family`. Validates the slot and that the model + * accepts the kind. The returned buffer must be kept alive (held via the params + * object) until the native call returns. + */ +function buildFamily(n: Native, modelHandle: any, family: FamilyExtension, slot: ExtSlot): any { + const reg = FAMILY[family.kind]; + if (!reg) throw new InvalidArgument(`unknown family extension kind ${JSON.stringify(family.kind)}`); + if (reg.slot !== slot) { + throw new InvalidArgument( + `family "${family.kind}" is a ${reg.slot} extension, not valid for a ${slot} call`, + ); + } + if (!n.F.modelAcceptsExtKind(modelHandle, SLOT[reg.slot], reg.kind)) { + throw new UnsupportedRequest(`this model does not accept the "${family.kind}" ${reg.slot} extension`); + } + const ext: any = {}; + n.F[reg.init](ext); // defaults + struct_size + kind + for (const [k, v] of Object.entries(reg.map(family))) { + if (v !== undefined) ext[k] = v; + } + const buf = n.koffi.alloc(n.T[reg.type], 1); + n.koffi.encode(buf, n.T[reg.type], ext); + return buf; +} + +function toStreamUpdate(u: any): StreamUpdate { + return { + resultChanged: u.result_changed, + isFinal: u.is_final, + revision: u.revision, + inputReceivedMs: num(u.input_received_ms), + audioCommittedMs: num(u.audio_committed_ms), + bufferedMs: num(u.buffered_ms), + committedChanged: u.committed_changed, + tentativeChanged: u.tentative_changed, + }; +} + +/** + * Module-private teardown control for each Stream, keyed by the instance. These + * ops mutate the stream's `#active`/lease state, so they must NOT be reachable + * from user code: calling the lease release on a live stream would clear the + * model-wide lease and let a sibling run/stream overlap it — the exact race the + * lease prevents. Public `@internal` is only a type hint (the method still exists + * at runtime), so the control surface lives here instead, reached only by the + * owning Session within this module. The closures capture the Stream; WeakMap + * ephemeron semantics keep that from pinning it in memory. */ +const STREAM_TEARDOWN = new WeakMap(); + +interface SessionControl { + enterCompute(kind: string): void; + leaveCompute(kind: string): void; + isCurrentStream(stream: Stream): boolean; + replaceCurrentStream(stream: Stream): void; + clearCurrentStream(stream: Stream): void; +} + +const SESSION_CONTROL = new WeakMap(); + +// ---- Session --------------------------------------------------------------- + +export class Session { + #n: Native; + #h: any; + #model: TranscribeModel; // keep the model alive while this session lives + #lock: Mutex; // shared with the model; serializes compute model-wide + #untrack: (self: Session) => void; // drop self from the model's session set + #inFlight: string | null = null; // set while a native call runs on a worker + #activeStream: Stream | null = null; // current wrapper for the session's native stream slot + #disposed = false; + + /** @internal */ + constructor( + n: Native, + model: TranscribeModel, + handle: any, + lock: Mutex, + untrack: (self: Session) => void, + ) { + this.#n = n; + this.#model = model; + this.#h = handle; + this.#lock = lock; + this.#untrack = untrack; + SESSION_CONTROL.set(this, { + enterCompute: (kind) => { + this.#inFlight = kind; + }, + leaveCompute: (kind) => { + if (this.#inFlight === kind) this.#inFlight = null; + }, + isCurrentStream: (stream) => this.#activeStream === stream, + replaceCurrentStream: (stream) => { + if (this.#activeStream && this.#activeStream !== stream) { + STREAM_TEARDOWN.get(this.#activeStream)?.invalidate(); + } + this.#activeStream = stream; + }, + clearCurrentStream: (stream) => { + if (this.#activeStream === stream) this.#activeStream = null; + }, + }); + } + + /** @internal */ + get handle(): any { + if (this.#disposed) throw new TranscribeError("session has been disposed"); + return this.#h; + } + + /** Reads touch the session; forbidden while a worker call is in flight. */ + #assertNotComputing(what: string): void { + if (this.#inFlight) { + throw new TranscribeError(`cannot read session ${what} while ${this.#inFlight} is in flight; await it first`); + } + } + + get limits(): SessionLimits { + this.#assertNotComputing("limits"); + const n = this.#n; + const l: any = {}; + n.F.sessionLimitsInit(l); + check(n, n.F.sessionGetLimits(this.handle, l), "reading session limits"); + return { + effectiveNCtx: l.effective_n_ctx, + effectiveMaxAudioMs: num(l.effective_max_audio_ms), + maxKvBytes: num(l.max_kv_bytes), + }; + } + + /** + * Transcribe one clip. The input PCM is borrowed, not copied: native code + * reads it on a worker thread while this runs, so do not mutate the buffer + * (e.g. reuse a scratch array) until the returned promise resolves. + */ + async run(pcm: PcmLike, opts: TranscribeOptions = {}): Promise { + const n = this.#n; + const F = n.F; + const h = this.handle; + const samples = toFloat32(pcm); + + const p = this.#buildRunParams(opts); + + return this.#lock.run(async () => { + if (this.#disposed) throw new TranscribeError("session has been disposed"); + if (this.#lock.streamActive) throw busyError("run"); + const cancel = this.#installAbort(opts.signal); + let status: number; + this.#inFlight = "run()"; + try { + status = await callAsync(F.run, h, samples, samples.length, p); + } finally { + this.#inFlight = null; + cancel?.(); + } + + if (status === g.TRANSCRIBE_ERR_ABORTED || status === g.TRANSCRIBE_ERR_OUTPUT_TRUNCATED) { + const partial: TranscriptionResult = { + ...materialize(n, singleAccessors(n, h)), + aborted: F.wasAborted(h), + truncated: F.wasTruncated(h), + }; + const exc = + status === g.TRANSCRIBE_ERR_ABORTED + ? new Aborted(`run aborted`, status) + : new OutputTruncated(`run output truncated`, status); + exc.partialResult = partial; + throw exc; + } + check(n, status, "transcribe_run"); + + return { ...materialize(n, singleAccessors(n, h)), aborted: F.wasAborted(h), truncated: F.wasTruncated(h) }; + }); + } + + #buildRunParams(opts: TranscribeOptions): any { + const n = this.#n; + const p: any = {}; + n.F.runParamsInit(p); + p.task = lookup(TASKS, opts.task ?? "transcribe", "task"); + p.timestamps = lookup(TIMESTAMPS, opts.timestamps ?? "none", "timestamps"); + if (opts.language !== undefined) p.language = opts.language; + if (opts.targetLanguage !== undefined) p.target_language = opts.targetLanguage; + if (opts.keepSpecialTags !== undefined) p.keep_special_tags = opts.keepSpecialTags; + if (opts.specKDrafts !== undefined) p.spec_k_drafts = opts.specKDrafts; + if (opts.family) p.family = buildFamily(n, this.#model.handle, opts.family, "run"); + return p; + } + + /** + * Offline batch transcription; each item carries its own success/failure. + * Inputs are borrowed, not copied: native code reads them on a worker thread + * while this runs, so do not mutate them until the returned promise resolves. + */ + async runBatch(pcms: PcmLike[], opts: TranscribeOptions = {}): Promise { + const n = this.#n; + const F = n.F; + const h = this.handle; + if (pcms.length === 0) throw new InvalidArgument("runBatch requires at least one input"); + const arrays = pcms.map(toFloat32); + const counts = Int32Array.from(arrays, (a) => a.length); + const p = this.#buildRunParams(opts); + + return this.#lock.run(async () => { + if (this.#disposed) throw new TranscribeError("session has been disposed"); + if (this.#lock.streamActive) throw busyError("runBatch"); + const cancel = this.#installAbort(opts.signal); + let status: number; + this.#inFlight = "runBatch()"; + try { + status = await callAsync(F.runBatch, h, arrays, counts, arrays.length, p); + } finally { + this.#inFlight = null; + cancel?.(); + } + // A batch returns OK even with per-utterance failures; only a top-level + // error (or a whole-batch abort) is fatal here. + if (status !== g.TRANSCRIBE_OK && status !== g.TRANSCRIBE_ERR_ABORTED) { + check(n, status, "transcribe_run_batch"); + } + const out: BatchItem[] = []; + for (let i = 0, c = F.batchNResults(h); i < c; i++) { + const st = F.batchStatus(h, i); + if (st === g.TRANSCRIBE_OK) { + out.push({ + ok: true, + result: { ...materialize(n, batchAccessors(n, h, i)), aborted: false, truncated: false }, + }); + } else { + const error = exceptionForStatus(st, F.statusString(st), `utterance ${i}`); + error.utteranceIndex = i; + if (st === g.TRANSCRIBE_ERR_ABORTED || st === g.TRANSCRIBE_ERR_OUTPUT_TRUNCATED) { + error.partialResult = { + ...materialize(n, batchAccessors(n, h, i)), + aborted: st === g.TRANSCRIBE_ERR_ABORTED, + truncated: st === g.TRANSCRIBE_ERR_OUTPUT_TRUNCATED, + }; + } + out.push({ ok: false, error }); + } + } + return out; + }); + } + + /** Begin a streaming session. The returned Stream owns the begin params. */ + async stream(opts: StreamOptions = {}): Promise { + const n = this.#n; + const F = n.F; + const h = this.handle; + const rp = this.#buildRunParams({ + task: opts.task, + language: opts.language, + targetLanguage: opts.targetLanguage, + timestamps: opts.timestamps, + keepSpecialTags: opts.keepSpecialTags, + specKDrafts: -1, + }); + const sp: any = {}; + F.streamParamsInit(sp); + sp.commit_policy = lookup(COMMIT_POLICIES, opts.commitPolicy ?? "auto", "commitPolicy"); + if (opts.stablePrefixAgreementN !== undefined) { + sp.stable_prefix_agreement_n = opts.stablePrefixAgreementN; + } + if (opts.family) sp.family = buildFamily(n, this.#model.handle, opts.family, "stream"); + + return this.#lock.run(async () => { + // Recheck inside the lock: dispose() may have run after we captured `h` + // but before this queued body — don't begin a stream on a dead session. + if (this.#disposed) throw new TranscribeError("session has been disposed"); + if (this.#lock.streamActive) throw busyError("begin a stream"); + check(n, F.streamBegin(h, rp, sp), "transcribe_stream_begin"); + this.#lock.streamActive = true; // claim the lease for the whole stream lifetime + // The Stream holds the Session (not a raw handle) so its calls fail fast + // once the session is disposed, and so dispose() can find and invalidate it. + const stream = new Stream(n, this, this.#lock, [rp, sp]); // pin params until reset + const control = SESSION_CONTROL.get(this); + if (!control) throw new TranscribeError("session control is missing"); + control.replaceCurrentStream(stream); + return stream; + }); + } + + /** + * Wire an AbortSignal to a native abort callback for one run. The callback is + * installed on *this session's* handle, but install/run/uninstall is only safe + * because every caller holds the model-wide #lock for the whole run — the lock, + * not the per-session handle, is what guarantees no run overlaps the window + * between setAbortCallback(cb) and setAbortCallback(null). A future change that + * relaxes the lock must keep this install/uninstall paired within one run. + */ + #installAbort(signal?: AbortSignal): (() => void) | null { + if (!signal) return null; + const n = this.#n; + const flag = { aborted: signal.aborted }; + const onAbort = () => { + flag.aborted = true; + }; + signal.addEventListener("abort", onAbort, { once: true }); + const cbPtr = n.koffi.register(() => flag.aborted, n.koffi.pointer(n.abortProto)); + n.F.setAbortCallback(this.handle, cbPtr, null); + return () => { + n.F.setAbortCallback(this.handle, null, null); + n.koffi.unregister(cbPtr); + signal.removeEventListener("abort", onAbort); + }; + } + + get wasAborted(): boolean { + this.#assertNotComputing("wasAborted"); + return this.#n.F.wasAborted(this.handle); + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#untrack(this); // stop the model from holding a dead session + // Deactivate any live stream NOW (its reset() no-ops, reads throw via the + // disposed handle), but release its model lease only inside the deferred + // teardown, after sessionFree — so a stream/run queued ahead of this can't + // claim the slot before the native session is actually gone. + const stream = this.#activeStream; + this.#activeStream = null; + const teardown = stream ? STREAM_TEARDOWN.get(stream) : undefined; + teardown?.deactivate(); + // Free behind the model lock: a run/feed worker may still hold this handle, + // and sessionFree mid-call is a use-after-free. Queuing on the FIFO lock + // runs the free after any in-flight (and queued) compute drains. The JS-side + // guard is already synchronous (#disposed/handle), so use-after-dispose + // still throws immediately; only the native free + lease release are deferred. + const n = this.#n; + const h = this.#h; + this.#h = null; + deferFree(this.#lock, () => n.F.sessionFree(h), () => teardown?.releaseLease()); + } + + [Symbol.dispose](): void { + this.dispose(); + } +} + +// ---- Stream ---------------------------------------------------------------- + +export class Stream { + #n: Native; + #session: Session; // owns the native handle; throws once disposed (no stale handle) + #lock: Mutex; + #sessionControl: SessionControl; + #keepalive: unknown[] | null; + #active = true; + #stale = false; // true once the session has begun a newer native stream + #inFlight = false; // true while a feed/finalize native call runs on a worker + #holdsLease = true; // born holding the model's compute lease (claimed at begin) + + /** @internal */ + constructor(n: Native, session: Session, lock: Mutex, keepalive: unknown[]) { + this.#n = n; + this.#session = session; + this.#lock = lock; + const sessionControl = SESSION_CONTROL.get(session); + if (!sessionControl) throw new TranscribeError("session control is missing"); + this.#sessionControl = sessionControl; + this.#keepalive = keepalive; + // Expose the teardown surface ONLY to the owning Session, via the + // module-private map — never as public methods (see STREAM_TEARDOWN). + STREAM_TEARDOWN.set(this, { + // Synchronous deactivation on Session dispose: a later reset() no-ops and + // reads fail fast via the disposed handle, so nothing touches freed memory. + deactivate: () => { + this.#active = false; + this.#keepalive = null; + }, + invalidate: () => { + this.#stale = true; + this.#keepalive = null; + }, + // Release the lease (guarded); the Session calls this inside its deferred + // teardown, after sessionFree, so the slot frees in FIFO order. + releaseLease: () => this.#releaseLease(), + }); + } + + /** + * Release the model's compute lease, once, if this stream still holds it. The + * `#holdsLease` guard ensures a reset() after finalize() (or a double reset) + * never clears a lease that another session has since claimed. + */ + #releaseLease(): void { + if (this.#holdsLease) { + this.#holdsLease = false; + this.#lock.streamActive = false; + } + } + + #assertCurrent(what: string): void { + if (this.#stale) { + throw new TranscribeError(`cannot ${what}: stream is no longer current for this session`); + } + } + + /** + * Feed one chunk of PCM; returns the update snapshot. The chunk is borrowed, + * not copied: native code reads it on a worker thread while the feed runs, so + * do not mutate it (e.g. reuse a capture buffer) until the promise resolves. + */ + async feed(pcm: PcmLike): Promise { + const n = this.#n; + const h = this.#session.handle; // throws if the session was disposed + this.#assertCurrent("feed"); + if (!this.#active) throw new TranscribeError("stream has been reset"); + const samples = toFloat32(pcm); + return this.#lock.run(async () => { + const u: any = {}; + n.F.streamUpdateInit(u); + // The native feed runs on a libuv worker. While it is in flight the + // session must not be touched from the main thread — the C session API + // is single-threaded (transcribe.h), and stream_get_text hands back + // pointers the feed may free/realloc. Flag it so the read getters fail + // fast instead of racing into a use-after-free. + this.#inFlight = true; + this.#sessionControl.enterCompute("feed()/finalize()"); + try { + const status = await callAsync(n.F.streamFeed, h, samples, samples.length, u); + if (status !== g.TRANSCRIBE_OK) { + // Native feed failures leave the stream in FAILED, which is no longer + // an active stream in the C API. Keep the wrapper readable for + // state/lastStatus, but free the model-wide compute slot. + this.#releaseLease(); + } + check(n, status, "transcribe_stream_feed"); + } finally { + this.#inFlight = false; + this.#sessionControl.leaveCompute("feed()/finalize()"); + } + return toStreamUpdate(u); + }); + } + + /** Flush remaining audio and commit the final text. */ + async finalize(): Promise { + const n = this.#n; + const h = this.#session.handle; // throws if the session was disposed + this.#assertCurrent("finalize stream"); + if (!this.#active) throw new TranscribeError("stream has been reset"); + return this.#lock.run(async () => { + const u: any = {}; + n.F.streamUpdateInit(u); + this.#inFlight = true; // see feed(): worker-thread compute, no concurrent reads + this.#sessionControl.enterCompute("feed()/finalize()"); + try { + check( + n, + await callAsync(n.F.streamFinalize, h, u), + "transcribe_stream_finalize", + ); + } finally { + this.#inFlight = false; + this.#sessionControl.leaveCompute("feed()/finalize()"); + // Finalize ends the active stream (FINISHED on success, FAILED on + // error), so the model is free again — release the lease either way. + this.#releaseLease(); + } + return toStreamUpdate(u); + }); + } + + /** + * Reads borrow session-owned snapshot memory, so they are forbidden while a + * feed()/finalize() is computing on a worker thread (concurrent use of a + * single session is undefined per transcribe.h). The natural pattern — + * `await stream.feed(chunk)` then read — is unaffected; this only rejects a + * read issued against an un-awaited feed. + */ + #assertNotFeeding(what: string): void { + if (this.#inFlight) { + throw new TranscribeError( + `cannot read stream ${what} while a feed()/finalize() is in flight; await it first`, + ); + } + } + + /** Current text snapshot (copied at the boundary). */ + get text(): StreamText { + const h = this.#session.handle; // throws if the session was disposed + this.#assertCurrent("read stream text"); + this.#assertNotFeeding("text"); + const n = this.#n; + const t: any = {}; + n.F.streamTextInit(t); + check(n, n.F.streamGetText(h, t), "transcribe_stream_get_text"); + return { + full: t.full_text ?? "", + committed: t.committed_text ?? "", + tentative: t.tentative_text ?? "", + }; + } + + get state(): StreamState { + const h = this.#session.handle; // throws if the session was disposed + this.#assertCurrent("read stream state"); + if (!this.#active) return "idle"; // reset() returns to idle; native reset may still be queued + this.#assertNotFeeding("state"); + return STREAM_STATES[this.#n.F.streamGetState(h)] ?? "idle"; + } + + get revision(): number { + const h = this.#session.handle; // throws if the session was disposed + this.#assertCurrent("read stream revision"); + this.#assertNotFeeding("revision"); + return this.#n.F.streamRevision(h); + } + + /** + * The stream's recorded terminal failure, or `null` while it is healthy. Set + * after a feed()/finalize() transitions the stream to `"failed"`; reset by a + * new stream. Inspect it when `state === "failed"`. + */ + get lastStatus(): TranscribeError | null { + const h = this.#session.handle; // throws if the session was disposed + this.#assertCurrent("read stream lastStatus"); + this.#assertNotFeeding("lastStatus"); + const n = this.#n; + const status = n.F.streamLastStatus(h); + if (status === g.TRANSCRIBE_OK) return null; + return exceptionForStatus(status, n.F.statusString(status), "stream"); + } + + /** End the stream and return the session to idle. Idempotent. */ + reset(): void { + if (this.#stale) return; // a newer stream owns the session slot now + if (!this.#active) return; // already reset, finalized-and-reset, or invalidated + this.#active = false; + this.#keepalive = null; + // Defer BOTH the native reset and the lease release into one queued slot, + // behind any in-flight feed/finalize. Releasing the lease only after the + // native streamReset means a stream/run queued before this reset still sees + // the lease held — it cannot begin and overlap the not-yet-reset stream. + // #active was true, so the session is still alive (dispose() deactivates + // streams first), and the handle is valid here. + const n = this.#n; + const h = this.#session.handle; + deferFree( + this.#lock, + () => { + if (this.#sessionControl.isCurrentStream(this)) { + n.F.streamReset(h); + this.#sessionControl.clearCurrentStream(this); + } + }, + () => this.#releaseLease(), + ); + } + + [Symbol.dispose](): void { + this.reset(); + } +} + +// ---- Model ----------------------------------------------------------------- + +export class TranscribeModel { + #n: Native; + #h: any; + #disposed = false; + #sessions = new Set(); + #lock = new Mutex(); // serializes compute across all sessions of this model + + private constructor(n: Native, handle: any) { + this.#n = n; + this.#h = handle; + } + + static async load(path: string, opts: ModelOptions = {}): Promise { + const n = native(); + const p: any = {}; + n.F.modelLoadParamsInit(p); + if (opts.backend) p.backend = lookup(BACKENDS, opts.backend, "backend"); + if (opts.gpuDevice !== undefined) p.gpu_device = opts.gpuDevice; + + const out: any[] = [null]; + const st = await callAsync(n.F.modelLoadFile, path, p, out); + check(n, st, `loading model ${path}`); + if (!out[0]) throw new ModelLoadError(`model load returned a null handle for ${path}`); + return new TranscribeModel(n, out[0]); + } + + /** @internal */ + get handle(): any { + if (this.#disposed) throw new TranscribeError("model has been disposed"); + return this.#h; + } + + createSession(opts: SessionOptions = {}): Session { + const n = this.#n; + const p: any = {}; + n.F.sessionParamsInit(p); + if (opts.nThreads !== undefined) p.n_threads = opts.nThreads; + if (opts.kvType) p.kv_type = lookup(KV_TYPES, opts.kvType, "kvType"); + if (opts.nCtx !== undefined) p.n_ctx = opts.nCtx; + + const out: any[] = [null]; + check(n, n.F.sessionInit(this.handle, p, out), "opening session"); + if (!out[0]) throw new TranscribeError("session init returned a null handle"); + const session = new Session(n, this, out[0], this.#lock, (s) => this.#sessions.delete(s)); + this.#sessions.add(session); + return session; + } + + /** Convenience: one session, one run, disposed after. */ + async transcribe(pcm: PcmLike, opts: TranscribeOptions = {}): Promise { + const session = this.createSession(); + try { + return await session.run(pcm, opts); + } finally { + session.dispose(); // untracks itself from #sessions + } + } + + get capabilities(): Capabilities { + const n = this.#n; + const c: any = {}; + n.F.capabilitiesInit(c); + check(n, n.F.modelGetCapabilities(this.handle, c), "reading capabilities"); + let languages: string[] = []; + try { + if (c.languages && c.n_languages > 0) { + languages = n.koffi.decode(c.languages, "char *", c.n_languages); + } + } catch { + languages = []; + } + return { + nativeSampleRate: c.native_sample_rate, + languages, + maxTimestampKind: TIMESTAMP_NAMES[c.max_timestamp_kind] ?? "none", + supportsLanguageDetect: c.supports_language_detect, + supportsTranslate: c.supports_translate, + supportsStreaming: c.supports_streaming, + supportsSpecDecode: c.supports_spec_decode, + maxAudioMs: num(c.max_audio_ms), + }; + } + + supports(feature: Feature): boolean { + return this.#n.F.modelSupports(this.handle, lookup(FEATURES, feature, "feature")); + } + + /** Whether this model accepts the given family extension on its slot. */ + accepts(family: FamilyExtension): boolean { + const reg = FAMILY[family.kind]; + if (!reg) return false; + return this.#n.F.modelAcceptsExtKind(this.handle, SLOT[reg.slot], reg.kind); + } + + /** Tokenize plain UTF-8 text into the model's vocabulary (no special tokens). */ + tokenize(text: string): Int32Array { + const F = this.#n.F; + const INT_MIN = -2147483648; + let cap = Math.max(16, text.length + 16); + for (let attempt = 0; attempt < 4; attempt++) { + const buf = new Int32Array(cap); + const r = F.tokenize(this.handle, text, buf, cap); + if (r === INT_MIN) { + throw new NotImplementedByModel("this model's tokenizer does not support encode"); + } + if (r >= 0) return buf.subarray(0, r); + cap = -r; // buffer too small; -r is the count needed + } + throw new TranscribeError("tokenize did not converge"); + } + + get arch(): string { + return this.#n.F.modelArch(this.handle) ?? ""; + } + get variant(): string { + return this.#n.F.modelVariant(this.handle) ?? ""; + } + get backend(): string { + return this.#n.F.modelBackend(this.handle) ?? ""; + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + // Snapshot: each dispose() untracks itself from #sessions as we go and + // queues its native free on the model lock. Queue modelFree last, so the + // FIFO lock runs it after every session free (the C contract: a model may + // only be freed once all derived sessions are). All deferred behind any + // in-flight worker call, so nothing is freed out from under a compute. + for (const s of [...this.#sessions]) s.dispose(); + this.#sessions.clear(); + const n = this.#n; + const h = this.#h; + this.#h = null; + deferFree(this.#lock, () => n.F.modelFree(h)); + } + + [Symbol.dispose](): void { + this.dispose(); + } +} -/** Version of this placeholder package. */ -export const version = "0.0.0"; +/** One-shot: load (or reuse) a model, transcribe, return the result. */ +export async function transcribe( + model: TranscribeModel | string, + pcm: PcmLike, + opts: TranscribeOptions & ModelOptions = {}, +): Promise { + if (model instanceof TranscribeModel) return model.transcribe(pcm, opts); + const m = await TranscribeModel.load(model, opts); + try { + return await m.transcribe(pcm, opts); + } finally { + m.dispose(); + } +} diff --git a/bindings/typescript/src/loader.ts b/bindings/typescript/src/loader.ts new file mode 100644 index 00000000..abd5b8e8 --- /dev/null +++ b/bindings/typescript/src/loader.ts @@ -0,0 +1,162 @@ +/** + * Resolve the native transcribe.cpp library. Resolution order (mirrors the + * Python provider choke point in `_library.py`): + * + * 1. TRANSCRIBE_LIBRARY — explicit path (developer escape hatch). + * 2. A per-platform npm package `@transcribe-cpp/` (the prebuilt + * bundle wrapping the shared `transcribe-native-` bytes). + * 3. A dev build tree (build-shared/src, build/src, …) found by walking up. + * + * Before dlopen of a platform package, its `contract.json` is validated + * (version base-match + header_hash equals our PUBLIC_HEADER_HASH). + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import * as g from "./_generated.js"; +import { AbiError, BackendError, TranscribeError, VersionMismatch } from "./errors.js"; + +export interface Resolved { + libraryPath: string; + /** Directory holding the library and any ggml backend modules. */ + artifactDir: string; + /** Provider package name, or null for env/dev resolution. */ + provider: string | null; +} + +const LIB_NAME = + process.platform === "darwin" + ? "libtranscribe.dylib" + : process.platform === "win32" + ? "transcribe.dll" + : "libtranscribe.so"; + +const OUR_VERSION = `${g.TRANSCRIBE_VERSION_MAJOR}.${g.TRANSCRIBE_VERSION_MINOR}.${g.TRANSCRIBE_VERSION_PATCH}`; + +function baseVersion(v: string): string { + const m = /^\d+(?:\.\d+)*/.exec(v.trim()); + return m ? m[0] : v.trim(); +} + +/** + * The platform-package tuple for this host, or null if unsupported. Uses the + * Node platform-arch convention (matches npm os/cpu fields), e.g. + * `darwin-arm64-metal`. The release workflow maps each shared + * `transcribe-native-` bundle onto its tuple here. + */ +function platformTuple(): string | null { + const { platform, arch } = process; + if (platform === "darwin") { + if (arch === "arm64") return "darwin-arm64-metal"; + if (arch === "x64") return "darwin-x64-cpu"; + } else if (platform === "linux") { + if (arch === "x64") return "linux-x64-cpu-vulkan"; + if (arch === "arm64") return "linux-arm64-cpu-vulkan"; + } else if (platform === "win32") { + if (arch === "x64") return "win32-x64-cpu-vulkan"; + } + return null; +} + +/** Validate a bundle's contract.json. Throws on a real mismatch. */ +function validateContract(dir: string, provider: string): void { + const file = path.join(dir, "contract.json"); + let contract: { version?: string; header_hash?: string }; + try { + contract = JSON.parse(fs.readFileSync(file, "utf8")); + } catch (e) { + throw new BackendError( + `provider ${provider} is missing a readable contract.json at ${file}: ${String(e)}`, + ); + } + if (contract.header_hash !== g.PUBLIC_HEADER_HASH) { + throw new AbiError( + `provider ${provider} was built against header hash ${contract.header_hash} ` + + `but this binding expects ${g.PUBLIC_HEADER_HASH}. Reinstall matching packages.`, + ); + } + if (contract.version && baseVersion(contract.version) !== OUR_VERSION) { + throw new VersionMismatch( + `provider ${provider} is version ${contract.version} but this binding is ${OUR_VERSION}. ` + + `Pre-1.0 requires an exact base-version match.`, + ); + } +} + +function tryPlatformPackage(): Resolved | null { + const tuple = platformTuple(); + if (!tuple) return null; + const provider = `@transcribe-cpp/${tuple}`; + const require = createRequire(import.meta.url); + let dir: string; + try { + dir = path.dirname(require.resolve(`${provider}/package.json`)); + } catch { + return null; // package not installed — fall through to dev tree + } + validateContract(dir, provider); // a contract mismatch is fatal, not a fall-through + return { libraryPath: path.join(dir, LIB_NAME), artifactDir: dir, provider }; +} + +/** A library produced by the source build (`npm run build:native`). */ +function tryLocalPrebuild(): Resolved | null { + const tuple = platformTuple(); + if (!tuple) return null; + // /prebuilds//lib/ (build-from-source installs here). + const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const dir = path.join(pkgRoot, "prebuilds", tuple, "lib"); + const cand = path.join(dir, LIB_NAME); + return fs.existsSync(cand) + ? { libraryPath: cand, artifactDir: dir, provider: `source-build:${tuple}` } + : null; +} + +/** Walk up to the repo root, then probe known build trees. */ +function tryDevTree(): Resolved | null { + let dir = path.dirname(fileURLToPath(import.meta.url)); + let root: string | null = null; + for (let i = 0; i < 12; i++) { + if ( + fs.existsSync(path.join(dir, "CMakeLists.txt")) && + fs.existsSync(path.join(dir, "include", "transcribe.h")) + ) { + root = dir; + break; + } + const up = path.dirname(dir); + if (up === dir) break; + dir = up; + } + if (!root) return null; + for (const rel of ["build-shared/src", "build-shared/bin", "build/src", "build/bin"]) { + const cand = path.join(root, rel, LIB_NAME); + if (fs.existsSync(cand)) { + return { libraryPath: cand, artifactDir: path.dirname(cand), provider: null }; + } + } + return null; +} + +export function resolveLibrary(): Resolved { + const override = process.env.TRANSCRIBE_LIBRARY; + if (override) { + if (!fs.existsSync(override)) { + throw new TranscribeError(`TRANSCRIBE_LIBRARY points at a missing file: ${override}`); + } + return { libraryPath: override, artifactDir: path.dirname(override), provider: null }; + } + return ( + tryPlatformPackage() ?? + tryLocalPrebuild() ?? + tryDevTree() ?? + (() => { + throw new BackendError( + "No transcribe.cpp native library found. Set TRANSCRIBE_LIBRARY, install a " + + "matching @transcribe-cpp/ package, run `npm run build:native` to " + + `build from source, or build the shared library (build-shared/src/${LIB_NAME}).`, + ); + })() + ); +} diff --git a/bindings/typescript/src/native.ts b/bindings/typescript/src/native.ts new file mode 100644 index 00000000..c61c7f55 --- /dev/null +++ b/bindings/typescript/src/native.ts @@ -0,0 +1,99 @@ +/** + * Native bootstrap: resolve -> load -> bind -> verify ABI -> version-gate -> + * init backends. The single choke point both the public API and tests go + * through. Lazy and memoized: nothing loads until the first call. + */ + +import { resolveLibrary } from "./loader.js"; +import { abortProto, bindLibrary, type Bound, logProto } from "./ffi.js"; +import { verifyLayouts } from "./abi.js"; +import { BackendError, VersionMismatch } from "./errors.js"; +import * as g from "./_generated.js"; + +export interface Native extends Bound { + libraryPath: string; + provider: string | null; + abortProto: any; + logProto: any; +} + +let cached: Native | null = null; + +const OUR_VERSION = `${g.TRANSCRIBE_VERSION_MAJOR}.${g.TRANSCRIBE_VERSION_MINOR}.${g.TRANSCRIBE_VERSION_PATCH}`; +const baseVersion = (v: string): string => (/^\d+(?:\.\d+)*/.exec(v.trim())?.[0] ?? v.trim()); + +// ---- log routing ----------------------------------------------------------- + +export type LogHandler = (level: number, message: string) => void; +let logHandler: LogHandler | null = null; +let logRegistered: any = null; + +function ensureLogCallback(bound: Bound): void { + if (logRegistered) return; + const thunk = (level: number, msg: string) => { + const h = logHandler; + if (!h) return; + try { + h(level, msg ?? ""); + } catch { + /* a logging handler must never propagate into native code */ + } + }; + logRegistered = bound.koffi.register(thunk, bound.koffi.pointer(logProto())); + bound.F.logSet(logRegistered, null); +} + +export function native(): Native { + if (cached) return cached; + + const resolved = resolveLibrary(); + const bound = bindLibrary(resolved.libraryPath); + verifyLayouts(bound); + + const nativeVersion = bound.F.version(); + if (baseVersion(nativeVersion) !== OUR_VERSION) { + throw new VersionMismatch( + `native library is version ${nativeVersion} but this binding is ${OUR_VERSION} ` + + `(pre-1.0 requires an exact base-version match)`, + ); + } + + // transcribe_log_set is only supported as a startup-time install. Register a + // stable JS dispatch callback once, before backend init can create worker + // threads; setLogHandler later only swaps the JS target. + ensureLogCallback(bound); + + // Register backend modules package-local. On a compiled-in build this is a + // near no-op; on a dynamic-backend bundle it scans the artifact dir. + let st = bound.F.initBackends(resolved.artifactDir); + if (st !== g.TRANSCRIBE_OK) st = bound.F.initBackendsDefault(); + if (st !== g.TRANSCRIBE_OK) { + throw new BackendError( + `transcribe_init_backends found no usable compute device in ${resolved.artifactDir}: ` + + `${bound.F.statusString(st)} (status ${st})`, + ); + } + + cached = { + ...bound, + libraryPath: resolved.libraryPath, + provider: resolved.provider, + abortProto: abortProto(), + logProto: logProto(), + }; + return cached; +} + +/** + * Route native (and ggml) diagnostics to `handler`, or pass null to disable. + * The callback may fire from ggml worker threads; koffi marshals it to the + * event-loop thread (proven by the M2 spike), so the handler runs on the main + * thread. Exceptions thrown by the handler are swallowed (never re-enter C). + * + * The native callback is installed once during lazy bootstrap, before backend + * initialization. Later calls only swap this JS handler; they never call + * transcribe_log_set again. + */ +export function setLogHandler(handler: LogHandler | null): void { + logHandler = handler; +} diff --git a/bindings/typescript/src/types.ts b/bindings/typescript/src/types.ts new file mode 100644 index 00000000..efa7923f --- /dev/null +++ b/bindings/typescript/src/types.ts @@ -0,0 +1,203 @@ +/** Public value types for the transcribe.cpp TypeScript binding. */ + +import type { TranscribeError } from "./errors.js"; + +export type Backend = "auto" | "cpu" | "cpu_accel" | "cuda" | "vulkan" | "metal"; +export type KvType = "auto" | "f32" | "f16"; +export type Task = "transcribe" | "translate"; +export type TimestampKind = "none" | "auto" | "segment" | "word" | "token"; +export type Feature = + | "initial_prompt" + | "temperature_fallback" + | "long_form" + | "cancellation" + | "pnc" + | "itn"; + +/** Mono float32 PCM at the model's native sample rate (16 kHz for v1). */ +export type PcmLike = Float32Array | number[] | ArrayBuffer | Buffer; + +export interface Segment { + text: string; + t0Ms: number; + t1Ms: number; + firstWord: number; + nWords: number; + firstToken: number; + nTokens: number; +} + +export interface Word { + text: string; + t0Ms: number; + t1Ms: number; + segIndex: number; + firstToken: number; + nTokens: number; +} + +export interface Token { + text: string; + id: number; + p: number; + t0Ms: number; + t1Ms: number; + segIndex: number; + wordIndex: number; +} + +export interface Timings { + loadMs: number; + melMs: number; + encodeMs: number; + decodeMs: number; +} + +export interface Capabilities { + nativeSampleRate: number; + languages: string[]; + maxTimestampKind: TimestampKind; + supportsLanguageDetect: boolean; + supportsTranslate: boolean; + supportsStreaming: boolean; + supportsSpecDecode: boolean; + maxAudioMs: number; +} + +export interface SessionLimits { + effectiveNCtx: number; + effectiveMaxAudioMs: number; + maxKvBytes: number; +} + +export interface TranscriptionResult { + text: string; + language: string; + timestampKind: TimestampKind; + segments: Segment[]; + words: Word[]; + tokens: Token[]; + timings: Timings; + aborted: boolean; + truncated: boolean; +} + +export interface BackendInfo { + name: string; + description: string; + kind: string; +} + +export interface ModelOptions { + /** "auto" (default), or an explicit backend. */ + backend?: Backend; + /** GPU device ordinal for multi-GPU hosts. */ + gpuDevice?: number; +} + +export interface SessionOptions { + /** CPU threads for CPU-side ops; 0 = library default. */ + nThreads?: number; + kvType?: KvType; + /** Context-token cap; 0 = model default. */ + nCtx?: number; +} + +export interface TranscribeOptions { + task?: Task; + language?: string; + targetLanguage?: string; + timestamps?: TimestampKind; + keepSpecialTags?: boolean; + /** Speculative-decode draft count; -1 = family default. */ + specKDrafts?: number; + /** Cancel the run cooperatively. */ + signal?: AbortSignal; + /** A run-slot family extension (e.g. whisper). */ + family?: FamilyExtension; +} + +/** A native compute device the runtime discovered. */ +export interface DeviceInfo extends BackendInfo {} + +/** One result of a batch run: success carries the transcript, failure the error. + * On failure, `error.utteranceIndex` is set, and `error.partialResult` carries any + * recovered transcript when the failure was an abort/truncation. */ +export type BatchItem = + | { ok: true; result: TranscriptionResult } + | { ok: false; error: TranscribeError }; + +// ---- streaming ------------------------------------------------------------- + +export type CommitPolicy = "auto" | "on_finalize" | "stable_prefix"; +export type StreamState = "idle" | "active" | "finished" | "failed"; +export type ExtSlot = "run" | "stream"; + +export interface StreamUpdate { + resultChanged: boolean; + isFinal: boolean; + revision: number; + inputReceivedMs: number; + audioCommittedMs: number; + bufferedMs: number; + committedChanged: boolean; + tentativeChanged: boolean; +} + +export interface StreamText { + /** Raw model hypothesis. */ + full: string; + /** Append-only, stable prefix. */ + committed: string; + /** Volatile suffix. */ + tentative: string; +} + +export interface StreamOptions { + task?: Task; + language?: string; + targetLanguage?: string; + timestamps?: TimestampKind; + keepSpecialTags?: boolean; + commitPolicy?: CommitPolicy; + stablePrefixAgreementN?: number; + /** A stream-slot family extension (moonshine, parakeet, voxtral). */ + family?: FamilyExtension; +} + +// ---- family extensions (typed, discriminated by `kind`) -------------------- + +export interface WhisperRunOptions { + initialPrompt?: string; + conditionOnPrevTokens?: boolean; + temperature?: number; + temperatureInc?: number; + compressionRatioThold?: number; + logprobThold?: number; + noSpeechThold?: number; + maxPrevContextTokens?: number; + seed?: number; + maxInitialTimestamp?: number; +} +export interface MoonshineStreamingOptions { + minDecodeIntervalMs?: number; +} +export interface ParakeetStreamOptions { + attContextRight?: number; +} +export interface ParakeetBufferedStreamOptions { + leftMs?: number; + chunkMs?: number; + rightMs?: number; +} +export interface VoxtralRealtimeStreamOptions { + numDelayTokens?: number; + minDecodeIntervalMs?: number; +} + +export type FamilyExtension = + | ({ kind: "whisper" } & WhisperRunOptions) + | ({ kind: "moonshine" } & MoonshineStreamingOptions) + | ({ kind: "parakeet" } & ParakeetStreamOptions) + | ({ kind: "parakeet_buffered" } & ParakeetBufferedStreamOptions) + | ({ kind: "voxtral" } & VoxtralRealtimeStreamOptions); diff --git a/bindings/typescript/test/batch.test.mjs b/bindings/typescript/test/batch.test.mjs new file mode 100644 index 00000000..88a17c3e --- /dev/null +++ b/bindings/typescript/test/batch.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { modelTest, MODEL, jfk } from "./common.mjs"; +import { TranscribeModel } from "../dist/index.js"; + +modelTest("batch returns one result per utterance", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + try { + const s = m.createSession(); + const pcm = jfk(); + const items = await s.runBatch([pcm, pcm.subarray(0, pcm.length / 2)]); + assert.equal(items.length, 2); + assert.ok(items[0].ok && /ask not/i.test(items[0].result.text)); + assert.ok(items[1].ok); + s.dispose(); + } finally { + m.dispose(); + } +}); + +modelTest("empty batch is rejected", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + try { + const s = m.createSession(); + await assert.rejects(() => s.runBatch([])); + s.dispose(); + } finally { + m.dispose(); + } +}); diff --git a/bindings/typescript/test/cancel.test.mjs b/bindings/typescript/test/cancel.test.mjs new file mode 100644 index 00000000..7f9e3beb --- /dev/null +++ b/bindings/typescript/test/cancel.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { modelTest, MODEL, jfk } from "./common.mjs"; +import { TranscribeModel, Aborted } from "../dist/index.js"; + +modelTest("an uncancelled run is not aborted", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + try { + const r = await m.transcribe(jfk()); + assert.equal(r.aborted, false); + } finally { + m.dispose(); + } +}); + +modelTest("a pre-aborted signal raises Aborted with a partial result", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + try { + const s = m.createSession(); + const ac = new AbortController(); + ac.abort(); + await assert.rejects( + () => s.run(jfk(), { signal: ac.signal }), + (e) => e instanceof Aborted && e.partialResult !== undefined, + ); + s.dispose(); + } finally { + m.dispose(); + } +}); diff --git a/bindings/typescript/test/common.mjs b/bindings/typescript/test/common.mjs new file mode 100644 index 00000000..366417ed --- /dev/null +++ b/bindings/typescript/test/common.mjs @@ -0,0 +1,56 @@ +// Shared test plumbing. Two CI tiers (requirements §4): no-model tests run +// always; model-gated tests skip cleanly when their canary GGUF is absent +// (resolved from the TRANSCRIBE_SMOKE_* env vars fetch-canary exports). + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +export const MODEL = process.env.TRANSCRIBE_SMOKE_MODEL || ""; +export const STREAMING_MODEL = process.env.TRANSCRIBE_SMOKE_STREAMING_MODEL || ""; +export const PARAKEET_STREAM_MODEL = process.env.TRANSCRIBE_SMOKE_PARAKEET_STREAM_MODEL || ""; +export const PARAKEET_BUFFERED_MODEL = process.env.TRANSCRIBE_SMOKE_PARAKEET_BUFFERED_MODEL || ""; +// Voxtral realtime is local-only (~2.5 GB+, too heavy for the CI canary set): +// its env var is NOT exported by fetch-canary, so this skips cleanly in CI. +export const VOXTRAL_MODEL = process.env.TRANSCRIBE_SMOKE_VOXTRAL_MODEL || ""; + +// jfk.wav ships in-repo; fetch-canary exports only the model paths. +export const AUDIO = + process.env.TRANSCRIBE_SMOKE_AUDIO || path.resolve(HERE, "../../../samples/jfk.wav"); + +export const have = (p) => Boolean(p) && fs.existsSync(p); + +/** A test that skips cleanly when its canary model is unavailable. */ +export function modelTest(name, model, fn) { + test(name, { skip: have(model) ? false : `canary model unavailable` }, fn); +} + +/** Minimal 16-bit PCM WAV reader -> Float32Array (mono). */ +export function readWav(file) { + const b = fs.readFileSync(file); + let o = 12, + fmt = null, + d = null; + while (o + 8 <= b.length) { + const id = b.toString("ascii", o, o + 4); + const s = b.readUInt32LE(o + 4); + if (id === "fmt ") fmt = { ch: b.readUInt16LE(o + 10) }; + else if (id === "data") d = b.subarray(o + 8, o + 8 + s); + o += 8 + s + (s & 1); + } + const ch = fmt.ch; + const n = Math.floor(d.length / 2 / ch); + const p = new Float32Array(n); + for (let i = 0; i < n; i++) p[i] = d.readInt16LE(i * 2 * ch) / 32768; + return p; +} + +export const jfk = () => readWav(AUDIO); + +/** Feed `pcm` to a stream in ~1s chunks. */ +export async function feedChunks(stream, pcm, chunk = 16000) { + for (let i = 0; i < pcm.length; i += chunk) await stream.feed(pcm.subarray(i, i + chunk)); +} diff --git a/bindings/typescript/test/family.test.mjs b/bindings/typescript/test/family.test.mjs new file mode 100644 index 00000000..92a15233 --- /dev/null +++ b/bindings/typescript/test/family.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { + modelTest, + MODEL, + PARAKEET_STREAM_MODEL, + PARAKEET_BUFFERED_MODEL, + VOXTRAL_MODEL, + jfk, + feedChunks, +} from "./common.mjs"; +import { TranscribeModel } from "../dist/index.js"; + +modelTest("whisper run extension applies; accepts() discriminates", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + try { + assert.equal(m.accepts({ kind: "whisper" }), true); + assert.equal(m.accepts({ kind: "moonshine" }), false); + const r = await m.transcribe(jfk(), { + family: { kind: "whisper", initialPrompt: "A JFK speech." }, + }); + assert.match(r.text, /ask not/i); + } finally { + m.dispose(); + } +}); + +modelTest("run() rejects a stream-slot family with InvalidArgument", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + try { + await assert.rejects( + () => m.transcribe(jfk(), { family: { kind: "moonshine" } }), + (e) => e.constructor.name === "InvalidArgument", + ); + } finally { + m.dispose(); + } +}); + +modelTest("parakeet cache-aware stream extension", PARAKEET_STREAM_MODEL, async () => { + const m = await TranscribeModel.load(PARAKEET_STREAM_MODEL); + try { + assert.equal(m.accepts({ kind: "parakeet" }), true); + const s = m.createSession(); + const stream = await s.stream({ family: { kind: "parakeet", attContextRight: 13 } }); + await feedChunks(stream, jfk()); + await stream.finalize(); + assert.ok((stream.text.committed + stream.text.full).trim().length > 0); + stream.reset(); + s.dispose(); + } finally { + m.dispose(); + } +}); + +modelTest("parakeet buffered stream extension", PARAKEET_BUFFERED_MODEL, async () => { + const m = await TranscribeModel.load(PARAKEET_BUFFERED_MODEL); + try { + assert.equal(m.accepts({ kind: "parakeet_buffered" }), true); + const s = m.createSession(); + // Defaults land in the model's chunk/left/right menu; an arbitrary chunkMs + // (e.g. 2000 -> 25 encoder frames) is rejected by stream_begin. Field + // plumbing for a stream-slot ext is already covered by the cache-aware test + // above (attContextRight), so here we just exercise the buffered path. + const stream = await s.stream({ family: { kind: "parakeet_buffered" } }); + await feedChunks(stream, jfk()); + await stream.finalize(); + assert.ok((stream.text.committed + stream.text.full).trim().length > 0); + stream.reset(); + s.dispose(); + } finally { + m.dispose(); + } +}); + +// Local-only (CI never sets TRANSCRIBE_SMOKE_VOXTRAL_MODEL — the GGUF is too +// heavy for the canary set), matching the other bindings' voxtral footnote. +modelTest("voxtral realtime stream extension", VOXTRAL_MODEL, async () => { + const m = await TranscribeModel.load(VOXTRAL_MODEL); + try { + assert.equal(m.accepts({ kind: "voxtral" }), true); + const s = m.createSession(); + const stream = await s.stream({ + family: { kind: "voxtral", numDelayTokens: 4, minDecodeIntervalMs: 0 }, + }); + await feedChunks(stream, jfk()); + await stream.finalize(); + assert.ok((stream.text.committed + stream.text.full).trim().length > 0); + stream.reset(); + s.dispose(); + } finally { + m.dispose(); + } +}); diff --git a/bindings/typescript/test/lifetime.test.mjs b/bindings/typescript/test/lifetime.test.mjs new file mode 100644 index 00000000..95f0c363 --- /dev/null +++ b/bindings/typescript/test/lifetime.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { modelTest, MODEL } from "./common.mjs"; +import { TranscribeModel } from "../dist/index.js"; + +modelTest("double dispose is safe; disposing a model closes its sessions", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + const s = m.createSession(); + m.dispose(); + m.dispose(); // idempotent + assert.throws(() => s.limits, /disposed/); // closed by the model +}); + +modelTest("dispose ordering is safe either way", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + const s = m.createSession(); + s.dispose(); + s.dispose(); // idempotent + m.dispose(); // model after session +}); + +modelTest("Symbol.dispose releases the model", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + m.createSession(); + m[Symbol.dispose](); // what a `using` declaration calls at block exit + assert.throws(() => m.capabilities, /disposed/); +}); diff --git a/bindings/typescript/test/no-model.test.mjs b/bindings/typescript/test/no-model.test.mjs new file mode 100644 index 00000000..f6d43ed0 --- /dev/null +++ b/bindings/typescript/test/no-model.test.mjs @@ -0,0 +1,82 @@ +// No-model tier: always runs (needs the native library loadable, no GGUF). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as os from "node:os"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + version, + getAvailableBackends, + backendAvailable, + setLogHandler, + TranscribeModel, + Stream, + ModelFileNotFound, + ModelLoadError, +} from "../dist/index.js"; + +test("Stream does not expose internal lease/teardown controls", () => { + // releaseLease()/deactivate() must NOT be reachable from user code — calling + // the lease release on a live stream would reopen the cross-session race. + // They live in a module-private map, not on the class. + for (const name of ["releaseLease", "deactivate", "invalidate"]) { + assert.equal(name in Stream.prototype, false, `${name} must not be on Stream.prototype`); + } +}); + +test("setLogHandler works before and after native bootstrap", () => { + const seen = []; + setLogHandler((level, message) => seen.push([level, message])); + const v = version(); + assert.match(v.version, /^\d+\.\d+\.\d+/); + + setLogHandler(null); + setLogHandler(() => {}); + setLogHandler(null); +}); + +test("version exposes base version, commit, and header hash", () => { + const v = version(); + assert.match(v.version, /^\d+\.\d+\.\d+/); + assert.match(v.headerHash, /^[0-9a-f]{16}$/); + assert.equal(typeof v.commit, "string"); +}); + +test("at least one CPU backend is registered", () => { + const devices = getAvailableBackends(); + assert.ok(devices.length >= 1, "expected >= 1 device"); + assert.ok( + devices.some((d) => d.kind === "cpu"), + "expected a cpu device", + ); +}); + +test("backendAvailable is a clean boolean probe", () => { + assert.equal(backendAvailable("cpu"), true); + assert.equal(typeof backendAvailable("cuda"), "boolean"); +}); + +test("invalid backend string is rejected, not silently coerced", () => { + // @ts-expect-error intentionally bogus + assert.throws(() => backendAvailable("nope")); +}); + +test("missing model file maps to ModelFileNotFound", async () => { + await assert.rejects( + () => TranscribeModel.load("/no/such/model.gguf"), + (e) => e instanceof ModelFileNotFound, + ); +}); + +test("junk file maps to ModelLoadError", async () => { + const f = path.join(os.tmpdir(), `transcribe-junk-${process.pid}.gguf`); + fs.writeFileSync(f, Buffer.from("definitely not a gguf")); + try { + await assert.rejects( + () => TranscribeModel.load(f), + (e) => e instanceof ModelLoadError, + ); + } finally { + fs.rmSync(f, { force: true }); + } +}); diff --git a/bindings/typescript/test/pcm.test.mjs b/bindings/typescript/test/pcm.test.mjs new file mode 100644 index 00000000..ddfcadf8 --- /dev/null +++ b/bindings/typescript/test/pcm.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { modelTest, MODEL, jfk } from "./common.mjs"; +import { TranscribeModel } from "../dist/index.js"; + +modelTest("empty PCM is rejected before any native call", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + try { + const s = m.createSession(); + await assert.rejects(() => s.run(new Float32Array(0)), /empty/i); + s.dispose(); + } finally { + m.dispose(); + } +}); + +modelTest("number[] PCM is accepted (converted to float32)", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + try { + const r = await m.transcribe(Array.from(jfk())); + assert.match(r.text, /ask not/i); + } finally { + m.dispose(); + } +}); + +modelTest("a Buffer of float32 bytes is accepted", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + try { + const f = jfk(); + const buf = Buffer.from(f.buffer, f.byteOffset, f.byteLength); + const r = await m.transcribe(buf); + assert.match(r.text, /ask not/i); + } finally { + m.dispose(); + } +}); diff --git a/bindings/typescript/test/streaming.test.mjs b/bindings/typescript/test/streaming.test.mjs new file mode 100644 index 00000000..b873de20 --- /dev/null +++ b/bindings/typescript/test/streaming.test.mjs @@ -0,0 +1,242 @@ +import assert from "node:assert/strict"; +import { modelTest, MODEL, STREAMING_MODEL, jfk, feedChunks } from "./common.mjs"; +import { TranscribeModel, Busy } from "../dist/index.js"; + +modelTest("streaming commits text and finalizes", STREAMING_MODEL, async () => { + const m = await TranscribeModel.load(STREAMING_MODEL); + try { + assert.equal(m.capabilities.supportsStreaming, true); + const s = m.createSession(); + const stream = await s.stream({ commitPolicy: "stable_prefix" }); + assert.equal(stream.state, "active"); + await feedChunks(stream, jfk()); + const fin = await stream.finalize(); + assert.equal(fin.isFinal, true); + assert.ok(stream.revision > 0); + assert.equal(stream.lastStatus, null, "a healthy finished stream has no failure status"); + const t = stream.text; + assert.ok((t.committed + t.full).trim().length > 0, "expected non-empty streamed text"); + stream.reset(); + assert.equal(stream.state, "idle"); + s.dispose(); + } finally { + m.dispose(); + } +}); + +modelTest("stream reads reject while a feed is in flight", STREAMING_MODEL, async () => { + const m = await TranscribeModel.load(STREAMING_MODEL); + try { + const s = m.createSession(); + const stream = await s.stream({ commitPolicy: "stable_prefix" }); + const chunk = jfk().subarray(0, 16000); + + const pending = stream.feed(chunk); // do NOT await — leave it in flight + await Promise.resolve(); // let the feed's native call reach the worker + assert.throws(() => stream.text, /in flight/i, "reading .text mid-feed must throw"); + assert.throws(() => stream.state, /in flight/i); + assert.throws(() => stream.revision, /in flight/i); + assert.throws(() => s.limits, /in flight/i); + assert.throws(() => s.wasAborted, /in flight/i); + + await pending; // once awaited, reads are fine again + assert.doesNotThrow(() => stream.text); + + stream.reset(); + s.dispose(); + } finally { + m.dispose(); + } +}); + +modelTest("an active stream holds a model-wide compute lease", STREAMING_MODEL, async () => { + const m = await TranscribeModel.load(STREAMING_MODEL); + try { + const a = m.createSession(); + const b = m.createSession(); + const stream = await a.stream({ commitPolicy: "stable_prefix" }); + await stream.feed(jfk().subarray(0, 16000)); + + // While the stream is active, a sibling run/stream on the same model is + // refused with Busy rather than allowed to race the C library. + await assert.rejects(() => b.run(jfk().subarray(0, 16000)), Busy); + await assert.rejects(() => b.stream(), Busy); + + // Finalizing releases the lease; a sibling stream may then begin. + await stream.finalize(); + const b2 = await b.stream({ commitPolicy: "stable_prefix" }); + b2.reset(); + + a.dispose(); + b.dispose(); + } finally { + m.dispose(); + } +}); + +modelTest("reset() and dispose() are safe during an un-awaited feed", STREAMING_MODEL, async () => { + const m = await TranscribeModel.load(STREAMING_MODEL); + try { + const s = m.createSession(); + const stream = await s.stream({ commitPolicy: "stable_prefix" }); + + // Tear down while a feed is still in flight: the native reset/free must be + // deferred behind the worker call, never run concurrently with it. + const pending = stream.feed(jfk().subarray(0, 16000)); + stream.reset(); + assert.equal(stream.state, "idle", "reset() returns the stream to idle synchronously"); + await pending; // the in-flight feed still settles cleanly + s.dispose(); + } finally { + m.dispose(); + } + assert.ok(true, "no native crash: teardown ordered behind the worker"); +}); + +modelTest("disposing a session with an active stream frees the lease", STREAMING_MODEL, async () => { + const m = await TranscribeModel.load(STREAMING_MODEL); + try { + const a = m.createSession(); + const b = m.createSession(); + const stream = await a.stream({ commitPolicy: "stable_prefix" }); + await stream.feed(jfk().subarray(0, 16000)); + + // Dispose the session WITHOUT finalizing/resetting the stream. + a.dispose(); + + // The model lease must be released — a sibling session can stream again. + const bs = await b.stream({ commitPolicy: "stable_prefix" }); + bs.reset(); + + // The orphaned stream must fail fast, never touch the freed handle. + assert.throws(() => stream.revision, /disposed/i); + await assert.rejects(() => stream.feed(jfk().subarray(0, 16000)), /disposed/i); + stream.reset(); // no-op, no crash + + b.dispose(); + } finally { + m.dispose(); + } +}); + +modelTest("disposing a model with an active stream is crash-safe", STREAMING_MODEL, async () => { + const m = await TranscribeModel.load(STREAMING_MODEL); + const s = m.createSession(); + const stream = await s.stream({ commitPolicy: "stable_prefix" }); + await stream.feed(jfk().subarray(0, 16000)); + + m.dispose(); // disposes the session, invalidates the stream, defers the frees + + // The orphaned stream must throw, not use a freed session/model handle. + assert.throws(() => stream.text, /disposed/i); + await assert.rejects(() => stream.finalize(), /disposed/i); + stream.reset(); // no-op + assert.ok(true, "no native crash after model dispose with an active stream"); +}); + +modelTest("a stream queued before another's reset is refused, not overlapped", STREAMING_MODEL, async () => { + const m = await TranscribeModel.load(STREAMING_MODEL); + try { + const a = m.createSession(); + const b = m.createSession(); + const sa = await a.stream({ commitPolicy: "stable_prefix" }); + await sa.feed(jfk().subarray(0, 16000)); + + // Queue b.stream() while a's stream is still active, THEN reset a. The lease + // is released only after a's native reset runs (FIFO), so at b's turn the + // slot is still held — b must see Busy, not begin and overlap a's stream. + const pb = b.stream({ commitPolicy: "stable_prefix" }); + sa.reset(); + await assert.rejects(pb, Busy); + + a.dispose(); + b.dispose(); + } finally { + m.dispose(); + } +}); + +modelTest("a stream queued before another's dispose is refused, not overlapped", STREAMING_MODEL, async () => { + const m = await TranscribeModel.load(STREAMING_MODEL); + try { + const a = m.createSession(); + const b = m.createSession(); + const sa = await a.stream({ commitPolicy: "stable_prefix" }); + await sa.feed(jfk().subarray(0, 16000)); + + // Same ordering hazard via dispose: the lease releases only after a's + // sessionFree runs, so b is refused rather than overlapping the live stream. + const pb = b.stream({ commitPolicy: "stable_prefix" }); + a.dispose(); + await assert.rejects(pb, Busy); + + // Once a is actually gone, the slot is free again. + const sb = await b.stream({ commitPolicy: "stable_prefix" }); + sb.reset(); + b.dispose(); + } finally { + m.dispose(); + } +}); + +modelTest("a stream begin queued before its own dispose is rejected, not leaked", STREAMING_MODEL, async () => { + const m = await TranscribeModel.load(STREAMING_MODEL); + try { + const a = m.createSession(); + const b = m.createSession(); + + // Queue a stream begin, then dispose the session before the queued body + // runs. The body must recheck disposal and abort — not begin a stream on a + // dead session and leak the lease. + const p = a.stream({ commitPolicy: "stable_prefix" }); + a.dispose(); + await assert.rejects(p, /disposed/i); + + // The lease never got claimed: another session can still stream. + const sb = await b.stream({ commitPolicy: "stable_prefix" }); + sb.reset(); + b.dispose(); + } finally { + m.dispose(); + } +}); + +modelTest("a stale stream wrapper cannot reset a newer same-session stream", STREAMING_MODEL, async () => { + const m = await TranscribeModel.load(STREAMING_MODEL); + try { + const s = m.createSession(); + const first = await s.stream({ commitPolicy: "stable_prefix" }); + await first.feed(jfk().subarray(0, 16000)); + await first.finalize(); + + // Queue a new begin, then reset the old finished wrapper before that begin + // runs. The old reset must not clear the newer stream once it owns the + // session's single native stream slot. + const secondBegin = s.stream({ commitPolicy: "stable_prefix" }); + first.reset(); + const second = await secondBegin; + assert.equal(second.state, "active"); + assert.throws(() => first.revision, /no longer current/i); + + first.reset(); // stale reset is a no-op, not a reset of `second` + assert.equal(second.state, "active"); + await second.feed(jfk().subarray(0, 16000)); + assert.equal(second.state, "active"); + + second.reset(); + s.dispose(); + } finally { + m.dispose(); + } +}); + +modelTest("a non-streaming model rejects stream begin", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + try { + const s = m.createSession(); + await assert.rejects(() => s.stream()); + s.dispose(); + } finally { + m.dispose(); + } +}); diff --git a/bindings/typescript/test/transcribe.test.mjs b/bindings/typescript/test/transcribe.test.mjs new file mode 100644 index 00000000..fc68eab3 --- /dev/null +++ b/bindings/typescript/test/transcribe.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { modelTest, MODEL, jfk } from "./common.mjs"; +import { TranscribeModel } from "../dist/index.js"; + +modelTest("offline transcription returns text + detected language", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + try { + const r = await m.transcribe(jfk()); + assert.match(r.text, /ask not what your country/i); + assert.equal(r.language, "en"); + assert.equal(r.aborted, false); + assert.equal(r.truncated, false); + } finally { + m.dispose(); + } +}); + +modelTest("segment timestamps populate", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + try { + const r = await m.transcribe(jfk(), { timestamps: "segment" }); + assert.equal(r.timestampKind, "segment"); + assert.ok(r.segments.length >= 1); + const s = r.segments[0]; + assert.ok(s.t1Ms > s.t0Ms, "segment should span time"); + assert.ok(s.text.length > 0); + } finally { + m.dispose(); + } +}); + +modelTest("capabilities + identity", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + try { + const c = m.capabilities; + assert.equal(c.nativeSampleRate, 16000); + assert.ok(c.languages.length > 0); + assert.equal(typeof m.arch, "string"); + assert.ok(m.arch.length > 0); + assert.ok(m.backend.length > 0); + } finally { + m.dispose(); + } +}); + +modelTest("tokenize returns a non-empty token array", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + try { + const toks = m.tokenize("ask not what your country can do for you"); + assert.ok(toks instanceof Int32Array); + assert.ok(toks.length > 0); + } finally { + m.dispose(); + } +}); + +modelTest("one model serves many sessions", MODEL, async () => { + const m = await TranscribeModel.load(MODEL); + try { + const a = m.createSession(); + const b = m.createSession(); + const [ra, rb] = await Promise.all([a.run(jfk()), b.run(jfk())]); + assert.match(ra.text, /ask not/i); + assert.match(rb.text, /ask not/i); + a.dispose(); + b.dispose(); + } finally { + m.dispose(); + } +}); diff --git a/bindings/typescript/tsconfig.json b/bindings/typescript/tsconfig.json index 34837c59..a034c2e4 100644 --- a/bindings/typescript/tsconfig.json +++ b/bindings/typescript/tsconfig.json @@ -3,12 +3,14 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", + "lib": ["ES2022", "ESNext.Disposable"], "declaration": true, + "stripInternal": true, "outDir": "dist", "rootDir": "src", "strict": true, "skipLibCheck": true, - "types": [] + "types": ["node"] }, "include": ["src"] } diff --git a/scripts/ci/ts_packed_smoke.mjs b/scripts/ci/ts_packed_smoke.mjs new file mode 100644 index 00000000..69d24242 --- /dev/null +++ b/scripts/ci/ts_packed_smoke.mjs @@ -0,0 +1,44 @@ +// Verify the SHIPPED npm tarballs (the "test the shipped artifact" gate, §4): +// import the installed transcribe-cpp by absolute path — so node resolves it +// from the clean consumer's node_modules, not the repo tree — and transcribe +// the canary. No TRANSCRIBE_LIBRARY: the install must stand on the platform +// package alone. +// +// Env: TS_PKG_ENTRY (.../node_modules/transcribe-cpp/dist/index.js), +// TRANSCRIBE_SMOKE_MODEL (canary GGUF), TRANSCRIBE_SMOKE_AUDIO (a WAV). + +import * as fs from "node:fs"; + +const entry = process.env.TS_PKG_ENTRY; +const model = process.env.TRANSCRIBE_SMOKE_MODEL; +const audio = process.env.TRANSCRIBE_SMOKE_AUDIO; +if (!entry || !model || !audio) { + console.log("skip: set TS_PKG_ENTRY + TRANSCRIBE_SMOKE_MODEL + TRANSCRIBE_SMOKE_AUDIO"); + process.exit(0); +} + +function readWav(file) { + const b = fs.readFileSync(file); + let o = 12, fmt = null, d = null; + while (o + 8 <= b.length) { + const id = b.toString("ascii", o, o + 4), s = b.readUInt32LE(o + 4); + if (id === "fmt ") fmt = { ch: b.readUInt16LE(o + 10) }; + else if (id === "data") d = b.subarray(o + 8, o + 8 + s); + o += 8 + s + (s & 1); + } + const ch = fmt.ch, n = Math.floor(d.length / 2 / ch), p = new Float32Array(n); + for (let i = 0; i < n; i++) p[i] = d.readInt16LE(i * 2 * ch) / 32768; + return p; +} + +const { TranscribeModel, libraryPath, version } = await import(entry); +console.log("version:", JSON.stringify(version())); +console.log("libraryPath:", libraryPath()); +const m = await TranscribeModel.load(model); +const r = await m.transcribe(readWav(audio)); +m.dispose(); +const fromPackage = /node_modules\/@transcribe-cpp\//.test(libraryPath()); +const ok = fromPackage && /ask not what your country/i.test(r.text); +console.log(`packed smoke: ${ok ? "PASS" : "FAIL"} (fromPackage=${fromPackage})`); +console.log(` text: ${r.text.trim()}`); +process.exit(ok ? 0 : 1);