diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..5a2ac849 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,4 @@ +# Workspace-wide cargo configuration for the Rust bindings. +[alias] +# `cargo xtask bindgen [--check]` — regenerate / drift-check the committed FFI. +xtask = "run --package xtask --" diff --git a/.github/workflows/native-ci.yml b/.github/workflows/native-ci.yml index f40dff29..0e3b4b7d 100644 --- a/.github/workflows/native-ci.yml +++ b/.github/workflows/native-ci.yml @@ -16,7 +16,7 @@ name: native-ci # hand-assembled provider directory, plus the # link-smoke in the shared/DL posture (the toy # consumer drives transcribe_init_backends on the -# installed module dir — the Rust dylib-mode shape). +# installed module dir — the Rust dynamic-backends shape). # - posture-lint: the wheel-preset <-> pyproject-lane mirror gate # (scripts/ci/check_lane_mirror.py): anchored lane # regexes + settled-posture equality. The 2026-06-12 @@ -186,7 +186,7 @@ jobs: # Before the build tree is deleted, the shared/DL leg of the link smoke # runs against `cmake --install` output: the toy consumer links # libtranscribe alone and drives transcribe_init_backends() on the - # installed module directory — the exact shape a Rust dylib-feature + # installed module directory — the exact shape a Rust dynamic-backends # consumer has. runs-on: blacksmith-2vcpu-ubuntu-2404 env: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 079c52c7..7dd98fed 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -216,3 +216,59 @@ jobs: run: | gh workflow run wheel-index.yml --repo "$GITHUB_REPOSITORY" || \ echo "::warning::wheel-index dispatch failed — is the workflow on the default branch and Pages enabled?" + + # ---- Rust crates (crates.io) ------------------------------------------------- + # The Rust release path mirrors the Python one: a dispatch REHEARSAL that + # validates without uploading, and a tag-gated RELEASE cut from CI. crates.io + # has no TestPyPI analog, so the rehearsal is `cargo publish --dry-run` plus + # the packed-crate smoke (build libtranscribe from the shipped tarball and + # transcribe) — the Rust "test the shipped artifact" gate (requirements §4). + + rust-rehearsal: + # Dispatch only. Independent of the wheel jobs (no `needs`): the Rust and + # Python release artifacts don't share build steps. + if: github.event_name == 'workflow_dispatch' + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 40 + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: astral-sh/setup-uv@v8.2.0 # fetch-canary + the packed-smoke script + - name: Install build deps + run: sudo apt-get update && sudo apt-get install -y cmake ninja-build zlib1g-dev + - name: Dry-run transcribe-cpp-sys (full verify build, as cargo would publish it) + run: cargo publish --dry-run --allow-dirty -p transcribe-cpp-sys + # The safe crate cannot be dry-run-verified pre-publish: it depends on + # transcribe-cpp-sys = "0.0.1", and the registry only has the 0.0.0 + # name-reservation placeholder until sys actually publishes (sys-first + # ordering). Its packaging is verified at release time below; here the + # packed-crate smoke exercises it against the packed sys instead. + - uses: ./.github/actions/fetch-canary + with: + hf-token: ${{ secrets.HF_TOKEN }} + - name: Packed-crate smoke (build the native lib from the shipped tarball, transcribe) + run: uv run --no-project python scripts/ci/rust_packed_smoke.py + + rust-release: + # Tags only. Releases are cut from CI, never a laptop (requirements §5). + # sys publishes FIRST (the safe crate resolves it by version), then the safe + # crate. The `crates-io` environment carries the approval gate (CJ approves) + # and holds CARGO_REGISTRY_TOKEN. cargo waits for the index to carry sys + # before returning, so the safe publish that follows resolves it. + if: startsWith(github.ref, 'refs/tags/v') + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 40 + environment: crates-io + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - name: Install build deps + run: sudo apt-get update && sudo apt-get install -y cmake ninja-build zlib1g-dev + - name: Publish transcribe-cpp-sys (the native-carrying crate; verifies the build) + run: cargo publish -p transcribe-cpp-sys + - name: Publish transcribe-cpp (the safe wrapper; resolves the just-published sys) + run: cargo publish -p transcribe-cpp diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml new file mode 100644 index 00000000..0134046f --- /dev/null +++ b/.github/workflows/rust-ci.yml @@ -0,0 +1,241 @@ +name: rust-ci + +# Every-PR gates for the Rust bindings (transcribe-cpp-sys + transcribe-cpp). +# Thin per-binding workflow on the shared rails laid by bindings-shared-infra: +# the binding-agnostic C contracts are certified in native-ci.yml; this file +# adds only what the Rust layer introduces. +# +# - rust-gates: bindgen drift check (pinned to include/transcribe.abihash), +# version-sync, the cargo-package content/size audit, and +# rustfmt. No native build — fast, runs everywhere. +# - rust-build: the real source build (build.rs drives the vendored CMake +# tree, links from lib/transcribe-link.json) across the posture +# matrix on linux + macos + windows, then the -sys smoke tests +# (transcribe_version / abi-struct-size through the FFI), the +# safe-crate conformance suite, and clippy. Postures: STATIC +# (default), SHARED (shared libtranscribe), and DYNAMIC-BACKENDS +# (linux: shared + loadable per-ISA backend modules, exercised +# through transcribe_init_backends from Rust). +# +# Two test tiers (requirements §4): the no-model tests (version/ABI/error +# mapping/device discovery, in tests/no_model.rs) always run, including on +# forks. The model-gated tests (real transcription, streaming, cancel, family +# extensions) un-skip only when the canary GGUFs are fetched — the same +# fetch-canary composite action and HF_TOKEN gate the Python lane uses — and +# `return` early (a clean skip) otherwise. One `cargo test` invocation runs +# both tiers; the model tier self-skips when the canary env is absent. +# +# 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 +# on x86_64-pc-windows-msvc), resolved by find_package(ZLIB) via +# CMAKE_PREFIX_PATH. The static Windows link manifest's zlib translation lives +# in cmake/transcribe-install.cmake. +# +# Path filters follow native-ci.yml's shape: the binding's own tree plus the +# native paths it compiles from (binding behavior depends on the C side, so +# do not narrow). Branch protection is OFF (project decision, 2026-06-13) — no +# required-check maintenance comes with this workflow. + +on: + push: + branches: [main] + paths: &paths + - "bindings/rust/**" + - "Cargo.toml" + - "Cargo.lock" + - ".cargo/**" + - "src/**" + - "include/**" + - "ggml/**" + - "cmake/**" + - "CMakeLists.txt" + - "CMakePresets.json" + - "scripts/ci/rust_package_audit.py" + - "bindings/python/_generate/check_version_sync.py" + - ".github/workflows/rust-ci.yml" + pull_request: + paths: *paths + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + rust-gates: + # Cheap, native-build-free gates. The drift check needs libclang (bindgen); + # users never do, because the generated FFI 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: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Install libclang (bindgen drift check) + run: sudo apt-get update && sudo apt-get install -y libclang-dev clang + - name: FFI drift gate (bindgen vs committed, pinned to transcribe.abihash) + run: cargo xtask bindgen --check + - name: rustfmt + run: cargo fmt --all --check + - name: Version sync (header <-> every active manifest) + run: uv run --no-project python bindings/python/_generate/check_version_sync.py + - name: Crate package audit (contents + 10 MB size cap) + run: uv run --no-project python scripts/ci/rust_package_audit.py + + rust-build: + name: rust-build (${{ matrix.label }}) + strategy: + fail-fast: false + # Posture matrix: {static, shared} × {linux, macos, windows}, plus a + # linux dynamic-backends leg. `features` is forwarded verbatim to the + # build/test cargo commands ("" = the static default; "--features shared" + # = a shared libtranscribe; "--features dynamic-backends" = shared + + # loadable backend modules). The shared leg is the end-to-end proof of the + # shared-link posture: its tests load libtranscribe at runtime via the + # rpath the safe crate's build.rs emits on Unix, and on Windows (which has + # no rpath) via the -sys build.rs staging transcribe.dll + the ggml DLLs + # next to each cargo artifact. The dynamic-backends leg additionally proves + # the loadable-module path: the build configures GGML_BACKEND_DL + + # (x86) GGML_CPU_ALL_VARIANTS, and the tests/examples call + # init_backends_default() to load the per-ISA CPU module and transcribe. + matrix: + include: + - label: linux-static + runner: blacksmith-2vcpu-ubuntu-2404 + features: "" + - label: linux-shared + runner: blacksmith-2vcpu-ubuntu-2404 + features: "--features shared" + # 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 + # init_backends_default() loads them. CPU-only (no Vulkan SDK needed); + # the C-level Vulkan degradation three-tier stays in native-ci. + - label: linux-dynamic-backends + runner: blacksmith-2vcpu-ubuntu-2404 + features: "--features dynamic-backends" + # 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: "" + - label: macos-arm64-shared + runner: [self-hosted, macOS, ARM64] + features: "--features shared" + # 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 — + # hence the wider timeout. sccache is a later optimization. Both + # postures: static is self-contained; shared links transcribe.dll (the + # -sys build.rs stages the DLLs next to the test/example binaries). + - label: windows-static + runner: blacksmith-2vcpu-windows-2025 + features: "" + - label: windows-shared + runner: blacksmith-2vcpu-windows-2025 + features: "--features shared" + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + 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: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: astral-sh/setup-uv@v8.2.0 # fetch-canary fetches via uvx + - name: Enable ccache compiler launcher (non-Windows) + # CMake reads these at first configure, so build.rs's cmake-crate build + # picks up ccache with no -D plumbing. NOT set on Windows: ccache isn't + # installed there and an unresolvable launcher fails configure. + if: runner.os != 'Windows' + shell: bash + run: | + echo "CMAKE_C_COMPILER_LAUNCHER=ccache" >> "$GITHUB_ENV" + echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache" >> "$GITHUB_ENV" + - name: Install build deps (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y cmake ninja-build zlib1g-dev ccache + - name: Install build deps (macOS) + if: runner.os == 'macOS' + run: | + brew install ninja + command -v ccache >/dev/null || brew install ccache + - name: Set up MSVC (cl.exe on PATH for the Ninja generator) + # The cmake-crate build uses the Ninja generator (CMAKE_GENERATOR below); + # Ninja needs cl.exe ambient. Without vcvars, CMake silently falls back + # to the image's MinGW gcc (gcc-flavored M_PI errors) — same trap the + # wheel lane documents. + 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 + # Static zlib against the DYNAMIC CRT (-static-md): matches Rust's + # default /MD on x86_64-pc-windows-msvc. A /MT (x64-windows-static) + # zlib would clash with the CRT rustc links. find_package(ZLIB) + # resolves it from CMAKE_PREFIX_PATH — no vcpkg toolchain file needed. + vcpkg install zlib:x64-windows-static-md + # Forward slashes: backslashes get eaten as escapes downstream. + $zlibPrefix = "$env:VCPKG_INSTALLATION_ROOT/installed/x64-windows-static-md" -replace '\\','/' + Add-Content $env:GITHUB_ENV "CMAKE_PREFIX_PATH=$zlibPrefix" + # cmake-crate honors CMAKE_GENERATOR from the environment. + Add-Content $env:GITHUB_ENV "CMAKE_GENERATOR=Ninja" + - name: CPU ISA signature (segregates ccache across the heterogeneous fleet) + # ggml builds with -march=native in this source posture; ccache hashes + # the literal flag, not the resolved ISA, so key the cache by the CPU's + # feature flags (matches native-ci.yml). + if: runner.os == 'Linux' + run: echo "CPU_SIG=$(grep -m1 '^flags' /proc/cpuinfo | sha256sum | cut -c1-8)" >> "$GITHUB_ENV" + - name: ccache (compile cache for the native source build) + if: runner.os == 'Linux' # the mini is persistent; its local cache suffices + uses: actions/cache@v5 + with: + path: ~/.cache/ccache + key: ccache-rust-build-${{ matrix.label }}-${{ env.CPU_SIG }}-${{ github.sha }} + restore-keys: ccache-rust-build-${{ matrix.label }}-${{ env.CPU_SIG }}- + # Package-scoped (not --workspace): a single posture's native build, and + # it sidesteps the backend features choking on the xtask member, which has + # none. xtask is compiled/run in rust-gates (the bindgen check). + - name: Build (${{ matrix.label }} posture; build.rs drives CMake) + run: cargo build -p transcribe-cpp ${{ matrix.features }} --verbose + - name: "-sys smoke (transcribe_version / abi size through the FFI)" + run: cargo test -p transcribe-cpp-sys ${{ matrix.features }} + # The canary GGUFs upgrade the safe-crate suite from no-model gates to real + # transcription/streaming/cancel/extension coverage. Guarded on HF_TOKEN + # (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 + with: + hf-token: ${{ secrets.HF_TOKEN }} + - name: Safe-crate conformance (no-model tier always; model tier when canary present) + run: cargo test -p transcribe-cpp ${{ matrix.features }} + # The 5 canonical examples (requirements §6), run headless under the same + # canary skip rules as the model tests: each resolves its model/audio from + # 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) + shell: bash + run: | + for ex in transcribe-file streaming batch backend-select error-handling; do + echo "=== example: $ex ===" + cargo run -p transcribe-cpp --example "$ex" ${{ matrix.features }} + done + - name: Clippy (deny warnings) + # Once is enough; the linux-static leg covers it (clippy on another + # leg would force a second full native build for no extra lint signal). + if: matrix.label == 'linux-static' + run: cargo clippy --workspace --all-targets -- -D warnings + - name: ccache stats + if: runner.os == 'Linux' + run: ccache -s | head -8 diff --git a/.gitignore b/.gitignore index 474eac36..f7cf944a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ /build-*/ /cmake-build-*/ +# Rust workspace build output (Cargo.lock IS committed — workspace has a binary) +/target/ + # Python distribution output (provider wheels/sdists at the repo root; # bindings/python/dist for the pure API package; wheelhouse* from local # cibuildwheel / wheel-repair runs) diff --git a/CMakeLists.txt b/CMakeLists.txt index 504422f7..6502bdd3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,6 +60,8 @@ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE) endif() +include(GNUInstallDirs) + # Hidden symbol visibility by default per PLAN.md. set(CMAKE_C_VISIBILITY_PRESET hidden) set(CMAKE_CXX_VISIBILITY_PRESET hidden) @@ -281,11 +283,35 @@ if(TRANSCRIBE_X86_CONSERVATIVE) endforeach() endif() -# OpenMP: ggml defaults GGML_OPENMP ON and probes for it gracefully, so leave -# ggml's own default in place when we want OpenMP. Only force it OFF (matching -# the GGML_OPENMP=OFF posture official wheels use) when OpenMP is switched off, -# so one TRANSCRIBE_USE_OPENMP knob covers ggml and our host-decoder TU. -if(NOT TRANSCRIBE_USE_OPENMP) +# OpenMP — CENTRAL POLICY (read before changing the Windows/MSVC threading). +# +# ggml defaults GGML_OPENMP ON and probes for it gracefully, so leave ggml's own +# default in place when we want OpenMP. Only force it OFF (matching the +# GGML_OPENMP=OFF posture official wheels use) when OpenMP is switched off, so +# one TRANSCRIBE_USE_OPENMP knob covers ggml and our host-decoder TU. +# +# The catch: ggml's NON-OpenMP CPU threadpool barrier (ggml_barrier's custom +# spin path) DEADLOCKS under MSVC codegen on Windows — any multi-threaded CPU +# run wedges its workers in the barrier spin (it works on every other compiler). +# OpenMP's `#pragma omp barrier` is the only working multi-threaded CPU path on +# MSVC. But OpenMP is a per-CONSUMER tradeoff, not a global flag, so this is not +# one switch — it is a deliberate, documented split: +# +# - Rust binding (CPU is its default backend, standalone process): MUST have +# OpenMP on Windows. Its build.rs passes -DGGML_OPENMP=ON; the guard below +# honors that explicit value. MSVC auto-links vcomp via the /openmp pragma in +# the ggml objects (no -fopenmp flag, so the link manifest is untouched); +# vcomp140.dll ships in the VC++ runtime the binary already needs. +# - Python wheels (default to the Vulkan GPU backend; deliberately vendor NO +# OpenMP so ggml's runtime cannot collide with numpy/torch's own OpenMP/MKL +# in one process — see python-wheels.yml): leave GGML_OPENMP unset and get +# the force-off below. KNOWN LIMITATION as a consequence: multi-threaded CPU +# compute on Windows is unsupported for the wheels (they use Vulkan). Lifting +# it needs a non-OpenMP fix (e.g. single-threaded CPU on Windows, or patching +# ggml's barrier) rather than forcing OpenMP and the coexistence hazard back. +# +# So: honor an explicit -DGGML_OPENMP=... (the Rust opt-in); otherwise force OFF. +if(NOT TRANSCRIBE_USE_OPENMP AND NOT DEFINED CACHE{GGML_OPENMP}) set(GGML_OPENMP OFF CACHE BOOL "" FORCE) endif() @@ -314,6 +340,20 @@ if(TRANSCRIBE_GGML_BACKEND_DL) "(backend modules are shared libraries loaded at runtime)") endif() set(GGML_BACKEND_DL ON CACHE BOOL "" FORCE) + # The package-local default loader resolves the directory that contains + # libtranscribe itself. Install backend modules there too, unless a caller + # explicitly chose a different provider layout and will pass it to + # transcribe_init_backends(dir). + # + # Test emptiness, not DEFINED: ggml declares GGML_BACKEND_DIR as an empty + # cache var of its own, so on any reconfigure it is already DEFINED (as "") + # and a `NOT DEFINED CACHE{...}` guard would skip our override, leaving the + # modules in ggml's default bin/ where the lib-dir loader cannot find them. + # `NOT GGML_BACKEND_DIR` treats an empty/unset value as "ours to set" while + # still respecting a non-empty caller-provided path. + if(NOT GGML_BACKEND_DIR) + set(GGML_BACKEND_DIR "${CMAKE_INSTALL_LIBDIR}" CACHE PATH "" FORCE) + endif() # ggml hard-errors on GGML_NATIVE + GGML_BACKEND_DL (x86): native tuning # is incompatible with feature-scored modules. A module build is a # distribution build, so never tune it to the build host. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..c1a2507e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,357 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "transcribe-cpp" +version = "0.0.1" +dependencies = [ + "hound", + "log", + "thiserror", + "transcribe-cpp-sys", +] + +[[package]] +name = "transcribe-cpp-sys" +version = "0.0.1" +dependencies = [ + "cmake", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "xtask" +version = "0.0.0" +dependencies = [ + "bindgen", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..6d2fca30 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,90 @@ +# This Cargo.toml lives at the repo root on purpose, mirroring the root +# pyproject.toml: the `transcribe-cpp-sys` crate must carry the whole C++ tree +# (include/, src/, vendored ggml/, cmake/, CMakeLists.txt) so a `cargo build` +# can compile libtranscribe from source on any machine, and a registry tarball +# cannot reach outside its manifest's directory. The `include` whitelist below +# is the same move and the same root cause as pyproject-at-root. +# +# The sys crate's Rust sources live under bindings/rust/sys/ (via the [lib] +# `path` and `build` keys); the safe `transcribe-cpp` wrapper is the workspace +# member at bindings/rust/. See notes/rust-bindings-plan.md. + +[package] +name = "transcribe-cpp-sys" +version = "0.0.1" +edition = "2021" +rust-version = "1.74" +description = "Native FFI bindings for transcribe.cpp, a C/C++ speech-to-text library built on ggml" +license = "MIT" +readme = "bindings/rust/sys/README.md" +repository = "https://github.com/handy-computer/transcribe.cpp" +homepage = "https://github.com/handy-computer/transcribe.cpp" +keywords = ["transcription", "speech", "asr", "stt", "ggml"] +categories = ["multimedia::audio", "external-ffi-bindings"] +links = "transcribe" +build = "bindings/rust/sys/build.rs" + +# Allowlist (not a denylist): the crate tarball contains exactly these paths. +# git-ignored trees (models/, build*/, target/, dumps are NOT ignored — see +# below) are pruned by cargo's git file listing too, but the whitelist is the +# load-bearing guarantee. dumps/ and reports/ are git-TRACKED, so only an +# allowlist keeps them out. The `cargo package` audit gate (rust-ci.yml) +# asserts ggml is present and models/dumps/reports are absent, plus the 10 MB +# crates.io size cap. ggml/examples and ggml/tests are pruned (not needed to +# build libggml) to keep weight down. +include = [ + "/CMakeLists.txt", + "/CMakePresets.json", + "/LICENSE", + "/include/**/*.h", + "/include/transcribe.abihash", + "/src/**", + "/cmake/**", + "/ggml/CMakeLists.txt", + "/ggml/cmake/**", + "/ggml/include/**", + "/ggml/src/**", + "/ggml/scripts/**", + "/ggml/LICENSE", + "/ggml/AUTHORS", + "/ggml/ggml.pc.in", + "/bindings/rust/sys/**", +] + +[lib] +name = "transcribe_cpp_sys" +path = "bindings/rust/sys/src/lib.rs" + +[features] +# Metal is the default backend on Apple targets; on every other target the +# `metal` feature is a no-op (build.rs only forwards -DTRANSCRIBE_METAL=ON when +# the host is Apple — non-Apple uses the CMake default, which is CPU). A pure +# CPU build on macOS is `default-features = false`. +default = ["metal"] +metal = [] +vulkan = [] +cuda = [] +openmp = [] +# `shared` links a shared libtranscribe (.so/.dylib/.dll) loaded at runtime +# instead of statically baking it into the consumer binary. The default is a +# self-contained static link. (Renamed from `dylib`.) +shared = [] +# `dynamic-backends` additionally ships each compute backend (the per-ISA CPU +# tiers, Vulkan, CUDA, ...) as a loadable module next to the library, selected at +# runtime by transcribe_init_backends(). Requires a shared library, so it implies +# `shared`. See notes/rust-dynamic-backends-plan.md. +dynamic-backends = ["shared"] + +[build-dependencies] +cmake = "0.1" +serde_json = "1" + +# The sys crate's manifest is THIS file (repo root) so the tarball can carry +# the whole C++ tree; its Rust sources are the only non-package subtree under +# bindings/rust/ (build.rs + src/ at bindings/rust/sys/). The two sibling +# packages — the safe wrapper and the dev xtask — are workspace members. The +# sys sources must NOT live inside a member's directory, or cargo would refuse +# to package them with the root crate (a member owns its whole subtree). +[workspace] +resolver = "2" +members = ["bindings/rust/transcribe-cpp", "bindings/rust/xtask"] diff --git a/bindings/python/_generate/check_version_sync.py b/bindings/python/_generate/check_version_sync.py index 6b723efe..d33d359c 100644 --- a/bindings/python/_generate/check_version_sync.py +++ b/bindings/python/_generate/check_version_sync.py @@ -40,8 +40,12 @@ # so its gate is the tag itself (release-workflow concern, not this script). BINDING_MANIFESTS = [ # (relative path, extractor name, active) - ("bindings/rust/Cargo.toml", "cargo", False), - ("bindings/rust/sys/Cargo.toml", "cargo", False), + # The Rust crates are real (0.0.1), so they're version-locked. The sys + # crate's manifest is the repo-root Cargo.toml (it carries the whole C++ + # tree); the safe wrapper is the sibling member at + # bindings/rust/transcribe-cpp/. + ("Cargo.toml", "cargo", True), + ("bindings/rust/transcribe-cpp/Cargo.toml", "cargo", True), ("bindings/typescript/package.json", "npm", False), ] diff --git a/bindings/python/src/transcribe_cpp/_generated.py b/bindings/python/src/transcribe_cpp/_generated.py index aa5aa8f7..d5424be9 100644 --- a/bindings/python/src/transcribe_cpp/_generated.py +++ b/bindings/python/src/transcribe_cpp/_generated.py @@ -13,7 +13,7 @@ # Stable digest of the ABI surface below (structs, enums, macros, layout, # prototypes). A native provider package echoes this back so the API # package can reject an ABI-mismatched provider before dlopen. -PUBLIC_HEADER_HASH = "0007af60bfcecf7e" +PUBLIC_HEADER_HASH = "fe9ed398c408e5d9" # === enum constants === TRANSCRIBE_OK = 0 @@ -276,6 +276,8 @@ def configure(lib): lib.transcribe_get_word.argtypes = [_c.c_void_p, _c.c_int, _c.POINTER(transcribe_word)] lib.transcribe_init_backends.restype = _c.c_int lib.transcribe_init_backends.argtypes = [_c.c_char_p] + lib.transcribe_init_backends_default.restype = _c.c_int + lib.transcribe_init_backends_default.argtypes = [] lib.transcribe_log_set.restype = None lib.transcribe_log_set.argtypes = [_c.CFUNCTYPE(None, _c.c_int, _c.c_char_p, _c.c_void_p), _c.c_void_p] lib.transcribe_model_accepts_ext_kind.restype = _c.c_bool diff --git a/bindings/rust/.gitignore b/bindings/rust/.gitignore deleted file mode 100644 index 96ef6c0b..00000000 --- a/bindings/rust/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/target -Cargo.lock diff --git a/bindings/rust/Cargo.toml b/bindings/rust/Cargo.toml deleted file mode 100644 index dc002af8..00000000 --- a/bindings/rust/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "transcribe-cpp" -version = "0.0.0" -edition = "2021" -description = "Rust bindings for transcribe.cpp (pre-release name reservation placeholder)" -license = "MIT" -readme = "README.md" -repository = "https://github.com/handy-computer/transcribe.cpp" -homepage = "https://github.com/handy-computer/transcribe.cpp" -keywords = ["transcription", "speech", "asr", "stt", "ggml"] -categories = ["multimedia::audio", "api-bindings"] - -[lib] -name = "transcribe_cpp" -path = "src/lib.rs" diff --git a/bindings/rust/README.md b/bindings/rust/README.md deleted file mode 100644 index b33088e0..00000000 --- a/bindings/rust/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# transcribe-cpp - -Rust bindings for [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp), -a C/C++ speech-to-text library built on ggml. - -> **Status: placeholder.** This release reserves the `transcribe-cpp` name on -> crates.io while the first-party bindings are developed. It ships no -> functionality yet. Watch the repository for the first functional release. - -- Crate: `transcribe-cpp` -- License: MIT diff --git a/bindings/rust/src/lib.rs b/bindings/rust/src/lib.rs deleted file mode 100644 index 09488f2c..00000000 --- a/bindings/rust/src/lib.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Rust bindings for [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp). -//! -//! This is a placeholder crate that reserves the `transcribe-cpp` name on -//! crates.io while first-party bindings are developed. It ships no -//! functionality yet. - -/// Version of this placeholder crate. -pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/bindings/rust/sys/.gitignore b/bindings/rust/sys/.gitignore deleted file mode 100644 index 96ef6c0b..00000000 --- a/bindings/rust/sys/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/target -Cargo.lock diff --git a/bindings/rust/sys/Cargo.toml b/bindings/rust/sys/Cargo.toml deleted file mode 100644 index 35b5e8d8..00000000 --- a/bindings/rust/sys/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "transcribe-cpp-sys" -version = "0.0.0" -edition = "2021" -description = "Native FFI bindings for transcribe.cpp (pre-release name reservation placeholder)" -license = "MIT" -readme = "README.md" -repository = "https://github.com/handy-computer/transcribe.cpp" -homepage = "https://github.com/handy-computer/transcribe.cpp" -keywords = ["transcription", "speech", "asr", "stt", "ggml"] -categories = ["multimedia::audio", "external-ffi-bindings"] - -[lib] -name = "transcribe_cpp_sys" -path = "src/lib.rs" diff --git a/bindings/rust/sys/README.md b/bindings/rust/sys/README.md index ebc36c69..52600396 100644 --- a/bindings/rust/sys/README.md +++ b/bindings/rust/sys/README.md @@ -1,11 +1,60 @@ # transcribe-cpp-sys -Native FFI bindings for [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp), -a C/C++ speech-to-text library built on ggml. +Raw native FFI bindings for +[transcribe.cpp](https://github.com/handy-computer/transcribe.cpp), a C/C++ +speech-to-text library built on ggml. -> **Status: placeholder.** This release reserves the `transcribe-cpp-sys` name -> on crates.io while the first-party bindings are developed. It ships no -> functionality yet. Watch the repository for the first functional release. +> **Status: in development (0.0.1).** This crate exposes the unsafe, generated +> FFI surface. Most users want the safe wrapper, +> [`transcribe-cpp`](https://crates.io/crates/transcribe-cpp). -- Crate: `transcribe-cpp-sys` (raw FFI; the safe API will be `transcribe-cpp`) +## What it does + +`build.rs` compiles the vendored C++ tree from source via CMake (the crate +tarball carries the whole tree) and reconstructs the link line from the +installed `transcribe-link.json` manifest — no hardcoded per-platform link +lists. The committed bindgen output means **libclang is not needed** to build +this crate. + +## Build prerequisites + +A C++ toolchain, **CMake**, and **zlib**. zlib is system-provided on Linux +(`zlib1g-dev`) and macOS; on Windows install it with +`vcpkg install zlib:x64-windows-static-md` (static lib against the dynamic CRT, +matching Rust's default `/MD` on `x86_64-pc-windows-msvc`) and point +`CMAKE_PREFIX_PATH` at the vcpkg install tree. The static link is the default; +the `shared` feature links a shared library instead. + +## Features + +- `metal` (default on Apple), `vulkan`, `cuda`, `openmp` — each forwards to the + matching `TRANSCRIBE_*` CMake option. +- `shared` — link a shared `libtranscribe` (`.so`/`.dylib`/`.dll`) loaded at + runtime instead of statically baking it in. The default is a self-contained + static link. +- `dynamic-backends` — additionally ship each compute backend (the per-ISA CPU + tiers, Vulkan, CUDA, …) as a loadable module next to the library, selected at + runtime by `transcribe_init_backends_default()` when the modules sit next to + `libtranscribe`, or `transcribe_init_backends(dir)` for a custom provider + directory. Implies `shared`. + +## Build-flag escape hatch + +The features above cover the common, tested configurations. Anything else CMake +accepts can be forwarded via the `TRANSCRIBE_CMAKE_ARGS` (or `CMAKE_ARGS`) env +var — e.g. `TRANSCRIBE_CMAKE_ARGS="-DGGML_VULKAN=ON" cargo build`. These are +split on whitespace with simple double-quote handling, applied after the +feature-derived defines (so a user `-D` wins), and unsupported/untested by +design: they exist so a Cargo feature is never a hard ceiling on what you can +configure. The link line is still reconstructed from the generated manifest, so +whatever you turn on links correctly. + +## ABI drift + +The generated FFI is committed and CI-checked against `include/transcribe.abihash` +(`cargo xtask bindgen --check`): a public-header ABI change turns the check red +until the bindings are regenerated. Per-field layout checks are waived because +bindgen takes layout from a real compiler at generation time. + +- Crate: `transcribe-cpp-sys` (raw FFI; the safe API is `transcribe-cpp`) - License: MIT diff --git a/bindings/rust/sys/build.rs b/bindings/rust/sys/build.rs new file mode 100644 index 00000000..5e2a9d92 --- /dev/null +++ b/bindings/rust/sys/build.rs @@ -0,0 +1,321 @@ +//! Build script for `transcribe-cpp-sys`. +//! +//! Source build is the primary (and currently only) path: the `cmake` crate +//! drives the vendored C++ tree with `TRANSCRIBE_INSTALL=ON`, and the link +//! line is reconstructed from NOTHING but the installed +//! `lib/transcribe-link.json` manifest — the same artifact the `link_smoke` +//! CI lane compiles a toy C consumer against. No per-platform link lists are +//! hardcoded here (the whisper-rs drift class this avoids). +//! +//! Cargo features map directly to CMake options: +//! `shared` -> TRANSCRIBE_BUILD_SHARED=ON (default: static) +//! `dynamic-backends` -> the above + TRANSCRIBE_GGML_BACKEND_DL=ON (+ x86: +//! GGML_CPU_ALL_VARIANTS=ON, TRANSCRIBE_X86_CONSERVATIVE=ON) +//! `metal` -> TRANSCRIBE_METAL=ON (Apple targets only; no-op elsewhere) +//! `vulkan` -> TRANSCRIBE_VULKAN=ON +//! `cuda` -> TRANSCRIBE_CUDA=ON +//! `openmp` -> TRANSCRIBE_USE_OPENMP=ON +//! Official-artifact hygiene flags (OpenMP/BLAS off) are deliberately NOT +//! forced here: a source build is the consumer's build (same philosophy as +//! the Python sdist). +//! +//! Escape hatch: anything else CMake accepts can be passed via the +//! TRANSCRIBE_CMAKE_ARGS (or CMAKE_ARGS) env var — see the passthrough at the +//! end of main(). This is the "no Cargo feature is a hard ceiling" guarantee. + +use std::env; +use std::path::{Path, PathBuf}; + +fn feature(name: &str) -> bool { + env::var_os(format!("CARGO_FEATURE_{name}")).is_some() +} + +/// Split a CMAKE_ARGS-style string into individual arguments, honoring simple +/// double-quotes so a value containing spaces survives (e.g. `-DFOO="a b"`). +/// Whitespace-separated otherwise; quotes are stripped from the emitted token. +fn split_cmake_args(s: &str) -> Vec { + let mut args = Vec::new(); + let mut cur = String::new(); + let mut in_quotes = false; + let mut has_token = false; + for c in s.chars() { + match c { + '"' => { + in_quotes = !in_quotes; + has_token = true; + } + c if c.is_whitespace() && !in_quotes => { + if has_token { + args.push(std::mem::take(&mut cur)); + has_token = false; + } + } + c => { + cur.push(c); + has_token = true; + } + } + } + if has_token { + args.push(cur); + } + args +} + +fn main() { + // CARGO_MANIFEST_DIR for this crate is the repo root (the sys crate's + // manifest lives there so the tarball can carry the whole C++ tree). + let root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + + for p in [ + "CMakeLists.txt", + "CMakePresets.json", + "include", + "src", + "ggml", + "cmake", + "bindings/rust/sys/build.rs", + "bindings/rust/sys/src", + ] { + println!("cargo:rerun-if-changed={}", root.join(p).display()); + } + + // `dynamic-backends` (loadable backend modules) requires a shared library, + // so it implies `shared`. The Cargo manifest already encodes that implication + // (`dynamic-backends = ["shared"]`), but treat it as load-bearing here too. + let dynamic_backends = feature("DYNAMIC_BACKENDS"); + let shared = feature("SHARED") || dynamic_backends; + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + let is_apple = matches!(target_os.as_str(), "macos" | "ios"); + let is_x86 = matches!(target_arch.as_str(), "x86" | "x86_64"); + + let mut cfg = cmake::Config::new(&root); + cfg.profile("Release") // a transcription library always wants an optimized native core + .define("TRANSCRIBE_INSTALL", "ON") + .define("TRANSCRIBE_BUILD_TESTS", "OFF") + .define("TRANSCRIBE_BUILD_EXAMPLES", "OFF") + .define("TRANSCRIBE_BUILD_TOOLS", "OFF") + .define("TRANSCRIBE_BUILD_SHARED", if shared { "ON" } else { "OFF" }); + + // Dynamic backend modules: each compute backend becomes a loadable module + // next to libtranscribe, picked at runtime by transcribe_init_backends(). + // The root CMakeLists validates BACKEND_DL => SHARED and force-sets + // GGML_NATIVE=OFF, so this just flips the knobs. On x86, fan the CPU backend + // out into one module per ISA tier (runtime feature scoring) over the + // SIGILL-safe x86 floor — the same posture the Linux/Windows cpu-vulkan + // wheel lane ships. ALL_VARIANTS is an x86 concept; on arm a DL build is a + // single portable CPU module. + if dynamic_backends { + cfg.define("TRANSCRIBE_GGML_BACKEND_DL", "ON"); + if is_x86 { + cfg.define("GGML_CPU_ALL_VARIANTS", "ON"); + cfg.define("TRANSCRIBE_X86_CONSERVATIVE", "ON"); + } + } + + // Metal: on Apple, set TRANSCRIBE_METAL EXPLICITLY to track the `metal` + // feature. CMake defaults TRANSCRIBE_METAL ON on Apple Silicon, so without an + // explicit OFF a `--no-default-features` (metal off) build would still enable + // Metal — breaking Cargo.toml's "pure CPU build on macOS is + // default-features = false" contract. Off Apple the feature is a no-op (CMake + // already defaults it OFF). + if is_apple { + if feature("METAL") { + cfg.define("TRANSCRIBE_METAL", "ON"); + // Self-contained installed tree: embed the metallib instead of a + // sidecar default.metallib next to the lib (matches the shipped macOS + // wheel posture; what the shared-infra link-smoke uses). + cfg.define("GGML_METAL_EMBED_LIBRARY", "ON"); + } else { + cfg.define("TRANSCRIBE_METAL", "OFF"); + } + } + if feature("VULKAN") { + cfg.define("TRANSCRIBE_VULKAN", "ON"); + } + if feature("CUDA") { + cfg.define("TRANSCRIBE_CUDA", "ON"); + } + // Force OpenMP OFF unless explicitly opted in. TRANSCRIBE_USE_OPENMP + // defaults ON and auto-detects, but its `-fopenmp` shows up only as a + // manifest link_flag → a `cargo:rustc-link-arg` that does NOT propagate to + // downstream binaries, so a static consumer link fails with undefined + // GOMP_*/omp_* symbols. A self-contained static build is the default; + // `--features openmp` opts in (and then owns providing the OpenMP runtime). + cfg.define( + "TRANSCRIBE_USE_OPENMP", + if feature("OPENMP") { "ON" } else { "OFF" }, + ); + // ggml's non-OpenMP CPU threadpool barrier deadlocks under MSVC on Windows; + // OpenMP's `#pragma omp barrier` is the only working multi-threaded CPU path + // there. The Rust binding is CPU-default and standalone (no numpy/torch + // OpenMP-coexistence concern), so it opts ggml into OpenMP on Windows. + // GGML-internal only: TRANSCRIBE_USE_OPENMP stays off, so the link manifest + // emits no (GNU-only) -fopenmp and the Parakeet host-decoder TU is unchanged; + // MSVC auto-links vcomp via the ggml objects' /openmp pragma. CMakeLists + // honors this explicit GGML_OPENMP and skips its force-off. See the full + // per-consumer policy (incl. why the Python wheels stay OpenMP-free) in the + // "OpenMP — CENTRAL POLICY" block of the root CMakeLists.txt. + if target_os == "windows" { + cfg.define("GGML_OPENMP", "ON"); + } + + // Escape hatch: forward arbitrary configure args so the curated features are + // never a hard ceiling. Anything CMake accepts (-DGGML_*, a -DTRANSCRIBE_* + // the features don't cover, a toolchain define, ...) can be passed via + // TRANSCRIBE_CMAKE_ARGS / CMAKE_ARGS. Fed AFTER the feature-derived defines + // so a user -D wins on the first configure. Unsupported/untested by design — + // it exists so a consumer is never blocked. The link line is still + // reconstructed from the regenerated manifest, so whatever this turns on is + // linked correctly with no per-flag knowledge here. + for var in ["TRANSCRIBE_CMAKE_ARGS", "CMAKE_ARGS"] { + println!("cargo:rerun-if-env-changed={var}"); + if let Ok(extra) = env::var(var) { + for arg in split_cmake_args(&extra) { + cfg.configure_arg(arg); + } + } + } + + // Builds + installs into OUT_DIR; the returned path IS the install prefix. + let prefix = cfg.build(); + + let manifest = find_manifest(&prefix) + .unwrap_or_else(|| panic!("transcribe-link.json not found under {}", prefix.display())); + emit_link_lines(&prefix, &manifest); +} + +/// GNUInstallDirs picks `lib` or `lib64`; find the manifest under either. +fn find_manifest(prefix: &Path) -> Option { + for libdir in ["lib", "lib64"] { + let p = prefix.join(libdir).join("transcribe-link.json"); + if p.is_file() { + return Some(p); + } + } + None +} + +fn emit_link_lines(prefix: &Path, manifest_path: &Path) { + let text = std::fs::read_to_string(manifest_path).expect("read transcribe-link.json"); + let json: serde_json::Value = serde_json::from_str(&text).expect("parse transcribe-link.json"); + + let strs = |key: &str| -> Vec { + json[key] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() + }; + + let shared = json["shared"].as_bool().unwrap_or(false); + let lib_dir = prefix.join(json["lib_dir"].as_str().unwrap_or("lib")); + println!("cargo:rustc-link-search=native={}", lib_dir.display()); + + // Archives (static) or the single shared lib. The manifest order is + // single-pass-GNU-ld safe (each archive's undefined refs resolve in a + // later one: transcribe -> ggml -> backends -> ggml-base). + let kind = if shared { "dylib" } else { "static" }; + for name in strs("libraries") { + println!("cargo:rustc-link-lib={kind}={name}"); + } + + // Absolute library paths (e.g. a find_package(BLAS) result): link the file. + for path in strs("library_paths") { + println!("cargo:rustc-link-arg={path}"); + } + // System libraries the C++/backend archives drag in. + for name in strs("system_libs") { + println!("cargo:rustc-link-lib=dylib={name}"); + } + // Apple frameworks (Metal/Foundation/Accelerate...). + for name in strs("frameworks") { + println!("cargo:rustc-link-lib=framework={name}"); + } + // Extra link flags (e.g. -fopenmp). + for flag in strs("link_flags") { + println!("cargo:rustc-link-arg={flag}"); + } + // Shared posture: the installed libs carry $ORIGIN/@loader_path rpaths, but + // the consumer binary still needs to find lib_dir itself. Unix uses an + // rpath; Windows has no rpath (the DLL resolves via PATH / the exe dir), so + // nothing is emitted there. NOTE: rustc-link-arg does NOT propagate to + // downstream crates, so this rpath only reaches THIS crate's own artifacts + // (the -sys smoke tests). The safe crate re-emits it for its tests/examples + // — see bindings/rust/transcribe-cpp/build.rs. + let is_windows = env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows"); + if shared && !is_windows { + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_dir.display()); + } + // Windows has no rpath: a shared-posture binary resolves transcribe.dll + + // the ggml DLLs from its OWN directory. Stage the installed DLLs next to + // every artifact cargo produces so tests/examples/bins run in place. + if shared && is_windows { + stage_windows_dlls(prefix); + } + + // Metadata for the safe crate's build script (available as + // DEP_TRANSCRIBE_* because this crate sets `links = "transcribe"`). + println!( + "cargo:include_dir={}", + prefix + .join(json["include_dir"].as_str().unwrap_or("include")) + .display() + ); + println!("cargo:lib_dir={}", lib_dir.display()); +} + +/// Copy the installed runtime DLLs (`/bin/*.dll` — transcribe.dll plus +/// the ggml DLLs) next to every artifact cargo will build, so a shared-posture +/// consumer's tests/examples/bins find them with no rpath (Windows has none) +/// and no PATH fiddling. Windows + `shared` only; a no-op anywhere else. +/// +/// Windows resolves a process's DLLs from the EXE's own directory first, and +/// cargo emits tests into `deps/`, examples into `examples/`, and bins into the +/// profile root — so all three get the DLLs. This runs from the -sys build +/// script (it owns the native build), which is always in the dep graph, so it +/// covers the safe crate's artifacts too. Best-effort: copy failures warn +/// rather than fail the build. +fn stage_windows_dlls(prefix: &Path) { + let bin_dir = prefix.join("bin"); + let dlls: Vec = match std::fs::read_dir(&bin_dir) { + Ok(entries) => entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("dll")) + .collect(), + Err(_) => Vec::new(), + }; + if dlls.is_empty() { + println!( + "cargo:warning=transcribe-cpp-sys: no DLLs under {} to stage for the shared posture", + bin_dir.display() + ); + return; + } + // OUT_DIR = //build/-/out; the profile root is + // four ancestors up. tests -> deps/, examples -> examples/, bins -> root. + let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")); + let Some(profile_dir) = out_dir.ancestors().nth(3) else { + return; + }; + for sub in ["", "deps", "examples"] { + let dest = if sub.is_empty() { + profile_dir.to_path_buf() + } else { + profile_dir.join(sub) + }; + let _ = std::fs::create_dir_all(&dest); + for dll in &dlls { + if let Some(name) = dll.file_name() { + let _ = std::fs::copy(dll, dest.join(name)); + } + } + } + // Re-stage when the built DLLs change. + println!("cargo:rerun-if-changed={}", bin_dir.display()); +} diff --git a/bindings/rust/sys/src/lib.rs b/bindings/rust/sys/src/lib.rs index 38d81860..7ccae0a4 100644 --- a/bindings/rust/sys/src/lib.rs +++ b/bindings/rust/sys/src/lib.rs @@ -1,8 +1,51 @@ -//! Native FFI bindings for [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp). +//! Raw native FFI bindings for [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp), +//! a C/C++ speech-to-text library built on ggml. //! -//! This is a placeholder crate that reserves the `transcribe-cpp-sys` name on -//! crates.io while first-party bindings are developed. It ships no -//! functionality yet. +//! This crate is the unsafe, generated FFI surface. The safe, idiomatic API is +//! the [`transcribe-cpp`](https://crates.io/crates/transcribe-cpp) crate. +//! +//! The bindings in [`transcribe_sys`] are generated by `bindings/rust/xtask` +//! (bindgen, comments disabled for determinism) from +//! `include/transcribe/extensions.h` — the flattened public surface +//! (`transcribe.h` plus every family extension header) — and committed to the +//! repo, so building this crate needs no libclang. CI regenerates them and +//! diffs against the committed copy, pinned to `include/transcribe.abihash` +//! (`xtask check`); per-field ABI layout checks are waived because bindgen +//! takes layout from a real compiler (see the crate README). + +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] +#![allow(dead_code)] + +#[allow(clippy::all)] +mod transcribe_sys { + include!("transcribe_sys.rs"); +} + +pub use transcribe_sys::*; + +#[cfg(test)] +mod smoke { + //! Proves the whole chain wires up: the cmake source build, the link + //! manifest -> link line, and the committed bindgen surface. If this links + //! and prints a version, `build.rs` and the FFI are sound. + use super::*; + use std::ffi::CStr; + + #[test] + fn version_links_and_matches_package() { + let raw = unsafe { transcribe_version() }; + assert!(!raw.is_null()); + let version = unsafe { CStr::from_ptr(raw) }.to_str().unwrap(); + assert_eq!(version, env!("CARGO_PKG_VERSION")); + } -/// Version of this placeholder crate. -pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + #[test] + fn abi_struct_size_is_live() { + // A real call into the native lib that returns a runtime value. + let size = + unsafe { transcribe_abi_struct_size(transcribe_abi_struct::TRANSCRIBE_ABI_RUN_PARAMS) }; + assert!(size > 0); + } +} diff --git a/bindings/rust/sys/src/transcribe_sys.rs b/bindings/rust/sys/src/transcribe_sys.rs new file mode 100644 index 00000000..d57c77a2 --- /dev/null +++ b/bindings/rust/sys/src/transcribe_sys.rs @@ -0,0 +1,1190 @@ +// @generated by `cargo xtask bindgen` from include/transcribe/extensions.h +// DO NOT EDIT BY HAND. Regenerate: `cargo xtask bindgen`. +// Pinned to include/transcribe.abihash = fe9ed398c408e5d9 + +/// The public-ABI digest these bindings were generated against +/// (sha256/16 over the normalized FFI surface). The load-time version +/// gate and the CI drift check both anchor on this value. +pub const PUBLIC_HEADER_HASH: &str = "fe9ed398c408e5d9"; + +/* automatically generated by rust-bindgen 0.72.1 */ + +pub const TRANSCRIBE_VERSION_MAJOR: u32 = 0; +pub const TRANSCRIBE_VERSION_MINOR: u32 = 0; +pub const TRANSCRIBE_VERSION_PATCH: u32 = 1; +pub const TRANSCRIBE_VERSION_NUMBER: u32 = 1; +pub const TRANSCRIBE_EXT_KIND_MOONSHINE_STREAMING_STREAM: u32 = 1414746957; +pub const TRANSCRIBE_EXT_KIND_PARAKEET_STREAM: u32 = 1414744912; +pub const TRANSCRIBE_EXT_KIND_PARAKEET_BUFFERED_STREAM: u32 = 1396853584; +pub const TRANSCRIBE_EXT_KIND_VOXTRAL_REALTIME_STREAM: u32 = 1414746710; +pub const TRANSCRIBE_EXT_KIND_WHISPER_RUN: u32 = 1314015319; +impl transcribe_status { + pub const TRANSCRIBE_OK: transcribe_status = transcribe_status(0); + pub const TRANSCRIBE_ERR_INVALID_ARG: transcribe_status = transcribe_status(1); + pub const TRANSCRIBE_ERR_NOT_IMPLEMENTED: transcribe_status = transcribe_status(2); + pub const TRANSCRIBE_ERR_FILE_NOT_FOUND: transcribe_status = transcribe_status(3); + pub const TRANSCRIBE_ERR_GGUF: transcribe_status = transcribe_status(4); + pub const TRANSCRIBE_ERR_UNSUPPORTED_ARCH: transcribe_status = transcribe_status(5); + pub const TRANSCRIBE_ERR_UNSUPPORTED_VARIANT: transcribe_status = transcribe_status(6); + pub const TRANSCRIBE_ERR_OOM: transcribe_status = transcribe_status(7); + pub const TRANSCRIBE_ERR_BACKEND: transcribe_status = transcribe_status(8); + pub const TRANSCRIBE_ERR_SAMPLE_RATE: transcribe_status = transcribe_status(9); + pub const TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE: transcribe_status = transcribe_status(10); + pub const TRANSCRIBE_ERR_UNSUPPORTED_TASK: transcribe_status = transcribe_status(11); + pub const TRANSCRIBE_ERR_UNSUPPORTED_TIMESTAMPS: transcribe_status = transcribe_status(12); + pub const TRANSCRIBE_ERR_ABORTED: transcribe_status = transcribe_status(13); + pub const TRANSCRIBE_ERR_BAD_STRUCT_SIZE: transcribe_status = transcribe_status(14); + pub const TRANSCRIBE_ERR_UNSUPPORTED_PNC: transcribe_status = transcribe_status(15); + pub const TRANSCRIBE_ERR_UNSUPPORTED_ITN: transcribe_status = transcribe_status(16); + pub const TRANSCRIBE_ERR_INPUT_TOO_LONG: transcribe_status = transcribe_status(17); + pub const TRANSCRIBE_ERR_OUTPUT_TRUNCATED: transcribe_status = transcribe_status(18); +} +#[repr(transparent)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub struct transcribe_status(pub ::std::os::raw::c_uint); +unsafe extern "C" { + pub fn transcribe_status_string(status: ::std::os::raw::c_int) + -> *const ::std::os::raw::c_char; +} +unsafe extern "C" { + pub fn transcribe_version() -> *const ::std::os::raw::c_char; +} +unsafe extern "C" { + pub fn transcribe_version_commit() -> *const ::std::os::raw::c_char; +} +impl transcribe_abi_struct { + pub const TRANSCRIBE_ABI_MODEL_LOAD_PARAMS: transcribe_abi_struct = transcribe_abi_struct(0); + pub const TRANSCRIBE_ABI_SESSION_PARAMS: transcribe_abi_struct = transcribe_abi_struct(1); + pub const TRANSCRIBE_ABI_RUN_PARAMS: transcribe_abi_struct = transcribe_abi_struct(2); + pub const TRANSCRIBE_ABI_STREAM_PARAMS: transcribe_abi_struct = transcribe_abi_struct(3); + pub const TRANSCRIBE_ABI_CAPABILITIES: transcribe_abi_struct = transcribe_abi_struct(4); + pub const TRANSCRIBE_ABI_TIMINGS: transcribe_abi_struct = transcribe_abi_struct(5); + pub const TRANSCRIBE_ABI_SEGMENT: transcribe_abi_struct = transcribe_abi_struct(6); + pub const TRANSCRIBE_ABI_WORD: transcribe_abi_struct = transcribe_abi_struct(7); + pub const TRANSCRIBE_ABI_TOKEN: transcribe_abi_struct = transcribe_abi_struct(8); + pub const TRANSCRIBE_ABI_STREAM_UPDATE: transcribe_abi_struct = transcribe_abi_struct(9); + pub const TRANSCRIBE_ABI_STREAM_TEXT: transcribe_abi_struct = transcribe_abi_struct(10); + pub const TRANSCRIBE_ABI_SESSION_LIMITS: transcribe_abi_struct = transcribe_abi_struct(11); + pub const TRANSCRIBE_ABI_EXT: transcribe_abi_struct = transcribe_abi_struct(12); + pub const TRANSCRIBE_ABI_BACKEND_DEVICE: transcribe_abi_struct = transcribe_abi_struct(13); +} +#[repr(transparent)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub struct transcribe_abi_struct(pub ::std::os::raw::c_uint); +unsafe extern "C" { + pub fn transcribe_abi_struct_size(which: transcribe_abi_struct) -> usize; +} +unsafe extern "C" { + pub fn transcribe_abi_struct_align(which: transcribe_abi_struct) -> usize; +} +impl transcribe_log_level { + pub const TRANSCRIBE_LOG_LEVEL_NONE: transcribe_log_level = transcribe_log_level(0); + pub const TRANSCRIBE_LOG_LEVEL_INFO: transcribe_log_level = transcribe_log_level(1); + pub const TRANSCRIBE_LOG_LEVEL_WARN: transcribe_log_level = transcribe_log_level(2); + pub const TRANSCRIBE_LOG_LEVEL_ERROR: transcribe_log_level = transcribe_log_level(3); + pub const TRANSCRIBE_LOG_LEVEL_DEBUG: transcribe_log_level = transcribe_log_level(4); + pub const TRANSCRIBE_LOG_LEVEL_CONT: transcribe_log_level = transcribe_log_level(5); +} +#[repr(transparent)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub struct transcribe_log_level(pub ::std::os::raw::c_uint); +pub type transcribe_log_callback = ::std::option::Option< + unsafe extern "C" fn( + level: transcribe_log_level, + msg: *const ::std::os::raw::c_char, + userdata: *mut ::std::os::raw::c_void, + ), +>; +unsafe extern "C" { + pub fn transcribe_log_set(cb: transcribe_log_callback, userdata: *mut ::std::os::raw::c_void); +} +impl transcribe_task { + pub const TRANSCRIBE_TASK_TRANSCRIBE: transcribe_task = transcribe_task(0); + pub const TRANSCRIBE_TASK_TRANSLATE: transcribe_task = transcribe_task(1); +} +#[repr(transparent)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub struct transcribe_task(pub ::std::os::raw::c_uint); +impl transcribe_timestamp_kind { + pub const TRANSCRIBE_TIMESTAMPS_NONE: transcribe_timestamp_kind = transcribe_timestamp_kind(0); + pub const TRANSCRIBE_TIMESTAMPS_AUTO: transcribe_timestamp_kind = transcribe_timestamp_kind(1); + pub const TRANSCRIBE_TIMESTAMPS_SEGMENT: transcribe_timestamp_kind = + transcribe_timestamp_kind(2); + pub const TRANSCRIBE_TIMESTAMPS_WORD: transcribe_timestamp_kind = transcribe_timestamp_kind(3); + pub const TRANSCRIBE_TIMESTAMPS_TOKEN: transcribe_timestamp_kind = transcribe_timestamp_kind(4); +} +#[repr(transparent)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub struct transcribe_timestamp_kind(pub ::std::os::raw::c_uint); +impl transcribe_kv_type { + pub const TRANSCRIBE_KV_TYPE_AUTO: transcribe_kv_type = transcribe_kv_type(0); + pub const TRANSCRIBE_KV_TYPE_F32: transcribe_kv_type = transcribe_kv_type(1); + pub const TRANSCRIBE_KV_TYPE_F16: transcribe_kv_type = transcribe_kv_type(2); +} +#[repr(transparent)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub struct transcribe_kv_type(pub ::std::os::raw::c_uint); +impl transcribe_pnc_mode { + pub const TRANSCRIBE_PNC_MODE_DEFAULT: transcribe_pnc_mode = transcribe_pnc_mode(0); + pub const TRANSCRIBE_PNC_MODE_OFF: transcribe_pnc_mode = transcribe_pnc_mode(1); + pub const TRANSCRIBE_PNC_MODE_ON: transcribe_pnc_mode = transcribe_pnc_mode(2); +} +#[repr(transparent)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub struct transcribe_pnc_mode(pub ::std::os::raw::c_uint); +impl transcribe_itn_mode { + pub const TRANSCRIBE_ITN_MODE_DEFAULT: transcribe_itn_mode = transcribe_itn_mode(0); + pub const TRANSCRIBE_ITN_MODE_OFF: transcribe_itn_mode = transcribe_itn_mode(1); + pub const TRANSCRIBE_ITN_MODE_ON: transcribe_itn_mode = transcribe_itn_mode(2); +} +#[repr(transparent)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub struct transcribe_itn_mode(pub ::std::os::raw::c_uint); +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_model { + _unused: [u8; 0], +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_session { + _unused: [u8; 0], +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_ext { + pub size: u64, + pub kind: u32, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_ext"][::std::mem::size_of::() - 16usize]; + ["Alignment of transcribe_ext"][::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_ext::size"] + [::std::mem::offset_of!(transcribe_ext, size) - 0usize]; + ["Offset of field: transcribe_ext::kind"] + [::std::mem::offset_of!(transcribe_ext, kind) - 8usize]; +}; +unsafe extern "C" { + pub fn transcribe_ext_check( + ext: *const transcribe_ext, + expected_kind: u32, + min_size: u64, + ) -> transcribe_status; +} +impl transcribe_ext_slot { + pub const TRANSCRIBE_EXT_SLOT_RUN: transcribe_ext_slot = transcribe_ext_slot(0); + pub const TRANSCRIBE_EXT_SLOT_STREAM: transcribe_ext_slot = transcribe_ext_slot(1); +} +#[repr(transparent)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub struct transcribe_ext_slot(pub ::std::os::raw::c_uint); +unsafe extern "C" { + pub fn transcribe_model_accepts_ext_kind( + model: *const transcribe_model, + slot: transcribe_ext_slot, + kind: u32, + ) -> bool; +} +impl transcribe_backend_request { + pub const TRANSCRIBE_BACKEND_AUTO: transcribe_backend_request = transcribe_backend_request(0); + pub const TRANSCRIBE_BACKEND_CPU: transcribe_backend_request = transcribe_backend_request(1); + pub const TRANSCRIBE_BACKEND_METAL: transcribe_backend_request = transcribe_backend_request(2); + pub const TRANSCRIBE_BACKEND_VULKAN: transcribe_backend_request = transcribe_backend_request(3); + pub const TRANSCRIBE_BACKEND_CPU_ACCEL: transcribe_backend_request = + transcribe_backend_request(4); + pub const TRANSCRIBE_BACKEND_CUDA: transcribe_backend_request = transcribe_backend_request(5); +} +#[repr(transparent)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub struct transcribe_backend_request(pub ::std::os::raw::c_uint); +unsafe extern "C" { + pub fn transcribe_init_backends( + artifact_dir: *const ::std::os::raw::c_char, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_init_backends_default() -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_backend_device_count() -> ::std::os::raw::c_int; +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_backend_device { + pub struct_size: u64, + pub name: *const ::std::os::raw::c_char, + pub description: *const ::std::os::raw::c_char, + pub kind: *const ::std::os::raw::c_char, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_backend_device"] + [::std::mem::size_of::() - 32usize]; + ["Alignment of transcribe_backend_device"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_backend_device::struct_size"] + [::std::mem::offset_of!(transcribe_backend_device, struct_size) - 0usize]; + ["Offset of field: transcribe_backend_device::name"] + [::std::mem::offset_of!(transcribe_backend_device, name) - 8usize]; + ["Offset of field: transcribe_backend_device::description"] + [::std::mem::offset_of!(transcribe_backend_device, description) - 16usize]; + ["Offset of field: transcribe_backend_device::kind"] + [::std::mem::offset_of!(transcribe_backend_device, kind) - 24usize]; +}; +unsafe extern "C" { + pub fn transcribe_backend_device_init(p: *mut transcribe_backend_device); +} +unsafe extern "C" { + pub fn transcribe_get_backend_device( + index: ::std::os::raw::c_int, + out: *mut transcribe_backend_device, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_backend_available(kind: transcribe_backend_request) -> bool; +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_model_load_params { + pub struct_size: u64, + pub backend: transcribe_backend_request, + pub gpu_device: ::std::os::raw::c_int, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_model_load_params"] + [::std::mem::size_of::() - 16usize]; + ["Alignment of transcribe_model_load_params"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_model_load_params::struct_size"] + [::std::mem::offset_of!(transcribe_model_load_params, struct_size) - 0usize]; + ["Offset of field: transcribe_model_load_params::backend"] + [::std::mem::offset_of!(transcribe_model_load_params, backend) - 8usize]; + ["Offset of field: transcribe_model_load_params::gpu_device"] + [::std::mem::offset_of!(transcribe_model_load_params, gpu_device) - 12usize]; +}; +unsafe extern "C" { + pub fn transcribe_model_load_params_init(params: *mut transcribe_model_load_params); +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_session_params { + pub struct_size: u64, + pub n_threads: ::std::os::raw::c_int, + pub kv_type: transcribe_kv_type, + pub n_ctx: i32, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_session_params"] + [::std::mem::size_of::() - 24usize]; + ["Alignment of transcribe_session_params"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_session_params::struct_size"] + [::std::mem::offset_of!(transcribe_session_params, struct_size) - 0usize]; + ["Offset of field: transcribe_session_params::n_threads"] + [::std::mem::offset_of!(transcribe_session_params, n_threads) - 8usize]; + ["Offset of field: transcribe_session_params::kv_type"] + [::std::mem::offset_of!(transcribe_session_params, kv_type) - 12usize]; + ["Offset of field: transcribe_session_params::n_ctx"] + [::std::mem::offset_of!(transcribe_session_params, n_ctx) - 16usize]; +}; +unsafe extern "C" { + pub fn transcribe_session_params_init(params: *mut transcribe_session_params); +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_run_params { + pub struct_size: u64, + pub task: transcribe_task, + pub timestamps: transcribe_timestamp_kind, + pub pnc: transcribe_pnc_mode, + pub itn: transcribe_itn_mode, + pub language: *const ::std::os::raw::c_char, + pub target_language: *const ::std::os::raw::c_char, + pub keep_special_tags: bool, + pub family: *const transcribe_ext, + pub spec_k_drafts: i32, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_run_params"][::std::mem::size_of::() - 64usize]; + ["Alignment of transcribe_run_params"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_run_params::struct_size"] + [::std::mem::offset_of!(transcribe_run_params, struct_size) - 0usize]; + ["Offset of field: transcribe_run_params::task"] + [::std::mem::offset_of!(transcribe_run_params, task) - 8usize]; + ["Offset of field: transcribe_run_params::timestamps"] + [::std::mem::offset_of!(transcribe_run_params, timestamps) - 12usize]; + ["Offset of field: transcribe_run_params::pnc"] + [::std::mem::offset_of!(transcribe_run_params, pnc) - 16usize]; + ["Offset of field: transcribe_run_params::itn"] + [::std::mem::offset_of!(transcribe_run_params, itn) - 20usize]; + ["Offset of field: transcribe_run_params::language"] + [::std::mem::offset_of!(transcribe_run_params, language) - 24usize]; + ["Offset of field: transcribe_run_params::target_language"] + [::std::mem::offset_of!(transcribe_run_params, target_language) - 32usize]; + ["Offset of field: transcribe_run_params::keep_special_tags"] + [::std::mem::offset_of!(transcribe_run_params, keep_special_tags) - 40usize]; + ["Offset of field: transcribe_run_params::family"] + [::std::mem::offset_of!(transcribe_run_params, family) - 48usize]; + ["Offset of field: transcribe_run_params::spec_k_drafts"] + [::std::mem::offset_of!(transcribe_run_params, spec_k_drafts) - 56usize]; +}; +unsafe extern "C" { + pub fn transcribe_run_params_init(params: *mut transcribe_run_params); +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_capabilities { + pub struct_size: u64, + pub native_sample_rate: i32, + pub n_languages: ::std::os::raw::c_int, + pub languages: *const *const ::std::os::raw::c_char, + pub max_timestamp_kind: transcribe_timestamp_kind, + pub supports_language_detect: bool, + pub supports_translate: bool, + pub supports_streaming: bool, + pub supports_spec_decode: bool, + pub max_audio_ms: i64, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_capabilities"][::std::mem::size_of::() - 40usize]; + ["Alignment of transcribe_capabilities"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_capabilities::struct_size"] + [::std::mem::offset_of!(transcribe_capabilities, struct_size) - 0usize]; + ["Offset of field: transcribe_capabilities::native_sample_rate"] + [::std::mem::offset_of!(transcribe_capabilities, native_sample_rate) - 8usize]; + ["Offset of field: transcribe_capabilities::n_languages"] + [::std::mem::offset_of!(transcribe_capabilities, n_languages) - 12usize]; + ["Offset of field: transcribe_capabilities::languages"] + [::std::mem::offset_of!(transcribe_capabilities, languages) - 16usize]; + ["Offset of field: transcribe_capabilities::max_timestamp_kind"] + [::std::mem::offset_of!(transcribe_capabilities, max_timestamp_kind) - 24usize]; + ["Offset of field: transcribe_capabilities::supports_language_detect"] + [::std::mem::offset_of!(transcribe_capabilities, supports_language_detect) - 28usize]; + ["Offset of field: transcribe_capabilities::supports_translate"] + [::std::mem::offset_of!(transcribe_capabilities, supports_translate) - 29usize]; + ["Offset of field: transcribe_capabilities::supports_streaming"] + [::std::mem::offset_of!(transcribe_capabilities, supports_streaming) - 30usize]; + ["Offset of field: transcribe_capabilities::supports_spec_decode"] + [::std::mem::offset_of!(transcribe_capabilities, supports_spec_decode) - 31usize]; + ["Offset of field: transcribe_capabilities::max_audio_ms"] + [::std::mem::offset_of!(transcribe_capabilities, max_audio_ms) - 32usize]; +}; +unsafe extern "C" { + pub fn transcribe_capabilities_init(out: *mut transcribe_capabilities); +} +unsafe extern "C" { + pub fn transcribe_model_get_capabilities( + model: *const transcribe_model, + out_caps: *mut transcribe_capabilities, + ) -> transcribe_status; +} +impl transcribe_feature { + pub const TRANSCRIBE_FEATURE_INITIAL_PROMPT: transcribe_feature = transcribe_feature(0); + pub const TRANSCRIBE_FEATURE_TEMPERATURE_FALLBACK: transcribe_feature = transcribe_feature(1); + pub const TRANSCRIBE_FEATURE_LONG_FORM: transcribe_feature = transcribe_feature(2); + pub const TRANSCRIBE_FEATURE_CANCELLATION: transcribe_feature = transcribe_feature(3); + pub const TRANSCRIBE_FEATURE_PNC: transcribe_feature = transcribe_feature(4); + pub const TRANSCRIBE_FEATURE_ITN: transcribe_feature = transcribe_feature(5); +} +#[repr(transparent)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub struct transcribe_feature(pub ::std::os::raw::c_uint); +unsafe extern "C" { + pub fn transcribe_model_supports( + model: *const transcribe_model, + feature: transcribe_feature, + ) -> bool; +} +unsafe extern "C" { + pub fn transcribe_model_arch_string( + model: *const transcribe_model, + ) -> *const ::std::os::raw::c_char; +} +unsafe extern "C" { + pub fn transcribe_model_variant_string( + model: *const transcribe_model, + ) -> *const ::std::os::raw::c_char; +} +unsafe extern "C" { + pub fn transcribe_model_backend( + model: *const transcribe_model, + ) -> *const ::std::os::raw::c_char; +} +unsafe extern "C" { + pub fn transcribe_model_load_file( + path: *const ::std::os::raw::c_char, + params: *const transcribe_model_load_params, + out_model: *mut *mut transcribe_model, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_model_free(model: *mut transcribe_model); +} +unsafe extern "C" { + pub fn transcribe_session_init( + model: *mut transcribe_model, + params: *const transcribe_session_params, + out_session: *mut *mut transcribe_session, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_session_free(session: *mut transcribe_session); +} +unsafe extern "C" { + pub fn transcribe_open( + path: *const ::std::os::raw::c_char, + load_params: *const transcribe_model_load_params, + session_params: *const transcribe_session_params, + out_session: *mut *mut transcribe_session, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_close(session: *mut transcribe_session); +} +unsafe extern "C" { + pub fn transcribe_get_model(session: *const transcribe_session) -> *const transcribe_model; +} +unsafe extern "C" { + pub fn transcribe_run( + session: *mut transcribe_session, + pcm: *const f32, + n_samples: ::std::os::raw::c_int, + params: *const transcribe_run_params, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_run_batch( + session: *mut transcribe_session, + pcm: *const *const f32, + n_samples: *const ::std::os::raw::c_int, + n: ::std::os::raw::c_int, + params: *const transcribe_run_params, + ) -> transcribe_status; +} +pub type transcribe_abort_callback = + ::std::option::Option bool>; +unsafe extern "C" { + pub fn transcribe_set_abort_callback( + session: *mut transcribe_session, + cb: transcribe_abort_callback, + user_data: *mut ::std::os::raw::c_void, + ); +} +unsafe extern "C" { + pub fn transcribe_was_aborted(session: *const transcribe_session) -> bool; +} +unsafe extern "C" { + pub fn transcribe_was_truncated(session: *const transcribe_session) -> bool; +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_session_limits { + pub struct_size: u64, + pub effective_n_ctx: i32, + pub effective_max_audio_ms: i64, + pub max_kv_bytes: i64, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_session_limits"] + [::std::mem::size_of::() - 32usize]; + ["Alignment of transcribe_session_limits"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_session_limits::struct_size"] + [::std::mem::offset_of!(transcribe_session_limits, struct_size) - 0usize]; + ["Offset of field: transcribe_session_limits::effective_n_ctx"] + [::std::mem::offset_of!(transcribe_session_limits, effective_n_ctx) - 8usize]; + ["Offset of field: transcribe_session_limits::effective_max_audio_ms"] + [::std::mem::offset_of!(transcribe_session_limits, effective_max_audio_ms) - 16usize]; + ["Offset of field: transcribe_session_limits::max_kv_bytes"] + [::std::mem::offset_of!(transcribe_session_limits, max_kv_bytes) - 24usize]; +}; +unsafe extern "C" { + pub fn transcribe_session_limits_init(out: *mut transcribe_session_limits); +} +unsafe extern "C" { + pub fn transcribe_session_get_limits( + session: *const transcribe_session, + out: *mut transcribe_session_limits, + ) -> transcribe_status; +} +impl transcribe_stream_state { + pub const TRANSCRIBE_STREAM_IDLE: transcribe_stream_state = transcribe_stream_state(0); + pub const TRANSCRIBE_STREAM_ACTIVE: transcribe_stream_state = transcribe_stream_state(1); + pub const TRANSCRIBE_STREAM_FINISHED: transcribe_stream_state = transcribe_stream_state(2); + pub const TRANSCRIBE_STREAM_FAILED: transcribe_stream_state = transcribe_stream_state(3); +} +#[repr(transparent)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub struct transcribe_stream_state(pub ::std::os::raw::c_uint); +impl transcribe_stream_commit_policy { + pub const TRANSCRIBE_STREAM_COMMIT_AUTO: transcribe_stream_commit_policy = + transcribe_stream_commit_policy(0); + pub const TRANSCRIBE_STREAM_COMMIT_ON_FINALIZE: transcribe_stream_commit_policy = + transcribe_stream_commit_policy(1); + pub const TRANSCRIBE_STREAM_COMMIT_STABLE_PREFIX: transcribe_stream_commit_policy = + transcribe_stream_commit_policy(2); +} +#[repr(transparent)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub struct transcribe_stream_commit_policy(pub ::std::os::raw::c_uint); +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_stream_params { + pub struct_size: u64, + pub family: *const transcribe_ext, + pub commit_policy: transcribe_stream_commit_policy, + pub stable_prefix_agreement_n: u32, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_stream_params"] + [::std::mem::size_of::() - 24usize]; + ["Alignment of transcribe_stream_params"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_stream_params::struct_size"] + [::std::mem::offset_of!(transcribe_stream_params, struct_size) - 0usize]; + ["Offset of field: transcribe_stream_params::family"] + [::std::mem::offset_of!(transcribe_stream_params, family) - 8usize]; + ["Offset of field: transcribe_stream_params::commit_policy"] + [::std::mem::offset_of!(transcribe_stream_params, commit_policy) - 16usize]; + ["Offset of field: transcribe_stream_params::stable_prefix_agreement_n"] + [::std::mem::offset_of!(transcribe_stream_params, stable_prefix_agreement_n) - 20usize]; +}; +unsafe extern "C" { + pub fn transcribe_stream_params_init(params: *mut transcribe_stream_params); +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_stream_update { + pub struct_size: u64, + pub result_changed: bool, + pub is_final: bool, + pub revision: i32, + pub input_received_ms: i64, + pub audio_committed_ms: i64, + pub buffered_ms: i64, + pub committed_changed: bool, + pub tentative_changed: bool, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_stream_update"] + [::std::mem::size_of::() - 48usize]; + ["Alignment of transcribe_stream_update"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_stream_update::struct_size"] + [::std::mem::offset_of!(transcribe_stream_update, struct_size) - 0usize]; + ["Offset of field: transcribe_stream_update::result_changed"] + [::std::mem::offset_of!(transcribe_stream_update, result_changed) - 8usize]; + ["Offset of field: transcribe_stream_update::is_final"] + [::std::mem::offset_of!(transcribe_stream_update, is_final) - 9usize]; + ["Offset of field: transcribe_stream_update::revision"] + [::std::mem::offset_of!(transcribe_stream_update, revision) - 12usize]; + ["Offset of field: transcribe_stream_update::input_received_ms"] + [::std::mem::offset_of!(transcribe_stream_update, input_received_ms) - 16usize]; + ["Offset of field: transcribe_stream_update::audio_committed_ms"] + [::std::mem::offset_of!(transcribe_stream_update, audio_committed_ms) - 24usize]; + ["Offset of field: transcribe_stream_update::buffered_ms"] + [::std::mem::offset_of!(transcribe_stream_update, buffered_ms) - 32usize]; + ["Offset of field: transcribe_stream_update::committed_changed"] + [::std::mem::offset_of!(transcribe_stream_update, committed_changed) - 40usize]; + ["Offset of field: transcribe_stream_update::tentative_changed"] + [::std::mem::offset_of!(transcribe_stream_update, tentative_changed) - 41usize]; +}; +unsafe extern "C" { + pub fn transcribe_stream_update_init(out: *mut transcribe_stream_update); +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_stream_text { + pub struct_size: u64, + pub full_text: *const ::std::os::raw::c_char, + pub full_text_bytes: u64, + pub committed_text: *const ::std::os::raw::c_char, + pub committed_text_bytes: u64, + pub tentative_text: *const ::std::os::raw::c_char, + pub tentative_text_bytes: u64, + pub raw_tentative_start_bytes: u64, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_stream_text"][::std::mem::size_of::() - 64usize]; + ["Alignment of transcribe_stream_text"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_stream_text::struct_size"] + [::std::mem::offset_of!(transcribe_stream_text, struct_size) - 0usize]; + ["Offset of field: transcribe_stream_text::full_text"] + [::std::mem::offset_of!(transcribe_stream_text, full_text) - 8usize]; + ["Offset of field: transcribe_stream_text::full_text_bytes"] + [::std::mem::offset_of!(transcribe_stream_text, full_text_bytes) - 16usize]; + ["Offset of field: transcribe_stream_text::committed_text"] + [::std::mem::offset_of!(transcribe_stream_text, committed_text) - 24usize]; + ["Offset of field: transcribe_stream_text::committed_text_bytes"] + [::std::mem::offset_of!(transcribe_stream_text, committed_text_bytes) - 32usize]; + ["Offset of field: transcribe_stream_text::tentative_text"] + [::std::mem::offset_of!(transcribe_stream_text, tentative_text) - 40usize]; + ["Offset of field: transcribe_stream_text::tentative_text_bytes"] + [::std::mem::offset_of!(transcribe_stream_text, tentative_text_bytes) - 48usize]; + ["Offset of field: transcribe_stream_text::raw_tentative_start_bytes"] + [::std::mem::offset_of!(transcribe_stream_text, raw_tentative_start_bytes) - 56usize]; +}; +unsafe extern "C" { + pub fn transcribe_stream_text_init(out: *mut transcribe_stream_text); +} +unsafe extern "C" { + pub fn transcribe_stream_get_text( + session: *const transcribe_session, + out: *mut transcribe_stream_text, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_stream_begin( + session: *mut transcribe_session, + run_params: *const transcribe_run_params, + stream_params: *const transcribe_stream_params, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_stream_feed( + session: *mut transcribe_session, + pcm: *const f32, + n_samples: ::std::os::raw::c_int, + update: *mut transcribe_stream_update, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_stream_finalize( + session: *mut transcribe_session, + update: *mut transcribe_stream_update, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_stream_reset(session: *mut transcribe_session); +} +unsafe extern "C" { + pub fn transcribe_stream_get_state( + session: *const transcribe_session, + ) -> transcribe_stream_state; +} +unsafe extern "C" { + pub fn transcribe_stream_revision(session: *const transcribe_session) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn transcribe_stream_n_committed_segments( + session: *const transcribe_session, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn transcribe_stream_n_committed_words( + session: *const transcribe_session, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn transcribe_stream_n_committed_tokens( + session: *const transcribe_session, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn transcribe_stream_last_status(session: *const transcribe_session) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_tokenize( + model: *const transcribe_model, + text: *const ::std::os::raw::c_char, + tokens: *mut i32, + n_max: usize, + ) -> ::std::os::raw::c_int; +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_timings { + pub struct_size: u64, + pub load_ms: f32, + pub mel_ms: f32, + pub encode_ms: f32, + pub decode_ms: f32, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_timings"][::std::mem::size_of::() - 24usize]; + ["Alignment of transcribe_timings"][::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_timings::struct_size"] + [::std::mem::offset_of!(transcribe_timings, struct_size) - 0usize]; + ["Offset of field: transcribe_timings::load_ms"] + [::std::mem::offset_of!(transcribe_timings, load_ms) - 8usize]; + ["Offset of field: transcribe_timings::mel_ms"] + [::std::mem::offset_of!(transcribe_timings, mel_ms) - 12usize]; + ["Offset of field: transcribe_timings::encode_ms"] + [::std::mem::offset_of!(transcribe_timings, encode_ms) - 16usize]; + ["Offset of field: transcribe_timings::decode_ms"] + [::std::mem::offset_of!(transcribe_timings, decode_ms) - 20usize]; +}; +unsafe extern "C" { + pub fn transcribe_timings_init(out: *mut transcribe_timings); +} +unsafe extern "C" { + pub fn transcribe_get_timings( + session: *const transcribe_session, + out_timings: *mut transcribe_timings, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_print_timings(session: *const transcribe_session); +} +unsafe extern "C" { + pub fn transcribe_reset_timings(session: *mut transcribe_session); +} +unsafe extern "C" { + pub fn transcribe_full_text( + session: *const transcribe_session, + ) -> *const ::std::os::raw::c_char; +} +unsafe extern "C" { + pub fn transcribe_returned_timestamp_kind( + session: *const transcribe_session, + ) -> transcribe_timestamp_kind; +} +unsafe extern "C" { + pub fn transcribe_n_segments(session: *const transcribe_session) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn transcribe_n_words(session: *const transcribe_session) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn transcribe_n_tokens(session: *const transcribe_session) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn transcribe_detected_language( + session: *const transcribe_session, + ) -> *const ::std::os::raw::c_char; +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_segment { + pub struct_size: u64, + pub t0_ms: i64, + pub t1_ms: i64, + pub first_word: ::std::os::raw::c_int, + pub n_words: ::std::os::raw::c_int, + pub first_token: ::std::os::raw::c_int, + pub n_tokens: ::std::os::raw::c_int, + pub text: *const ::std::os::raw::c_char, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_segment"][::std::mem::size_of::() - 48usize]; + ["Alignment of transcribe_segment"][::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_segment::struct_size"] + [::std::mem::offset_of!(transcribe_segment, struct_size) - 0usize]; + ["Offset of field: transcribe_segment::t0_ms"] + [::std::mem::offset_of!(transcribe_segment, t0_ms) - 8usize]; + ["Offset of field: transcribe_segment::t1_ms"] + [::std::mem::offset_of!(transcribe_segment, t1_ms) - 16usize]; + ["Offset of field: transcribe_segment::first_word"] + [::std::mem::offset_of!(transcribe_segment, first_word) - 24usize]; + ["Offset of field: transcribe_segment::n_words"] + [::std::mem::offset_of!(transcribe_segment, n_words) - 28usize]; + ["Offset of field: transcribe_segment::first_token"] + [::std::mem::offset_of!(transcribe_segment, first_token) - 32usize]; + ["Offset of field: transcribe_segment::n_tokens"] + [::std::mem::offset_of!(transcribe_segment, n_tokens) - 36usize]; + ["Offset of field: transcribe_segment::text"] + [::std::mem::offset_of!(transcribe_segment, text) - 40usize]; +}; +unsafe extern "C" { + pub fn transcribe_segment_init(out: *mut transcribe_segment); +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_word { + pub struct_size: u64, + pub t0_ms: i64, + pub t1_ms: i64, + pub seg_index: ::std::os::raw::c_int, + pub first_token: ::std::os::raw::c_int, + pub n_tokens: ::std::os::raw::c_int, + pub text: *const ::std::os::raw::c_char, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_word"][::std::mem::size_of::() - 48usize]; + ["Alignment of transcribe_word"][::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_word::struct_size"] + [::std::mem::offset_of!(transcribe_word, struct_size) - 0usize]; + ["Offset of field: transcribe_word::t0_ms"] + [::std::mem::offset_of!(transcribe_word, t0_ms) - 8usize]; + ["Offset of field: transcribe_word::t1_ms"] + [::std::mem::offset_of!(transcribe_word, t1_ms) - 16usize]; + ["Offset of field: transcribe_word::seg_index"] + [::std::mem::offset_of!(transcribe_word, seg_index) - 24usize]; + ["Offset of field: transcribe_word::first_token"] + [::std::mem::offset_of!(transcribe_word, first_token) - 28usize]; + ["Offset of field: transcribe_word::n_tokens"] + [::std::mem::offset_of!(transcribe_word, n_tokens) - 32usize]; + ["Offset of field: transcribe_word::text"] + [::std::mem::offset_of!(transcribe_word, text) - 40usize]; +}; +unsafe extern "C" { + pub fn transcribe_word_init(out: *mut transcribe_word); +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_token { + pub struct_size: u64, + pub id: ::std::os::raw::c_int, + pub p: f32, + pub t0_ms: i64, + pub t1_ms: i64, + pub seg_index: ::std::os::raw::c_int, + pub word_index: ::std::os::raw::c_int, + pub text: *const ::std::os::raw::c_char, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_token"][::std::mem::size_of::() - 48usize]; + ["Alignment of transcribe_token"][::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_token::struct_size"] + [::std::mem::offset_of!(transcribe_token, struct_size) - 0usize]; + ["Offset of field: transcribe_token::id"] + [::std::mem::offset_of!(transcribe_token, id) - 8usize]; + ["Offset of field: transcribe_token::p"][::std::mem::offset_of!(transcribe_token, p) - 12usize]; + ["Offset of field: transcribe_token::t0_ms"] + [::std::mem::offset_of!(transcribe_token, t0_ms) - 16usize]; + ["Offset of field: transcribe_token::t1_ms"] + [::std::mem::offset_of!(transcribe_token, t1_ms) - 24usize]; + ["Offset of field: transcribe_token::seg_index"] + [::std::mem::offset_of!(transcribe_token, seg_index) - 32usize]; + ["Offset of field: transcribe_token::word_index"] + [::std::mem::offset_of!(transcribe_token, word_index) - 36usize]; + ["Offset of field: transcribe_token::text"] + [::std::mem::offset_of!(transcribe_token, text) - 40usize]; +}; +unsafe extern "C" { + pub fn transcribe_token_init(out: *mut transcribe_token); +} +unsafe extern "C" { + pub fn transcribe_get_segment( + session: *const transcribe_session, + i: ::std::os::raw::c_int, + out: *mut transcribe_segment, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_get_word( + session: *const transcribe_session, + i: ::std::os::raw::c_int, + out: *mut transcribe_word, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_get_token( + session: *const transcribe_session, + i: ::std::os::raw::c_int, + out: *mut transcribe_token, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_batch_n_results(session: *const transcribe_session) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn transcribe_batch_status( + session: *const transcribe_session, + i: ::std::os::raw::c_int, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_batch_full_text( + session: *const transcribe_session, + i: ::std::os::raw::c_int, + ) -> *const ::std::os::raw::c_char; +} +unsafe extern "C" { + pub fn transcribe_batch_returned_timestamp_kind( + session: *const transcribe_session, + i: ::std::os::raw::c_int, + ) -> transcribe_timestamp_kind; +} +unsafe extern "C" { + pub fn transcribe_batch_detected_language( + session: *const transcribe_session, + i: ::std::os::raw::c_int, + ) -> *const ::std::os::raw::c_char; +} +unsafe extern "C" { + pub fn transcribe_batch_n_segments( + session: *const transcribe_session, + i: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn transcribe_batch_n_words( + session: *const transcribe_session, + i: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn transcribe_batch_n_tokens( + session: *const transcribe_session, + i: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn transcribe_batch_get_segment( + session: *const transcribe_session, + i: ::std::os::raw::c_int, + j: ::std::os::raw::c_int, + out: *mut transcribe_segment, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_batch_get_word( + session: *const transcribe_session, + i: ::std::os::raw::c_int, + j: ::std::os::raw::c_int, + out: *mut transcribe_word, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_batch_get_token( + session: *const transcribe_session, + i: ::std::os::raw::c_int, + j: ::std::os::raw::c_int, + out: *mut transcribe_token, + ) -> transcribe_status; +} +unsafe extern "C" { + pub fn transcribe_batch_get_timings( + session: *const transcribe_session, + i: ::std::os::raw::c_int, + out: *mut transcribe_timings, + ) -> transcribe_status; +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_moonshine_streaming_stream_ext { + pub ext: transcribe_ext, + pub min_decode_interval_ms: i32, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_moonshine_streaming_stream_ext"] + [::std::mem::size_of::() - 24usize]; + ["Alignment of transcribe_moonshine_streaming_stream_ext"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_moonshine_streaming_stream_ext::ext"] + [::std::mem::offset_of!(transcribe_moonshine_streaming_stream_ext, ext) - 0usize]; + ["Offset of field: transcribe_moonshine_streaming_stream_ext::min_decode_interval_ms"][::std::mem::offset_of!( + transcribe_moonshine_streaming_stream_ext, + min_decode_interval_ms + ) + - 16usize]; +}; +unsafe extern "C" { + pub fn transcribe_moonshine_streaming_stream_ext_init( + ext: *mut transcribe_moonshine_streaming_stream_ext, + ); +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_parakeet_stream_ext { + pub ext: transcribe_ext, + pub att_context_right: i32, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_parakeet_stream_ext"] + [::std::mem::size_of::() - 24usize]; + ["Alignment of transcribe_parakeet_stream_ext"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_parakeet_stream_ext::ext"] + [::std::mem::offset_of!(transcribe_parakeet_stream_ext, ext) - 0usize]; + ["Offset of field: transcribe_parakeet_stream_ext::att_context_right"] + [::std::mem::offset_of!(transcribe_parakeet_stream_ext, att_context_right) - 16usize]; +}; +unsafe extern "C" { + pub fn transcribe_parakeet_stream_ext_init(ext: *mut transcribe_parakeet_stream_ext); +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_parakeet_buffered_stream_ext { + pub ext: transcribe_ext, + pub left_ms: i32, + pub chunk_ms: i32, + pub right_ms: i32, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_parakeet_buffered_stream_ext"] + [::std::mem::size_of::() - 32usize]; + ["Alignment of transcribe_parakeet_buffered_stream_ext"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_parakeet_buffered_stream_ext::ext"] + [::std::mem::offset_of!(transcribe_parakeet_buffered_stream_ext, ext) - 0usize]; + ["Offset of field: transcribe_parakeet_buffered_stream_ext::left_ms"] + [::std::mem::offset_of!(transcribe_parakeet_buffered_stream_ext, left_ms) - 16usize]; + ["Offset of field: transcribe_parakeet_buffered_stream_ext::chunk_ms"] + [::std::mem::offset_of!(transcribe_parakeet_buffered_stream_ext, chunk_ms) - 20usize]; + ["Offset of field: transcribe_parakeet_buffered_stream_ext::right_ms"] + [::std::mem::offset_of!(transcribe_parakeet_buffered_stream_ext, right_ms) - 24usize]; +}; +unsafe extern "C" { + pub fn transcribe_parakeet_buffered_stream_ext_init( + ext: *mut transcribe_parakeet_buffered_stream_ext, + ); +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_voxtral_realtime_stream_ext { + pub ext: transcribe_ext, + pub num_delay_tokens: i32, + pub min_decode_interval_ms: i32, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_voxtral_realtime_stream_ext"] + [::std::mem::size_of::() - 24usize]; + ["Alignment of transcribe_voxtral_realtime_stream_ext"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_voxtral_realtime_stream_ext::ext"] + [::std::mem::offset_of!(transcribe_voxtral_realtime_stream_ext, ext) - 0usize]; + ["Offset of field: transcribe_voxtral_realtime_stream_ext::num_delay_tokens"][::std::mem::offset_of!( + transcribe_voxtral_realtime_stream_ext, + num_delay_tokens + ) - 16usize]; + ["Offset of field: transcribe_voxtral_realtime_stream_ext::min_decode_interval_ms"][::std::mem::offset_of!( + transcribe_voxtral_realtime_stream_ext, + min_decode_interval_ms + ) + - 20usize]; +}; +unsafe extern "C" { + pub fn transcribe_voxtral_realtime_stream_ext_init( + ext: *mut transcribe_voxtral_realtime_stream_ext, + ); +} +impl transcribe_whisper_prompt_condition { + pub const TRANSCRIBE_WHISPER_PROMPT_FIRST_SEGMENT: transcribe_whisper_prompt_condition = + transcribe_whisper_prompt_condition(0); + pub const TRANSCRIBE_WHISPER_PROMPT_ALL_SEGMENTS: transcribe_whisper_prompt_condition = + transcribe_whisper_prompt_condition(1); +} +#[repr(transparent)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub struct transcribe_whisper_prompt_condition(pub ::std::os::raw::c_uint); +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_whisper_run_ext { + pub ext: transcribe_ext, + pub initial_prompt: *const ::std::os::raw::c_char, + pub prompt_tokens: *const i32, + pub n_prompt_tokens: usize, + pub prompt_condition: transcribe_whisper_prompt_condition, + pub condition_on_prev_tokens: bool, + pub max_prev_context_tokens: i32, + pub temperature: f32, + pub temperature_inc: f32, + pub compression_ratio_thold: f32, + pub logprob_thold: f32, + pub no_speech_thold: f32, + pub seed: u32, + pub max_initial_timestamp: f32, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_whisper_run_ext"] + [::std::mem::size_of::() - 80usize]; + ["Alignment of transcribe_whisper_run_ext"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_whisper_run_ext::ext"] + [::std::mem::offset_of!(transcribe_whisper_run_ext, ext) - 0usize]; + ["Offset of field: transcribe_whisper_run_ext::initial_prompt"] + [::std::mem::offset_of!(transcribe_whisper_run_ext, initial_prompt) - 16usize]; + ["Offset of field: transcribe_whisper_run_ext::prompt_tokens"] + [::std::mem::offset_of!(transcribe_whisper_run_ext, prompt_tokens) - 24usize]; + ["Offset of field: transcribe_whisper_run_ext::n_prompt_tokens"] + [::std::mem::offset_of!(transcribe_whisper_run_ext, n_prompt_tokens) - 32usize]; + ["Offset of field: transcribe_whisper_run_ext::prompt_condition"] + [::std::mem::offset_of!(transcribe_whisper_run_ext, prompt_condition) - 40usize]; + ["Offset of field: transcribe_whisper_run_ext::condition_on_prev_tokens"] + [::std::mem::offset_of!(transcribe_whisper_run_ext, condition_on_prev_tokens) - 44usize]; + ["Offset of field: transcribe_whisper_run_ext::max_prev_context_tokens"] + [::std::mem::offset_of!(transcribe_whisper_run_ext, max_prev_context_tokens) - 48usize]; + ["Offset of field: transcribe_whisper_run_ext::temperature"] + [::std::mem::offset_of!(transcribe_whisper_run_ext, temperature) - 52usize]; + ["Offset of field: transcribe_whisper_run_ext::temperature_inc"] + [::std::mem::offset_of!(transcribe_whisper_run_ext, temperature_inc) - 56usize]; + ["Offset of field: transcribe_whisper_run_ext::compression_ratio_thold"] + [::std::mem::offset_of!(transcribe_whisper_run_ext, compression_ratio_thold) - 60usize]; + ["Offset of field: transcribe_whisper_run_ext::logprob_thold"] + [::std::mem::offset_of!(transcribe_whisper_run_ext, logprob_thold) - 64usize]; + ["Offset of field: transcribe_whisper_run_ext::no_speech_thold"] + [::std::mem::offset_of!(transcribe_whisper_run_ext, no_speech_thold) - 68usize]; + ["Offset of field: transcribe_whisper_run_ext::seed"] + [::std::mem::offset_of!(transcribe_whisper_run_ext, seed) - 72usize]; + ["Offset of field: transcribe_whisper_run_ext::max_initial_timestamp"] + [::std::mem::offset_of!(transcribe_whisper_run_ext, max_initial_timestamp) - 76usize]; +}; +unsafe extern "C" { + pub fn transcribe_whisper_run_ext_init(ext: *mut transcribe_whisper_run_ext); +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct transcribe_whisper_chunk_trace { + pub struct_size: u64, + pub t0_ms: i64, + pub t1_ms: i64, + pub temperature_used: f32, + pub compression_ratio: f32, + pub avg_logprob: f32, + pub no_speech_prob: f32, + pub no_speech_triggered: bool, + pub n_fallbacks: i32, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of transcribe_whisper_chunk_trace"] + [::std::mem::size_of::() - 48usize]; + ["Alignment of transcribe_whisper_chunk_trace"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: transcribe_whisper_chunk_trace::struct_size"] + [::std::mem::offset_of!(transcribe_whisper_chunk_trace, struct_size) - 0usize]; + ["Offset of field: transcribe_whisper_chunk_trace::t0_ms"] + [::std::mem::offset_of!(transcribe_whisper_chunk_trace, t0_ms) - 8usize]; + ["Offset of field: transcribe_whisper_chunk_trace::t1_ms"] + [::std::mem::offset_of!(transcribe_whisper_chunk_trace, t1_ms) - 16usize]; + ["Offset of field: transcribe_whisper_chunk_trace::temperature_used"] + [::std::mem::offset_of!(transcribe_whisper_chunk_trace, temperature_used) - 24usize]; + ["Offset of field: transcribe_whisper_chunk_trace::compression_ratio"] + [::std::mem::offset_of!(transcribe_whisper_chunk_trace, compression_ratio) - 28usize]; + ["Offset of field: transcribe_whisper_chunk_trace::avg_logprob"] + [::std::mem::offset_of!(transcribe_whisper_chunk_trace, avg_logprob) - 32usize]; + ["Offset of field: transcribe_whisper_chunk_trace::no_speech_prob"] + [::std::mem::offset_of!(transcribe_whisper_chunk_trace, no_speech_prob) - 36usize]; + ["Offset of field: transcribe_whisper_chunk_trace::no_speech_triggered"] + [::std::mem::offset_of!(transcribe_whisper_chunk_trace, no_speech_triggered) - 40usize]; + ["Offset of field: transcribe_whisper_chunk_trace::n_fallbacks"] + [::std::mem::offset_of!(transcribe_whisper_chunk_trace, n_fallbacks) - 44usize]; +}; +unsafe extern "C" { + pub fn transcribe_whisper_chunk_trace_init(out: *mut transcribe_whisper_chunk_trace); +} +unsafe extern "C" { + pub fn transcribe_get_whisper_chunk_count( + session: *const transcribe_session, + ) -> ::std::os::raw::c_int; +} +unsafe extern "C" { + pub fn transcribe_get_whisper_chunk_trace( + session: *const transcribe_session, + i: ::std::os::raw::c_int, + out_trace: *mut transcribe_whisper_chunk_trace, + ) -> transcribe_status; +} diff --git a/bindings/rust/transcribe-cpp/Cargo.toml b/bindings/rust/transcribe-cpp/Cargo.toml new file mode 100644 index 00000000..9719efae --- /dev/null +++ b/bindings/rust/transcribe-cpp/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "transcribe-cpp" +version = "0.0.1" +edition = "2021" +rust-version = "1.74" +description = "Rust bindings for transcribe.cpp, a C/C++ speech-to-text library built on ggml" +license = "MIT" +readme = "README.md" +repository = "https://github.com/handy-computer/transcribe.cpp" +homepage = "https://github.com/handy-computer/transcribe.cpp" +keywords = ["transcription", "speech", "asr", "stt", "ggml"] +categories = ["multimedia::audio", "api-bindings"] + +[lib] +name = "transcribe_cpp" +path = "src/lib.rs" + +[features] +# These mirror transcribe-cpp-sys: enabling a backend here just forwards the +# feature to the -sys crate, which forwards it to the CMake build. +default = ["metal"] +metal = ["transcribe-cpp-sys/metal"] +vulkan = ["transcribe-cpp-sys/vulkan"] +cuda = ["transcribe-cpp-sys/cuda"] +openmp = ["transcribe-cpp-sys/openmp"] +# `shared` links a shared libtranscribe; `dynamic-backends` additionally ships +# each compute backend as a runtime-loaded module (and implies `shared`, both +# here so CARGO_FEATURE_SHARED is set for this crate's build.rs rpath gate, and +# via the -sys feature). See notes/rust-dynamic-backends-plan.md. +shared = ["transcribe-cpp-sys/shared"] +dynamic-backends = ["shared", "transcribe-cpp-sys/dynamic-backends"] + +[dependencies] +# default-features = false so the safe crate's own features are the ONLY control +# surface: without it, sys's `default = ["metal"]` stays active even under +# `transcribe-cpp --no-default-features`, silently re-enabling Metal. The backend +# features below forward explicitly, so the default (`metal`) still reaches sys +# via `metal = ["transcribe-cpp-sys/metal"]`. +transcribe-cpp-sys = { version = "0.0.1", path = "../../..", default-features = false } +thiserror = "2" +log = "0.4" + +[dev-dependencies] +hound = "3" diff --git a/bindings/rust/LICENSE b/bindings/rust/transcribe-cpp/LICENSE similarity index 100% rename from bindings/rust/LICENSE rename to bindings/rust/transcribe-cpp/LICENSE diff --git a/bindings/rust/transcribe-cpp/README.md b/bindings/rust/transcribe-cpp/README.md new file mode 100644 index 00000000..a12a6b4d --- /dev/null +++ b/bindings/rust/transcribe-cpp/README.md @@ -0,0 +1,74 @@ +# transcribe-cpp + +Safe, idiomatic Rust bindings for +[transcribe.cpp](https://github.com/handy-computer/transcribe.cpp), a C/C++ +speech-to-text library built on ggml. + +> **Status: in development (0.0.1).** The full feature surface — model load, +> sessions, `run`/`run_batch`, owned results, error mapping, backend discovery, +> the version/ABI gate, streaming, the five family extensions, cancellation, +> tokenize, and log routing — is implemented and tested against the canary +> models, with the five canonical examples CI-executed on every push. + +## Install + +```sh +cargo add transcribe-cpp +``` + +The native library is compiled from source by the `transcribe-cpp-sys` crate, so +a first build needs a C++ toolchain, **CMake**, and **zlib** (system zlib on +Linux/macOS; on Windows, `vcpkg install zlib:x64-windows-static-md` and point +`CMAKE_PREFIX_PATH` at it). No prebuilt download and no environment variables on +the happy path. + +## Quickstart + +```rust +use transcribe_cpp::{Model, RunOptions}; + +let mut session = Model::load("model.gguf")?.session()?; +// pcm: 16 kHz mono f32 in [-1, 1] +let result = session.run(&pcm, &RunOptions::default())?; +println!("{}", result.text); +# Ok::<(), transcribe_cpp::Error>(()) +``` + +The five canonical examples (`cargo run --example transcribe-file`, `streaming`, +`batch`, `backend-select`, `error-handling`) are the same set, under the same +names, in every first-class binding. The raw FFI layer is the +[`transcribe-cpp-sys`](https://crates.io/crates/transcribe-cpp-sys) crate; this +crate is the safe wrapper on top of it. + +## Backends + +The native library is built from source by `transcribe-cpp-sys` (see its +README). Backends are selected with cargo features — `metal` (default on Apple), +`vulkan`, `cuda`, `openmp` — forwarded to the underlying build. A static, +self-contained link is the default; `shared` links a shared library, and +`dynamic-backends` ships the compute backends as runtime-loaded modules (the +multi-ISA CPU / GPU provider posture; implies `shared`, loaded via +`init_backends_default()` when the modules sit next to `libtranscribe`). These +are advanced packaging modes: a distributed binary must arrange for the runtime +loader to find `libtranscribe`, and either co-locate backend modules with it or +call `init_backends(dir)` with the bundled module directory before loading a +model. Any other CMake flag can be passed through `TRANSCRIBE_CMAKE_ARGS` — see +the `transcribe-cpp-sys` README. + +## Threading + +- `Model` is `Send + Sync` and cheap to clone (`Arc`-backed); the native model + is freed only after the last handle and every `Session` drop. +- `Session` is `Send` but not `Sync`; mutating calls take `&mut self`. +- In 0.x the C library allows at most one in-flight run across all sessions of a + model; this crate enforces it with a per-model mutex, so concurrent calls + queue rather than race. For real parallelism, use one `Model` per worker. + +## ABI verification + +The per-field struct-layout check the ctypes binding performs is **waived** +here: bindgen takes every struct's layout from a real compiler at generation +time, so the generated FFI cannot disagree with the headers it was built +against. The load-time base-version lock (pre-1.0) is retained. + +- License: MIT diff --git a/bindings/rust/transcribe-cpp/build.rs b/bindings/rust/transcribe-cpp/build.rs new file mode 100644 index 00000000..a02c4631 --- /dev/null +++ b/bindings/rust/transcribe-cpp/build.rs @@ -0,0 +1,38 @@ +//! Build script for the safe `transcribe-cpp` crate. +//! +//! The native build and link live entirely in `transcribe-cpp-sys`; this script +//! exists only to carry two pieces of that crate's `links` metadata onto the +//! safe crate's OWN test/example/bin link line — `cargo:rustc-link-arg` does +//! NOT propagate from a dependency's build script to its dependents, so the +//! rpath the sys crate emits for itself never reaches here on its own. +//! +//! 1. **shared-lib rpath.** In the `shared` posture the consumer binary must +//! find `libtranscribe` at runtime. On Unix that is an rpath to the sys +//! crate's installed lib dir (`DEP_TRANSCRIBE_LIB_DIR`); Windows has no +//! rpath (the DLL resolves via PATH / the exe dir) so nothing is emitted — +//! a Windows shared consumer puts the lib dir on PATH instead. +//! +//! In the default static posture this branch is inert (no shared lib), so this +//! script is a near no-op. + +use std::env; +use std::path::Path; + +fn main() { + println!("cargo:rerun-if-env-changed=DEP_TRANSCRIBE_LIB_DIR"); + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + // `shared` is set whenever the lib is a separate file — both the plain + // `shared` feature and `dynamic-backends` (which enables `shared` here). + let shared = env::var_os("CARGO_FEATURE_SHARED").is_some(); + + // Mirror the sys crate's runtime rpath onto THIS crate's binaries (tests, + // examples), which the dependency's rustc-link-arg cannot reach. + if shared && target_os != "windows" { + if let Some(lib_dir) = env::var_os("DEP_TRANSCRIBE_LIB_DIR") { + println!( + "cargo:rustc-link-arg=-Wl,-rpath,{}", + Path::new(&lib_dir).display() + ); + } + } +} diff --git a/bindings/rust/transcribe-cpp/examples/backend-select.rs b/bindings/rust/transcribe-cpp/examples/backend-select.rs new file mode 100644 index 00000000..3c9c7f9a --- /dev/null +++ b/bindings/rust/transcribe-cpp/examples/backend-select.rs @@ -0,0 +1,75 @@ +//! backend-select — device discovery, explicit `backend=`, graceful failure. +//! +//! cargo run --example backend-select -- [model.gguf] +//! +//! Device discovery needs no model and always runs. With a model it shows an +//! explicit, satisfiable request (`Backend::Auto`) and, to demonstrate the +//! degradation contract, an explicit request for a backend this build lacks — +//! which must error cleanly from the request path, not crash. + +#[path = "common/mod.rs"] +mod common; + +use transcribe_cpp::{ + backend_available, devices, init_backends_default, Backend, Model, ModelOptions, +}; + +fn main() -> Result<(), Box> { + // Register backend modules before anything else. A no-op in a compiled-in + // build; in a `dynamic-backends` build it loads the per-ISA CPU / GPU + // modules so the device list below reflects what actually registered. Must + // happen once, before the first model load. + init_backends_default()?; + + println!("registered compute devices:"); + for d in devices() { + println!(" {} [{}] — {}", d.name, d.kind, d.description); + } + println!("\nbackend availability:"); + for b in [Backend::Cpu, Backend::Metal, Backend::Vulkan, Backend::Cuda] { + println!(" {b:?} = {}", backend_available(b)); + } + + // The first optional backend this build does NOT have — used below to show + // a request that cannot be satisfied. (CPU is always present, so it is not + // a candidate.) + let unavailable = [Backend::Cuda, Backend::Vulkan, Backend::Metal] + .into_iter() + .find(|&b| !backend_available(b)); + + let mut args = std::env::args().skip(1); + let Some(model_path) = common::model_path(args.next().as_deref()) else { + eprintln!("\nskip the model half of backend-select: no model found"); + return Ok(()); + }; + + // Explicit, satisfiable request: Auto resolves to the best available device. + let model = Model::load_with( + &model_path, + &ModelOptions { + backend: Backend::Auto, + ..Default::default() + }, + )?; + println!( + "\nloaded with Backend::Auto -> bound backend: {}", + model.backend() + ); + drop(model); + + // Graceful failure: requesting an unavailable backend must error cleanly. + match unavailable { + Some(b) => match Model::load_with( + &model_path, + &ModelOptions { + backend: b, + ..Default::default() + }, + ) { + Ok(_) => println!("requesting {b:?} unexpectedly succeeded"), + Err(e) => println!("requesting {b:?} failed cleanly: {e}"), + }, + None => println!("every optional backend is available on this build"), + } + Ok(()) +} diff --git a/bindings/rust/transcribe-cpp/examples/batch.rs b/bindings/rust/transcribe-cpp/examples/batch.rs new file mode 100644 index 00000000..50013cdb --- /dev/null +++ b/bindings/rust/transcribe-cpp/examples/batch.rs @@ -0,0 +1,39 @@ +//! batch — transcribe several inputs in one call with per-result accessors. +//! +//! cargo run --example batch -- [model.gguf] [audio.wav] +//! +//! `run_batch` takes a slice of PCM buffers and returns one `Result` per input, +//! so a single bad utterance does not sink the others. Here we submit the same +//! clip twice; a real caller would pass distinct utterances. + +#[path = "common/mod.rs"] +mod common; + +use transcribe_cpp::{Model, RunOptions}; + +fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let (Some(model_path), Some(audio_path)) = ( + common::model_path(args.next().as_deref()), + common::audio_path(args.next().as_deref()), + ) else { + eprintln!("skip batch: model/audio not found (set TRANSCRIBE_SMOKE_MODEL / _AUDIO)"); + return Ok(()); + }; + let pcm = common::load_wav(&audio_path); + + let model = Model::load(&model_path)?; + let mut session = model.session()?; + + let inputs: Vec<&[f32]> = vec![&pcm, &pcm]; + let results = session.run_batch(&inputs, &RunOptions::default())?; + + println!("batch of {}:", results.len()); + for (i, result) in results.iter().enumerate() { + match result { + Ok(t) => println!(" [{i}] ok: {}", t.text.trim()), + Err(e) => println!(" [{i}] error: {e}"), + } + } + Ok(()) +} diff --git a/bindings/rust/transcribe-cpp/examples/common/mod.rs b/bindings/rust/transcribe-cpp/examples/common/mod.rs new file mode 100644 index 00000000..c6468967 --- /dev/null +++ b/bindings/rust/transcribe-cpp/examples/common/mod.rs @@ -0,0 +1,76 @@ +//! Shared fixture resolution for the canonical examples (the example-runner +//! analog of `tests/common/mod.rs`). +//! +//! Each example resolves its model + audio from, in order: (1) a CLI arg, (2) +//! the `TRANSCRIBE_SMOKE_*` env var CI exports via `fetch-canary`, (3) an +//! in-repo default. When nothing resolves the example prints a skip note and +//! exits 0 — the same skip rule the model-gated tests use, so every example +//! runs headless in CI (and on forks, where the canary is absent). + +#![allow(dead_code)] + +use std::path::PathBuf; + +/// Register backend modules before the first model load (idempotent; a no-op in +/// compiled-in builds, loads the per-ISA CPU / GPU modules in a `dynamic-backends` +/// build). Folded into the model resolvers so every example works in every +/// posture; `backend-select` also calls it explicitly to demonstrate the pattern. +fn ensure_backends() { + transcribe_cpp::init_backends_default().expect("init_backends_default"); +} + +/// Repo root: walk up from this crate until the marker only the root carries. +pub fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .find(|p| p.join("include/transcribe.h").is_file() && p.join("ggml").is_dir()) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")) +} + +/// The offline model: `arg` | `$TRANSCRIBE_SMOKE_MODEL` | in-repo whisper canary. +pub fn model_path(arg: Option<&str>) -> Option { + ensure_backends(); + resolve( + arg, + "TRANSCRIBE_SMOKE_MODEL", + "models/whisper-tiny.en/whisper-tiny.en-Q5_K_M.gguf", + ) +} + +/// The streaming model: `arg` | `$TRANSCRIBE_SMOKE_STREAMING_MODEL` | canary. +pub fn streaming_model_path(arg: Option<&str>) -> Option { + ensure_backends(); + resolve( + arg, + "TRANSCRIBE_SMOKE_STREAMING_MODEL", + "models/moonshine-streaming-tiny/moonshine-streaming-tiny-Q8_0.gguf", + ) +} + +/// The audio: `arg` | `$TRANSCRIBE_SMOKE_AUDIO` | the in-repo `samples/jfk.wav`. +pub fn audio_path(arg: Option<&str>) -> Option { + resolve(arg, "TRANSCRIBE_SMOKE_AUDIO", "samples/jfk.wav") +} + +fn resolve(arg: Option<&str>, env: &str, default_rel: &str) -> Option { + let path = arg + .map(PathBuf::from) + .or_else(|| std::env::var_os(env).map(PathBuf::from)) + .unwrap_or_else(|| repo_root().join(default_rel)); + path.is_file().then_some(path) +} + +/// Load a 16 kHz mono 16-bit WAV as f32 PCM — the exact format the binding +/// consumes (it does not decode containers or resample). +pub fn load_wav(path: &std::path::Path) -> Vec { + let mut reader = hound::WavReader::open(path).expect("open wav"); + let spec = reader.spec(); + assert_eq!(spec.channels, 1, "{path:?} must be mono"); + assert_eq!(spec.sample_rate, 16_000, "{path:?} must be 16 kHz"); + assert_eq!(spec.bits_per_sample, 16, "{path:?} must be 16-bit"); + reader + .samples::() + .map(|s| s.expect("wav sample") as f32 / 32768.0) + .collect() +} diff --git a/bindings/rust/transcribe-cpp/examples/error-handling.rs b/bindings/rust/transcribe-cpp/examples/error-handling.rs new file mode 100644 index 00000000..185a553b --- /dev/null +++ b/bindings/rust/transcribe-cpp/examples/error-handling.rs @@ -0,0 +1,86 @@ +//! error-handling — idiomatic error mapping, cancellation, and cleanup. +//! +//! cargo run --example error-handling -- [model.gguf] [audio.wav] +//! +//! Every `transcribe_status` maps to a distinct `Error` variant, so callers +//! `match` instead of parsing strings. The error-mapping half needs no model +//! and always runs; cancellation needs a model and skips cleanly otherwise. +//! Cleanup is automatic — `Model` and `Session` free on `Drop`, in any order. + +#[path = "common/mod.rs"] +mod common; + +use std::thread; +use std::time::Duration; + +use transcribe_cpp::{CancelToken, Error, Model, RunOptions}; + +fn main() -> Result<(), Box> { + // 1. Error mapping (no model needed): a missing file is a typed variant, + // distinct from a corrupt-file load error. + match Model::load("/no/such/model.gguf") { + Err(Error::ModelFileNotFound(msg)) => { + println!("missing file -> ModelFileNotFound: {msg}") + } + other => println!("unexpected for missing file: {other:?}"), + } + + let mut args = std::env::args().skip(1); + let (Some(model_path), Some(audio_path)) = ( + common::model_path(args.next().as_deref()), + common::audio_path(args.next().as_deref()), + ) else { + eprintln!("skip the model half of error-handling: no model/audio found"); + return Ok(()); + }; + let pcm = common::load_wav(&audio_path); + let model = Model::load(&model_path)?; + + // 2. Empty PCM is an InvalidArgument — a clean error, not a panic. + { + let mut session = model.session()?; + match session.run(&[], &RunOptions::default()) { + Err(Error::InvalidArgument(msg)) => { + println!("empty PCM -> InvalidArgument: {msg}") + } + other => println!("unexpected for empty PCM: {other:?}"), + } + } + + // 3. Cancellation: cancel an in-flight run from another thread. On a long + // enough clip the run aborts with a partial transcript; a fast machine + // may finish the run before the timer — both outcomes are handled. + { + let mut session = model.session()?; + let token = CancelToken::new(); + session.set_cancel_token(&token); + + let long: Vec = std::iter::repeat(pcm.iter().copied()) + .take(12) + .flatten() + .collect(); + + let canceller = { + let token = token.clone(); + thread::spawn(move || { + thread::sleep(Duration::from_millis(40)); + token.cancel(); + }) + }; + + match session.run(&long, &RunOptions::default()) { + Err(Error::Aborted { partial, .. }) => println!( + "run aborted by request; partial transcript preserved: {}", + partial.is_some() + ), + Ok(_) => println!("run completed before the cancel fired (fast machine)"), + Err(other) => println!("unexpected run error: {other:?}"), + } + canceller.join().unwrap(); + } + + // 4. Cleanup is automatic: dropping the model and its sessions frees the + // native handles, safe in any order under error paths. + println!("done; native resources released on Drop"); + Ok(()) +} diff --git a/bindings/rust/transcribe-cpp/examples/streaming.rs b/bindings/rust/transcribe-cpp/examples/streaming.rs new file mode 100644 index 00000000..2afb3f7c --- /dev/null +++ b/bindings/rust/transcribe-cpp/examples/streaming.rs @@ -0,0 +1,72 @@ +//! streaming — feed audio in chunks and watch committed vs tentative text. +//! +//! cargo run --example streaming -- [streaming-model.gguf] [audio.wav] +//! +//! Committed text is the UI-stable prefix (it only grows); tentative text is +//! the still-revisable tail. The commit policy decides when tentative becomes +//! committed. Needs a streaming-capable model (the moonshine-streaming canary); +//! skips cleanly otherwise. + +#[path = "common/mod.rs"] +mod common; + +use transcribe_cpp::{CommitPolicy, Model, RunOptions, StreamOptions, StreamState}; + +fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let (Some(model_path), Some(audio_path)) = ( + common::streaming_model_path(args.next().as_deref()), + common::audio_path(args.next().as_deref()), + ) else { + eprintln!( + "skip streaming: streaming model/audio not found \ + (set TRANSCRIBE_SMOKE_STREAMING_MODEL / _AUDIO)" + ); + return Ok(()); + }; + let pcm = common::load_wav(&audio_path); + + let model = Model::load(&model_path)?; + if !model.capabilities().supports_streaming { + // Not an error — the wrong-model case, stated plainly (a whisper model + // would land here). Exit 0 so CI's no-canary path stays green. + eprintln!("{} does not support streaming", model.arch()); + return Ok(()); + } + + let mut session = model.session()?; + let opts = StreamOptions { + commit_policy: CommitPolicy::Auto, + ..Default::default() + }; + let mut stream = session.stream(&RunOptions::default(), &opts)?; + println!( + "commit policy: {:?} | initial state: {:?}", + opts.commit_policy, + stream.state() + ); + + // Feed 100 ms chunks; print only when the committed/tentative split moves. + for (i, frame) in pcm.chunks(1600).enumerate() { + let update = stream.feed(frame)?; + if update.committed_changed || update.tentative_changed { + let text = stream.text(); + println!( + "chunk {i:>3} | committed: {:?} | tentative: {:?}", + text.committed, text.tentative + ); + } + } + + let update = stream.finalize()?; + assert!(update.is_final); + assert_eq!(stream.state(), StreamState::Finished); + + let text = stream.text(); + println!( + "\nfinal (revision {}): {}", + stream.revision(), + text.full.trim() + ); + Ok(()) +} diff --git a/bindings/rust/transcribe-cpp/examples/transcribe-file.rs b/bindings/rust/transcribe-cpp/examples/transcribe-file.rs new file mode 100644 index 00000000..0def25b0 --- /dev/null +++ b/bindings/rust/transcribe-cpp/examples/transcribe-file.rs @@ -0,0 +1,77 @@ +//! transcribe-file — load a model, transcribe a WAV, print text + segments. +//! +//! cargo run --example transcribe-file -- [model.gguf] [audio.wav] +//! +//! With no args it falls back to `$TRANSCRIBE_SMOKE_MODEL` / `_AUDIO` (CI's +//! fetch-canary exports these) or the in-repo canary, and skips cleanly when +//! neither resolves. This is the offline-run Rosetta stone — the same program +//! exists under the same name in every first-class binding. + +#[path = "common/mod.rs"] +mod common; + +use transcribe_cpp::{Model, RunOptions, TimestampKind}; + +fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let model_arg = args.next(); + let audio_arg = args.next(); + + println!( + "transcribe-cpp {} ({})", + transcribe_cpp::version(), + transcribe_cpp::version_commit() + ); + + let (Some(model_path), Some(audio_path)) = ( + common::model_path(model_arg.as_deref()), + common::audio_path(audio_arg.as_deref()), + ) else { + eprintln!( + "skip transcribe-file: model/audio not found \ + (pass paths, or set TRANSCRIBE_SMOKE_MODEL / _AUDIO)" + ); + return Ok(()); + }; + let pcm = common::load_wav(&audio_path); + + let model = Model::load(&model_path)?; + let caps = model.capabilities(); + println!( + "model: {} on {} | max_ts={:?}", + model.arch(), + model.backend(), + caps.max_timestamp_kind + ); + + let mut session = model.session()?; + // Ask for the finest timestamps the model actually supports (segment for + // whisper); a request finer than the model's max is a clean Unsupported. + let result = session.run( + &pcm, + &RunOptions { + timestamps: caps.max_timestamp_kind, + ..Default::default() + }, + )?; + + println!( + "\nlanguage: {} | timestamps: {:?} | encode {:.0} ms", + result.language.as_deref().unwrap_or("(n/a)"), + result.timestamp_kind, + result.timings.encode_ms + ); + println!("\n{}\n", result.text.trim()); + + if result.timestamp_kind != TimestampKind::None { + for seg in &result.segments { + println!( + " [{:6.2} -> {:6.2}] {}", + seg.t0_ms as f64 / 1000.0, + seg.t1_ms as f64 / 1000.0, + seg.text.trim() + ); + } + } + Ok(()) +} diff --git a/bindings/rust/transcribe-cpp/src/backend.rs b/bindings/rust/transcribe-cpp/src/backend.rs new file mode 100644 index 00000000..c9ebe00e --- /dev/null +++ b/bindings/rust/transcribe-cpp/src/backend.rs @@ -0,0 +1,94 @@ +//! Backend module loading and compute-device discovery. +//! +//! In a static (or plain `shared`) build the compiled-in backends are already +//! registered and [`init_backends`] is a harmless no-op. In a `dynamic-backends` +//! build the compute backends are loadable modules; a host points the library at +//! the provider directory ONCE, before the first model load — use +//! [`init_backends_default`] when the modules sit next to libtranscribe, or +//! [`init_backends`] with an explicit bundled provider directory. See the C +//! header's backend-module section for the degradation contract. + +use std::ffi::CString; +use std::path::Path; + +use transcribe_cpp_sys as sys; + +use crate::error::{check, Result}; +use crate::result::owned_str; +use crate::types::Backend; + +/// One registered compute device. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Device { + /// ggml device name, e.g. "Metal". + pub name: String, + /// Human-readable description, e.g. "Apple M4 Max". + pub description: String, + /// Classified kind: "cpu", "accel", "metal", "vulkan", "cuda", "sycl", + /// "gpu", or "unknown". + pub kind: String, +} + +/// Load backend modules from `dir` (dynamic builds) and register their +/// devices. A no-op in static builds. Call once, before the first model load. +/// +/// Errors with [`crate::Error::Backend`] if, after loading, the process has no +/// registered compute device (a dynamic build pointed at a directory with no +/// usable modules). This call is idempotent per directory and NOT retryable in +/// the same process. +pub fn init_backends(dir: impl AsRef) -> Result<()> { + let dir = dir.as_ref(); + // Pass the path bytes through faithfully (Unix) / reject non-UTF-8 (Windows), + // matching model loading — never lossily mangle a path with to_string_lossy. + let c_dir = CString::new(crate::model::path_bytes(dir)?)?; + let status = unsafe { sys::transcribe_init_backends(c_dir.as_ptr()) }; + check(status, "init_backends") +} + +/// Load the backend modules the way the build expects, with no caller-supplied +/// path. +/// +/// - **Compiled-in builds** (the default static build, or a plain `shared` +/// build): a no-op returning `Ok(())` — the backends are already registered. +/// - **`dynamic-backends` builds**: resolves the directory containing the +/// loaded libtranscribe and scans only that directory. Ship the backend +/// modules next to libtranscribe for this helper to find them. +/// +/// If your app uses a different layout, call [`init_backends`] with that +/// resolved module directory instead. Like [`init_backends`], this is +/// idempotent and must run once before the first model load. +pub fn init_backends_default() -> Result<()> { + let status = unsafe { sys::transcribe_init_backends_default() }; + check(status, "init_backends_default") +} + +/// The number of compute devices currently registered. +pub fn device_count() -> usize { + let n = unsafe { sys::transcribe_backend_device_count() }; + n.max(0) as usize +} + +/// Every registered compute device. +pub fn devices() -> Vec { + let mut out = Vec::with_capacity(device_count()); + for i in 0..device_count() as i32 { + let mut raw: sys::transcribe_backend_device = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_backend_device_init(&mut raw) }; + let status = unsafe { sys::transcribe_get_backend_device(i, &mut raw) }; + if status == sys::transcribe_status::TRANSCRIBE_OK { + out.push(Device { + name: owned_str(raw.name), + description: owned_str(raw.description), + kind: owned_str(raw.kind), + }); + } + } + out +} + +/// Whether a backend request can be satisfied by some registered device. This +/// is the probe to turn `Backend::Vulkan` on a machine without Vulkan into a +/// clear error instead of a failed model load. +pub fn backend_available(backend: Backend) -> bool { + unsafe { sys::transcribe_backend_available(backend.to_raw()) } +} diff --git a/bindings/rust/transcribe-cpp/src/cancel.rs b/bindings/rust/transcribe-cpp/src/cancel.rs new file mode 100644 index 00000000..30c5d58a --- /dev/null +++ b/bindings/rust/transcribe-cpp/src/cancel.rs @@ -0,0 +1,55 @@ +//! Cooperative cancellation for in-flight runs and streams. +//! +//! A [`CancelToken`] wraps a shared atomic flag. Install it on a session, then +//! call [`CancelToken::cancel`] from any thread to abort the in-flight +//! `run`/`feed`/`finalize`: the native abort callback (polled between decode +//! steps) sees the flag and stops, the call returns [`crate::Error::Aborted`] +//! with the partial transcript, and [`crate::Session::was_aborted`] reports +//! true. + +use std::os::raw::c_void; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +/// A clonable cancellation handle. Clones share one underlying flag, so a +/// worker thread can hold a clone and cancel a run executing on another thread. +#[derive(Debug, Clone, Default)] +pub struct CancelToken { + pub(crate) flag: Arc, +} + +impl CancelToken { + /// A fresh, un-cancelled token. + pub fn new() -> Self { + CancelToken::default() + } + + /// Request cancellation of the run/stream this token is installed on. + pub fn cancel(&self) { + self.flag.store(true, Ordering::SeqCst); + } + + /// Whether cancellation has been requested. + pub fn is_cancelled(&self) -> bool { + self.flag.load(Ordering::SeqCst) + } + + /// Clear the cancelled state so the token can be reused for another run. + pub fn reset(&self) { + self.flag.store(false, Ordering::SeqCst); + } +} + +/// The C abort callback. `userdata` is an `Arc`'s pointee, kept +/// alive by the session for the duration of any run. Invoked on the run thread +/// (the C library polls it between decode steps), so it must be cheap and +/// thread-safe — an atomic load is both. +pub(crate) extern "C" fn abort_trampoline(userdata: *mut c_void) -> bool { + if userdata.is_null() { + return false; + } + // SAFETY: `userdata` is the `*const AtomicBool` the session installed and + // keeps alive (via a retained Arc) for as long as the callback is set. + let flag = unsafe { &*(userdata as *const AtomicBool) }; + flag.load(Ordering::SeqCst) +} diff --git a/bindings/rust/transcribe-cpp/src/error.rs b/bindings/rust/transcribe-cpp/src/error.rs new file mode 100644 index 00000000..a1aee480 --- /dev/null +++ b/bindings/rust/transcribe-cpp/src/error.rs @@ -0,0 +1,185 @@ +//! Error type mapped from `transcribe_status`. +//! +//! The C API reports failures as a `transcribe_status` int; this module maps +//! each one to a distinct [`Error`] variant (requirements §3: "distinct +//! failures stay distinct"). The grouping mirrors the Python binding's +//! exception hierarchy — the conformance precedent — and the raw status is +//! preserved via [`Error::raw_status`]. + +use crate::result::Transcript; +use transcribe_cpp_sys as sys; + +/// Convenience alias for results from this crate. +pub type Result = std::result::Result; + +/// Everything that can go wrong talking to the native library. +/// +/// Match on the variant for typed handling; [`Error::raw_status`] recovers the +/// underlying `transcribe_status` code (0 for errors raised purely on the Rust +/// side, e.g. the load-time version gate or a `NUL` in a caller string). +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// `TRANSCRIBE_ERR_INVALID_ARG` (or `_SAMPLE_RATE`). + #[error("invalid argument: {0}")] + InvalidArgument(String), + /// `TRANSCRIBE_ERR_NOT_IMPLEMENTED` — the model has no path for this call. + #[error("not implemented by this model: {0}")] + NotImplemented(String), + /// `TRANSCRIBE_ERR_FILE_NOT_FOUND` — the GGUF path does not exist. + #[error("model file not found: {0}")] + ModelFileNotFound(String), + /// `TRANSCRIBE_ERR_GGUF` / `_UNSUPPORTED_ARCH` / `_UNSUPPORTED_VARIANT`. + #[error("model load failed: {0}")] + ModelLoad(String), + /// `TRANSCRIBE_ERR_OOM`. + #[error("out of memory: {0}")] + OutOfMemory(String), + /// `TRANSCRIBE_ERR_BACKEND` — the requested backend could not be satisfied. + #[error("backend error: {0}")] + Backend(String), + /// `TRANSCRIBE_ERR_UNSUPPORTED_{TASK,LANGUAGE,TIMESTAMPS,PNC,ITN}` — the + /// model cannot satisfy this request shape. + #[error("unsupported request: {0}")] + Unsupported(String), + /// `TRANSCRIBE_ERR_BAD_STRUCT_SIZE` — an ABI/struct-size fault. With the + /// generated FFI this indicates a header/library skew, not caller error. + #[error("ABI struct-size mismatch: {0}")] + BadStructSize(String), + /// `TRANSCRIBE_ERR_INPUT_TOO_LONG` — audio longer than the model can decode. + #[error("input too long: {0}")] + InputTooLong(String), + /// `TRANSCRIBE_ERR_ABORTED` — the abort callback returned true. The + /// transcript of chunks that completed before the abort is preserved. + #[error("operation aborted: {message}")] + Aborted { + message: String, + /// Partial transcript from chunks that completed before the abort. + partial: Option>, + }, + /// `TRANSCRIBE_ERR_OUTPUT_TRUNCATED` — the decode hit the generation budget + /// before end-of-stream; the transcript is incomplete by contract. The + /// partial transcript is always preserved. + #[error("output truncated before end-of-stream: {message}")] + OutputTruncated { + message: String, + /// The (incomplete) transcript produced before truncation. + partial: Option>, + }, + /// The loaded library's base version disagrees with the headers this crate + /// was generated against (the pre-1.0 version lock). Raised on first use. + #[error("native library version mismatch: {0}")] + VersionMismatch(String), + /// A caller-supplied string contained an interior NUL byte. + #[error("string contains an interior NUL byte: {0}")] + Nul(#[from] std::ffi::NulError), + /// Another session of the same model already has a compute operation in + /// flight. The C library serializes compute per model (the 0.x limitation): + /// at most one run / batch / active stream across ALL of a model's sessions + /// at a time. Finish or drop the in-flight stream (one-shot runs/batches + /// just queue) before starting another, or give each concurrent worker its + /// own model. Raised purely on the Rust side, so [`Error::raw_status`] is 0. + #[error("model busy: {0}")] + Busy(String), + /// A non-OK status with no more specific mapping. + #[error("{0}")] + Other(String), +} + +impl Error { + /// The `transcribe_status` this error was mapped from, or `0` for errors + /// raised on the Rust side (version gate, NUL in a string, …). + pub fn raw_status(&self) -> i32 { + use sys::transcribe_status as S; + let s = match self { + Error::InvalidArgument(_) => S::TRANSCRIBE_ERR_INVALID_ARG, + Error::NotImplemented(_) => S::TRANSCRIBE_ERR_NOT_IMPLEMENTED, + Error::ModelFileNotFound(_) => S::TRANSCRIBE_ERR_FILE_NOT_FOUND, + Error::ModelLoad(_) => S::TRANSCRIBE_ERR_GGUF, + Error::OutOfMemory(_) => S::TRANSCRIBE_ERR_OOM, + Error::Backend(_) => S::TRANSCRIBE_ERR_BACKEND, + Error::Unsupported(_) => S::TRANSCRIBE_ERR_UNSUPPORTED_TASK, + Error::BadStructSize(_) => S::TRANSCRIBE_ERR_BAD_STRUCT_SIZE, + Error::InputTooLong(_) => S::TRANSCRIBE_ERR_INPUT_TOO_LONG, + Error::Aborted { .. } => S::TRANSCRIBE_ERR_ABORTED, + Error::OutputTruncated { .. } => S::TRANSCRIBE_ERR_OUTPUT_TRUNCATED, + _ => S::TRANSCRIBE_OK, + }; + s.0 as i32 + } + + /// The partial transcript carried by [`Error::Aborted`] / + /// [`Error::OutputTruncated`], if any. `None` for every other variant. + pub fn partial(&self) -> Option<&Transcript> { + match self { + Error::Aborted { partial, .. } | Error::OutputTruncated { partial, .. } => { + partial.as_deref() + } + _ => None, + } + } +} + +/// Human-readable text for a `transcribe_status` (from the C side). +pub(crate) fn status_string(status: i32) -> String { + // Borrowed pointer into static storage; copy at the boundary. + let ptr = unsafe { sys::transcribe_status_string(status) }; + if ptr.is_null() { + return format!("status {status}"); + } + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned() +} + +/// Build the mapped error for a non-OK status (without a partial transcript). +/// The run path re-attaches partials for the two result-bearing statuses. +pub(crate) fn error_for_status(status: sys::transcribe_status, context: &str) -> Error { + use sys::transcribe_status as S; + let code = status.0 as i32; + let msg = { + let s = status_string(code); + if context.is_empty() { + format!("{s} (status {code})") + } else { + format!("{context}: {s} (status {code})") + } + }; + match status { + S::TRANSCRIBE_ERR_INVALID_ARG | S::TRANSCRIBE_ERR_SAMPLE_RATE => { + Error::InvalidArgument(msg) + } + S::TRANSCRIBE_ERR_NOT_IMPLEMENTED => Error::NotImplemented(msg), + S::TRANSCRIBE_ERR_FILE_NOT_FOUND => Error::ModelFileNotFound(msg), + S::TRANSCRIBE_ERR_GGUF + | S::TRANSCRIBE_ERR_UNSUPPORTED_ARCH + | S::TRANSCRIBE_ERR_UNSUPPORTED_VARIANT => Error::ModelLoad(msg), + S::TRANSCRIBE_ERR_OOM => Error::OutOfMemory(msg), + S::TRANSCRIBE_ERR_BACKEND => Error::Backend(msg), + S::TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE + | S::TRANSCRIBE_ERR_UNSUPPORTED_TASK + | S::TRANSCRIBE_ERR_UNSUPPORTED_TIMESTAMPS + | S::TRANSCRIBE_ERR_UNSUPPORTED_PNC + | S::TRANSCRIBE_ERR_UNSUPPORTED_ITN => Error::Unsupported(msg), + S::TRANSCRIBE_ERR_BAD_STRUCT_SIZE => Error::BadStructSize(msg), + S::TRANSCRIBE_ERR_INPUT_TOO_LONG => Error::InputTooLong(msg), + S::TRANSCRIBE_ERR_ABORTED => Error::Aborted { + message: msg, + partial: None, + }, + S::TRANSCRIBE_ERR_OUTPUT_TRUNCATED => Error::OutputTruncated { + message: msg, + partial: None, + }, + _ => Error::Other(msg), + } +} + +/// `Ok(())` for `TRANSCRIBE_OK`, otherwise the mapped error. +pub(crate) fn check(status: sys::transcribe_status, context: &str) -> Result<()> { + if status == sys::transcribe_status::TRANSCRIBE_OK { + Ok(()) + } else { + Err(error_for_status(status, context)) + } +} diff --git a/bindings/rust/transcribe-cpp/src/family.rs b/bindings/rust/transcribe-cpp/src/family.rs new file mode 100644 index 00000000..13dce620 --- /dev/null +++ b/bindings/rust/transcribe-cpp/src/family.rs @@ -0,0 +1,207 @@ +//! Typed family-extension options. +//! +//! Each family exposes model-specific knobs via a kind-tagged extension struct +//! (`include/transcribe/.h`). These typed options mirror those structs: +//! every field is an `Option`, and only the fields you set override the +//! defaults the C `*_init()` stamps. A [`RunExtension`] attaches to +//! [`RunOptions`](crate::RunOptions); a [`StreamExtension`] attaches to +//! [`StreamOptions`](crate::StreamOptions). +//! +//! Probe [`Model::accepts_ext`](crate::Model::accepts_ext) to learn whether a +//! loaded model accepts a given kind on a slot; an unaccepted extension is +//! rejected by `run`/`stream` with [`Error::InvalidArgument`](crate::Error). + +use std::ffi::CString; + +use transcribe_cpp_sys as sys; + +use crate::error::Result; + +/// Whisper run-extension knobs (run slot): initial prompt, temperature +/// fallback, and decode thresholds. `None` keeps the family default. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct WhisperRunOptions { + pub initial_prompt: Option, + pub condition_on_prev_tokens: Option, + pub temperature: Option, + pub temperature_inc: Option, + pub compression_ratio_thold: Option, + pub logprob_thold: Option, + pub no_speech_thold: Option, + pub max_prev_context_tokens: Option, + pub seed: Option, + pub max_initial_timestamp: Option, +} + +/// Moonshine-streaming stream-extension knobs (stream slot). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MoonshineStreamingOptions { + pub min_decode_interval_ms: Option, +} + +/// Parakeet cache-aware streaming knobs (stream slot). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ParakeetStreamOptions { + pub att_context_right: Option, +} + +/// Parakeet chunked-attention buffered streaming knobs (stream slot). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ParakeetBufferedStreamOptions { + pub left_ms: Option, + pub chunk_ms: Option, + pub right_ms: Option, +} + +/// Voxtral-realtime streaming knobs (stream slot). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct VoxtralRealtimeStreamOptions { + pub num_delay_tokens: Option, + pub min_decode_interval_ms: Option, +} + +/// A family extension for the run slot (offline `run`/`run_batch`). +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum RunExtension { + Whisper(WhisperRunOptions), +} + +/// A family extension for the stream slot. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum StreamExtension { + ParakeetStream(ParakeetStreamOptions), + ParakeetBuffered(ParakeetBufferedStreamOptions), + MoonshineStreaming(MoonshineStreamingOptions), + VoxtralRealtime(VoxtralRealtimeStreamOptions), +} + +/// Owns a materialized run-slot C extension struct (and any strings it points +/// at) so its address — and the `transcribe_ext` pointer handed to the C API — +/// stays valid for the duration of the native call. Boxed for a stable address +/// across moves of the holder. +pub(crate) enum RunExtRaw { + Whisper { + ext: Box, + _prompt: Option, + }, +} + +impl RunExtRaw { + pub(crate) fn ext_ptr(&self) -> *const sys::transcribe_ext { + match self { + // `ext` is field 0, so &ext == &the family struct. + RunExtRaw::Whisper { ext, .. } => { + (&**ext) as *const sys::transcribe_whisper_run_ext as *const sys::transcribe_ext + } + } + } +} + +impl RunExtension { + pub(crate) fn materialize(&self) -> Result { + match self { + RunExtension::Whisper(o) => { + let mut ext: sys::transcribe_whisper_run_ext = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_whisper_run_ext_init(&mut ext) }; + // Propagate an interior NUL as Error::Nul (like language / + // target_language) instead of silently dropping the prompt. + let prompt = o.initial_prompt.as_deref().map(CString::new).transpose()?; + if let Some(c) = prompt.as_ref() { + ext.initial_prompt = c.as_ptr(); + } + set( + &mut ext.condition_on_prev_tokens, + o.condition_on_prev_tokens, + ); + set(&mut ext.temperature, o.temperature); + set(&mut ext.temperature_inc, o.temperature_inc); + set(&mut ext.compression_ratio_thold, o.compression_ratio_thold); + set(&mut ext.logprob_thold, o.logprob_thold); + set(&mut ext.no_speech_thold, o.no_speech_thold); + set(&mut ext.max_prev_context_tokens, o.max_prev_context_tokens); + set(&mut ext.seed, o.seed); + set(&mut ext.max_initial_timestamp, o.max_initial_timestamp); + Ok(RunExtRaw::Whisper { + ext: Box::new(ext), + _prompt: prompt, + }) + } + } + } +} + +/// Owns a materialized stream-slot C extension struct (none carry strings). +pub(crate) enum StreamExtRaw { + ParakeetStream(Box), + ParakeetBuffered(Box), + MoonshineStreaming(Box), + VoxtralRealtime(Box), +} + +impl StreamExtRaw { + pub(crate) fn ext_ptr(&self) -> *const sys::transcribe_ext { + match self { + StreamExtRaw::ParakeetStream(e) => { + (&**e) as *const sys::transcribe_parakeet_stream_ext as *const sys::transcribe_ext + } + StreamExtRaw::ParakeetBuffered(e) => { + (&**e) as *const sys::transcribe_parakeet_buffered_stream_ext + as *const sys::transcribe_ext + } + StreamExtRaw::MoonshineStreaming(e) => { + (&**e) as *const sys::transcribe_moonshine_streaming_stream_ext + as *const sys::transcribe_ext + } + StreamExtRaw::VoxtralRealtime(e) => { + (&**e) as *const sys::transcribe_voxtral_realtime_stream_ext + as *const sys::transcribe_ext + } + } + } +} + +impl StreamExtension { + pub(crate) fn materialize(&self) -> StreamExtRaw { + match self { + StreamExtension::ParakeetStream(o) => { + let mut e: sys::transcribe_parakeet_stream_ext = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_parakeet_stream_ext_init(&mut e) }; + set(&mut e.att_context_right, o.att_context_right); + StreamExtRaw::ParakeetStream(Box::new(e)) + } + StreamExtension::ParakeetBuffered(o) => { + let mut e: sys::transcribe_parakeet_buffered_stream_ext = + unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_parakeet_buffered_stream_ext_init(&mut e) }; + set(&mut e.left_ms, o.left_ms); + set(&mut e.chunk_ms, o.chunk_ms); + set(&mut e.right_ms, o.right_ms); + StreamExtRaw::ParakeetBuffered(Box::new(e)) + } + StreamExtension::MoonshineStreaming(o) => { + let mut e: sys::transcribe_moonshine_streaming_stream_ext = + unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_moonshine_streaming_stream_ext_init(&mut e) }; + set(&mut e.min_decode_interval_ms, o.min_decode_interval_ms); + StreamExtRaw::MoonshineStreaming(Box::new(e)) + } + StreamExtension::VoxtralRealtime(o) => { + let mut e: sys::transcribe_voxtral_realtime_stream_ext = + unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_voxtral_realtime_stream_ext_init(&mut e) }; + set(&mut e.num_delay_tokens, o.num_delay_tokens); + set(&mut e.min_decode_interval_ms, o.min_decode_interval_ms); + StreamExtRaw::VoxtralRealtime(Box::new(e)) + } + } + } +} + +/// Overwrite `slot` only when the caller provided a value. +fn set(slot: &mut T, value: Option) { + if let Some(v) = value { + *slot = v; + } +} diff --git a/bindings/rust/transcribe-cpp/src/lib.rs b/bindings/rust/transcribe-cpp/src/lib.rs new file mode 100644 index 00000000..5a39406a --- /dev/null +++ b/bindings/rust/transcribe-cpp/src/lib.rs @@ -0,0 +1,93 @@ +//! Safe, idiomatic Rust bindings for +//! [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp), a C/C++ +//! speech-to-text library built on ggml. +//! +//! The raw FFI lives in the [`transcribe-cpp-sys`](transcribe_cpp_sys) crate +//! (re-exported as [`sys`]); this crate wraps it with owned result types, an +//! error enum, RAII handles, and a thread-safety model that matches the C +//! contract. +//! +//! # Quickstart +//! +//! ```no_run +//! use transcribe_cpp::{Model, RunOptions}; +//! +//! let mut session = Model::load("model.gguf")?.session()?; +//! let pcm: Vec = vec![/* 16 kHz mono samples in [-1, 1] */]; +//! let result = session.run(&pcm, &RunOptions::default())?; +//! println!("{}", result.text); +//! # Ok::<(), transcribe_cpp::Error>(()) +//! ``` +//! +//! # Threading +//! +//! - [`Model`] is `Send + Sync` and cheap to clone (`Arc`-backed). The native +//! model is freed only after the last [`Model`] handle and every [`Session`] +//! derived from it are dropped — in any order. +//! - [`Session`] is `Send` but not `Sync`; its mutating calls take `&mut self`, +//! so the type system enforces one-call-at-a-time. +//! - **0.x concurrency:** the C library permits at most one in-flight +//! `run`/stream across all sessions of a model. This crate enforces that with +//! a per-model mutex (held only for the native compute call), so concurrent +//! calls from many sessions queue rather than race. For real parallelism, use +//! one [`Model`] per worker today. +//! +//! # ABI verification +//! +//! The per-field struct-layout check that the ctypes binding performs is +//! **waived** here: bindgen takes every struct's layout from a real compiler at +//! generation time, so the generated FFI cannot disagree with the headers it +//! was built against. The load-time base-version lock (see [`Model::load`]) is +//! retained. + +#![doc(html_root_url = "https://docs.rs/transcribe-cpp")] +#![warn(missing_debug_implementations)] + +pub use transcribe_cpp_sys as sys; + +mod backend; +mod cancel; +mod error; +mod family; +mod logging; +mod model; +mod result; +mod session; +mod streaming; +mod types; +mod version; + +pub use backend::{ + backend_available, device_count, devices, init_backends, init_backends_default, Device, +}; +pub use cancel::CancelToken; +pub use error::{Error, Result}; +pub use family::{ + MoonshineStreamingOptions, ParakeetBufferedStreamOptions, ParakeetStreamOptions, RunExtension, + StreamExtension, VoxtralRealtimeStreamOptions, WhisperRunOptions, +}; +pub use logging::{disable_logging, init_logging}; +pub use model::{Capabilities, Model, ModelOptions, SessionLimits, SessionOptions}; +pub use result::{Segment, Timings, Token, Transcript, Word}; +pub use session::{RunOptions, Session, Stream}; +pub use streaming::{StreamOptions, StreamText, StreamUpdate}; +pub use types::{ + AbiStruct, Backend, CommitPolicy, ExtSlot, Feature, Itn, KvType, Pnc, StreamState, Task, + TimestampKind, +}; +pub use version::{ + abi_struct_align, abi_struct_size, compiled_version, header_hash, version, version_commit, +}; + +/// Convenience: load a model, transcribe one PCM buffer, and return the result. +/// +/// For repeated transcription, hold a [`Session`] instead of paying model load +/// on every call. +pub fn transcribe( + model_path: impl AsRef, + pcm: &[f32], + options: &RunOptions, +) -> Result { + let mut session = Model::load(model_path)?.session()?; + session.run(pcm, options) +} diff --git a/bindings/rust/transcribe-cpp/src/logging.rs b/bindings/rust/transcribe-cpp/src/logging.rs new file mode 100644 index 00000000..1d38868e --- /dev/null +++ b/bindings/rust/transcribe-cpp/src/logging.rs @@ -0,0 +1,48 @@ +//! Routing the native log sink into the [`log`] crate facade. +//! +//! Call [`init_logging`] once at process startup (before loading any model) to +//! send every library and ggml diagnostic through `log` (and thus to whatever +//! `env_logger`/`tracing-log`/etc. the application installed). This matches the +//! C contract: `transcribe_log_set` is a once-at-startup global. + +use std::ffi::CStr; + +use transcribe_cpp_sys as sys; + +/// The C log callback: map the level and forward the message to `log`. +extern "C" fn log_trampoline( + level: sys::transcribe_log_level, + msg: *const std::os::raw::c_char, + _userdata: *mut std::os::raw::c_void, +) { + if msg.is_null() { + return; + } + // SAFETY: `msg` is a NUL-terminated string valid for this call. + let text = unsafe { CStr::from_ptr(msg) }.to_string_lossy(); + let text = text.trim_end_matches('\n'); + use sys::transcribe_log_level as L; + let level = match level { + L::TRANSCRIBE_LOG_LEVEL_ERROR => log::Level::Error, + L::TRANSCRIBE_LOG_LEVEL_WARN => log::Level::Warn, + L::TRANSCRIBE_LOG_LEVEL_DEBUG => log::Level::Debug, + // INFO and CONT (continuation fragments) both surface at info. + L::TRANSCRIBE_LOG_LEVEL_INFO | L::TRANSCRIBE_LOG_LEVEL_CONT => log::Level::Info, + // NONE or any unknown level: drop. + _ => return, + }; + log::log!(target: "transcribe_cpp", level, "{text}"); +} + +/// Route native + ggml diagnostics into the [`log`] crate. Call once at +/// startup, before loading models. +pub fn init_logging() { + // SAFETY: install-once-at-startup is the supported usage model. + unsafe { sys::transcribe_log_set(Some(log_trampoline), std::ptr::null_mut()) }; +} + +/// Silence the native library entirely (drops both library and ggml messages, +/// including the stderr default). Call once at startup. +pub fn disable_logging() { + unsafe { sys::transcribe_log_set(None, std::ptr::null_mut()) }; +} diff --git a/bindings/rust/transcribe-cpp/src/model.rs b/bindings/rust/transcribe-cpp/src/model.rs new file mode 100644 index 00000000..cdac12b8 --- /dev/null +++ b/bindings/rust/transcribe-cpp/src/model.rs @@ -0,0 +1,300 @@ +//! [`Model`] — a loaded GGUF, shareable across threads. +//! +//! A `Model` is `Arc`-backed and `Send + Sync`: cloning it is cheap and hands +//! out another handle to the same native model. The native model is freed when +//! the last handle AND every [`Session`](crate::Session) derived from it have +//! been dropped — Rust ownership gives the C "model must outlive its sessions" +//! contract (and close-ordering safety) for free, in any drop order. +//! +//! The `Arc` also carries the per-model compute lock that enforces the C +//! library's 0.x concurrency limitation: at most one `run` / `run_batch` / +//! active stream may be in flight across ALL sessions of a model. One-shot +//! runs/batches queue on the lock (serialized); an *active stream* spans many +//! native calls (`begin`→`feed`*→`finalize`), so the lock also carries a flag +//! marking a stream in flight — any run/batch/stream that would overlap it is +//! refused with [`Error::Busy`](crate::Error) rather than allowed to race. + +use std::ffi::CString; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use transcribe_cpp_sys as sys; + +use crate::error::{check, Result}; +use crate::result::owned_str; +use crate::session::Session; +use crate::types::{Backend, ExtSlot, Feature, TimestampKind}; +use crate::version; + +/// Options for loading a model. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelOptions { + /// Which backend to request. Default [`Backend::Auto`]. + pub backend: Backend, + /// Reserved for multi-device selection; must be 0 in 0.x. + pub gpu_device: i32, +} + +impl Default for ModelOptions { + fn default() -> Self { + ModelOptions { + backend: Backend::Auto, + gpu_device: 0, + } + } +} + +/// Immutable, model-level capabilities read from GGUF metadata. +#[derive(Debug, Clone, PartialEq)] +pub struct Capabilities { + pub native_sample_rate: i32, + /// Supported language codes (empty if the model is language-agnostic). + pub languages: Vec, + /// The finest timestamp granularity the model can produce. + pub max_timestamp_kind: TimestampKind, + pub supports_language_detect: bool, + pub supports_translate: bool, + pub supports_streaming: bool, + pub supports_spec_decode: bool, + /// Longest accepted audio in ms (0 = no practical limit). + pub max_audio_ms: i64, +} + +/// The native model handle plus the per-model compute lock, shared via `Arc`. +pub(crate) struct ModelInner { + pub(crate) ptr: *mut sys::transcribe_model, + /// Serializes the compute path across all sessions of this model. The bool + /// is `true` while a stream is ACTIVE (from `begin` until the earliest of + /// `finalize` / `reset` / the `Stream` being dropped); a held lock plus a + /// `true` flag is how `run`/`run_batch`/`stream` detect and refuse an + /// overlapping compute. See the module docs. + pub(crate) compute_lock: Mutex, +} + +// SAFETY: the native model is documented thread-safe for query + session +// creation; the compute path is serialized by `compute_lock`. The raw pointer +// is only ever used behind `&ModelInner`, never mutated through aliasing. +unsafe impl Send for ModelInner {} +unsafe impl Sync for ModelInner {} + +impl Drop for ModelInner { + fn drop(&mut self) { + // Only reached once every Session (each holding an Arc clone) is gone. + unsafe { sys::transcribe_model_free(self.ptr) }; + } +} + +/// A loaded transcription model. +#[derive(Clone)] +pub struct Model { + pub(crate) inner: Arc, +} + +impl std::fmt::Debug for Model { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Model") + .field("arch", &self.arch()) + .field("variant", &self.variant()) + .field("backend", &self.backend()) + .finish() + } +} + +impl Model { + /// Load a GGUF model from disk with default options. + pub fn load(path: impl AsRef) -> Result { + Model::load_with(path, &ModelOptions::default()) + } + + /// Load a GGUF model from disk with explicit options. + pub fn load_with(path: impl AsRef, options: &ModelOptions) -> Result { + // Pre-1.0 base-version lock against the loaded library (once). + version::ensure_compatible()?; + + let path = path.as_ref(); + let c_path = CString::new(path_bytes(path)?)?; + + let mut params: sys::transcribe_model_load_params = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_model_load_params_init(&mut params) }; + params.backend = options.backend.to_raw(); + params.gpu_device = options.gpu_device; + + let mut out: *mut sys::transcribe_model = std::ptr::null_mut(); + let status = unsafe { sys::transcribe_model_load_file(c_path.as_ptr(), ¶ms, &mut out) }; + check_model_load(status, &format!("load {}", path.display()))?; + debug_assert!(!out.is_null()); + + Ok(Model { + inner: Arc::new(ModelInner { + ptr: out, + compute_lock: Mutex::new(false), + }), + }) + } + + /// Open a session bound to this model with default session options. + pub fn session(&self) -> Result { + self.session_with(&SessionOptions::default()) + } + + /// Open a session bound to this model with explicit options. + pub fn session_with(&self, options: &SessionOptions) -> Result { + Session::new(self, options) + } + + /// The model's immutable capabilities. + pub fn capabilities(&self) -> Capabilities { + let mut caps: sys::transcribe_capabilities = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_capabilities_init(&mut caps) }; + // A NULL/struct-size fault cannot happen here (we own a valid model + // and an init'd struct), so a non-OK status leaves zeroed defaults. + let _ = unsafe { sys::transcribe_model_get_capabilities(self.inner.ptr, &mut caps) }; + + let mut languages = Vec::new(); + if !caps.languages.is_null() && caps.n_languages > 0 { + let slice = + unsafe { std::slice::from_raw_parts(caps.languages, caps.n_languages as usize) }; + for &lang in slice { + languages.push(owned_str(lang)); + } + } + + Capabilities { + native_sample_rate: caps.native_sample_rate, + languages, + max_timestamp_kind: TimestampKind::from_raw(caps.max_timestamp_kind), + supports_language_detect: caps.supports_language_detect, + supports_translate: caps.supports_translate, + supports_streaming: caps.supports_streaming, + supports_spec_decode: caps.supports_spec_decode, + max_audio_ms: caps.max_audio_ms, + } + } + + /// Probe a yes/no feature. + pub fn supports(&self, feature: Feature) -> bool { + unsafe { sys::transcribe_model_supports(self.inner.ptr, feature.to_raw()) } + } + + /// Whether this model accepts the given family-extension `kind` in `slot`. + pub fn accepts_ext(&self, slot: ExtSlot, kind: u32) -> bool { + unsafe { sys::transcribe_model_accepts_ext_kind(self.inner.ptr, slot.to_raw(), kind) } + } + + /// The `general.architecture` string, e.g. "parakeet". + pub fn arch(&self) -> String { + owned_str(unsafe { sys::transcribe_model_arch_string(self.inner.ptr) }) + } + + /// The `stt.variant` string, e.g. "tdt-0.6b-v2" (may be empty). + pub fn variant(&self) -> String { + owned_str(unsafe { sys::transcribe_model_variant_string(self.inner.ptr) }) + } + + /// The runtime backend bound to this model, e.g. "cpu", "metal" — the way + /// to detect CPU fallback after requesting a GPU. + pub fn backend(&self) -> String { + owned_str(unsafe { sys::transcribe_model_backend(self.inner.ptr) }) + } + + /// Tokenize plain UTF-8 text into the model's vocabulary (no BOS/EOS, no + /// special tags). Errors with [`Error::NotImplemented`](crate::Error) for + /// families whose tokenizer has no encode path (e.g. SentencePiece today). + pub fn tokenize(&self, text: &str) -> Result> { + let c_text = CString::new(text)?; + // Grow-and-retry on the negative-N "buffer too small" signal. + let mut buf = vec![0i32; text.len().max(16)]; + loop { + let n = unsafe { + sys::transcribe_tokenize( + self.inner.ptr, + c_text.as_ptr(), + buf.as_mut_ptr(), + buf.len(), + ) + }; + if n == i32::MIN { + return Err(crate::error::Error::NotImplemented( + "model tokenizer has no encode path".to_string(), + )); + } + if n < 0 { + buf.resize((-n) as usize, 0); + continue; + } + buf.truncate(n as usize); + return Ok(buf); + } + } +} + +fn check_model_load(status: sys::transcribe_status, context: &str) -> Result<()> { + #[cfg(feature = "dynamic-backends")] + { + if status == sys::transcribe_status::TRANSCRIBE_ERR_BACKEND + && crate::backend::device_count() == 0 + { + let code = status.0 as i32; + let base = crate::error::status_string(code); + return Err(crate::error::Error::Backend(format!( + "{context}: {base} (status {code}); dynamic-backends builds \ + require backend modules to be loaded before model load. Call \ + transcribe_cpp::init_backends_default() when modules are \ + bundled next to libtranscribe, or init_backends(dir) for a \ + custom provider directory." + ))); + } + } + check(status, context) +} + +/// Options for creating a session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionOptions { + /// CPU threads for ops that run on CPU; 0 = library default. + pub n_threads: i32, + /// K/V activation precision. + pub kv_type: crate::types::KvType, + /// Optional decoder context cap in tokens; 0 = model maximum. + pub n_ctx: i32, +} + +impl Default for SessionOptions { + fn default() -> Self { + SessionOptions { + n_threads: 0, + kv_type: crate::types::KvType::Auto, + n_ctx: 0, + } + } +} + +/// Per-session effective limits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionLimits { + /// Decoder context cap in force for this session (0 = unbounded family). + pub effective_n_ctx: i32, + /// Longest accepted audio in ms (0 = no practical limit). + pub effective_max_audio_ms: i64, + /// Worst-case decoder KV bytes for one utterance (0 = no decoder KV). + pub max_kv_bytes: i64, +} + +/// Convert a filesystem path to the bytes the C API's `const char *` expects. +/// On Unix paths are arbitrary bytes, passed through verbatim; elsewhere the +/// API takes UTF-8, so a non-UTF-8 path is rejected rather than lossily mangled +/// (`to_string_lossy` would silently substitute U+FFFD). Shared with +/// [`crate::backend::init_backends`]. +#[cfg(unix)] +pub(crate) fn path_bytes(path: &Path) -> Result> { + use std::os::unix::ffi::OsStrExt; + Ok(path.as_os_str().as_bytes().to_vec()) +} + +#[cfg(not(unix))] +pub(crate) fn path_bytes(path: &Path) -> Result> { + // The C API takes a UTF-8 `const char *`; require a UTF-8-representable path. + path.to_str().map(|s| s.as_bytes().to_vec()).ok_or_else(|| { + crate::error::Error::InvalidArgument(format!("non-UTF-8 path: {}", path.display())) + }) +} diff --git a/bindings/rust/transcribe-cpp/src/result.rs b/bindings/rust/transcribe-cpp/src/result.rs new file mode 100644 index 00000000..0e8169c6 --- /dev/null +++ b/bindings/rust/transcribe-cpp/src/result.rs @@ -0,0 +1,155 @@ +//! Owned result types. +//! +//! Every result is fully materialized into these types — all text is copied at +//! the FFI boundary (`CStr -> String`), so no borrowed session pointer ever +//! escapes to user code (the "copy at the FFI boundary" rule). Holding a +//! [`Transcript`] across the next `run`/`stream` call is therefore always safe. + +use std::ffi::CStr; +use std::os::raw::c_char; + +use transcribe_cpp_sys as sys; + +use crate::types::TimestampKind; + +/// A fully-materialized transcription result. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Transcript { + /// The full transcript text. + pub text: String, + /// The model-detected language (ISO code), if it predicted one. `None` + /// when a language hint was given, the model lacks LID, or none applies. + pub language: Option, + /// The finest timestamp granularity actually populated below. + pub timestamp_kind: TimestampKind, + /// Segment rows (empty unless segment-or-finer timestamps were produced). + pub segments: Vec, + /// Word rows (empty unless word-or-finer timestamps were produced). + pub words: Vec, + /// Token rows (empty unless token timestamps were produced). + pub tokens: Vec, + /// Stage timings for the run. + pub timings: Timings, +} + +/// One segment row. Times are milliseconds relative to the original audio. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Segment { + pub t0_ms: i64, + pub t1_ms: i64, + pub first_word: i32, + pub n_words: i32, + pub first_token: i32, + pub n_tokens: i32, + pub text: String, +} + +/// One word row. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Word { + pub t0_ms: i64, + pub t1_ms: i64, + pub seg_index: i32, + pub first_token: i32, + pub n_tokens: i32, + pub text: String, +} + +/// One token row. `p` is a per-token confidence hint (NaN when the family +/// produces none); its exact meaning is family-specific. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Token { + pub id: i32, + pub p: f32, + pub t0_ms: i64, + pub t1_ms: i64, + pub seg_index: i32, + pub word_index: i32, + pub text: String, +} + +/// Per-run stage timings, in milliseconds. Zero means "unknown / not measured". +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct Timings { + pub load_ms: f32, + pub mel_ms: f32, + pub encode_ms: f32, + pub decode_ms: f32, +} + +/// Copy a borrowed C string into an owned `String` (empty for NULL). +pub(crate) fn owned_str(ptr: *const c_char) -> String { + if ptr.is_null() { + return String::new(); + } + unsafe { CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned() +} + +/// Copy a borrowed C string into `Some(String)`, or `None` for NULL/empty. +pub(crate) fn owned_opt_str(ptr: *const c_char) -> Option { + if ptr.is_null() { + return None; + } + let s = unsafe { CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(); + if s.is_empty() { + None + } else { + Some(s) + } +} + +impl Segment { + pub(crate) fn from_raw(raw: &sys::transcribe_segment) -> Self { + Segment { + t0_ms: raw.t0_ms, + t1_ms: raw.t1_ms, + first_word: raw.first_word, + n_words: raw.n_words, + first_token: raw.first_token, + n_tokens: raw.n_tokens, + text: owned_str(raw.text), + } + } +} + +impl Word { + pub(crate) fn from_raw(raw: &sys::transcribe_word) -> Self { + Word { + t0_ms: raw.t0_ms, + t1_ms: raw.t1_ms, + seg_index: raw.seg_index, + first_token: raw.first_token, + n_tokens: raw.n_tokens, + text: owned_str(raw.text), + } + } +} + +impl Token { + pub(crate) fn from_raw(raw: &sys::transcribe_token) -> Self { + Token { + id: raw.id, + p: raw.p, + t0_ms: raw.t0_ms, + t1_ms: raw.t1_ms, + seg_index: raw.seg_index, + word_index: raw.word_index, + text: owned_str(raw.text), + } + } +} + +impl Timings { + pub(crate) fn from_raw(raw: &sys::transcribe_timings) -> Self { + Timings { + load_ms: raw.load_ms, + mel_ms: raw.mel_ms, + encode_ms: raw.encode_ms, + decode_ms: raw.decode_ms, + } + } +} diff --git a/bindings/rust/transcribe-cpp/src/session.rs b/bindings/rust/transcribe-cpp/src/session.rs new file mode 100644 index 00000000..617b9190 --- /dev/null +++ b/bindings/rust/transcribe-cpp/src/session.rs @@ -0,0 +1,588 @@ +//! [`Session`] — a transcription context bound to a [`Model`]. +//! +//! A session is single-threaded (`Send` but not `Sync`): `run` takes `&mut +//! self`, so the type system enforces "one call at a time" on a given session. +//! Across *different* sessions of one model the per-model compute lock +//! ([`ModelInner::compute_lock`](crate::model::ModelInner)) enforces the C +//! library's "one in-flight compute per model" rule: one-shot runs/batches +//! serialize on it, and while a stream is in flight any overlapping +//! run/batch/stream is refused with [`Error::Busy`]. + +use std::ffi::CString; +use std::os::raw::c_void; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use transcribe_cpp_sys as sys; + +use crate::cancel::{abort_trampoline, CancelToken}; +use crate::error::{check, error_for_status, Error, Result}; +use crate::family::{RunExtRaw, RunExtension}; +use crate::model::{Model, ModelInner, SessionLimits, SessionOptions}; +use crate::result::{owned_opt_str, owned_str, Segment, Timings, Token, Transcript, Word}; +use crate::streaming::{build_stream_params, StreamOptions, StreamText, StreamUpdate}; +use crate::types::{Itn, Pnc, StreamState, Task, TimestampKind}; + +/// Per-run parameters. +#[derive(Debug, Clone, PartialEq)] +pub struct RunOptions { + pub task: Task, + pub timestamps: TimestampKind, + pub pnc: Pnc, + pub itn: Itn, + /// Source language hint (ISO code), or `None` to autodetect. + pub language: Option, + /// Target language for translation, or `None`. + pub target_language: Option, + /// Keep special vocab tags (e.g. `<|...|>`) in the returned text. + pub keep_special_tags: bool, + /// Speculative-decode draft length. `-1` = family default, `0` = disabled. + pub spec_k_drafts: i32, + /// Optional family-specific run extension (e.g. whisper decode knobs). + pub family: Option, +} + +impl Default for RunOptions { + fn default() -> Self { + RunOptions { + task: Task::Transcribe, + timestamps: TimestampKind::None, + pnc: Pnc::Default, + itn: Itn::Default, + language: None, + target_language: None, + keep_special_tags: false, + spec_k_drafts: -1, + family: None, + } + } +} + +/// A transcription session. +pub struct Session { + ptr: *mut sys::transcribe_session, + // Keeps the native model alive (and carries the per-model run lock). The + // model is freed only after this Arc — and every other handle — is dropped. + model: Arc, + // Retained so the abort callback's userdata pointer stays valid while + // installed. `None` when no cancel token is set. + cancel: Option>, +} + +impl std::fmt::Debug for Session { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Session").finish_non_exhaustive() + } +} + +// SAFETY: a session may be moved between threads as long as it is used by at +// most one at a time, which `&mut self` on the mutating calls enforces. It is +// deliberately NOT Sync. +unsafe impl Send for Session {} + +impl Drop for Session { + fn drop(&mut self) { + unsafe { sys::transcribe_session_free(self.ptr) }; + // self.model Arc is dropped here, possibly freeing the model. + } +} + +impl Session { + pub(crate) fn new(model: &Model, options: &SessionOptions) -> Result { + let mut params: sys::transcribe_session_params = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_session_params_init(&mut params) }; + params.n_threads = options.n_threads; + params.kv_type = options.kv_type.to_raw(); + params.n_ctx = options.n_ctx; + + let mut out: *mut sys::transcribe_session = std::ptr::null_mut(); + let status = unsafe { sys::transcribe_session_init(model.inner.ptr, ¶ms, &mut out) }; + check(status, "session init")?; + debug_assert!(!out.is_null()); + + Ok(Session { + ptr: out, + model: Arc::clone(&model.inner), + cancel: None, + }) + } + + /// Install a [`CancelToken`] so an in-flight run/stream can be aborted from + /// another thread. Replaces any previously installed token. The token's + /// flag is shared, so cancel via any clone of it. (Cancellation only takes + /// effect on models whose family honors the abort callback — + /// [`Feature::Cancellation`](crate::Feature::Cancellation).) + pub fn set_cancel_token(&mut self, token: &CancelToken) { + let flag = Arc::clone(&token.flag); + let userdata = Arc::as_ptr(&flag) as *mut c_void; + // SAFETY: single-threaded session contract — no run is in flight when + // this is called. The new callback is installed before the old retained + // Arc is dropped, so the previous userdata is never dangling-referenced. + unsafe { sys::transcribe_set_abort_callback(self.ptr, Some(abort_trampoline), userdata) }; + self.cancel = Some(flag); + } + + /// Remove any installed cancel token. + pub fn clear_cancel_token(&mut self) { + unsafe { sys::transcribe_set_abort_callback(self.ptr, None, std::ptr::null_mut()) }; + self.cancel = None; + } + + /// Whether the most recent run/stream was ended by cancellation. + pub fn was_aborted(&self) -> bool { + unsafe { sys::transcribe_was_aborted(self.ptr) } + } + + /// Whether the most recent decode stopped at the generation budget before + /// end-of-stream (the transcript is incomplete). + pub fn was_truncated(&self) -> bool { + unsafe { sys::transcribe_was_truncated(self.ptr) } + } + + /// Transcribe one buffer of 16 kHz mono float32 PCM in `[-1, 1]`. + /// + /// On an aborted or truncated decode the partial transcript is preserved + /// on the returned [`Error::Aborted`] / [`Error::OutputTruncated`]. + pub fn run(&mut self, pcm: &[f32], options: &RunOptions) -> Result { + let (params, _lang, _target, _family) = build_run_params(options)?; + let n = clamp_len(pcm.len())?; + + // The compute path is serialized per model; hold the lock for the native + // call, then materialize this session's own result storage. Refuse a run + // that would overlap an active stream on another session (the C contract). + let status = { + let guard = self + .model + .compute_lock + .lock() + .unwrap_or_else(|e| e.into_inner()); + if *guard { + return Err(Error::Busy( + "a stream is active on this model; finish or drop it before run()".into(), + )); + } + unsafe { sys::transcribe_run(self.ptr, pcm.as_ptr(), n, ¶ms) } + }; + + match status { + s if s == sys::transcribe_status::TRANSCRIBE_OK => Ok(self.materialize_run()), + s if s == sys::transcribe_status::TRANSCRIBE_ERR_ABORTED + || s == sys::transcribe_status::TRANSCRIBE_ERR_OUTPUT_TRUNCATED => + { + // Partial transcript is preserved by the C API for both. + let partial = Box::new(self.materialize_run()); + Err(attach_partial(error_for_status(s, "run"), partial)) + } + s => Err(error_for_status(s, "run")), + } + } + + /// Transcribe several buffers in one call (throughput path). + /// + /// Returns the whole-batch outcome as an outer `Result`; each utterance's + /// individual outcome is an inner `Result` in the returned vector (one per + /// input, in order). A malformed single utterance fails only its own slot. + pub fn run_batch( + &mut self, + pcms: &[&[f32]], + options: &RunOptions, + ) -> Result>> { + let (params, _lang, _target, _family) = build_run_params(options)?; + let ptrs: Vec<*const f32> = pcms.iter().map(|p| p.as_ptr()).collect(); + let lens: Vec = pcms + .iter() + .map(|p| clamp_len(p.len())) + .collect::>()?; + let n = clamp_len(pcms.len())?; + + let status = { + let guard = self + .model + .compute_lock + .lock() + .unwrap_or_else(|e| e.into_inner()); + if *guard { + return Err(Error::Busy( + "a stream is active on this model; finish or drop it before run_batch()".into(), + )); + } + unsafe { sys::transcribe_run_batch(self.ptr, ptrs.as_ptr(), lens.as_ptr(), n, ¶ms) } + }; + + // OK and ABORTED both populate one per-utterance slot per input; fold + // the batch-level abort into the per-utterance view (the C contract). + let ok = status == sys::transcribe_status::TRANSCRIBE_OK + || status == sys::transcribe_status::TRANSCRIBE_ERR_ABORTED; + if !ok { + return Err(error_for_status(status, "run_batch")); + } + + let count = unsafe { sys::transcribe_batch_n_results(self.ptr) }; + let mut results = Vec::with_capacity(count.max(0) as usize); + for i in 0..count { + let st = unsafe { sys::transcribe_batch_status(self.ptr, i) }; + if st == sys::transcribe_status::TRANSCRIBE_OK { + results.push(Ok(self.materialize_batch(i))); + } else if st == sys::transcribe_status::TRANSCRIBE_ERR_ABORTED + || st == sys::transcribe_status::TRANSCRIBE_ERR_OUTPUT_TRUNCATED + { + let partial = Box::new(self.materialize_batch(i)); + let err = attach_partial(error_for_status(st, "run_batch utterance"), partial); + results.push(Err(err)); + } else { + results.push(Err(error_for_status(st, "run_batch utterance"))); + } + } + Ok(results) + } + + /// The effective per-session limits. + pub fn limits(&self) -> Result { + let mut out: sys::transcribe_session_limits = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_session_limits_init(&mut out) }; + let status = unsafe { sys::transcribe_session_get_limits(self.ptr, &mut out) }; + check(status, "session limits")?; + Ok(SessionLimits { + effective_n_ctx: out.effective_n_ctx, + effective_max_audio_ms: out.effective_max_audio_ms, + max_kv_bytes: out.max_kv_bytes, + }) + } + + /// The model this session is bound to. + pub fn model(&self) -> Model { + Model { + inner: Arc::clone(&self.model), + } + } + + /// Begin a streaming run, returning a [`Stream`] that borrows this session + /// for its lifetime (so the session can't be used for an offline `run` + /// while a stream is active). `run` supplies task / language / timestamps; + /// `stream` supplies the commit policy and any stream-slot family extension. + /// Dropping the returned `Stream` abandons it and returns the session to + /// idle. + pub fn stream(&mut self, run: &RunOptions, stream: &StreamOptions) -> Result> { + let (run_params, _lang, _target, _family) = build_run_params(run)?; + let (stream_params, _stream_family) = build_stream_params(stream); + { + // Claim the model's compute lease for the whole stream lifetime: a + // stream spans begin..drop, so per-call locking alone would let a + // second stream (or a run) on another session race it. + let mut guard = self + .model + .compute_lock + .lock() + .unwrap_or_else(|e| e.into_inner()); + if *guard { + return Err(Error::Busy( + "a stream is already active on this model".into(), + )); + } + check( + unsafe { sys::transcribe_stream_begin(self.ptr, &run_params, &stream_params) }, + "stream begin", + )?; + *guard = true; // released at finalize/reset, or by Stream::drop + } + Ok(Stream { + session: self, + holds_lease: true, + }) + } + + // --- result materialization (top-level / single accessors) --------------- + + fn materialize_run(&self) -> Transcript { + let n_seg = unsafe { sys::transcribe_n_segments(self.ptr) }; + let n_word = unsafe { sys::transcribe_n_words(self.ptr) }; + let n_tok = unsafe { sys::transcribe_n_tokens(self.ptr) }; + + Transcript { + text: owned_str(unsafe { sys::transcribe_full_text(self.ptr) }), + language: owned_opt_str(unsafe { sys::transcribe_detected_language(self.ptr) }), + timestamp_kind: TimestampKind::from_raw(unsafe { + sys::transcribe_returned_timestamp_kind(self.ptr) + }), + segments: (0..n_seg).map(|i| self.segment(i)).collect(), + words: (0..n_word).map(|i| self.word(i)).collect(), + tokens: (0..n_tok).map(|i| self.token(i)).collect(), + timings: self.timings(), + } + } + + fn segment(&self, i: i32) -> Segment { + let mut raw: sys::transcribe_segment = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_segment_init(&mut raw) }; + let _ = unsafe { sys::transcribe_get_segment(self.ptr, i, &mut raw) }; + Segment::from_raw(&raw) + } + + fn word(&self, i: i32) -> Word { + let mut raw: sys::transcribe_word = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_word_init(&mut raw) }; + let _ = unsafe { sys::transcribe_get_word(self.ptr, i, &mut raw) }; + Word::from_raw(&raw) + } + + fn token(&self, i: i32) -> Token { + let mut raw: sys::transcribe_token = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_token_init(&mut raw) }; + let _ = unsafe { sys::transcribe_get_token(self.ptr, i, &mut raw) }; + Token::from_raw(&raw) + } + + fn timings(&self) -> Timings { + let mut raw: sys::transcribe_timings = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_timings_init(&mut raw) }; + let _ = unsafe { sys::transcribe_get_timings(self.ptr, &mut raw) }; + Timings::from_raw(&raw) + } + + // --- result materialization (batch accessors, utterance i) --------------- + + fn materialize_batch(&self, i: i32) -> Transcript { + let n_seg = unsafe { sys::transcribe_batch_n_segments(self.ptr, i) }; + let n_word = unsafe { sys::transcribe_batch_n_words(self.ptr, i) }; + let n_tok = unsafe { sys::transcribe_batch_n_tokens(self.ptr, i) }; + + Transcript { + text: owned_str(unsafe { sys::transcribe_batch_full_text(self.ptr, i) }), + language: owned_opt_str(unsafe { + sys::transcribe_batch_detected_language(self.ptr, i) + }), + timestamp_kind: TimestampKind::from_raw(unsafe { + sys::transcribe_batch_returned_timestamp_kind(self.ptr, i) + }), + segments: (0..n_seg).map(|j| self.batch_segment(i, j)).collect(), + words: (0..n_word).map(|j| self.batch_word(i, j)).collect(), + tokens: (0..n_tok).map(|j| self.batch_token(i, j)).collect(), + timings: self.batch_timings(i), + } + } + + fn batch_segment(&self, i: i32, j: i32) -> Segment { + let mut raw: sys::transcribe_segment = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_segment_init(&mut raw) }; + let _ = unsafe { sys::transcribe_batch_get_segment(self.ptr, i, j, &mut raw) }; + Segment::from_raw(&raw) + } + + fn batch_word(&self, i: i32, j: i32) -> Word { + let mut raw: sys::transcribe_word = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_word_init(&mut raw) }; + let _ = unsafe { sys::transcribe_batch_get_word(self.ptr, i, j, &mut raw) }; + Word::from_raw(&raw) + } + + fn batch_token(&self, i: i32, j: i32) -> Token { + let mut raw: sys::transcribe_token = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_token_init(&mut raw) }; + let _ = unsafe { sys::transcribe_batch_get_token(self.ptr, i, j, &mut raw) }; + Token::from_raw(&raw) + } + + fn batch_timings(&self, i: i32) -> Timings { + let mut raw: sys::transcribe_timings = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_timings_init(&mut raw) }; + let _ = unsafe { sys::transcribe_batch_get_timings(self.ptr, i, &mut raw) }; + Timings::from_raw(&raw) + } +} + +/// Everything that must outlive a `transcribe_run` call: the params struct +/// plus the heap buffers its pointers borrow (language strings, family ext). +type RunParamsBundle = ( + sys::transcribe_run_params, + Option, + Option, + Option, +); + +/// Build `transcribe_run_params` from options. The returned keepalives own the +/// buffers the params' pointers borrow, so the caller must hold them for the +/// duration of the native call. +fn build_run_params(o: &RunOptions) -> Result { + let mut params: sys::transcribe_run_params = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_run_params_init(&mut params) }; + + params.task = o.task.to_raw(); + params.timestamps = o.timestamps.to_raw(); + params.pnc = o.pnc.to_raw(); + params.itn = o.itn.to_raw(); + params.keep_special_tags = o.keep_special_tags; + params.spec_k_drafts = o.spec_k_drafts; + + let lang = o.language.as_deref().map(CString::new).transpose()?; + let target = o.target_language.as_deref().map(CString::new).transpose()?; + params.language = lang.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()); + params.target_language = target.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()); + + let family = o + .family + .as_ref() + .map(RunExtension::materialize) + .transpose()?; + params.family = family.as_ref().map_or(std::ptr::null(), |f| f.ext_ptr()); + + Ok((params, lang, target, family)) +} + +/// PCM/utterance lengths cross the ABI as `int`; reject anything that overflows. +fn clamp_len(len: usize) -> Result { + i32::try_from(len).map_err(|_| Error::InvalidArgument(format!("length {len} exceeds i32::MAX"))) +} + +/// Replace the (partial-less) Aborted/OutputTruncated error with one carrying +/// the materialized partial transcript. +fn attach_partial(err: Error, partial: Box) -> Error { + match err { + Error::Aborted { message, .. } => Error::Aborted { + message, + partial: Some(partial), + }, + Error::OutputTruncated { message, .. } => Error::OutputTruncated { + message, + partial: Some(partial), + }, + other => other, + } +} + +/// An active streaming run, borrowed from a [`Session`]. +/// +/// Feed PCM with [`Stream::feed`], read the UI-stable text with +/// [`Stream::text`], and end input with [`Stream::finalize`]. Dropping the +/// `Stream` (without finalizing) abandons it and returns the session to idle. +/// `Send` but not `Sync`, like the session it borrows. +pub struct Stream<'a> { + session: &'a mut Session, + // True while this stream holds the model's compute lease. Set at begin, + // cleared the moment the stream stops being active (finalize/reset) or on + // drop — whichever comes first. Tracked per-stream so drop never releases a + // lease a *different* session has since acquired. + holds_lease: bool, +} + +impl std::fmt::Debug for Stream<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Stream") + .field("state", &self.state()) + .field("revision", &self.revision()) + .finish() + } +} + +impl Drop for Stream<'_> { + fn drop(&mut self) { + // Abandon any unfinalized stream (reset is idempotent and safe from any + // state) and release the lease IF this stream still holds it — under the + // lock. After finalize/reset the lease is already gone (and may now be + // another session's), so only clear it when holds_lease is still true. + let mut guard = self + .session + .model + .compute_lock + .lock() + .unwrap_or_else(|e| e.into_inner()); + unsafe { sys::transcribe_stream_reset(self.session.ptr) }; + if self.holds_lease { + *guard = false; + } + } +} + +impl Stream<'_> { + /// Feed a chunk of 16 kHz mono float32 PCM and return the change metadata. + pub fn feed(&mut self, pcm: &[f32]) -> Result { + let n = clamp_len(pcm.len())?; + let mut update: sys::transcribe_stream_update = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_stream_update_init(&mut update) }; + let status = { + // This stream already holds the model's compute lease (it is in + // flight); the lock here just serializes the native call. + let _guard = self + .session + .model + .compute_lock + .lock() + .unwrap_or_else(|e| e.into_inner()); + unsafe { sys::transcribe_stream_feed(self.session.ptr, pcm.as_ptr(), n, &mut update) } + }; + check(status, "stream feed")?; + Ok(StreamUpdate::from_raw(&update)) + } + + /// Signal end of input: flush buffered audio and emit the final text. The + /// session transitions to finished. + pub fn finalize(&mut self) -> Result { + let mut update: sys::transcribe_stream_update = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_stream_update_init(&mut update) }; + let status = { + let mut guard = self + .session + .model + .compute_lock + .lock() + .unwrap_or_else(|e| e.into_inner()); + let st = unsafe { sys::transcribe_stream_finalize(self.session.ptr, &mut update) }; + // Finalize ends the active stream (Finished, or Failed on error); + // either way it is no longer active, so free the model's compute + // lease now rather than holding other sessions off until drop. + if self.holds_lease { + *guard = false; + } + st + }; + self.holds_lease = false; + check(status, "stream finalize")?; + Ok(StreamUpdate::from_raw(&update)) + } + + /// Abandon the stream and return the session to idle (clears all snapshot + /// state). Releases the model's compute lease (the stream is no longer + /// active), so other sessions of the model can proceed without waiting for + /// this `Stream` to drop. + pub fn reset(&mut self) { + let mut guard = self + .session + .model + .compute_lock + .lock() + .unwrap_or_else(|e| e.into_inner()); + unsafe { sys::transcribe_stream_reset(self.session.ptr) }; + if self.holds_lease { + *guard = false; + } + self.holds_lease = false; + } + + /// The current UI-facing text snapshot (owned copies). + pub fn text(&self) -> StreamText { + let mut raw: sys::transcribe_stream_text = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_stream_text_init(&mut raw) }; + let _ = unsafe { sys::transcribe_stream_get_text(self.session.ptr, &mut raw) }; + StreamText::from_raw(&raw) + } + + /// A full structured snapshot (segments/words/tokens) of the current + /// hypothesis. For most UIs [`Stream::text`] is the better choice. + pub fn snapshot(&self) -> Transcript { + self.session.materialize_run() + } + + /// The stream's lifecycle state. + pub fn state(&self) -> StreamState { + StreamState::from_raw(unsafe { sys::transcribe_stream_get_state(self.session.ptr) }) + } + + /// The monotonic snapshot revision; diff against the previous value. + pub fn revision(&self) -> i32 { + unsafe { sys::transcribe_stream_revision(self.session.ptr) } + } + + /// Whether the stream was ended by cancellation. + pub fn was_aborted(&self) -> bool { + self.session.was_aborted() + } +} diff --git a/bindings/rust/transcribe-cpp/src/streaming.rs b/bindings/rust/transcribe-cpp/src/streaming.rs new file mode 100644 index 00000000..bdc62ffe --- /dev/null +++ b/bindings/rust/transcribe-cpp/src/streaming.rs @@ -0,0 +1,100 @@ +//! Streaming data types and the stream-params builder. +//! +//! The [`Stream`](crate::Stream) handle itself lives in `session.rs` (it needs +//! the session internals); this module holds the owned option/result types it +//! exchanges. + +use transcribe_cpp_sys as sys; + +use crate::family::{StreamExtRaw, StreamExtension}; +use crate::result::owned_str; +use crate::types::CommitPolicy; + +/// Options for beginning a stream. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct StreamOptions { + /// When committed text grows. Default [`CommitPolicy::Auto`]. + pub commit_policy: CommitPolicy, + /// Consecutive agreeing hypotheses required before a prefix commits + /// (stable-prefix policies). 0 selects the library default (3). + pub stable_prefix_agreement_n: u32, + /// Optional family-specific stream extension. + pub family: Option, +} + +/// Per-call change metadata from `feed`/`finalize`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct StreamUpdate { + /// Any observable property of the snapshot changed this call. + pub result_changed: bool, + /// True only on the `finalize` call's update. + pub is_final: bool, + /// Monotonic snapshot revision; diff against the previous value. + pub revision: i32, + /// Total audio received since begin (ms). + pub input_received_ms: i64, + /// Family-reported audio progress / drain hint (ms). + pub audio_committed_ms: i64, + /// Audio still buffered inside the family's streaming state (ms). + pub buffered_ms: i64, + /// `committed_text` changed this call. + pub committed_changed: bool, + /// `tentative_text` changed this call. + pub tentative_changed: bool, +} + +impl StreamUpdate { + pub(crate) fn from_raw(raw: &sys::transcribe_stream_update) -> Self { + StreamUpdate { + result_changed: raw.result_changed, + is_final: raw.is_final, + revision: raw.revision, + input_received_ms: raw.input_received_ms, + audio_committed_ms: raw.audio_committed_ms, + buffered_ms: raw.buffered_ms, + committed_changed: raw.committed_changed, + tentative_changed: raw.tentative_changed, + } + } +} + +/// A UI-facing snapshot of the stream's text, fully owned. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StreamText { + /// The raw current model hypothesis (authoritative; may rewrite anywhere). + pub full: String, + /// The append-only, flicker-free display/input prefix. + pub committed: String, + /// The volatile raw suffix after the committed prefix. + pub tentative: String, +} + +impl StreamText { + /// `committed + tentative` — the flicker-free render most UIs want. + pub fn display(&self) -> String { + format!("{}{}", self.committed, self.tentative) + } + + pub(crate) fn from_raw(raw: &sys::transcribe_stream_text) -> Self { + StreamText { + full: owned_str(raw.full_text), + committed: owned_str(raw.committed_text), + tentative: owned_str(raw.tentative_text), + } + } +} + +/// Build `transcribe_stream_params`, returning the family-ext keepalive so its +/// backing struct outlives the `transcribe_stream_begin` call. +pub(crate) fn build_stream_params( + o: &StreamOptions, +) -> (sys::transcribe_stream_params, Option) { + let mut params: sys::transcribe_stream_params = unsafe { std::mem::zeroed() }; + unsafe { sys::transcribe_stream_params_init(&mut params) }; + params.commit_policy = o.commit_policy.to_raw(); + params.stable_prefix_agreement_n = o.stable_prefix_agreement_n; + + let family = o.family.as_ref().map(StreamExtension::materialize); + params.family = family.as_ref().map_or(std::ptr::null(), |f| f.ext_ptr()); + (params, family) +} diff --git a/bindings/rust/transcribe-cpp/src/types.rs b/bindings/rust/transcribe-cpp/src/types.rs new file mode 100644 index 00000000..02c370ce --- /dev/null +++ b/bindings/rust/transcribe-cpp/src/types.rs @@ -0,0 +1,304 @@ +//! Idiomatic Rust enums mirroring the C API's parameter enums. +//! +//! Each maps to/from the generated `transcribe-cpp-sys` NewType. Conversions +//! INTO C are total; conversions OUT of C validate the raw integer and fall +//! back to a safe default for an unknown value (enum hygiene — we never assume +//! the library only ever hands back values this version knows about). + +use transcribe_cpp_sys as sys; + +/// The task a run performs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Task { + /// Transcribe speech in its source language. + #[default] + Transcribe, + /// Translate speech into the target language (model must support it). + Translate, +} + +impl Task { + pub(crate) fn to_raw(self) -> sys::transcribe_task { + match self { + Task::Transcribe => sys::transcribe_task::TRANSCRIBE_TASK_TRANSCRIBE, + Task::Translate => sys::transcribe_task::TRANSCRIBE_TASK_TRANSLATE, + } + } +} + +/// Requested (or returned) timestamp granularity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TimestampKind { + /// Text only, no alignment data. The default for a run. + #[default] + None, + /// "Richest the model supports" — resolved per-family at run time. + Auto, + /// Segment-level start/end times. + Segment, + /// Word-level start/end times. + Word, + /// Token-level start/end times. + Token, +} + +impl TimestampKind { + pub(crate) fn to_raw(self) -> sys::transcribe_timestamp_kind { + use sys::transcribe_timestamp_kind as K; + match self { + TimestampKind::None => K::TRANSCRIBE_TIMESTAMPS_NONE, + TimestampKind::Auto => K::TRANSCRIBE_TIMESTAMPS_AUTO, + TimestampKind::Segment => K::TRANSCRIBE_TIMESTAMPS_SEGMENT, + TimestampKind::Word => K::TRANSCRIBE_TIMESTAMPS_WORD, + TimestampKind::Token => K::TRANSCRIBE_TIMESTAMPS_TOKEN, + } + } + + pub(crate) fn from_raw(raw: sys::transcribe_timestamp_kind) -> Self { + use sys::transcribe_timestamp_kind as K; + match raw { + K::TRANSCRIBE_TIMESTAMPS_AUTO => TimestampKind::Auto, + K::TRANSCRIBE_TIMESTAMPS_SEGMENT => TimestampKind::Segment, + K::TRANSCRIBE_TIMESTAMPS_WORD => TimestampKind::Word, + K::TRANSCRIBE_TIMESTAMPS_TOKEN => TimestampKind::Token, + // includes TRANSCRIBE_TIMESTAMPS_NONE and any unknown value + _ => TimestampKind::None, + } + } +} + +/// K/V activation precision for the decoder's flash-attention path. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum KvType { + /// f16 for quantized weights, f32 for f32 weights. The best default. + #[default] + Auto, + /// Full-precision K/V. + F32, + /// Half-precision K/V. + F16, +} + +impl KvType { + pub(crate) fn to_raw(self) -> sys::transcribe_kv_type { + use sys::transcribe_kv_type as K; + match self { + KvType::Auto => K::TRANSCRIBE_KV_TYPE_AUTO, + KvType::F32 => K::TRANSCRIBE_KV_TYPE_F32, + KvType::F16 => K::TRANSCRIBE_KV_TYPE_F16, + } + } +} + +/// Punctuation + capitalization runtime toggle. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Pnc { + /// The family's shipped default (what its published WER was measured at). + #[default] + Default, + /// Explicitly disable runtime PNC (supporting families only). + Off, + /// Explicitly enable runtime PNC (supporting families only). + On, +} + +impl Pnc { + pub(crate) fn to_raw(self) -> sys::transcribe_pnc_mode { + use sys::transcribe_pnc_mode as P; + match self { + Pnc::Default => P::TRANSCRIBE_PNC_MODE_DEFAULT, + Pnc::Off => P::TRANSCRIBE_PNC_MODE_OFF, + Pnc::On => P::TRANSCRIBE_PNC_MODE_ON, + } + } +} + +/// Inverse-text-normalization runtime toggle. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Itn { + /// The family's shipped default. + #[default] + Default, + /// Explicitly disable runtime ITN (supporting families only). + Off, + /// Explicitly enable runtime ITN (supporting families only). + On, +} + +impl Itn { + pub(crate) fn to_raw(self) -> sys::transcribe_itn_mode { + use sys::transcribe_itn_mode as I; + match self { + Itn::Default => I::TRANSCRIBE_ITN_MODE_DEFAULT, + Itn::Off => I::TRANSCRIBE_ITN_MODE_OFF, + Itn::On => I::TRANSCRIBE_ITN_MODE_ON, + } + } +} + +/// Which compute backend to request when loading a model. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Backend { + /// Best available device; CPU is the always-present fallback. The default. + #[default] + Auto, + /// Strict CPU (no GPU, no host accelerators) — the determinism choice. + Cpu, + /// CPU plus host-memory accelerators (BLAS/AMX) when present. + CpuAccel, + /// Require Metal; errors if this build has no Metal. + Metal, + /// Require Vulkan; errors if this build has no Vulkan. + Vulkan, + /// Require CUDA; errors if this build has no CUDA. + Cuda, +} + +impl Backend { + pub(crate) fn to_raw(self) -> sys::transcribe_backend_request { + use sys::transcribe_backend_request as B; + match self { + Backend::Auto => B::TRANSCRIBE_BACKEND_AUTO, + Backend::Cpu => B::TRANSCRIBE_BACKEND_CPU, + Backend::CpuAccel => B::TRANSCRIBE_BACKEND_CPU_ACCEL, + Backend::Metal => B::TRANSCRIBE_BACKEND_METAL, + Backend::Vulkan => B::TRANSCRIBE_BACKEND_VULKAN, + Backend::Cuda => B::TRANSCRIBE_BACKEND_CUDA, + } + } +} + +/// A yes/no model capability probe (`transcribe_model_supports`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Feature { + /// Accepts a free-text/token decode prompt (whisper today). + InitialPrompt, + /// Runs a multi-tier temperature fallback loop (whisper today). + TemperatureFallback, + /// Handles audio longer than its native window in one run (whisper today). + LongForm, + /// Honors the abort callback between chunks/decode steps. + Cancellation, + /// Exposes a runtime PNC toggle. + Pnc, + /// Exposes a runtime ITN toggle. + Itn, +} + +impl Feature { + pub(crate) fn to_raw(self) -> sys::transcribe_feature { + use sys::transcribe_feature as F; + match self { + Feature::InitialPrompt => F::TRANSCRIBE_FEATURE_INITIAL_PROMPT, + Feature::TemperatureFallback => F::TRANSCRIBE_FEATURE_TEMPERATURE_FALLBACK, + Feature::LongForm => F::TRANSCRIBE_FEATURE_LONG_FORM, + Feature::Cancellation => F::TRANSCRIBE_FEATURE_CANCELLATION, + Feature::Pnc => F::TRANSCRIBE_FEATURE_PNC, + Feature::Itn => F::TRANSCRIBE_FEATURE_ITN, + } + } +} + +/// When the UI-facing committed text grows during a stream. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CommitPolicy { + /// The family's stable-prefix implementation. The default. + #[default] + Auto, + /// Commit nothing during feed; finalize commits the final text. + OnFinalize, + /// Commit a raw prefix accepted by the stable-prefix implementation. + StablePrefix, +} + +impl CommitPolicy { + pub(crate) fn to_raw(self) -> sys::transcribe_stream_commit_policy { + use sys::transcribe_stream_commit_policy as C; + match self { + CommitPolicy::Auto => C::TRANSCRIBE_STREAM_COMMIT_AUTO, + CommitPolicy::OnFinalize => C::TRANSCRIBE_STREAM_COMMIT_ON_FINALIZE, + CommitPolicy::StablePrefix => C::TRANSCRIBE_STREAM_COMMIT_STABLE_PREFIX, + } + } +} + +/// Stream lifecycle state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamState { + Idle, + Active, + Finished, + Failed, +} + +impl StreamState { + pub(crate) fn from_raw(raw: sys::transcribe_stream_state) -> Self { + use sys::transcribe_stream_state as S; + match raw { + S::TRANSCRIBE_STREAM_ACTIVE => StreamState::Active, + S::TRANSCRIBE_STREAM_FINISHED => StreamState::Finished, + S::TRANSCRIBE_STREAM_FAILED => StreamState::Failed, + _ => StreamState::Idle, + } + } +} + +/// A public ABI struct, for size/alignment introspection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AbiStruct { + ModelLoadParams, + SessionParams, + RunParams, + StreamParams, + Capabilities, + Timings, + Segment, + Word, + Token, + StreamUpdate, + StreamText, + SessionLimits, + Ext, + BackendDevice, +} + +impl AbiStruct { + pub(crate) fn to_raw(self) -> sys::transcribe_abi_struct { + use sys::transcribe_abi_struct as A; + match self { + AbiStruct::ModelLoadParams => A::TRANSCRIBE_ABI_MODEL_LOAD_PARAMS, + AbiStruct::SessionParams => A::TRANSCRIBE_ABI_SESSION_PARAMS, + AbiStruct::RunParams => A::TRANSCRIBE_ABI_RUN_PARAMS, + AbiStruct::StreamParams => A::TRANSCRIBE_ABI_STREAM_PARAMS, + AbiStruct::Capabilities => A::TRANSCRIBE_ABI_CAPABILITIES, + AbiStruct::Timings => A::TRANSCRIBE_ABI_TIMINGS, + AbiStruct::Segment => A::TRANSCRIBE_ABI_SEGMENT, + AbiStruct::Word => A::TRANSCRIBE_ABI_WORD, + AbiStruct::Token => A::TRANSCRIBE_ABI_TOKEN, + AbiStruct::StreamUpdate => A::TRANSCRIBE_ABI_STREAM_UPDATE, + AbiStruct::StreamText => A::TRANSCRIBE_ABI_STREAM_TEXT, + AbiStruct::SessionLimits => A::TRANSCRIBE_ABI_SESSION_LIMITS, + AbiStruct::Ext => A::TRANSCRIBE_ABI_EXT, + AbiStruct::BackendDevice => A::TRANSCRIBE_ABI_BACKEND_DEVICE, + } + } +} + +/// The slot a family extension is pointed at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtSlot { + /// `transcribe_run_params::family`. + Run, + /// `transcribe_stream_params::family`. + Stream, +} + +impl ExtSlot { + pub(crate) fn to_raw(self) -> sys::transcribe_ext_slot { + use sys::transcribe_ext_slot as E; + match self { + ExtSlot::Run => E::TRANSCRIBE_EXT_SLOT_RUN, + ExtSlot::Stream => E::TRANSCRIBE_EXT_SLOT_STREAM, + } + } +} diff --git a/bindings/rust/transcribe-cpp/src/version.rs b/bindings/rust/transcribe-cpp/src/version.rs new file mode 100644 index 00000000..bdb19983 --- /dev/null +++ b/bindings/rust/transcribe-cpp/src/version.rs @@ -0,0 +1,78 @@ +//! Version + ABI introspection and the load-time version gate. +//! +//! Pre-1.0 the on-disk ABI may break between minor releases, so the binding +//! and the linked library must agree on the base `MAJOR.MINOR.PATCH`. The gate +//! runs once, lazily, on the first model load (and is exposed directly so a +//! host can check up front). Packaging-only suffixes on the runtime string are +//! tolerated — only the leading release segment is compared. + +use std::sync::OnceLock; + +use transcribe_cpp_sys as sys; + +use crate::error::{Error, Result}; +use crate::result::owned_str; +use crate::types::AbiStruct; + +/// The base version string this crate's generated FFI was built against. +pub fn compiled_version() -> String { + format!( + "{}.{}.{}", + sys::TRANSCRIBE_VERSION_MAJOR, + sys::TRANSCRIBE_VERSION_MINOR, + sys::TRANSCRIBE_VERSION_PATCH + ) +} + +/// The `MAJOR.MINOR.PATCH` version string of the linked native library. +pub fn version() -> String { + owned_str(unsafe { sys::transcribe_version() }) +} + +/// The short git commit the native library was built from (or "unknown"). +pub fn version_commit() -> String { + owned_str(unsafe { sys::transcribe_version_commit() }) +} + +/// The public-ABI digest the committed bindings were generated against. +pub fn header_hash() -> &'static str { + sys::PUBLIC_HEADER_HASH +} + +/// The native library's `sizeof` for a public ABI struct, or 0 if unknown. +pub fn abi_struct_size(which: AbiStruct) -> usize { + unsafe { sys::transcribe_abi_struct_size(which.to_raw()) } +} + +/// The native library's `alignof` for a public ABI struct, or 0 if unknown. +pub fn abi_struct_align(which: AbiStruct) -> usize { + unsafe { sys::transcribe_abi_struct_align(which.to_raw()) } +} + +/// Leading dotted-numeric release segment ("0.0.1.post1" -> "0.0.1"). +fn base(v: &str) -> &str { + let end = v + .find(|c: char| !(c.is_ascii_digit() || c == '.')) + .unwrap_or(v.len()); + v[..end].trim_end_matches('.') +} + +static GATE: OnceLock> = OnceLock::new(); + +/// Run (once) the pre-1.0 base-version lock against the loaded library. +pub(crate) fn ensure_compatible() -> Result<()> { + let outcome = GATE.get_or_init(|| { + let runtime = version(); + let compiled = compiled_version(); + if base(&runtime) == base(&compiled) { + Ok(()) + } else { + Err(format!( + "loaded transcribe library is {runtime}, but these bindings \ + were generated for {compiled} (base versions must match \ + pre-1.0)" + )) + } + }); + outcome.clone().map_err(Error::VersionMismatch) +} diff --git a/bindings/rust/transcribe-cpp/tests/cancel.rs b/bindings/rust/transcribe-cpp/tests/cancel.rs new file mode 100644 index 00000000..9b842f09 --- /dev/null +++ b/bindings/rust/transcribe-cpp/tests/cancel.rs @@ -0,0 +1,74 @@ +//! Cancellation: a clean baseline run, and a cross-thread timer cancel of an +//! in-flight run on long (tiled) audio. Ports the Python C5 case. Skips the +//! abort assertion if the machine wins the race (run finishes before cancel). + +mod common; + +use std::thread; +use std::time::Duration; + +use transcribe_cpp::{CancelToken, Error, Feature, Model, RunOptions}; + +#[test] +fn uncancelled_run_is_not_aborted() { + let Some((model_path, pcm)) = common::smoke_fixtures("uncancelled_run_is_not_aborted") else { + return; + }; + let mut session = Model::load(&model_path).unwrap().session().unwrap(); + let token = CancelToken::new(); + session.set_cancel_token(&token); + let result = session.run(&pcm, &RunOptions::default()).unwrap(); + assert!(result.text.to_lowercase().contains("country")); + assert!(!session.was_aborted()); +} + +#[test] +fn cross_thread_cancel_of_in_flight_run() { + // Cancelling an in-flight whisper run aborts with a partial transcript on + // every platform (the Rust layer maps the abort to Error::Aborted) — or + // completes first on a fast machine. Both outcomes are asserted below. + let Some((model_path, pcm)) = common::smoke_fixtures("cross_thread_cancel_of_in_flight_run") + else { + return; + }; + let model = Model::load(&model_path).unwrap(); + if !model.supports(Feature::Cancellation) { + eprintln!("skip: model does not support cancellation"); + return; + } + // Tile the clip enough to be reliably mid-flight when we cancel. + let long: Vec = std::iter::repeat(pcm.iter().copied()) + .take(4) + .flatten() + .collect(); + + let mut session = model.session().unwrap(); + let token = CancelToken::new(); + session.set_cancel_token(&token); + + // Fire the cancel shortly after the run starts, from another thread. + let canceller = { + let token = token.clone(); + thread::spawn(move || { + thread::sleep(Duration::from_millis(40)); + token.cancel(); + }) + }; + + let outcome = session.run(&long, &RunOptions::default()); + canceller.join().unwrap(); + + match outcome { + Err(Error::Aborted { partial, .. }) => { + // The abort fired mid-run: partial transcript preserved, flag set. + assert!(session.was_aborted()); + assert!(partial.is_some(), "aborted run should carry a partial"); + } + Ok(_) => { + // The machine won the race (run completed before the 40 ms timer). + assert!(!session.was_aborted()); + eprintln!("note: run completed before cancel fired (fast machine)"); + } + Err(other) => panic!("unexpected error: {other:?}"), + } +} diff --git a/bindings/rust/transcribe-cpp/tests/common/mod.rs b/bindings/rust/transcribe-cpp/tests/common/mod.rs new file mode 100644 index 00000000..cb8983be --- /dev/null +++ b/bindings/rust/transcribe-cpp/tests/common/mod.rs @@ -0,0 +1,87 @@ +//! Shared fixtures for the integration tests, mirroring the Python conftest. +//! +//! Two tiers: no-model tests always run; model-gated tests resolve a local +//! whisper-tiny.en + jfk.wav and return `None` (clean skip) when absent. The +//! "country" content assertion is specific to jfk.wav and holds for any English +//! ASR model. + +#![allow(dead_code)] + +use std::path::PathBuf; +use std::sync::Once; + +/// Register backend modules before the first model load. Idempotent and a no-op +/// in compiled-in (static / plain `shared`) builds; in a `dynamic-backends` +/// build it loads the per-ISA CPU / GPU modules the native build installed — +/// without it a model load in that posture finds zero devices. Folded into the +/// fixture resolvers below so every model-gated test gets it for free. +fn ensure_backends() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + transcribe_cpp::init_backends_default().expect("init_backends_default"); + }); +} + +/// Repo root: walk up from this crate until we find the marker that only the +/// repo root carries (immune to how deep the crate dir is nested). +pub fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .find(|p| p.join("include/transcribe.h").is_file() && p.join("ggml").is_dir()) + .expect("could not locate repo root from CARGO_MANIFEST_DIR") + .to_path_buf() +} + +/// The smoke model path, or `None` when the GGUF is not present. +pub fn smoke_model() -> Option { + ensure_backends(); + let path = std::env::var_os("TRANSCRIBE_SMOKE_MODEL") + .map(PathBuf::from) + .unwrap_or_else(|| repo_root().join("models/whisper-tiny.en/whisper-tiny.en-Q5_K_M.gguf")); + path.is_file().then_some(path) +} + +/// The smoke audio as 16 kHz mono f32 PCM, or `None` when the WAV is absent. +pub fn smoke_audio() -> Option> { + let path = std::env::var_os("TRANSCRIBE_SMOKE_AUDIO") + .map(PathBuf::from) + .unwrap_or_else(|| repo_root().join("samples/jfk.wav")); + path.is_file().then(|| load_wav(&path)) +} + +/// The streaming smoke model (moonshine-streaming-tiny), or `None` if absent. +pub fn smoke_streaming_model() -> Option { + ensure_backends(); + let path = std::env::var_os("TRANSCRIBE_SMOKE_STREAMING_MODEL") + .map(PathBuf::from) + .unwrap_or_else(|| { + repo_root().join("models/moonshine-streaming-tiny/moonshine-streaming-tiny-Q8_0.gguf") + }); + path.is_file().then_some(path) +} + +/// Both fixtures together; prints a skip note and returns `None` if either is +/// missing (so the caller can `return` early — the Rust equivalent of skip). +pub fn smoke_fixtures(test: &str) -> Option<(PathBuf, Vec)> { + match (smoke_model(), smoke_audio()) { + (Some(m), Some(a)) => Some((m, a)), + _ => { + eprintln!( + "skip {test}: smoke model/audio not present (set TRANSCRIBE_SMOKE_MODEL / _AUDIO)" + ); + None + } + } +} + +fn load_wav(path: &std::path::Path) -> Vec { + let mut reader = hound::WavReader::open(path).expect("open wav"); + let spec = reader.spec(); + assert_eq!(spec.channels, 1, "{path:?} must be mono"); + assert_eq!(spec.sample_rate, 16_000, "{path:?} must be 16 kHz"); + assert_eq!(spec.bits_per_sample, 16, "{path:?} must be 16-bit"); + reader + .samples::() + .map(|s| s.expect("wav sample") as f32 / 32768.0) + .collect() +} diff --git a/bindings/rust/transcribe-cpp/tests/degradation.rs b/bindings/rust/transcribe-cpp/tests/degradation.rs new file mode 100644 index 00000000..1e311257 --- /dev/null +++ b/bindings/rust/transcribe-cpp/tests/degradation.rs @@ -0,0 +1,89 @@ +//! Backend degradation + dynamic-posture contract. +//! +//! These are no-model tests, so they run in EVERY CI posture — the static +//! default, the `shared` (shared-link) leg, and the `dynamic-backends` +//! (loadable-module) leg of the posture matrix (`rust-ci.yml`). In the +//! shared/dynamic-backends legs they are also the end-to-end proof that a +//! consumer loads and calls into `libtranscribe` (resolved via the rpath the +//! safe crate's build.rs emits), and that the backend modules register. +//! +//! They cover the REQUEST-PATH half of the degradation contract +//! (requirements §5): CPU is always the floor, an unavailable backend probes +//! cleanly (the hook that turns an explicit `Backend::Vulkan` into a clear +//! error rather than a crashed model load), and `init_backends` — the +//! provider/DL entry point — is callable without tearing down the floor. The +//! Vulkan-loader-absent three-tier (no loader / loader-no-device / real GPU) is +//! certified once at the C level (native-ci `provider-dl-vulkan`) and inherited +//! per requirements §4. +//! +//! Posture note: in a `dynamic-backends` build NO backend is compiled in — the +//! CPU floor only exists after the modules are loaded. So every test that +//! asserts the floor first calls [`init_backends_default`], which loads the +//! build's module directory in that posture and is a harmless no-op otherwise. + +use transcribe_cpp::{ + backend_available, device_count, devices, init_backends, init_backends_default, Backend, +}; + +#[test] +fn cpu_is_the_floor() { + // Establish the floor in every posture (no-op for compiled-in builds; loads + // the modules for a dynamic-backends build), then assert it holds. + init_backends_default().expect("init_backends_default"); + assert!(device_count() >= 1, "no compute devices registered"); + assert!(backend_available(Backend::Cpu), "CPU backend unavailable"); + assert!( + devices().iter().any(|d| d.kind == "cpu"), + "no cpu-kind device in {:?}", + devices() + ); +} + +#[test] +fn unavailable_backend_probes_cleanly() { + init_backends_default().expect("init_backends_default"); + // In a CPU/Metal build Vulkan and CUDA are not compiled in. The probe must + // answer (true/false) without crashing — this is what lets a host turn an + // explicit Backend::Vulkan request into a clear error instead of a failed + // model load. We assert it does not panic and is internally consistent; + // CPU is the one backend we can assert positively on every runner. + let _vulkan = backend_available(Backend::Vulkan); + let _cuda = backend_available(Backend::Cuda); + assert!(backend_available(Backend::Cpu)); +} + +#[test] +fn init_backends_keeps_the_cpu_floor() { + // The provider entry point. Establish the floor first (so this holds in a + // dynamic-backends build), then point init_backends at an EMPTY directory: + // the contract is that it neither panics nor tears down the already- + // registered floor. Mirrors link_smoke.c's init_backends call. + init_backends_default().expect("init_backends_default"); + let dir = std::env::temp_dir().join("transcribe-rs-empty-modules"); + std::fs::create_dir_all(&dir).ok(); + + let before = device_count(); + let _ = init_backends(&dir); // posture-dependent result; must not panic + assert!( + device_count() >= before.max(1), + "device count regressed after init_backends ({before} -> {})", + device_count() + ); + assert!(backend_available(Backend::Cpu), "CPU floor lost"); +} + +/// In a `dynamic-backends` build, the default loader must actually register a +/// CPU device from the on-disk modules — proving the loadable-module path works +/// end to end from Rust (the build configured BACKEND_DL, the modules installed, +/// and init_backends found and dlopened them). In other postures this path is a +/// no-op, so the assertion only carries signal under the feature. +#[cfg(feature = "dynamic-backends")] +#[test] +fn dynamic_backends_register_a_cpu_module() { + init_backends_default().expect("dynamic-backends provider load"); + assert!( + devices().iter().any(|d| d.kind == "cpu"), + "dynamic-backends build registered no cpu module: {:?}", + devices() + ); +} diff --git a/bindings/rust/transcribe-cpp/tests/extensions.rs b/bindings/rust/transcribe-cpp/tests/extensions.rs new file mode 100644 index 00000000..32ac6ace --- /dev/null +++ b/bindings/rust/transcribe-cpp/tests/extensions.rs @@ -0,0 +1,59 @@ +//! Family-extension (whisper run slot) and tokenizer tests against the whisper +//! canary. + +mod common; + +use transcribe_cpp::sys::TRANSCRIBE_EXT_KIND_WHISPER_RUN; +use transcribe_cpp::{ExtSlot, Model, RunExtension, RunOptions, WhisperRunOptions}; + +#[test] +fn whisper_accepts_run_extension() { + let Some((model_path, _)) = common::smoke_fixtures("whisper_accepts_run_extension") else { + return; + }; + let model = Model::load(&model_path).unwrap(); + assert!( + model.accepts_ext(ExtSlot::Run, TRANSCRIBE_EXT_KIND_WHISPER_RUN), + "whisper should accept its run extension" + ); + // A whisper run ext on the STREAM slot is not accepted. + assert!(!model.accepts_ext(ExtSlot::Stream, TRANSCRIBE_EXT_KIND_WHISPER_RUN)); +} + +#[test] +fn whisper_run_with_initial_prompt() { + let Some((model_path, pcm)) = common::smoke_fixtures("whisper_run_with_initial_prompt") else { + return; + }; + let mut session = Model::load(&model_path).unwrap().session().unwrap(); + let options = RunOptions { + family: Some(RunExtension::Whisper(WhisperRunOptions { + initial_prompt: Some("This is a presidential speech.".to_string()), + temperature: Some(0.0), + ..Default::default() + })), + ..Default::default() + }; + let result = session.run(&pcm, &options).unwrap(); + assert!( + result.text.to_lowercase().contains("country"), + "{:?}", + result.text + ); +} + +#[test] +fn tokenize_round_trips_nonempty() { + let Some((model_path, _)) = common::smoke_fixtures("tokenize_round_trips_nonempty") else { + return; + }; + let model = Model::load(&model_path).unwrap(); + // Whisper uses GPT-2 byte-level BPE; encode is plumbed. + let tokens = model + .tokenize("ask not what your country can do for you") + .unwrap(); + assert!(!tokens.is_empty(), "no tokens produced"); + // A longer string should not produce fewer tokens than a short prefix. + let short = model.tokenize("ask").unwrap(); + assert!(tokens.len() >= short.len()); +} diff --git a/bindings/rust/transcribe-cpp/tests/no_model.rs b/bindings/rust/transcribe-cpp/tests/no_model.rs new file mode 100644 index 00000000..2d1120e6 --- /dev/null +++ b/bindings/rust/transcribe-cpp/tests/no_model.rs @@ -0,0 +1,87 @@ +//! No-model tests: version/ABI introspection, the version gate, device +//! discovery, and error mapping. These always run (including on forks). + +mod common; + +use transcribe_cpp::{ + abi_struct_size, backend_available, compiled_version, device_count, devices, header_hash, + init_backends_default, version, AbiStruct, Backend, Error, Model, +}; + +#[test] +fn version_gate_agrees() { + // The pre-1.0 base-version lock: the linked library and the generated + // bindings must report the same MAJOR.MINOR.PATCH. + assert_eq!(version(), compiled_version()); + assert!(!version().is_empty()); +} + +#[test] +fn header_hash_is_pinned() { + // sha256/16 over the FFI surface — 16 hex chars. + let h = header_hash(); + assert_eq!(h.len(), 16, "hash: {h}"); + assert!(h.chars().all(|c| c.is_ascii_hexdigit())); +} + +#[test] +fn abi_struct_sizes_are_live() { + // Real calls into the native library returning runtime layout values. + for which in [ + AbiStruct::RunParams, + AbiStruct::Capabilities, + AbiStruct::Segment, + AbiStruct::SessionLimits, + ] { + assert!(abi_struct_size(which) > 0, "{which:?} reported size 0"); + } +} + +#[test] +fn at_least_a_cpu_device() { + // A compiled-in build has the CPU backend registered already; a + // dynamic-backends build registers it once the modules load. Establish it in + // every posture, then assert the floor. + init_backends_default().expect("init_backends_default"); + assert!(device_count() >= 1); + assert!(backend_available(Backend::Cpu)); + let devices = devices(); + assert!(devices.iter().any(|d| d.kind == "cpu"), "{devices:?}"); +} + +#[test] +fn missing_file_is_not_found() { + // Register backends first so a dynamic-backends build reaches the file check + // (a zero-device load would otherwise fail on the backend, not the path). + init_backends_default().expect("init_backends_default"); + let err = Model::load("/no/such/model.gguf").unwrap_err(); + assert!( + matches!(err, Error::ModelFileNotFound(_)), + "got {err:?} (status {})", + err.raw_status() + ); +} + +#[test] +fn junk_file_is_model_load_error() { + init_backends_default().expect("init_backends_default"); + let dir = std::env::temp_dir(); + let path = dir.join("transcribe-rs-junk.gguf"); + std::fs::write(&path, b"not a gguf file at all").unwrap(); + let err = Model::load(&path).unwrap_err(); + let _ = std::fs::remove_file(&path); + assert!( + matches!(err, Error::ModelLoad(_)), + "got {err:?} (status {})", + err.raw_status() + ); +} + +#[test] +fn handles_are_send_sync() { + fn assert_send_sync() {} + fn assert_send() {} + assert_send_sync::(); + assert_send::(); + // Session is intentionally NOT Sync (single-threaded use). +} diff --git a/bindings/rust/transcribe-cpp/tests/streaming.rs b/bindings/rust/transcribe-cpp/tests/streaming.rs new file mode 100644 index 00000000..91f965ab --- /dev/null +++ b/bindings/rust/transcribe-cpp/tests/streaming.rs @@ -0,0 +1,222 @@ +//! Streaming tests against the moonshine-streaming canary. Skip cleanly when +//! the streaming model or jfk.wav is absent. + +mod common; + +use transcribe_cpp::{Model, RunOptions, StreamExtension, StreamOptions, StreamState}; +use transcribe_cpp::{MoonshineStreamingOptions, Stream}; + +fn feed_in_chunks(stream: &mut Stream<'_>, pcm: &[f32], chunk: usize) { + for frame in pcm.chunks(chunk) { + stream.feed(frame).expect("feed"); + } +} + +#[test] +fn streams_jfk_committed_text() { + let (Some(model_path), Some(pcm)) = (common::smoke_streaming_model(), common::smoke_audio()) + else { + eprintln!("skip streams_jfk_committed_text: streaming model/audio absent"); + return; + }; + let model = Model::load(&model_path).unwrap(); + assert!( + model.capabilities().supports_streaming, + "model does not advertise streaming" + ); + + let mut session = model.session().unwrap(); + let mut stream = session + .stream(&RunOptions::default(), &StreamOptions::default()) + .unwrap(); + assert_eq!(stream.state(), StreamState::Active); + + feed_in_chunks(&mut stream, &pcm, 1600); // 100 ms chunks + let update = stream.finalize().unwrap(); + assert!(update.is_final); + assert_eq!(stream.state(), StreamState::Finished); + + let text = stream.text(); + // `full` is the authoritative raw hypothesis. (committed_text is a + // best-effort append-only prefix that can diverge for re-attending models + // like moonshine_streaming, so we assert on `full` here.) + assert!( + text.full.to_lowercase().contains("country"), + "stream text: {:?}", + text.full + ); +} + +#[test] +fn on_finalize_policy_commits_full_text() { + // Under ON_FINALIZE, committed_text is empty during feed and becomes the + // final full text at finalize — so display == full reliably. + let (Some(model_path), Some(pcm)) = (common::smoke_streaming_model(), common::smoke_audio()) + else { + return; + }; + let mut session = Model::load(&model_path).unwrap().session().unwrap(); + let opts = StreamOptions { + commit_policy: transcribe_cpp::CommitPolicy::OnFinalize, + ..Default::default() + }; + let mut stream = session.stream(&RunOptions::default(), &opts).unwrap(); + feed_in_chunks(&mut stream, &pcm, 1600); + stream.finalize().unwrap(); + let text = stream.text(); + assert!( + text.committed.to_lowercase().contains("country"), + "committed: {:?}", + text.committed + ); +} + +#[test] +fn stream_revision_advances() { + let (Some(model_path), Some(pcm)) = (common::smoke_streaming_model(), common::smoke_audio()) + else { + return; + }; + let mut session = Model::load(&model_path).unwrap().session().unwrap(); + let mut stream = session + .stream(&RunOptions::default(), &StreamOptions::default()) + .unwrap(); + let r0 = stream.revision(); + feed_in_chunks(&mut stream, &pcm, 1600); + stream.finalize().unwrap(); + assert!(stream.revision() >= r0, "revision regressed"); +} + +#[test] +fn stream_reset_returns_to_idle() { + let (Some(model_path), Some(pcm)) = (common::smoke_streaming_model(), common::smoke_audio()) + else { + return; + }; + let mut session = Model::load(&model_path).unwrap().session().unwrap(); + { + let mut stream = session + .stream(&RunOptions::default(), &StreamOptions::default()) + .unwrap(); + stream.feed(&pcm[..pcm.len().min(1600)]).unwrap(); + stream.reset(); + assert_eq!(stream.state(), StreamState::Idle); + } + // The session is usable again for a fresh stream after the borrow ends. + let stream = session + .stream(&RunOptions::default(), &StreamOptions::default()) + .unwrap(); + assert_eq!(stream.state(), StreamState::Active); +} + +#[test] +fn concurrent_compute_on_one_model_is_refused() { + // The C library allows at most one in-flight compute per model (the 0.x + // limitation, include/transcribe.h). An active stream spans begin..drop, so + // a second stream — or an offline run — on ANOTHER session of the same model + // must be refused with Error::Busy rather than race into the documented UB. + let (Some(model_path), Some(pcm)) = (common::smoke_streaming_model(), common::smoke_audio()) + else { + return; + }; + let model = Model::load(&model_path).unwrap(); + let mut s1 = model.session().unwrap(); + let mut s2 = model.session().unwrap(); + + let mut stream1 = s1 + .stream(&RunOptions::default(), &StreamOptions::default()) + .unwrap(); + stream1.feed(&pcm[..pcm.len().min(1600)]).unwrap(); + + // Second stream on the same model while the first is live -> Busy. + match s2.stream(&RunOptions::default(), &StreamOptions::default()) { + Err(transcribe_cpp::Error::Busy(_)) => {} + other => panic!("expected Error::Busy for a second stream, got {other:?}"), + } + // An offline run on the same model while a stream is live -> Busy too. + match s2.run(&pcm, &RunOptions::default()) { + Err(transcribe_cpp::Error::Busy(_)) => {} + other => panic!("expected Error::Busy for a run mid-stream, got {other:?}"), + } + + // Dropping the first stream releases the model's compute lease; now s2 can + // begin its own stream. + drop(stream1); + let stream2 = s2 + .stream(&RunOptions::default(), &StreamOptions::default()) + .unwrap(); + assert_eq!(stream2.state(), StreamState::Active); +} + +#[test] +fn compute_lease_frees_at_finalize_and_reset() { + // The lease tracks "stream is ACTIVE", not "Stream handle exists": after + // finalize() or reset() the stream is no longer active, so another session + // may proceed WITHOUT waiting for the first Stream to drop. + let (Some(model_path), Some(pcm)) = (common::smoke_streaming_model(), common::smoke_audio()) + else { + return; + }; + let model = Model::load(&model_path).unwrap(); + let mut s1 = model.session().unwrap(); + let mut s2 = model.session().unwrap(); + let chunk = &pcm[..pcm.len().min(1600)]; + + { + let mut stream1 = s1 + .stream(&RunOptions::default(), &StreamOptions::default()) + .unwrap(); + stream1.feed(chunk).unwrap(); + stream1.finalize().unwrap(); + // stream1 is finalized but STILL IN SCOPE (not dropped); the lease must + // already be free for s2. + let stream2 = s2.stream(&RunOptions::default(), &StreamOptions::default()); + assert!( + stream2.is_ok(), + "finalize() must release the lease before drop: {stream2:?}" + ); + } + { + let mut stream1 = s1 + .stream(&RunOptions::default(), &StreamOptions::default()) + .unwrap(); + stream1.feed(chunk).unwrap(); + stream1.reset(); + let stream2 = s2.stream(&RunOptions::default(), &StreamOptions::default()); + assert!( + stream2.is_ok(), + "reset() must release the lease before drop: {stream2:?}" + ); + } +} + +#[test] +fn stream_family_extension_accepted_or_rejected() { + // moonshine-streaming accepts its own stream extension; a parakeet stream + // extension on the same model is rejected at begin (InvalidArgument). + let Some(model_path) = common::smoke_streaming_model() else { + return; + }; + let model = Model::load(&model_path).unwrap(); + let mut session = model.session().unwrap(); + + let opts = StreamOptions { + family: Some(StreamExtension::MoonshineStreaming( + MoonshineStreamingOptions { + min_decode_interval_ms: Some(200), + }, + )), + ..Default::default() + }; + let ok = session.stream(&RunOptions::default(), &opts); + assert!(ok.is_ok(), "moonshine ext should be accepted: {ok:?}"); + drop(ok); + + // Wrong-family extension is rejected. + let wrong = StreamOptions { + family: Some(StreamExtension::ParakeetStream(Default::default())), + ..Default::default() + }; + let err = session.stream(&RunOptions::default(), &wrong); + assert!(err.is_err(), "wrong-family ext should be rejected"); +} diff --git a/bindings/rust/transcribe-cpp/tests/transcribe.rs b/bindings/rust/transcribe-cpp/tests/transcribe.rs new file mode 100644 index 00000000..067cc95d --- /dev/null +++ b/bindings/rust/transcribe-cpp/tests/transcribe.rs @@ -0,0 +1,227 @@ +//! Model-gated transcription tests (port of the Python test_transcribe / +//! test_pcm / test_lifetime case list). Skip cleanly when the canary GGUF and +//! jfk.wav are not on disk. + +mod common; + +use std::sync::Arc; +use std::thread; + +use transcribe_cpp::{Error, Model, RunOptions, TimestampKind}; + +#[test] +fn transcribes_jfk_with_segments() { + let Some((model_path, pcm)) = common::smoke_fixtures("transcribes_jfk_with_segments") else { + return; + }; + let model = Model::load(&model_path).unwrap(); + assert!(!model.arch().is_empty(), "empty arch string"); + + let mut session = model.session().unwrap(); + let result = session.run(&pcm, &RunOptions::default()).unwrap(); + + let text = result.text.trim().to_lowercase(); + assert!(!text.is_empty(), "empty transcription"); + assert!( + text.contains("country"), + "unexpected transcription: {:?}", + result.text + ); + assert!(!result.segments.is_empty(), "no segments materialized"); +} + +#[test] +fn one_model_many_sessions() { + let Some((model_path, pcm)) = common::smoke_fixtures("one_model_many_sessions") else { + return; + }; + let model = Model::load(&model_path).unwrap(); + // Two sessions from one model, used serially. + for _ in 0..2 { + let mut s = model.session().unwrap(); + let r = s.run(&pcm, &RunOptions::default()).unwrap(); + assert!(r.text.to_lowercase().contains("country")); + } +} + +#[test] +fn batch_run_two_utterances() { + let Some((model_path, pcm)) = common::smoke_fixtures("batch_run_two_utterances") else { + return; + }; + let mut session = Model::load(&model_path).unwrap().session().unwrap(); + let inputs: Vec<&[f32]> = vec![&pcm, &pcm]; + let results = session.run_batch(&inputs, &RunOptions::default()).unwrap(); + assert_eq!(results.len(), 2); + for r in &results { + let t = r.as_ref().expect("utterance ok"); + assert!(t.text.to_lowercase().contains("country"), "{:?}", t.text); + assert!(!t.segments.is_empty()); + } +} + +#[test] +fn capabilities_and_identity() { + let Some((model_path, _)) = common::smoke_fixtures("capabilities_and_identity") else { + return; + }; + let model = Model::load(&model_path).unwrap(); + assert_eq!(model.arch(), "whisper", "arch: {}", model.arch()); + assert!(model.backend().to_lowercase().contains("cpu") || !model.backend().is_empty()); + let caps = model.capabilities(); + assert!(caps.native_sample_rate > 0, "{caps:?}"); +} + +#[test] +fn session_limits_are_sane() { + let Some((model_path, _)) = common::smoke_fixtures("session_limits_are_sane") else { + return; + }; + let session = Model::load(&model_path).unwrap().session().unwrap(); + let limits = session.limits().unwrap(); + assert!(limits.effective_n_ctx >= 0); + assert!(limits.effective_max_audio_ms >= 0); + assert!(limits.max_kv_bytes >= 0); +} + +#[test] +fn spec_decode_default_equals_disabled() { + // -1 (family default) and 0 (disabled) must agree for a non-spec family. + let Some((model_path, pcm)) = common::smoke_fixtures("spec_decode_default_equals_disabled") + else { + return; + }; + let mut session = Model::load(&model_path).unwrap().session().unwrap(); + let base = session + .run( + &pcm, + &RunOptions { + spec_k_drafts: -1, + ..Default::default() + }, + ) + .unwrap() + .text; + let nospec = session + .run( + &pcm, + &RunOptions { + spec_k_drafts: 0, + ..Default::default() + }, + ) + .unwrap() + .text; + assert_eq!(base, nospec); +} + +#[test] +fn empty_pcm_is_invalid_argument() { + let Some((model_path, _)) = common::smoke_fixtures("empty_pcm_is_invalid_argument") else { + return; + }; + let mut session = Model::load(&model_path).unwrap().session().unwrap(); + let err = session.run(&[], &RunOptions::default()).unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_)), "got {err:?}"); +} + +#[test] +fn requested_timestamps_populate_rows() { + let Some((model_path, pcm)) = common::smoke_fixtures("requested_timestamps_populate_rows") + else { + return; + }; + let model = Model::load(&model_path).unwrap(); + let caps = model.capabilities(); + // Request the finest granularity the model actually supports (a request + // finer than max_timestamp_kind correctly returns Unsupported, status 12). + if caps.max_timestamp_kind == TimestampKind::None { + return; + } + let mut session = model.session().unwrap(); + let result = session + .run( + &pcm, + &RunOptions { + timestamps: caps.max_timestamp_kind, + ..Default::default() + }, + ) + .unwrap(); + assert_ne!(result.timestamp_kind, TimestampKind::None); + assert!(!result.segments.is_empty()); + // The aligned segments carry non-degenerate time spans. + assert!(result.segments.iter().any(|s| s.t1_ms >= s.t0_ms)); +} + +#[test] +fn timestamps_finer_than_supported_is_unsupported() { + // Validates the Unsupported (status 12) mapping: requesting Token on a + // model whose max is coarser must error, not silently downgrade. + let Some((model_path, pcm)) = + common::smoke_fixtures("timestamps_finer_than_supported_is_unsupported") + else { + return; + }; + let model = Model::load(&model_path).unwrap(); + let caps = model.capabilities(); + if caps.max_timestamp_kind == TimestampKind::Token { + return; // already at the finest; nothing finer to ask for + } + let mut session = model.session().unwrap(); + let err = session + .run( + &pcm, + &RunOptions { + timestamps: TimestampKind::Token, + ..Default::default() + }, + ) + .unwrap_err(); + assert!(matches!(err, Error::Unsupported(_)), "got {err:?}"); +} + +#[test] +fn close_ordering_drop_model_before_session() { + // Rust ownership makes the C "model must outlive its sessions" contract + // automatic: the session holds an Arc to the model, so dropping the Model + // handle first is safe and the session keeps working. + let Some((model_path, pcm)) = + common::smoke_fixtures("close_ordering_drop_model_before_session") + else { + return; + }; + let model = Model::load(&model_path).unwrap(); + let mut session = model.session().unwrap(); + drop(model); // model handle gone; session's Arc keeps the native model alive + let r = session.run(&pcm, &RunOptions::default()).unwrap(); + assert!(r.text.to_lowercase().contains("country")); + drop(session); // native model freed here +} + +#[test] +fn shared_model_across_threads_serializes() { + // Model is Send+Sync; the per-model mutex serializes the compute path. Two + // threads each run on their own session of one shared model — they queue + // rather than race, and both succeed. + let Some((model_path, pcm)) = common::smoke_fixtures("shared_model_across_threads_serializes") + else { + return; + }; + let model = Arc::new(Model::load(&model_path).unwrap()); + let pcm = Arc::new(pcm); + let handles: Vec<_> = (0..2) + .map(|_| { + let model = Arc::clone(&model); + let pcm = Arc::clone(&pcm); + thread::spawn(move || { + let mut s = model.session().unwrap(); + s.run(&pcm, &RunOptions::default()).unwrap().text + }) + }) + .collect(); + for h in handles { + let text = h.join().unwrap(); + assert!(text.to_lowercase().contains("country"), "{text}"); + } +} diff --git a/bindings/rust/xtask/Cargo.toml b/bindings/rust/xtask/Cargo.toml new file mode 100644 index 00000000..65ee33f0 --- /dev/null +++ b/bindings/rust/xtask/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xtask" +version = "0.0.0" +edition = "2021" +publish = false +# Dev tooling only: not shipped, so it does not carry the workspace MSRV. + +[dependencies] +bindgen = "0.72" diff --git a/bindings/rust/xtask/src/main.rs b/bindings/rust/xtask/src/main.rs new file mode 100644 index 00000000..16285b2c --- /dev/null +++ b/bindings/rust/xtask/src/main.rs @@ -0,0 +1,110 @@ +//! Workspace automation for the Rust bindings. +//! +//! `cargo xtask bindgen` regenerate the committed FFI bindings +//! `cargo xtask bindgen --check` fail if the committed bindings are stale +//! +//! The committed output lives at `bindings/rust/sys/src/transcribe_sys.rs`. +//! It is generated from `include/transcribe/extensions.h` (the flattened +//! public surface) and pinned to `include/transcribe.abihash`: the generated +//! header embeds the hash, so ANY public-ABI change moves either the bindgen +//! output or the embedded hash, and the `--check` diff goes red. This is the +//! Rust arm of the cross-binding drift gate (notes/bindings-requirements.md §2). +//! +//! Comments are disabled and a fixed enum style is used so the output is +//! deterministic across libclang versions; rustfmt formats it for review. + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +const GENERATED: &str = "bindings/rust/sys/src/transcribe_sys.rs"; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let check = args.iter().any(|a| a == "--check"); + match args.first().map(String::as_str) { + Some("bindgen") => run_bindgen(check), + _ => { + eprintln!("usage: cargo xtask bindgen [--check]"); + ExitCode::from(2) + } + } +} + +fn repo_root() -> PathBuf { + // CARGO_MANIFEST_DIR = /bindings/rust/xtask + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .expect("repo root above bindings/rust/xtask") + .to_path_buf() +} + +fn run_bindgen(check: bool) -> ExitCode { + let root = repo_root(); + let generated = generate(&root); + let target = root.join(GENERATED); + + if check { + let committed = std::fs::read_to_string(&target).unwrap_or_default(); + if committed == generated { + println!("xtask: {GENERATED} is up to date"); + ExitCode::SUCCESS + } else { + eprintln!( + "xtask: {GENERATED} is STALE.\n\ + The public header or its ABI digest changed. Regenerate with:\n\ + \n cargo xtask bindgen\n\n\ + and review + commit the result." + ); + ExitCode::FAILURE + } + } else { + std::fs::write(&target, generated).expect("write generated bindings"); + println!("xtask: wrote {GENERATED}"); + ExitCode::SUCCESS + } +} + +fn generate(root: &Path) -> String { + let include = root.join("include"); + let header = include.join("transcribe/extensions.h"); + let abihash = std::fs::read_to_string(include.join("transcribe.abihash")) + .expect("read include/transcribe.abihash"); + let abihash = abihash.trim(); + + let bindings = bindgen::Builder::default() + .header(header.to_string_lossy()) + .clang_arg(format!("-I{}", include.display())) + // Determinism: comment extraction varies by libclang version, so the + // committed file would drift across machines if we kept doc comments. + .generate_comments(false) + // FFI-safe enums: a NewType is a transparent integer wrapper, so an + // out-of-range value from C is never UB (the safe crate validates at + // the public boundary — the "never trust transmute" rule). + .default_enum_style(bindgen::EnumVariation::NewType { + is_bitfield: false, + is_global: false, + }) + .prepend_enum_name(false) + // Only emit declarations from our own headers (skip stdint/stddef). + .allowlist_file(r".*/include/transcribe\.h") + .allowlist_file(r".*/include/transcribe/.*\.h") + // Compile-time layout assertions (free belt-and-suspenders; the + // per-field check is otherwise waived for bindgen). + .layout_tests(true) + .generate() + .expect("bindgen generate"); + + let banner = format!( + "// @generated by `cargo xtask bindgen` from include/transcribe/extensions.h\n\ + // DO NOT EDIT BY HAND. Regenerate: `cargo xtask bindgen`.\n\ + // Pinned to include/transcribe.abihash = {abihash}\n\ + \n\ + /// The public-ABI digest these bindings were generated against\n\ + /// (sha256/16 over the normalized FFI surface). The load-time version\n\ + /// gate and the CI drift check both anchor on this value.\n\ + pub const PUBLIC_HEADER_HASH: &str = \"{abihash}\";\n\ + \n" + ); + format!("{banner}{bindings}") +} diff --git a/cmake/transcribe-install.cmake b/cmake/transcribe-install.cmake index a4152a51..b7c7225b 100644 --- a/cmake/transcribe-install.cmake +++ b/cmake/transcribe-install.cmake @@ -29,6 +29,37 @@ include(GNUInstallDirs) include("${CMAKE_CURRENT_LIST_DIR}/transcribe-backend-kinds.cmake") +# Resolve the absolute path of the static zlib archive find_package(ZLIB) gave +# us. Used on Windows, where vcpkg spells the lib `zlib.lib` (not `z`) and a +# bare `-l z` is unsatisfiable: we stage the real archive into the lib dir and +# link it by name. Tries the imported target's per-config location first, then +# the configuration-less location, then the raw ZLIB_LIBRARY cache var. +function(_transcribe_zlib_archive out) + set(_loc "") + # src/CMakeLists.txt stashes this from the find_package(ZLIB) scope: the + # imported target and ZLIB_LIBRARY are NOT visible at this top-level scope + # (subdirectory-local target / normal var), so prefer the captured cache var. + if(TRANSCRIBE_ZLIB_ARCHIVE) + set(_loc "${TRANSCRIBE_ZLIB_ARCHIVE}") + endif() + if(NOT _loc AND TARGET ZLIB::ZLIB) + get_target_property(_loc ZLIB::ZLIB IMPORTED_LOCATION_RELEASE) + if(NOT _loc) + get_target_property(_loc ZLIB::ZLIB IMPORTED_LOCATION) + endif() + endif() + if(NOT _loc AND ZLIB_LIBRARY) + set(_loc "${ZLIB_LIBRARY}") + endif() + # Only a single real archive on disk is stage-able (a debug/optimized + # keyworded list or a bare -l name is not). + if(_loc AND EXISTS "${_loc}") + set(${out} "${_loc}" PARENT_SCOPE) + else() + set(${out} "" PARENT_SCOPE) + endif() +endfunction() + # --- headers + the ABI digest ------------------------------------------------ install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/include/transcribe.h" @@ -75,6 +106,13 @@ if(NOT TRANSCRIBE_BUILD_SHARED) # The archives are C++; the consumer may be C or Rust. if(APPLE) list(APPEND _system_libs c++ m) + elseif(WIN32) + # MSVC links the CRT and the C++ runtime implicitly. ggml-cpu reads the + # registry for CPU feature detection (RegOpenKeyEx/RegCloseKey), so the + # static consumer must pull in advapi32 — ggml's own CMake never links + # it (it rides MSVC default-lib auto-linking the manifest reconstruction + # loses). + list(APPEND _system_libs advapi32) elseif(UNIX) list(APPEND _system_libs stdc++ m pthread dl) endif() @@ -84,7 +122,23 @@ if(NOT TRANSCRIBE_BUILD_SHARED) get_target_property(_transcribe_links transcribe LINK_LIBRARIES) foreach(_dep IN LISTS _transcribe_links) if(_dep STREQUAL "ZLIB::ZLIB") - list(APPEND _system_libs z) + if(WIN32) + # MSVC/vcpkg name the static lib `zlib.lib`, not `z`, so the + # POSIX `-l z` is unsatisfiable. Stage the resolved archive next + # to libtranscribe (keeping the lib dir self-contained) and link + # it by name through the same search path as the rest of the set. + _transcribe_zlib_archive(_zlib_archive) + if(_zlib_archive) + install(FILES "${_zlib_archive}" + DESTINATION ${CMAKE_INSTALL_LIBDIR}) + get_filename_component(_zlib_name "${_zlib_archive}" NAME_WE) + list(APPEND _libraries "${_zlib_name}") + else() + list(APPEND _system_libs zlib) # last resort: a bare name + endif() + else() + list(APPEND _system_libs z) + endif() elseif(_dep STREQUAL "OpenMP::OpenMP_CXX") list(APPEND _link_flags -fopenmp) elseif(_dep MATCHES "^-framework (.+)$") diff --git a/include/transcribe.abihash b/include/transcribe.abihash index 516731e7..812802c2 100644 --- a/include/transcribe.abihash +++ b/include/transcribe.abihash @@ -1 +1 @@ -0007af60bfcecf7e +fe9ed398c408e5d9 diff --git a/include/transcribe.h b/include/transcribe.h index 8e030989..040b2c82 100644 --- a/include/transcribe.h +++ b/include/transcribe.h @@ -173,7 +173,14 @@ #ifndef TRANSCRIBE_API # if defined(_WIN32) && !defined(__GNUC__) -# ifdef TRANSCRIBE_BUILD +# if defined(TRANSCRIBE_STATIC) + /* Static archive (lib build or static consumer): no dllimport/export. + * A consumer linking the static transcribe.lib must NOT see dllimport + * or the linker chases __imp_* thunks the archive never provides + * (LNK2019). The static `transcribe` CMake target propagates this + * PUBLIC; non-CMake static consumers define it themselves. */ +# define TRANSCRIBE_API +# elif defined(TRANSCRIBE_BUILD) # define TRANSCRIBE_API __declspec(dllexport) # else # define TRANSCRIBE_API __declspec(dllimport) @@ -745,6 +752,20 @@ typedef enum { TRANSCRIBE_API transcribe_status transcribe_init_backends( const char * artifact_dir); +/* + * Package-local default for dynamic-backend builds. + * + * In a dynamic-backend build, resolves the directory containing the loaded + * libtranscribe itself and delegates to transcribe_init_backends(dir). This + * keeps the search package-local: it does NOT scan the executable directory, + * current working directory, or system paths. Ship backend modules next to + * libtranscribe for this helper to find them. + * + * In non-dynamic builds the compute backends are compiled in, so this is a + * no-op returning TRANSCRIBE_OK. + */ +TRANSCRIBE_API transcribe_status transcribe_init_backends_default(void); + /* * Number of compute devices currently registered with the runtime * (compiled-in backends plus any modules loaded by diff --git a/include/transcribe/whisper.h b/include/transcribe/whisper.h index cf663c0b..5b68adad 100644 --- a/include/transcribe/whisper.h +++ b/include/transcribe/whisper.h @@ -23,6 +23,8 @@ #include "transcribe.h" +#include /* INFINITY (MSVC rejects a constant 1.0/0.0 — error C2124) */ + #ifdef __cplusplus extern "C" { #endif @@ -42,8 +44,8 @@ extern "C" { * Check shape "X > thold" → disable with +INF (_THOLD_DISABLED). * Check shape "X < thold" → disable with -INF (_LOGPROB_DISABLED). */ -#define TRANSCRIBE_WHISPER_THOLD_DISABLED ((float) (1.0 / 0.0)) /* +INF */ -#define TRANSCRIBE_WHISPER_LOGPROB_DISABLED ((float) (-1.0 / 0.0)) /* -INF */ +#define TRANSCRIBE_WHISPER_THOLD_DISABLED ((float) INFINITY) /* +INF */ +#define TRANSCRIBE_WHISPER_LOGPROB_DISABLED ((float) -INFINITY) /* -INF */ /* * How an initial prompt composes with condition_on_prev_tokens on diff --git a/scripts/ci/rust_package_audit.py b/scripts/ci/rust_package_audit.py new file mode 100644 index 00000000..8a0aed41 --- /dev/null +++ b/scripts/ci/rust_package_audit.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Audit the `transcribe-cpp-sys` crate tarball before it can be published. + + python3 scripts/ci/rust_package_audit.py + +The sys crate carries the whole C++ tree (it builds libtranscribe from source +via build.rs), so the same hazards as the Python sdist apply: ship too little +and a from-source build breaks; ship too much and multi-GB model/dump/report +trees leak into the registry. This is the Rust twin of the sdist content audit +(pyproject `sdist.exclude` + its CI assertion). + +Three gates, all from `cargo package`: + + - REQUIRED prefixes present (the vendored sources a build needs) + - FORBIDDEN prefixes absent (models/dumps/reports/etc. — never in a package) + - compressed size under crates.io's 10 MB default cap + +`cargo package` enumerates from the git index, so run it where the crate +sources are committed (CI checkout) or staged. + +Stdlib only. Exit 0 on a clean audit, 1 on any violation. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +CRATE = "transcribe-cpp-sys" + +# Sources a from-scratch build needs — at least one packaged file under each. +REQUIRED_PREFIXES = [ + "CMakeLists.txt", + "include/", + "src/", + "cmake/", + "ggml/", + "bindings/rust/sys/build.rs", + "bindings/rust/sys/src/lib.rs", + "bindings/rust/sys/src/transcribe_sys.rs", +] + +# Trees that must NEVER reach the registry. `tests/`, `dumps/`, and `reports/` +# are git-TRACKED, so only the manifest's `include` allowlist keeps them out — +# this gate is what proves the allowlist is doing its job. +FORBIDDEN_PREFIXES = [ + "models/", + "dumps/", + "reports/", + "tests/", + "samples/", + "benchmarks/", + "docs/", + "notes/", + "refs/", + "build/", + "dist/", + "target/", + ".github/", + "ggml/examples/", + "ggml/tests/", +] + +MAX_COMPRESSED_BYTES = 10 * 1024 * 1024 # crates.io default cap + + +def package_file_list() -> list[str]: + out = subprocess.run( + ["cargo", "package", "--list", "--package", CRATE, "--allow-dirty"], + cwd=REPO, + check=True, + capture_output=True, + text=True, + ) + return [line.strip() for line in out.stdout.splitlines() if line.strip()] + + +def build_crate() -> Path: + subprocess.run( + ["cargo", "package", "--no-verify", "--package", CRATE, "--allow-dirty"], + cwd=REPO, + check=True, + ) + crates = sorted((REPO / "target" / "package").glob(f"{CRATE}-*.crate")) + if not crates: + sys.exit("audit FAILED: no .crate produced") + return crates[-1] + + +def main() -> int: + files = package_file_list() + print(f"cargo packaged {len(files)} files") + failures: list[str] = [] + + for prefix in REQUIRED_PREFIXES: + if not any(f == prefix or f.startswith(prefix) for f in files): + failures.append(f"MISSING required path: {prefix}") + + for prefix in FORBIDDEN_PREFIXES: + hits = [f for f in files if f.startswith(prefix)] + if hits: + failures.append( + f"FORBIDDEN path present ({prefix}): " + f"{hits[0]}{' …' if len(hits) > 1 else ''} ({len(hits)} files)" + ) + + crate = build_crate() + size = crate.stat().st_size + print(f"crate: {crate.name} — {size / 1024 / 1024:.1f} MiB compressed") + if size > MAX_COMPRESSED_BYTES: + failures.append( + f"crate is {size / 1024 / 1024:.1f} MiB, over the " + f"{MAX_COMPRESSED_BYTES / 1024 / 1024:.0f} MiB crates.io cap" + ) + + if failures: + print("\nrust package audit FAILED:", file=sys.stderr) + for f in failures: + print(f" - {f}", file=sys.stderr) + return 1 + + print("rust package audit ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/rust_packed_smoke.py b/scripts/ci/rust_packed_smoke.py new file mode 100644 index 00000000..82d2ea17 --- /dev/null +++ b/scripts/ci/rust_packed_smoke.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Build the shipped `transcribe-cpp-sys` tarball and prove it transcribes. + + python3 scripts/ci/rust_packed_smoke.py [--model M.gguf] [--audio A.wav] + +The Rust twin of wheel_smoke.py and the sdist compile-and-transcribe gate +(requirements §4, "test the shipped artifact"): never the build tree, always +the packed crate. `transcribe-cpp-sys` is the load-bearing artifact — it carries +the whole C++ tree and builds libtranscribe from source via build.rs — so this +packs it, extracts it, and builds a throwaway consumer that compiles the native +library from NOTHING but the packed sources and then transcribes through the +real `transcribe-cpp` safe API. A tarball missing a vendored source, a broken +build.rs, or a bad link manifest is a red gate here, not a user's first build. + +Why only the sys crate is packed: the safe `transcribe-cpp` crate depends on +`transcribe-cpp-sys = "0.0.1"`, and pre-publish the registry only has the 0.0.0 +name-reservation placeholder, so `cargo package -p transcribe-cpp` cannot +resolve it (this is the inherent sys/safe publish ordering — sys lands first). +We therefore STAGE the safe crate (its source with the path stripped from the +sys dep, exactly what `cargo package` would emit) and redirect its sys dep to +the packed tarball via `[patch.crates-io]`. The safe crate's own registry +packaging is verified at release time, once the published sys crate exists. + +With no model (`--model` / `$TRANSCRIBE_SMOKE_MODEL` absent) it degrades to an +install-and-link check: the consumer just prints `transcribe_cpp::version()`. + +Stdlib only. Exit 0 on success, non-zero on any failure. +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import textwrap +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +SAFE_CRATE_DIR = REPO / "bindings" / "rust" / "transcribe-cpp" + +CONSUMER_MAIN = """\ +//! Throwaway consumer of the PACKED transcribe-cpp-sys crate — what +//! `cargo add transcribe-cpp` + a five-line transcription does, but with the +//! native library built from the shipped tarball's vendored sources. Generated +//! by scripts/ci/rust_packed_smoke.py. +use transcribe_cpp::{Model, RunOptions}; + +fn main() -> Result<(), Box> { + let version = transcribe_cpp::version(); + println!("packed transcribe-cpp {version}"); + assert!(!version.is_empty(), "empty version from the packed crate"); + + let (Ok(model), Ok(audio)) = (std::env::var("SMOKE_MODEL"), std::env::var("SMOKE_AUDIO")) + else { + println!("no model -> install + link check only (no transcription)"); + return Ok(()); + }; + + let mut reader = hound::WavReader::open(&audio)?; + let pcm: Vec = reader + .samples::() + .map(|s| s.map(|v| v as f32 / 32768.0)) + .collect::>()?; + + let mut session = Model::load(&model)?.session()?; + let text = session.run(&pcm, &RunOptions::default())?.text; + println!("transcript: {}", text.trim()); + assert!( + text.to_lowercase().contains("country"), + "unexpected transcript: {text:?}" + ); + println!("ok: native lib built from the packed sys tarball and transcribed"); + Ok(()) +} +""" + + +def pack_sys() -> Path: + """Pack transcribe-cpp-sys (packaging only) and return its .crate path.""" + subprocess.run( + ["cargo", "package", "--no-verify", "--allow-dirty", "-p", "transcribe-cpp-sys"], + cwd=REPO, + check=True, + ) + crates = sorted((REPO / "target" / "package").glob("transcribe-cpp-sys-*.crate")) + if not crates: + sys.exit("packed-smoke FAILED: no transcribe-cpp-sys .crate produced") + return crates[-1] + + +def extract(crate: Path, dest: Path) -> Path: + """Extract a .crate (a .tar.gz with one top-level pkg-version/ dir).""" + dest.mkdir(parents=True, exist_ok=True) + with tarfile.open(crate) as tar: + tar.extractall(dest, filter="data") # filter arg: Python >= 3.12 + return next(d for d in dest.iterdir() if d.is_dir()) + + +def stage_safe_crate(dest: Path) -> Path: + """Copy the safe crate and strip the `path` from its sys dependency so the + consumer's [patch.crates-io] can redirect it to the packed tarball — exactly + the transform `cargo package` applies to a path+version dependency.""" + dest.mkdir(parents=True, exist_ok=True) + for item in ["src", "build.rs", "Cargo.toml", "README.md", "LICENSE"]: + src = SAFE_CRATE_DIR / item + if src.is_dir(): + shutil.copytree(src, dest / item) + elif src.is_file(): + shutil.copy2(src, dest / item) + cargo_toml = dest / "Cargo.toml" + text = cargo_toml.read_text() + # `transcribe-cpp-sys = { version = "0.0.1", path = "../../.." }` + # -> `transcribe-cpp-sys = { version = "0.0.1" }` + patched = re.sub( + r'(transcribe-cpp-sys\s*=\s*\{[^}]*?),\s*path\s*=\s*"[^"]*"', + r"\1", + text, + ) + if patched == text: + sys.exit("packed-smoke FAILED: could not strip the sys path dep from the safe Cargo.toml") + cargo_toml.write_text(patched) + return dest + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--model", default=os.environ.get("TRANSCRIBE_SMOKE_MODEL")) + ap.add_argument( + "--audio", + default=os.environ.get("TRANSCRIBE_SMOKE_AUDIO") or str(REPO / "samples/jfk.wav"), + ) + args = ap.parse_args() + + sys_crate = pack_sys() + print(f"packed: {sys_crate.name}") + + staging = Path(tempfile.mkdtemp(prefix="rust-packed-smoke-")) + sys_dir = extract(sys_crate, staging / "sys") + safe_dir = stage_safe_crate(staging / "safe") + + consumer = staging / "consumer" + (consumer / "src").mkdir(parents=True) + (consumer / "Cargo.toml").write_text( + textwrap.dedent( + f"""\ + [package] + name = "packed-smoke" + version = "0.0.0" + edition = "2021" + + [dependencies] + transcribe-cpp = {{ path = "{safe_dir.as_posix()}" }} + hound = "3" + + # The sys crate is not on the registry at this version yet; redirect + # the safe crate's registry dep to the PACKED tarball under test. + [patch.crates-io] + transcribe-cpp-sys = {{ path = "{sys_dir.as_posix()}" }} + + [workspace] + """ + ) + ) + (consumer / "src" / "main.rs").write_text(CONSUMER_MAIN) + + env = os.environ.copy() + model = args.model + if model and Path(model).is_file() and Path(args.audio).is_file(): + env["SMOKE_MODEL"] = model + env["SMOKE_AUDIO"] = args.audio + print(f"transcribing {Path(args.audio).name} with {Path(model).name}") + else: + env.pop("SMOKE_MODEL", None) + env.pop("SMOKE_AUDIO", None) + print("no model on disk -> install + link check only") + + # The native lib is always built in Release by build.rs; the consumer's own + # (debug) build is just glue, so no --release needed for a fast smoke. + subprocess.run( + ["cargo", "run", "--manifest-path", str(consumer / "Cargo.toml")], + check=True, + env=env, + ) + print("rust packed smoke ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 80d27a62..3c0ce8cd 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -158,6 +158,23 @@ target_compile_definitions(transcribe TRANSCRIBE_COMMIT="${TRANSCRIBE_BUILD_COMMIT}" ) +if(TRANSCRIBE_GGML_BACKEND_DL) + target_compile_definitions(transcribe PRIVATE TRANSCRIBE_GGML_BACKEND_DL) + if(CMAKE_DL_LIBS) + target_link_libraries(transcribe PRIVATE ${CMAKE_DL_LIBS}) + endif() +endif() + +# Static posture: tell transcribe.h to drop the Windows __declspec(dllimport/ +# export) decoration. PUBLIC so it reaches both this archive's own TUs and any +# CMake consumer linking `transcribe` (C++ tests/examples) — without it a static +# MSVC consumer that includes transcribe.h links against __imp_* thunks the +# archive never provides (LNK2019). Bindgen/Python FFIs carry no __declspec and +# are unaffected. No-op off Windows/MSVC (the header guard is _WIN32-only). +if(NOT TRANSCRIBE_BUILD_SHARED) + target_compile_definitions(transcribe PUBLIC TRANSCRIBE_STATIC) +endif() + # ggml is the runtime substrate. Linked PRIVATE because the public header # stays ggml-free (callers never include ). target_link_libraries(transcribe @@ -220,6 +237,20 @@ set_target_properties(transcribe PROPERTIES find_package(ZLIB REQUIRED) target_link_libraries(transcribe PRIVATE ZLIB::ZLIB) +# Export the resolved zlib archive for the install link manifest +# (cmake/transcribe-install.cmake, top-level scope). On Windows the static +# consumer must link zlib by file (vcpkg spells it zlib.lib, not z), but +# ZLIB::ZLIB is a subdirectory-local imported target and ZLIB_LIBRARY is a +# normal var in THIS scope — neither is visible there, so stash the path now. +if(WIN32) + set(_transcribe_zlib "${ZLIB_LIBRARY_RELEASE}") + if(NOT _transcribe_zlib) + set(_transcribe_zlib "${ZLIB_LIBRARY}") + endif() + set(TRANSCRIBE_ZLIB_ARCHIVE "${_transcribe_zlib}" + CACHE INTERNAL "resolved zlib archive for the install link manifest") +endif() + if(WIN32) # Windows toolchains hide M_PI and friends behind _USE_MATH_DEFINES # (used by the mel frontends and Whisper's bin loader). MSVC and MinGW diff --git a/src/transcribe.cpp b/src/transcribe.cpp index ea032ba3..4b151384 100644 --- a/src/transcribe.cpp +++ b/src/transcribe.cpp @@ -37,6 +37,18 @@ #include "arch/whisper/bin_load.h" +#if defined(TRANSCRIBE_GGML_BACKEND_DL) && defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#elif defined(TRANSCRIBE_GGML_BACKEND_DL) +#include +#endif + #include // MSVC's has the _S_IFDIR mode bits but not the POSIX @@ -713,7 +725,85 @@ static bool valid_stream_commit_policy(int policy) { // unnamed namespace; gcc gives them internal linkage, which strips them // from the shared library on Linux (undefined references at link). +// ggml's MSVC timer (ggml_time_us/ms in ggml.c) divides QueryPerformanceCounter +// by a global frequency that ggml_time_init() fills in from +// QueryPerformanceFrequency. ggml_init() calls it on first use, but every +// family times its own load (`ggml_time_us()` at the top of _load) +// BEFORE any ggml context exists — so on Windows/MSVC the divisor is still 0 +// and model load faults with STATUS_INTEGER_DIVIDE_BY_ZERO (0xC0000094). POSIX +// ggml_time_* read clock_gettime directly (no divisor) so the missing init is +// silently harmless there; arm64 masks integer ÷0 as 0. ggml.h documents +// ggml_time_init() as "call this once at the beginning of the program" — do +// exactly that at the first public entry point. +static void ensure_ggml_time_init() { + static std::once_flag once; + std::call_once(once, [] { ggml_time_init(); }); +} + +#if defined(TRANSCRIBE_GGML_BACKEND_DL) +static std::filesystem::path library_self_dir() { +#if defined(_WIN32) + HMODULE module = nullptr; + const auto flags = + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT; + if (!GetModuleHandleExW( + flags, + reinterpret_cast(&transcribe_init_backends_default), + &module)) + { + return {}; + } + + std::wstring path(1024, L'\0'); + for (;;) { + const DWORD n = GetModuleFileNameW( + module, path.data(), static_cast(path.size())); + if (n == 0) { + return {}; + } + if (n < path.size()) { + path.resize(n); + break; + } + if (path.size() >= 32768) { + return {}; + } + path.resize(path.size() * 2); + } + return std::filesystem::path(path).parent_path(); +#else + Dl_info info {}; + if (dladdr(reinterpret_cast(&transcribe_init_backends_default), &info) == 0 || + info.dli_fname == nullptr || info.dli_fname[0] == '\0') + { + return {}; + } + std::filesystem::path path(info.dli_fname); + std::error_code ec; + auto canonical = std::filesystem::weakly_canonical(path, ec); + if (ec) { + canonical = std::filesystem::absolute(path, ec); + } + if (ec) { + canonical = path; + } + return canonical.parent_path(); +#endif +} + +static std::string path_for_c_api(const std::filesystem::path & path) { +#if defined(_WIN32) + const auto u8 = path.u8string(); + return std::string(u8.begin(), u8.end()); +#else + return path.string(); +#endif +} +#endif + extern "C" transcribe_status transcribe_init_backends(const char * artifact_dir) { + ensure_ggml_time_init(); if (artifact_dir == nullptr || artifact_dir[0] == '\0') { return TRANSCRIBE_ERR_INVALID_ARG; } @@ -784,6 +874,23 @@ extern "C" transcribe_status transcribe_init_backends(const char * artifact_dir) return TRANSCRIBE_OK; } +extern "C" transcribe_status transcribe_init_backends_default(void) { + ensure_ggml_time_init(); +#if !defined(TRANSCRIBE_GGML_BACKEND_DL) + return TRANSCRIBE_OK; +#else + const auto dir = library_self_dir(); + if (dir.empty()) { + transcribe::log_msg(TRANSCRIBE_LOG_LEVEL_ERROR, + "transcribe_init_backends_default: could not resolve the " + "directory containing libtranscribe"); + return TRANSCRIBE_ERR_BACKEND; + } + const auto s = path_for_c_api(dir); + return transcribe_init_backends(s.c_str()); +#endif +} + extern "C" int transcribe_backend_device_count(void) { return static_cast(ggml_backend_dev_count()); } @@ -1240,6 +1347,9 @@ extern "C" transcribe_status transcribe_model_load_file( const struct transcribe_model_load_params * params, struct transcribe_model ** out_model) { + // Set up ggml's timer before any family's load-timing ggml_time_us() runs + // (Windows/MSVC ÷0 otherwise — see ensure_ggml_time_init). + ensure_ggml_time_init(); if (out_model == nullptr) { return TRANSCRIBE_ERR_INVALID_ARG; } diff --git a/tests/whisper_e2e_smoke.cpp b/tests/whisper_e2e_smoke.cpp index 6d24d99f..8b0a0e7a 100644 --- a/tests/whisper_e2e_smoke.cpp +++ b/tests/whisper_e2e_smoke.cpp @@ -13,10 +13,13 @@ #include +#include +#include #include #include #include #include +#include #include #ifndef TRANSCRIBE_TEST_SAMPLES_DIR