diff --git a/.github/actions/fetch-canary/action.yml b/.github/actions/fetch-canary/action.yml new file mode 100644 index 00000000..7b21246c --- /dev/null +++ b/.github/actions/fetch-canary/action.yml @@ -0,0 +1,50 @@ +name: fetch-canary +description: > + Cache + fetch the private canary GGUFs and export TRANSCRIBE_SMOKE_MODEL / + TRANSCRIBE_SMOKE_STREAMING_MODEL. Skips cleanly when hf-token is empty + (forks have no secret): the model tests then skip, exactly as before this + action existed. Always fetches BOTH canaries (~95 MB total) under one cache + key — a per-consumer subset would let one job save the shared key with only + its subset in it, and every other consumer would re-download forever + (exact-key hits are never re-saved). Requires a checkout (composite actions + resolve from the repo) and uv on PATH (uvx fetches via huggingface_hub). + +inputs: + hf-token: + description: HF token (pass secrets.HF_TOKEN; empty means skip cleanly) + required: false + default: "" + model-path-prefix: + description: > + Prefix for the exported env paths. Default is the workspace; pass + /project for cibuildwheel's Linux containers (the project bind mount). + required: false + default: "" + +runs: + using: composite + steps: + - name: Canary model cache (avoid HF rate limits — 429s were real) + if: inputs.hf-token != '' + uses: actions/cache@v5 + with: + path: canary + key: canary-models-v1 + - name: Fetch canary models (cache miss only) + if: inputs.hf-token != '' + shell: bash + env: + HF_TOKEN: ${{ inputs.hf-token }} + run: | + [ -f canary/whisper-tiny-Q5_K_M.gguf ] || \ + uvx --from huggingface_hub hf download handy-computer/whisper-tiny-gguf \ + whisper-tiny-Q5_K_M.gguf --local-dir canary + [ -f canary/moonshine-streaming-tiny-Q8_0.gguf ] || \ + uvx --from huggingface_hub hf download handy-computer/moonshine-streaming-tiny-gguf \ + moonshine-streaming-tiny-Q8_0.gguf --local-dir canary + # GITHUB_WORKSPACE (not bash's $PWD) so Windows exports a path + # Python can open rather than an MSYS one. + prefix='${{ inputs.model-path-prefix }}' + if [ -z "$prefix" ]; then prefix="$GITHUB_WORKSPACE"; fi + echo "TRANSCRIBE_SMOKE_MODEL=$prefix/canary/whisper-tiny-Q5_K_M.gguf" >> "$GITHUB_ENV" + echo "TRANSCRIBE_SMOKE_STREAMING_MODEL=$prefix/canary/moonshine-streaming-tiny-Q8_0.gguf" >> "$GITHUB_ENV" diff --git a/.github/workflows/cuda-windows.yml b/.github/workflows/cuda-windows.yml new file mode 100644 index 00000000..f7627c83 --- /dev/null +++ b/.github/workflows/cuda-windows.yml @@ -0,0 +1,125 @@ +name: cuda-windows + +# Builds + packaging-smokes the transcribe-cpp-native-cu12 wheel for +# win_amd64 (plan: Windows CUDA is COMMITTED release scope, user decision +# 2026-06-11). Same design as the Linux cu12 wheel built on Modal: +# - the CUDA runtime is NEVER vendored: cudart/cublas come from the +# nvidia-*-cu12 runtime wheels (win_amd64 wheels exist — the PyTorch +# layout), preloaded by the package's prepare() hook, which knows the +# Windows DLL names (cudart64_12.dll, cublasLt64_12.dll, +# cublas64_12.dll under nvidia//bin/); nvcuda.dll is the driver. +# - the ggml-vulkan module is bundled alongside CUDA (superset-of-default +# rationale in bindings/python-native-cu12/pyproject.toml: a [cu12] +# install on an AMD/Intel-GPU Windows box must not silently lose Vulkan +# acceleration), built against the LunarG SDK like wheel-windows. +# - delvewheel repair EXCLUDES the CUDA DLLs + the driver + vulkan-1.dll, +# mirroring auditwheel's excludes on Linux. +# - GPU-less smoke: cu12 provider selected, declared backends +# {cuda, vulkan, cpu}, the cuda/vulkan modules quietly absent without a +# driver, CPU transcribes, full pytest suite. There is no Windows NVIDIA +# hardware in the fleet, so the RUNTIME evidence is carried by the Linux +# T4 smoke (same module mechanism, same prepare() contract) — documented +# gap. +# +# Trimmed cadence (user decision): NOT per PR push. Releases (tags) and +# manual dispatch only — nvcc over ggml-cuda × 5 arches is the heaviest +# build in the project. +# +# CUDA 12.9 to match the Linux cu12 provider (scripts/ci/modal_cuda_build.py +# pins cuda-toolkit-12-9; the nvidia runtime-wheel floors in the cu12 +# pyproject cover this minor). + +on: + # Called by publish.yml on release tags (which owns the tag trigger now — + # this workflow no longer listens to tags itself, so a release builds + # everything in ONE run and publishes those exact artifacts). + workflow_call: + workflow_dispatch: + +env: + PYTHONUTF8: "1" # hf CLI prints ✓; Windows cp1252 console chokes + +jobs: + cuda-wheel-windows: + # 16vcpu, deliberately breaking the 2vcpu rule: per-vcpu-minute billing + # makes the cost the same, and on 2vcpu this build (245 .cu × 5 arches + # under MSVC+nvcc) would risk the job time cap. It runs on releases and + # dispatch only, so the burn is rare and bounded. + runs-on: blacksmith-16vcpu-windows-2025 + timeout-minutes: 180 + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + CMAKE_GENERATOR: Ninja + # LunarG prunes old SDK downloads — when bumping, verify the URL exists. + # Keep in lockstep with wheel-windows (python-wheels.yml). + VULKAN_VERSION: "1.4.350.0" + steps: + - uses: actions/checkout@v6 + # vcvars for the whole job: nvcc needs cl.exe, and the Ninja generator + # needs the MSVC environment (same reasoning as wheel-windows). + # (still node20 upstream — no node24 release yet; runners force node24 from + # 2026-06-16, which is fine for this env-setup action) + - uses: ilammy/msvc-dev-cmd@v1 + - name: Install Vulkan SDK ${{ env.VULKAN_VERSION }} (glslc for the ggml-vulkan module) + run: | + curl.exe -o "$env:RUNNER_TEMP\vulkan_sdk.exe" -L "https://sdk.lunarg.com/sdk/download/${env:VULKAN_VERSION}/windows/vulkan_sdk.exe" + & "$env:RUNNER_TEMP\vulkan_sdk.exe" --accept-licenses --default-answer --confirm-command install + Add-Content $env:GITHUB_ENV "VULKAN_SDK=C:\VulkanSDK\${env:VULKAN_VERSION}" + Add-Content $env:GITHUB_PATH "C:\VulkanSDK\${env:VULKAN_VERSION}\bin" + - name: Install CUDA toolkit 12.9 (network installer, lean subset) + # Caching disabled on purpose: the first run (v0.2.27, caches on) + # died in 2.4s with a swallowed error surfaced as a cache-path + # failure — Blacksmith intercepts the Actions cache service and the + # action's @actions/cache path didn't survive it. The lane is + # tag/dispatch-rare; a fresh network install per run is fine. + uses: Jimver/cuda-toolkit@v0.2.35 + with: + cuda: "12.9.0" + method: network + use-github-cache: false + use-local-cache: false + sub-packages: '["nvcc", "cudart", "cublas", "cublas_dev", "thrust", "visual_studio_integration"]' + - uses: astral-sh/setup-uv@v8.2.0 + - name: Install zlib (vcpkg, static — libtranscribe requires it) + # Identical to wheel-windows: static-md so no zlib1.dll exists for + # delvewheel to vendor; forward slashes because CMAKE_ARGS passes + # through scikit-build-core's CMakeInit.txt where backslashes are + # eaten as escapes. + run: | + vcpkg install zlib:x64-windows-static-md + $tc = "$env:VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" -replace '\\','/' + Add-Content $env:GITHUB_ENV "CMAKE_ARGS=-DCMAKE_TOOLCHAIN_FILE=$tc -DVCPKG_TARGET_TRIPLET=x64-windows-static-md" + - uses: ./.github/actions/fetch-canary + with: + hf-token: ${{ secrets.HF_TOKEN }} + - name: Build the raw cu12 wheel (scikit-build-core; lane posture in + bindings/python-native-cu12/pyproject.toml) + run: uv build --wheel bindings/python-native-cu12 --out-dir dist-cu12-raw + - name: Repair (delvewheel; CUDA runtime + driver + Vulkan loader stay OUT) + run: | + uvx delvewheel repair ` + --exclude cudart64_12.dll ` + --exclude cublas64_12.dll ` + --exclude cublasLt64_12.dll ` + --exclude nvcuda.dll ` + --exclude vulkan-1.dll ` + -w wheelhouse-cu12 (Get-Item dist-cu12-raw/*.whl).FullName + Get-ChildItem wheelhouse-cu12 + - name: GPU-less packaging smoke (mirrors Modal's packaging_check) + run: | + uv venv --seed --python 3.12 smoke-venv + smoke-venv/Scripts/pip install -q --find-links wheelhouse-cu12 ` + transcribe-cpp-native-cu12 "pytest>=7" numpy huggingface_hub + $env:TRANSCRIBE_SMOKE_PROVIDER = "transcribe-cpp-native-cu12" + # The default provider is deliberately absent (its pin can't + # resolve pre-release); wheel_smoke installs the API package + # dep-free. Declared-backends gate asserts {cuda, vulkan, cpu}. + $env:TRANSCRIBE_SMOKE_PIP_NO_DEPS = "1" + $env:CI = "1" + smoke-venv/Scripts/python scripts/ci/wheel_smoke.py . + # Named OUTSIDE the dist-* pattern, like cuda-dist-linux-x86_64: the + # co-import job merges dist-* and must never pick up a cu12 provider. + - uses: actions/upload-artifact@v7 + with: + name: cuda-dist-windows-x86_64 + path: wheelhouse-cu12/*.whl diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 47dfade7..0f0e1b4d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -39,6 +39,11 @@ jobs: uses: ./.github/workflows/python-wheels.yml secrets: inherit cuda-windows: + # Tags only: the heaviest build in the project (nvcc over ggml-cuda x 5 + # arches under MSVC, ~3 h on 16vcpu), and the TestPyPI rehearsal neither + # publishes nor smokes its output (cu12 wheels exceed TestPyPI's file + # cap). Validate it on demand with its own workflow_dispatch. + if: startsWith(github.ref, 'refs/tags/') uses: ./.github/workflows/cuda-windows.yml secrets: inherit @@ -54,7 +59,7 @@ jobs: permissions: id-token: write # OIDC for trusted publishing steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: pattern: dist-* merge-multiple: true @@ -76,13 +81,15 @@ jobs: env: HF_TOKEN: ${{ secrets.HF_TOKEN }} steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: dist-api path: dist-api - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: - name: smoke-assets # samples/jfk.wav, uploaded by the sdist job + # samples/jfk.wav (+ the degradation script), uploaded by api-wheel; + # files keep their repo-relative paths inside the artifact. + name: smoke-assets path: smoke-assets - name: Install from TestPyPI (exact just-published version) and transcribe run: | @@ -114,7 +121,7 @@ jobs: import array, wave import transcribe_cpp as t - with wave.open("smoke-assets/jfk.wav", "rb") as w: + with wave.open("smoke-assets/samples/jfk.wav", "rb") as w: pcm16 = array.array("h"); pcm16.frombytes(w.readframes(w.getnframes())) pcm = array.array("f", (s / 32768.0 for s in pcm16)) with t.Model("canary/whisper-tiny-Q5_K_M.gguf") as m, m.session() as s: @@ -136,14 +143,14 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: pattern: dist-* merge-multiple: true path: dist - name: Add cu12 wheels (only once the PyPI size request is granted) if: vars.CU12_ON_PYPI == 'true' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: pattern: cuda-dist-* merge-multiple: true @@ -164,7 +171,7 @@ jobs: contents: write # create the release + upload assets actions: write # dispatch wheel-index steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: pattern: cuda-dist-* merge-multiple: true diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml new file mode 100644 index 00000000..5cbd3016 --- /dev/null +++ b/.github/workflows/python-bindings.yml @@ -0,0 +1,305 @@ +name: python-bindings + +# The EVERY-PR correctness gate: +# - lint: generated-FFI drift gate + version-sync across the three +# sources of truth (header / pyproject / __init__). +# - cpp-tests: the full C++ white-box suite against a static build. +# - python-shared: a shared libtranscribe, the pure-C api_smoke as an +# exported-symbol canary, and the Python test suite (model +# tests un-skip when the canary GGUFs are fetchable). +# - provider-dl-vulkan: the Vulkan degradation contract on a hand-assembled +# provider directory (the wheel-shaped artifact). +# +# The full WHEEL matrix (python-wheels.yml) deliberately does NOT run per PR: +# it runs on workflow_dispatch and on every publish (rehearsal or release). +# This workflow is what must stay green on every change. + +on: + push: + branches: [main] + paths: + - "bindings/python/**" + - "include/**" + - "src/**" + - "tests/**" + - "ggml/**" + - "CMakeLists.txt" + - ".github/workflows/python-bindings.yml" + - ".github/actions/**" + - "scripts/ci/vulkan_degradation_check.py" + pull_request: + paths: + - "bindings/python/**" + - "include/**" + - "src/**" + - "tests/**" + - "ggml/**" + - "CMakeLists.txt" + - ".github/workflows/python-bindings.yml" + - ".github/actions/**" + - "scripts/ci/vulkan_degradation_check.py" + workflow_dispatch: + +# The generated FFI layer is pinned to one libclang so the drift gate's embedded +# `libclang: ` line is reproducible across runs. +env: + LIBCLANG_PIN: "libclang==18.1.1" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + # Idle-friendly job on the Hetzner box (free); falls back to nothing — + # an offline box fails bounded via the timeout. BEFORE the repo goes + # public: move PR-triggered self-hosted jobs to hosted runners or gate + # them on non-PR events (fork PRs + self-hosted = code exec on the box). + runs-on: [self-hosted, Linux, X64, hetzner] + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 + - name: Install clang (libclang resource-dir headers) + # Idempotent for the persistent self-hosted box: install once, no-op + # after (and no sudo needed when clang is already present). + run: | + command -v clang >/dev/null || \ + { sudo apt-get update && sudo apt-get install -y clang; } + - name: Generated-FFI drift gate + run: | + uv run --no-project --with "$LIBCLANG_PIN" \ + bindings/python/_generate/generate.py --check + - name: Version sync (header / pyproject / __init__) + run: uv run --no-project bindings/python/_generate/check_version_sync.py + + cpp-tests: + name: cpp-tests (${{ matrix.label }}) + strategy: + fail-fast: false + matrix: + include: + - label: linux + runner: blacksmith-2vcpu-ubuntu-2404 + # macOS arm64 on the bare-metal M4 mini, not a GitHub VM (keep all + # macOS arm64 CI on owned hardware; the white-box suite builds with + # Metal on, and real hardware is the trustworthy place to run it). + - label: macos-arm64 + runner: [self-hosted, macOS, ARM64] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 # bound the run if the self-hosted mini is offline + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 # fixtures are generated via uv + - 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: CPU ISA signature (segregates ccache across the heterogeneous fleet) + # ggml builds with -march=native here (the dev posture under test); + # ccache hashes the literal flag, not the ISA it resolves to, so a + # cache compiled on a richer CPU SIGILLs on a weaker one. Key the + # cache by the CPU's feature flags instead. + if: runner.os == 'Linux' + run: echo "CPU_SIG=$(grep -m1 '^flags' /proc/cpuinfo | sha256sum | cut -c1-8)" >> "$GITHUB_ENV" + - name: ccache (compile cache across runs) + if: runner.os == 'Linux' # the mini is persistent; its local cache suffices + uses: actions/cache@v5 + with: + path: ~/.cache/ccache + key: ccache-cpp-tests-${{ matrix.label }}-${{ env.CPU_SIG }}-${{ github.sha }} + restore-keys: ccache-cpp-tests-${{ matrix.label }}-${{ env.CPU_SIG }}- + - name: Configure (static white-box build) + run: | + cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + - name: Build + run: cmake --build build -j + - name: Test (full white-box suite) + run: ctest --test-dir build --output-on-failure + - name: ccache stats + run: ccache -s | head -8 + + cpp-tests-sanitized: + # ASan+UBSan over the white-box suite. This is the lane that certifies + # the C lifetime/ABI contracts the FFI bindings rely on — including the + # params copy-out regression (stream_dispatch_unit), which reproduces a + # ctypes-shaped caller freeing its params right after stream_begin. + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 # fixtures are generated via uv + - name: Install build deps + run: sudo apt-get update && sudo apt-get install -y cmake ninja-build zlib1g-dev ccache + - name: CPU ISA signature (segregates ccache across the heterogeneous fleet) + # ggml builds with -march=native here (the dev posture under test); + # ccache hashes the literal flag, not the ISA it resolves to, so a + # cache compiled on a richer CPU SIGILLs on a weaker one. Key the + # cache by the CPU's feature flags instead. + run: echo "CPU_SIG=$(grep -m1 '^flags' /proc/cpuinfo | sha256sum | cut -c1-8)" >> "$GITHUB_ENV" + - name: ccache (compile cache across runs) + uses: actions/cache@v5 + with: + path: ~/.cache/ccache + key: ccache-asan-${{ env.CPU_SIG }}-${{ github.sha }} + restore-keys: ccache-asan-${{ env.CPU_SIG }}- + - name: Configure (static, sanitized) + run: | + cmake -B build-asan -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DTRANSCRIBE_SANITIZE=ON \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + - name: Build + run: cmake --build build-asan -j + - name: Test (white-box suite under ASan+UBSan) + run: ctest --test-dir build-asan --output-on-failure + - name: ccache stats + run: ccache -s | head -8 + + provider-dl-vulkan: + # Builds the default Linux provider shape — CPU + Vulkan as dynamic + # backend modules (GGML_BACKEND_DL) — assembles it into a flat + # wheel-like directory with $ORIGIN rpaths, deletes the build tree so + # nothing can resolve outside the directory, and proves the Vulkan + # degradation contract on the real runner via + # scripts/ci/vulkan_degradation_check.py: + # 1. loader REMOVED (no libvulkan) -> import + CPU devices work, + # vulkan answers unavailable; the module-load failure is quiet. + # 2. mesa lavapipe installed -> the SAME artifacts discover a + # software Vulkan device, and (with HF_TOKEN for the canary model) + # actually transcribe on it. + runs-on: blacksmith-2vcpu-ubuntu-2404 + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 + - name: Install build deps (Vulkan SDK pieces + patchelf) + run: | + sudo apt-get update + sudo apt-get install -y cmake ninja-build zlib1g-dev \ + libvulkan-dev glslc patchelf ccache + - name: CPU ISA signature (segregates ccache across the heterogeneous fleet) + # ggml builds with -march=native here (the dev posture under test); + # ccache hashes the literal flag, not the ISA it resolves to, so a + # cache compiled on a richer CPU SIGILLs on a weaker one. Key the + # cache by the CPU's feature flags instead. + run: echo "CPU_SIG=$(grep -m1 '^flags' /proc/cpuinfo | sha256sum | cut -c1-8)" >> "$GITHUB_ENV" + - name: ccache (compile cache across runs) + uses: actions/cache@v5 + with: + path: ~/.cache/ccache + key: ccache-dl-vulkan-${{ env.CPU_SIG }}-${{ github.sha }} + restore-keys: ccache-dl-vulkan-${{ env.CPU_SIG }}- + - name: Configure (DL provider, CPU + Vulkan modules, wheel posture) + run: | + cmake -B build-dl -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DTRANSCRIBE_BUILD_SHARED=ON \ + -DTRANSCRIBE_GGML_BACKEND_DL=ON \ + -DTRANSCRIBE_VULKAN=ON \ + -DTRANSCRIBE_USE_OPENMP=OFF \ + -DTRANSCRIBE_USE_SYSTEM_BLAS=OFF \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + - name: Build + run: cmake --build build-dl -j + - name: Assemble flat provider directory ($ORIGIN rpaths, build tree deleted) + run: | + mkdir -p provider + cp -L build-dl/src/libtranscribe.so provider/ + cp -L build-dl/ggml/src/libggml.so.* build-dl/ggml/src/libggml-base.so.* provider/ 2>/dev/null || \ + cp -L build-dl/ggml/src/libggml.so build-dl/ggml/src/libggml-base.so provider/ + cp build-dl/bin/libggml-*.so provider/ + for f in provider/*.so*; do patchelf --set-rpath '$ORIGIN' "$f"; done + ls -la provider/ + rm -rf build-dl # isolation: nothing may resolve outside provider/ + - name: "Tier 1: no Vulkan loader — CPU works, vulkan cleanly unavailable" + run: | + sudo apt-get remove -y libvulkan-dev libvulkan1 || \ + sudo rm -f /usr/lib/x86_64-linux-gnu/libvulkan.so.1* + export TRANSCRIBE_LIBRARY="$PWD/provider/libtranscribe.so" + uv run --project bindings/python python \ + scripts/ci/vulkan_degradation_check.py --tier no-loader + - name: "Tier 2: lavapipe installed — same artifacts discover Vulkan" + run: | + sudo apt-get install -y libvulkan1 mesa-vulkan-drivers + export TRANSCRIBE_LIBRARY="$PWD/provider/libtranscribe.so" + # lavapipe reports VK_PHYSICAL_DEVICE_TYPE_CPU, and ggml-vulkan's + # default selection takes only discrete/integrated GPUs. The env + # override bypasses the type filter so the software device counts — + # CI-only; real GPUs need no override. + export GGML_VK_VISIBLE_DEVICES=0 + uv run --project bindings/python python \ + scripts/ci/vulkan_degradation_check.py --tier loader + - uses: ./.github/actions/fetch-canary + with: + hf-token: ${{ secrets.HF_TOKEN }} + - name: "Tier 2b: real transcription on the Vulkan device (needs HF_TOKEN)" + if: env.HF_TOKEN != '' + run: | + export TRANSCRIBE_LIBRARY="$PWD/provider/libtranscribe.so" + export GGML_VK_VISIBLE_DEVICES=0 + uv run --project bindings/python python \ + scripts/ci/vulkan_degradation_check.py --tier loader \ + --model canary/whisper-tiny-Q5_K_M.gguf --audio samples/jfk.wav + + python-shared: + runs-on: blacksmith-2vcpu-ubuntu-2404 + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 + - name: Install build deps + run: sudo apt-get update && sudo apt-get install -y cmake ninja-build zlib1g-dev ccache + - name: CPU ISA signature (segregates ccache across the heterogeneous fleet) + # ggml builds with -march=native here (the dev posture under test); + # ccache hashes the literal flag, not the ISA it resolves to, so a + # cache compiled on a richer CPU SIGILLs on a weaker one. Key the + # cache by the CPU's feature flags instead. + run: echo "CPU_SIG=$(grep -m1 '^flags' /proc/cpuinfo | sha256sum | cut -c1-8)" >> "$GITHUB_ENV" + - name: ccache (compile cache across runs) + uses: actions/cache@v5 + with: + path: ~/.cache/ccache + key: ccache-shared-${{ env.CPU_SIG }}-${{ github.sha }} + restore-keys: ccache-shared-${{ env.CPU_SIG }}- + # Shared build in the official-wheel posture (no OpenMP / system BLAS), so + # the canary exercises the same configuration the provider wheels ship. + - name: Configure (shared, wheel posture) + run: | + cmake -B build-shared -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DTRANSCRIBE_BUILD_SHARED=ON \ + -DTRANSCRIBE_USE_OPENMP=OFF \ + -DTRANSCRIBE_USE_SYSTEM_BLAS=OFF \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + - name: Build + run: cmake --build build-shared -j + - name: api_smoke (exported-symbol canary) + extension umbrella + run: ctest --test-dir build-shared --output-on-failure + # The canary model upgrades this lane from "imports and ABI" to a real + # end-to-end transcription. Guarded on HF_TOKEN (the model repo is + # private for now; forks have no token) — without it the model tests + # skip cleanly, exactly as before. + - uses: ./.github/actions/fetch-canary + with: + hf-token: ${{ secrets.HF_TOKEN }} + - name: Python tests (real transcription when the canary is present) + run: | + # libtranscribe.so is dlopen'd by ctypes; help it resolve its sibling + # ggml shared libs in the build tree. + export LD_LIBRARY_PATH="$(find "$PWD/build-shared" -name '*.so' \ + -printf '%h\n' | sort -u | tr '\n' ':')$LD_LIBRARY_PATH" + # The loader auto-discovers build-shared/src/libtranscribe.so from the + # repo root; the TRANSCRIBE_SMOKE_* vars (exported by fetch-canary + # when HF_TOKEN exists) un-skip the offline AND streaming model + # tests. The prompted (nemotron) streaming test still skips here — + # its regression is pinned in CI by the sanitized C-level test. + uv run --project bindings/python --extra test \ + pytest bindings/python/tests -q -rs diff --git a/.github/workflows/python-wheels.yml b/.github/workflows/python-wheels.yml new file mode 100644 index 00000000..6b9b6ea8 --- /dev/null +++ b/.github/workflows/python-wheels.yml @@ -0,0 +1,561 @@ +name: python-wheels + +# Builds, repairs, and proves the distributable Python artifacts (plan M5): +# - wheel-linux: manylinux_2_28 {x86_64, aarch64}, fat CPU + Vulkan +# backend modules (Vulkan toolchain built from pinned +# Khronos tags inside the container; cached across +# runs; native ARM runner for aarch64 — no QEMU) +# - wheel-macos: macosx arm64, Metal with embedded shaders — built +# AND Metal-compute-tested on the bare-metal M4 mini +# (VM runners' paravirtual GPUs can't do Metal compute) +# - wheel-macos-x86: macosx x86_64, CPU only — cross-compiled on the +# mini, tested under Rosetta 2 +# - wheel-windows: win_amd64, fat CPU + Vulkan backend modules +# (LunarG SDK; Blacksmith Windows beta) +# - sdist: the from-source fallback, proven by actually +# compiling and transcribing from the tarball +# - api-wheel: the pure-Python transcribe-cpp package +# - cuda-wheel-modal: the cu12 provider built + T4-smoked on Modal +# - clean-install: bare container, wheels only; Vulkan degradation +# tiers incl. a REAL lavapipe transcription (the +# capability-positive gate) +# - vulkan-hw: shipped linux wheel on the T14 (RADV Renoir): +# real-GPU Vulkan + the fat-CPU tier assertion +# - co-import: numpy/torch coexistence with a real transcription, +# both import orders, on all three platforms +# +# CADENCE (user decision 2026-06-12): this matrix does NOT run per PR or per +# push — wheels matter at release time. It runs on workflow_dispatch (manual +# validation after packaging/CMake changes) and via workflow_call from +# publish.yml, so every rehearsal and release builds + validates the FULL +# matrix and publishes those exact artifacts. The every-PR correctness gate +# is python-bindings.yml. +# +# Every native wheel is tested AFTER repair (auditwheel/delocate/delvewheel) +# in a fresh venv — cibuildwheel's test phase guarantees the tested artifact +# is the one that ships. Real-model tests need the private canary GGUFs and +# run when the HF_TOKEN secret exists; forks degrade to no-model tests. + +on: + # Called by publish.yml so a release builds + validates the FULL matrix in + # the same run and publishes those exact artifacts. + workflow_call: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CIBW_PIN: "cibuildwheel>=4,<5" + +jobs: + wheel-linux: + # 2vcpu everywhere: Blacksmith bills per vcpu-minute and compiles scale + # ~linearly, so bigger runners cost the same and only buy wall-clock. + # One matrix job for both Linux arches — the lane posture is identical + # (cpu-vulkan; ggml has a dedicated Linux-ARM tier list for + # GGML_CPU_ALL_VARIANTS: armv8.0..armv9.2 with runtime feature scoring, + # and the x86 SIMD-off floor flags are inert options there). aarch64 + # targets Graviton/Ampere servers, Raspberry Pi 5, SBCs — a real + # on-device-transcription market. + name: wheel-linux (${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: blacksmith-2vcpu-ubuntu-2404 + cibw-archs: x86_64 + - arch: aarch64 + runner: blacksmith-2vcpu-ubuntu-2404-arm + cibw-archs: aarch64 + runs-on: ${{ matrix.runner }} + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Container path of the toolchain cache volume (see CIBW_CONTAINER_ENGINE). + VK_TOOLCHAIN_CACHE: /vk-toolchain-cache + # pyproject [tool.cibuildwheel.linux] pins archs=["x86_64"]; override + # per matrix leg (a no-op for x86_64, the aarch64 enabler otherwise). + CIBW_ARCHS_LINUX: ${{ matrix.cibw-archs }} + CIBW_MANYLINUX_AARCH64_IMAGE: manylinux_2_28 + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 + - name: Cache the source-built Vulkan toolchain + uses: actions/cache@v5 + with: + path: vk-toolchain-cache + key: vk-toolchain-${{ matrix.arch }}-${{ hashFiles('scripts/ci/manylinux-vulkan-toolchain.sh') }} + # cibuildwheel bind-mounts the project at /project inside the manylinux + # container; the canary env paths must resolve there. + - uses: ./.github/actions/fetch-canary + with: + hf-token: ${{ secrets.HF_TOKEN }} + model-path-prefix: /project + - name: Build, repair, and test the wheel (cibuildwheel) + env: + CIBW_CONTAINER_ENGINE: "docker; create_args: --volume ${{ github.workspace }}/vk-toolchain-cache:/vk-toolchain-cache" + run: | + mkdir -p vk-toolchain-cache + uvx --from "$CIBW_PIN" cibuildwheel --output-dir wheelhouse + - uses: actions/upload-artifact@v7 + with: + name: dist-native-linux-${{ matrix.arch }} + path: wheelhouse/*.whl + + wheel-macos: + # Bare-metal M4 Mac mini (self-hosted, runner "m4-mini"): builds the + # SHIPPING macOS wheel and proves Metal COMPUTE on it in one lane — the + # artifact that ships is the artifact tested on a real GPU. VM-based + # macOS CI cannot do this: the probe proved (2026-06-11) that Apple's + # Virtualization.framework paravirtual GPU decodes garbage on both + # GitHub-hosted and Blacksmith M4 VMs (the former Blacksmith lane was + # retired 2026-06-11; flip runs-on back to a hosted label if the mini's + # availability ever becomes a problem). Notes: cibuildwheel needs a + # python.org CPython framework on the runner (installed once, manually — + # the runner service cannot sudo); the Apple-clang CC/CXX pin in + # pyproject [tool.cibuildwheel.macos] is harmless here. + runs-on: [self-hosted, macOS, ARM64] + timeout-minutes: 45 # a sleeping/offline mini must not hang the run + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Host-side copy of the lane for wheel_smoke's declared-backends assert. + TRANSCRIBE_WHEEL_LANE: metal + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 + - uses: ./.github/actions/fetch-canary + with: + hf-token: ${{ secrets.HF_TOKEN }} + - name: Build, repair, and test the wheel on real Metal (cibuildwheel) + run: uvx --from "$CIBW_PIN" cibuildwheel --output-dir wheelhouse + - uses: actions/upload-artifact@v7 + with: + name: dist-native-macos-arm64 + path: wheelhouse/*.whl + + wheel-macos-x86: + # Intel macOS: the CPU-ONLY x86_64 wheel, CROSS-COMPILED on the M4 mini + # (no Intel hardware in the fleet or on Blacksmith; GitHub's macos-13 was + # the only hosted option and is billing-gated). cibuildwheel handles the + # cross: CIBW_ARCHS_MACOS=x86_64 on an arm64 host builds with + # -arch x86_64 and runs the test phase under Rosetta 2 via the python.org + # universal2 CPython already installed on the mini for wheel-macos. + # No Metal — Intel-Mac GPUs are out of scope (no hardware to validate + # Metal compute); Metal / tuned CPU on Intel is served by the sdist. + # The smoke is CPU-steered (TRANSCRIBE_SMOKE_BACKEND=cpu): wheel_smoke.py + # sees platform.machine()=="x86_64" under Rosetta and asserts the artifact + # exposes only CPU (no metal/vulkan/cuda), then runs the full suite on + # CPU. The arm64/Metal lane is `wheel-macos`. Overrides the pyproject + # macos defaults (arm64/metal) via CIBW_ARCHS_MACOS / CIBW_ENVIRONMENT_MACOS. + runs-on: [self-hosted, macOS, ARM64] + timeout-minutes: 45 + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Host-side copy of the lane for wheel_smoke's declared-backends assert. + TRANSCRIBE_WHEEL_LANE: cpu + CIBW_ARCHS_MACOS: x86_64 + CIBW_ENVIRONMENT_MACOS: >- + TRANSCRIBE_WHEEL_LANE=cpu + TRANSCRIBE_SMOKE_BACKEND=cpu + MACOSX_DEPLOYMENT_TARGET=11.0 + CC=/usr/bin/clang + CXX=/usr/bin/clang++ + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 + - uses: ./.github/actions/fetch-canary + with: + hf-token: ${{ secrets.HF_TOKEN }} + - name: Build, repair, and test the CPU-only wheel (cibuildwheel) + run: uvx --from "$CIBW_PIN" cibuildwheel --output-dir wheelhouse + - uses: actions/upload-artifact@v7 + with: + name: dist-native-macos-x86_64 + path: wheelhouse/*.whl + + wheel-windows: + # Blacksmith Windows Server 2025 (public beta) — same image family as + # GitHub's windows-2025, so vcvars/vcpkg/the Vulkan SDK installer behave + # identically. This lane is the overall long pole (MSVC over ggml + the + # fat-CPU tier matrix + Vulkan shaders) — expect a long wall-clock on + # 2vcpu; cost is the same as a bigger runner (per-vcpu-minute billing). + # If the beta misbehaves, the fallback is `windows-latest` (needs + # GitHub-hosted billing). + runs-on: blacksmith-2vcpu-windows-2025 + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Host-side copy of the lane for wheel_smoke's declared-backends assert + # (the test phase runs on the host here, not in a container). + TRANSCRIBE_WHEEL_LANE: cpu-vulkan + # LunarG prunes old SDK downloads — when bumping, verify the URL exists. + VULKAN_VERSION: "1.4.350.0" + # The hf CLI prints ✓ marks; Windows' default cp1252 console codec + # chokes on them (charmap codec error). Force UTF-8 for all Python. + PYTHONUTF8: "1" + steps: + - uses: actions/checkout@v6 + # vcvars for the whole job: the Ninja generator (CMAKE_GENERATOR in + # pyproject [tool.cibuildwheel.windows]) needs cl.exe on PATH — without + # it CMake silently picks the runner image's MinGW gcc (observed: + # gcc-flavored M_PI errors). The VS generator didn't need this but is + # ~3x slower on this lane. + # (still node20 upstream — no node24 release yet; runners force node24 from + # 2026-06-16, which is fine for this env-setup action) + - uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + - uses: astral-sh/setup-uv@v8.2.0 + - name: Install Vulkan SDK ${{ env.VULKAN_VERSION }} + run: | + curl.exe -o "$env:RUNNER_TEMP\vulkan_sdk.exe" -L "https://sdk.lunarg.com/sdk/download/${env:VULKAN_VERSION}/windows/vulkan_sdk.exe" + & "$env:RUNNER_TEMP\vulkan_sdk.exe" --accept-licenses --default-answer --confirm-command install + Add-Content $env:GITHUB_ENV "VULKAN_SDK=C:\VulkanSDK\${env:VULKAN_VERSION}" + Add-Content $env:GITHUB_PATH "C:\VulkanSDK\${env:VULKAN_VERSION}\bin" + - uses: ./.github/actions/fetch-canary + with: + hf-token: ${{ secrets.HF_TOKEN }} + - name: Install zlib (vcpkg, static — libtranscribe requires it) + # Static (-static-md: static lib, dynamic CRT) so no zlib1.dll exists + # at runtime and delvewheel has nothing to vendor for it. The + # toolchain file reaches scikit-build-core via CMAKE_ARGS. + run: | + vcpkg install zlib:x64-windows-static-md + # Forward slashes: CMAKE_ARGS values pass through scikit-build-core's + # CMakeInit.txt cache file, where backslashes are eaten as escapes. + $tc = "$env:VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" -replace '\\','/' + Add-Content $env:GITHUB_ENV "CMAKE_ARGS=-DCMAKE_TOOLCHAIN_FILE=$tc -DVCPKG_TARGET_TRIPLET=x64-windows-static-md" + - name: Build, repair, and test the wheel (cibuildwheel) + run: uvx --from "$env:CIBW_PIN" cibuildwheel --output-dir wheelhouse + - uses: actions/upload-artifact@v7 + with: + name: dist-native-windows-amd64 + path: wheelhouse/*.whl + + sdist: + # The universal fallback must actually work: build the sdist, audit what + # went into it, then compile-and-transcribe from the tarball alone. + runs-on: blacksmith-2vcpu-ubuntu-2404 + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 + - uses: ./.github/actions/fetch-canary + with: + hf-token: ${{ secrets.HF_TOKEN }} + - name: Build sdist + run: uv build --sdist + - name: Audit sdist contents + run: | + python3 - <<'EOF' + import glob, tarfile + [path] = glob.glob("dist/*.tar.gz") + with tarfile.open(path) as tf: + names = tf.getnames() + total = sum(m.size for m in tf.getmembers()) + print(f"{path}: {len(names)} files, {total/1e6:.1f} MB uncompressed") + # Weights by extension anywhere; heavy repo dirs at the ROOT only + # (docs/models/*.md is documentation and belongs in the sdist). + ROOT_BANNED = {"models", "dumps", "reports", "canary", "wheelhouse"} + banned = [n for n in names if n.endswith((".gguf", ".safetensors", ".bin")) + or (len(n.split("/")) > 1 and n.split("/")[1] in ROOT_BANNED)] + assert not banned, f"sdist contains artifacts that must never ship: {banned[:10]}" + assert any(n.endswith("src/arch/canary/model.cpp") for n in names), \ + "sdist excludes over-matched (root-anchoring regression)" + assert total < 40e6, f"sdist unexpectedly large: {total/1e6:.1f} MB (28 MB expected)" + for required in ("CMakeLists.txt", "include/transcribe.h", + "ggml/CMakeLists.txt", "cmake/python-wheel-install.cmake", + "bindings/python-native/_contract.py.in"): + assert any(n.endswith(required) for n in names), f"missing {required}" + print("sdist audit ok") + EOF + - name: Compile + transcribe from the sdist (no repo sources) + run: | + sudo apt-get update && sudo apt-get install -y zlib1g-dev + uv venv --seed sdist-venv --python 3.12 + # The tarball is the only native source here: pip drives the full + # scikit-build-core compile (cmake/ninja arrive as build deps). + ./sdist-venv/bin/pip install dist/*.tar.gz + ./sdist-venv/bin/pip install pytest numpy huggingface_hub + ./sdist-venv/bin/python scripts/ci/wheel_smoke.py "$PWD" + - uses: actions/upload-artifact@v7 + with: + name: dist-sdist + path: dist/*.tar.gz + + api-wheel: + runs-on: [self-hosted, Linux, X64, hetzner] + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 + - name: Build transcribe-cpp (pure wheel + sdist) + run: | + cd bindings/python + rm -rf dist && uv build + - uses: actions/upload-artifact@v7 + with: + name: dist-api + path: bindings/python/dist/* + # Assets for the checkout-free jobs (clean-install, smoke-testpypi): + # test audio + the shared degradation-check script. Paths inside the + # artifact keep their repo-relative layout (samples/..., scripts/ci/...). + - uses: actions/upload-artifact@v7 + with: + name: smoke-assets + path: | + samples/jfk.wav + scripts/ci/vulkan_degradation_check.py + + cuda-wheel-modal: + # The cu12 provider is built and smoked on Modal, not on CI runners: the + # manylinux_2_28 + CUDA-toolkit + Vulkan-toolchain environment is a + # cached Modal image layer, the 16-core build bills by the second, and + # the flow ends with a REAL T4 runtime smoke + # (scripts/ci/modal_cuda_build.py). This job is just the driver — almost + # all of its wall-clock is idle waiting on Modal, which is exactly why it + # lives on the (free, mostly idle) Hetzner box instead of a billed + # runner. uv supplies Python (its standalone builds are fully + # relocatable — actions/setup-python's hosted-image tarballs are not + # reliable on self-hosted runners). Gated on the Modal token secrets — + # absent on forks, steps no-op cleanly. + runs-on: [self-hosted, Linux, X64, hetzner] + timeout-minutes: 75 + env: + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} + HF_TOKEN: ${{ secrets.HF_TOKEN }} + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 + - name: Build, repair, packaging-smoke, and T4 runtime smoke (Modal) + if: env.MODAL_TOKEN_ID != '' + run: uvx --from modal modal run scripts/ci/modal_cuda_build.py + - name: Fetch the repaired wheel from the Modal volume + if: env.MODAL_TOKEN_ID != '' + run: | + mkdir -p wheelhouse-cu12 + # Root-path download is recursive (glob patterns are deprecated in + # the modal CLI); assets/ comes along but the artifact glob below + # only picks the wheels. + uvx --from modal modal volume get transcribe-cu12-wheelhouse / wheelhouse-cu12/ --force + ls -la wheelhouse-cu12/ + # Named OUTSIDE the dist-* pattern on purpose: co-import merges dist-* + # and must not pick up the cu12 provider (it would outrank the default + # provider and change what that job tests). + - uses: actions/upload-artifact@v7 + if: env.MODAL_TOKEN_ID != '' + with: + name: cuda-dist-linux-x86_64 + path: wheelhouse-cu12/*.whl + + clean-install: + # Punch-list "clean machine": a bare python:3.12-slim container — no + # compilers, no repo checkout, no env vars. Installs ONLY the built + # wheels (resolver pulls the native provider through the hard pin) and + # transcribes. This is also the shipped-artifact proof of the + # Vulkan-by-default degradation contract, driven by the shared + # scripts/ci/vulkan_degradation_check.py (from the smoke-assets + # artifact — this job has no checkout): + # tier A (bare container, no Vulkan loader): the bundled module stays + # quietly unloaded — CPU transcribes, vulkan answers unavailable, + # backend="vulkan" raises a clean error. + # tier B (apt install loader + lavapipe): the SAME installed wheel + # discovers a Vulkan device and transcribes on it. + needs: [wheel-linux, api-wheel] + runs-on: blacksmith-2vcpu-ubuntu-2404 + # (2026-06-12) tier B failures here were NOT a docker/runner problem: the + # wheel itself had been built with the wrong lane posture (unanchored + # scikit-build override regex — "cpu" matched "cpu-vulkan") and simply + # contained no Vulkan module. vulkaninfo inside this container proved the + # loader + lavapipe fine on Blacksmith docker with default options. + container: python:3.12-slim + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + steps: + - uses: actions/download-artifact@v8 + with: + name: dist-native-linux-x86_64 + path: wheelhouse + - uses: actions/download-artifact@v8 + with: + name: dist-api + path: wheelhouse + - uses: actions/download-artifact@v8 + with: + name: smoke-assets + path: assets + - name: Canary model cache (avoid HF rate limits) + if: env.HF_TOKEN != '' + uses: actions/cache@v5 + with: + path: canary + key: canary-models-v1 + - name: Fetch canary model (cache miss only; pip — no uv in this container) + if: env.HF_TOKEN != '' + run: | + pip install -q huggingface_hub + [ -f canary/whisper-tiny-Q5_K_M.gguf ] || \ + hf download handy-computer/whisper-tiny-gguf \ + whisper-tiny-Q5_K_M.gguf --local-dir canary + - name: Install from the built wheels only (no index) + run: pip install --no-index --find-links wheelhouse transcribe-cpp + - name: "Tier A: bare machine — CPU transcribes, Vulkan quietly absent" + run: | + if [ -f canary/whisper-tiny-Q5_K_M.gguf ]; then + python assets/scripts/ci/vulkan_degradation_check.py \ + --tier no-loader --expect-provider transcribe-cpp-native \ + --model canary/whisper-tiny-Q5_K_M.gguf --audio assets/samples/jfk.wav + else + echo "!! no canary (fork without HF_TOKEN) — install-only check" + python assets/scripts/ci/vulkan_degradation_check.py \ + --tier no-loader --expect-provider transcribe-cpp-native + fi + - name: "Tier B: install a Vulkan loader + lavapipe — same wheel uses it" + if: env.HF_TOKEN != '' + run: | + apt-get update -q && apt-get install -y -q libvulkan1 mesa-vulkan-drivers vulkan-tools + # Diagnostic first: prove the LOADER sees lavapipe before asking + # ggml to. If this shows no devices, the runner/docker host is the + # problem (seccomp/shm), not the wheel. + vulkaninfo --summary || echo "::warning::vulkaninfo failed — loader-level problem on this runner" + # lavapipe is a CPU-type Vulkan device; ggml's default filter only + # admits real GPUs (CI-only override, real GPUs need none). + export GGML_VK_VISIBLE_DEVICES=0 + python assets/scripts/ci/vulkan_degradation_check.py \ + --tier loader --expect-provider transcribe-cpp-native \ + --model canary/whisper-tiny-Q5_K_M.gguf --audio assets/samples/jfk.wav + + vulkan-hw: + # Real-GPU Vulkan truth lane — the Linux mirror of wheel-macos on the + # mini: installs the SHIPPED linux x86_64 wheel on the self-hosted T14 + # (AMD Renoir iGPU, RADV — runner "linux-t14-4750u", labels include + # "vulkan" so generic [self-hosted, Linux, X64] jobs can never land here + # by accident) and proves what no hosted runner can: + # 1. the Vulkan device is discovered through the DEFAULT selection path + # (no GGML_VK_VISIBLE_DEVICES — integrated GPUs pass ggml's filter, + # first proven manually on this same machine 2026-06-10); + # 2. a real transcription runs ON the GPU with backend="vulkan"; + # 3. the fat-CPU runtime scoring picks the right tier for Zen2 + # (AVX2, no AVX-512 ⇒ ggml-cpu-haswell) — the M6.5 hardware + # assertion. lavapipe degradation stays on the hosted lanes + # (provider-dl-vulkan, clean-install); this lane is the hardware + # truth for the artifact that ships. + needs: [wheel-linux, api-wheel] + runs-on: [self-hosted, Linux, X64, vulkan] + timeout-minutes: 20 # bound the run if the T14 is offline + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 + - uses: actions/download-artifact@v8 + with: + pattern: dist-* + merge-multiple: true + path: wheelhouse + - uses: ./.github/actions/fetch-canary + with: + hf-token: ${{ secrets.HF_TOKEN }} + - name: Install the built wheels (venv; resolver pulls the native pin) + run: | + python3 -m venv "$RUNNER_TEMP/vk-venv" + "$RUNNER_TEMP/vk-venv/bin/pip" install -q --no-index \ + --find-links wheelhouse transcribe-cpp + - name: "Real Vulkan: default-path discovery + GPU transcription + CPU tier" + if: env.HF_TOKEN != '' + run: | + "$RUNNER_TEMP/vk-venv/bin/python" - <<'EOF' 2>&1 | tee probe.log + import array, wave + import transcribe_cpp as t + + devs = [(d.name, d.kind) for d in t.backends()] + print("devices:", devs) + # DEFAULT selection path — no GGML_VK_VISIBLE_DEVICES override. + assert any(k == "vulkan" for _, k in devs), devs + assert t.backend_available("vulkan") + + with wave.open("samples/jfk.wav", "rb") as w: + pcm16 = array.array("h") + pcm16.frombytes(w.readframes(w.getnframes())) + pcm = array.array("f", (s / 32768.0 for s in pcm16)) + with t.Model("canary/whisper-tiny-Q5_K_M.gguf", backend="vulkan") as m: + with m.session() as s: + text = s.run(pcm).text + print("text (vulkan):", text.strip()) + assert "country" in text.lower(), text + print("ok: real-GPU Vulkan transcription on RADV") + EOF + # M6.5 tier assertion: Zen2 = AVX2 without AVX-512, so runtime + # feature scoring must select the haswell CPU module. + grep -q "load_backend: loaded CPU backend from .*ggml-cpu-haswell" probe.log \ + || { echo "::error::expected the haswell CPU tier on Zen2"; exit 1; } + + co-import: + # numpy/torch coexistence (the reason the wheels vendor no OpenMP/BLAS), + # proven by real transcriptions in both import orders. Also a de-facto + # clean-install test: pip resolves transcribe-cpp + its native pin purely + # from the built artifacts. + name: co-import (${{ matrix.label }}) + needs: [wheel-linux, wheel-macos, wheel-windows, api-wheel] + strategy: + fail-fast: false + matrix: + include: + - label: linux + runner: blacksmith-2vcpu-ubuntu-2404 + torch-args: "--index-url https://download.pytorch.org/whl/cpu" + # macOS arm64 runs on the bare-metal M4 mini, not a GitHub VM: the + # transcription here exercises the REAL shipping Metal path alongside + # numpy/torch, and avoids the broken "Apple Paravirtual device" Metal + # that GitHub-hosted macos runners expose (it decodes garbage — the + # exact reason wheel-macos is on the mini too). + - label: macos-arm64 + runner: [self-hosted, macOS, ARM64] + torch-args: "" + - label: windows + runner: blacksmith-2vcpu-windows-2025 + torch-args: "" + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 # bound the run if the self-hosted mini is offline + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 # for fetch-canary's uvx + # setup-python's macOS downloads are built for GitHub's hosted images + # (non-relocatable, hardcoded /Users/runner prefix) and cannot install + # on a self-hosted runner under a different user (observed: + # "mkdir: /Users/runner: Permission denied"). On the mini, use the + # python.org universal2 CPython already installed for wheel-macos, + # isolated in a fresh venv so pip installs never touch the system copy. + - uses: actions/setup-python@v6 + if: matrix.label != 'macos-arm64' + with: + python-version: "3.12" + - name: Python venv from the mini's python.org CPython + if: matrix.label == 'macos-arm64' + run: | + /Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12 \ + -m venv "$RUNNER_TEMP/ci-venv" + echo "$RUNNER_TEMP/ci-venv/bin" >> "$GITHUB_PATH" + - uses: actions/download-artifact@v8 + with: + pattern: dist-* + merge-multiple: true + path: wheelhouse + - name: Install the built artifacts (resolver picks the platform wheel) + run: | + pip install --no-index --find-links wheelhouse transcribe-cpp + pip install numpy + - uses: ./.github/actions/fetch-canary + with: + hf-token: ${{ secrets.HF_TOKEN }} + - name: numpy co-import smoke + run: python scripts/ci/co_import_smoke.py numpy . + - name: torch co-import smoke + run: | + pip install torch ${{ matrix.torch-args }} + python scripts/ci/co_import_smoke.py torch . diff --git a/.github/workflows/wheel-index.yml b/.github/workflows/wheel-index.yml new file mode 100644 index 00000000..98e0e9fb --- /dev/null +++ b/.github/workflows/wheel-index.yml @@ -0,0 +1,51 @@ +name: wheel-index + +# PEP 503 "simple repository" index on GitHub Pages for wheels that can't ship +# on PyPI: the cu12 CUDA provider (~197 MB, over PyPI's 100 MB cap) and future +# Windows-CUDA / cu13 / ROCm providers. GitHub hosts the wheels (as release +# assets); this workflow generates the few KB of HTML that links to them and +# publishes it to Pages. Consumed as: +# pip install "transcribe-cpp[cu12]" \ +# --extra-index-url https://.github.io//whl/cu12 +# +# ONE-TIME PREREQ: repo Settings -> Pages -> Source = "GitHub Actions". +# +# Timing: the index links to whatever .whl assets exist on releases at run +# time, so it must run AFTER the release plumbing attaches the cu12/windows +# wheels. That workflow should re-dispatch this (or rely on `release: edited`) +# once its uploads finish; `workflow_dispatch` is always available to refresh. +on: + release: + types: [published, edited] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Serialize Pages deploys; never cancel an in-flight one. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build-and-deploy: + runs-on: [self-hosted, Linux, X64, hetzner] + timeout-minutes: 15 + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 + - name: Generate the PEP 503 index from release assets + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: uv run --no-project scripts/ci/build_wheel_index.py --out index/whl + - uses: actions/configure-pages@v6 + - uses: actions/upload-pages-artifact@v5 + with: + path: index + - id: deploy + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index e8a22031..474eac36 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,16 @@ /build-*/ /cmake-build-*/ +# 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) +/dist/ +/bindings/python/dist/ +/wheelhouse*/ + +# Canary GGUFs fetched by CI / local smoke runs +/canary/ + # Scratch space for benchmarks, staging, etc. /tmp/ @@ -68,3 +78,6 @@ WER_TESTING.md # IDE / language-server artifacts .cache/ + +# Local working notes (plans, drafts) — never committed +/notes/ diff --git a/CMakeLists.txt b/CMakeLists.txt index e8d49efa..76deb18d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,50 @@ cmake_minimum_required(VERSION 3.16) -project(transcribe LANGUAGES C CXX) + +# ----------------------------------------------------------------------------- +# Version (single source of truth: include/transcribe.h) +# ----------------------------------------------------------------------------- +# +# The TRANSCRIBE_VERSION_MAJOR/MINOR/PATCH macros in the public header are the +# one place the library version is defined. Parse them here, before project(), +# so the CMake project version, the shared-library VERSION/SOVERSION, and the +# value transcribe_version() reports all derive from the same source and cannot +# drift. To bump the library version, edit include/transcribe.h. +file(READ "${CMAKE_CURRENT_SOURCE_DIR}/include/transcribe.h" _transcribe_header) +string(REGEX MATCH "define +TRANSCRIBE_VERSION_MAJOR +([0-9]+)" _ "${_transcribe_header}") +set(TRANSCRIBE_VERSION_MAJOR "${CMAKE_MATCH_1}") +string(REGEX MATCH "define +TRANSCRIBE_VERSION_MINOR +([0-9]+)" _ "${_transcribe_header}") +set(TRANSCRIBE_VERSION_MINOR "${CMAKE_MATCH_1}") +string(REGEX MATCH "define +TRANSCRIBE_VERSION_PATCH +([0-9]+)" _ "${_transcribe_header}") +set(TRANSCRIBE_VERSION_PATCH "${CMAKE_MATCH_1}") +if(NOT TRANSCRIBE_VERSION_MAJOR MATCHES "^[0-9]+$" OR + NOT TRANSCRIBE_VERSION_MINOR MATCHES "^[0-9]+$" OR + NOT TRANSCRIBE_VERSION_PATCH MATCHES "^[0-9]+$") + message(FATAL_ERROR + "Failed to parse TRANSCRIBE_VERSION_{MAJOR,MINOR,PATCH} from " + "include/transcribe.h") +endif() + +project(transcribe + VERSION ${TRANSCRIBE_VERSION_MAJOR}.${TRANSCRIBE_VERSION_MINOR}.${TRANSCRIBE_VERSION_PATCH} + LANGUAGES C CXX) + +# Short git commit for transcribe_version_commit(), captured at configure time. +# Best-effort: source tarballs and non-git trees fall back to "unknown". This is +# a configure-time snapshot (same posture as ggml's GGML_COMMIT); reconfigure to +# refresh it. +set(TRANSCRIBE_BUILD_COMMIT "unknown") +find_package(Git QUIET) +if(GIT_FOUND AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/.git") + execute_process( + COMMAND ${GIT_EXECUTABLE} -C "${CMAKE_CURRENT_SOURCE_DIR}" rev-parse --short HEAD + OUTPUT_VARIABLE _transcribe_git_sha + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + if(_transcribe_git_sha) + set(TRANSCRIBE_BUILD_COMMIT "${_transcribe_git_sha}") + endif() +endif() +message(STATUS "transcribe version: ${PROJECT_VERSION} (commit ${TRANSCRIBE_BUILD_COMMIT})") # ----------------------------------------------------------------------------- # Standards @@ -20,6 +65,34 @@ set(CMAKE_C_VISIBILITY_PRESET hidden) set(CMAKE_CXX_VISIBILITY_PRESET hidden) set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) +# ----------------------------------------------------------------------------- +# Python wheel build (scikit-build-core sets SKBUILD) +# ----------------------------------------------------------------------------- +# +# The repo-root pyproject.toml builds the transcribe-cpp-native provider +# package through scikit-build-core. Force the library shape that package +# ships (shared libtranscribe, no tests/examples/tools) and default to the +# official wheel-hygiene posture: no OpenMP and no non-Apple system BLAS, so +# nothing is vendored that collides with PyTorch/NumPy/MKL in-process, and +# Metal shaders embedded so the artifact has no sidecar files. The hygiene +# defaults (not the shape) can be overridden with -D for local source builds +# that want them. CMP0077 is NEW here, so option() below respects these. +if(SKBUILD) + set(TRANSCRIBE_BUILD_SHARED ON) + set(TRANSCRIBE_BUILD_TESTS OFF) + set(TRANSCRIBE_BUILD_EXAMPLES OFF) + set(TRANSCRIBE_BUILD_TOOLS OFF) + if(NOT DEFINED CACHE{TRANSCRIBE_USE_OPENMP}) + set(TRANSCRIBE_USE_OPENMP OFF) + endif() + if(NOT DEFINED CACHE{TRANSCRIBE_USE_SYSTEM_BLAS}) + set(TRANSCRIBE_USE_SYSTEM_BLAS OFF) + endif() + if(NOT DEFINED CACHE{GGML_METAL_EMBED_LIBRARY}) + set(GGML_METAL_EMBED_LIBRARY ON CACHE BOOL "" FORCE) + endif() +endif() + # ----------------------------------------------------------------------------- # Apple Silicon detection (drives Metal default) # ----------------------------------------------------------------------------- @@ -42,6 +115,51 @@ option(TRANSCRIBE_CUDA "Enable CUDA backend" OFF) # opt-in; Linu option(TRANSCRIBE_SANITIZE "Enable ASan + UBSan" OFF) option(TRANSCRIBE_LTO "Enable LTO in Release" OFF) +# Library link mode and wheel-hygiene switches. +# +# TRANSCRIBE_BUILD_SHARED OFF (default) builds a static libtranscribe + static +# ggml, so `cmake -B build` yields a self-contained transcribe-cli with no +# libtranscribe/libggml to chase at runtime — the posture every porting, test, +# and bench workflow relies on. The Python wheel/provider build sets it ON to +# produce a shared libtranscribe the FFI layer can dlopen. +# +# TRANSCRIBE_USE_OPENMP / TRANSCRIBE_USE_SYSTEM_BLAS default ON to keep the +# developer build fast (ggml OpenMP, Accelerate/system BLAS host decoder). +# Official provider wheels pass them OFF so no libgomp/libiomp or OpenBLAS/MKL +# runtime is vendored into the wheel where it can collide with PyTorch/NumPy. +option(TRANSCRIBE_BUILD_SHARED "Build libtranscribe + ggml as shared libraries" OFF) +option(TRANSCRIBE_USE_OPENMP "Use OpenMP (ggml + Parakeet host-decoder TU)" ON) +option(TRANSCRIBE_USE_SYSTEM_BLAS "Link non-Apple system BLAS for the host decoder" ON) + +# Dynamic ggml backends: each backend (CPU, Vulkan, CUDA, ...) becomes a +# separate loadable module living NEXT TO libtranscribe instead of being +# compiled in. This is the provider-directory artifact shape the Python +# wheels ship on Linux/Windows: the Vulkan module is present by default and +# simply fails to load (skipped; CPU keeps working) on machines without a +# Vulkan loader/driver. The host loads the module directory once via +# transcribe_init_backends(). Requires TRANSCRIBE_BUILD_SHARED=ON. +option(TRANSCRIBE_GGML_BACKEND_DL "Build ggml backends as loadable modules" OFF) + +# Conservative x86 floor: default GGML_NATIVE plus EVERY x86 SIMD tier to +# OFF so the one binary runs on baseline x86-64 without SIGILL. This is the +# single switch behind every official-wheel CPU posture (the per-flag lists +# used to be mirrored across the root pyproject lanes, the cu12 pyproject, +# and CMakePresets.json — one option, one place to drift). +# +# It is a DEFAULT, not a clamp (same posture as the SKBUILD hygiene knobs +# above): an explicit -DGGML_AVX2=ON etc. on the command line still wins, so +# a user can take the conservative floor and selectively re-enable tiers for +# their machine. With the option OFF (the default) the raw GGML_* flags are +# untouched and fully tunable, exactly as without this option. +# +# Interaction with GGML_CPU_ALL_VARIANTS=ON (the fat-CPU wheel lanes): ggml +# gates the per-flag options behind `if(NOT GGML_CPU_ALL_VARIANTS)`, so +# these are inert there and serve as the explicit floor if ALL_VARIANTS is +# ever disabled. Inert (harmless) on non-x86 targets: ggml's Linux-ARM tier +# list has its own flags. +option(TRANSCRIBE_X86_CONSERVATIVE + "Default GGML_NATIVE and every x86 SIMD tier to OFF (SIGILL-safe baseline; explicit -DGGML_* still wins)" OFF) + # Real-model gated tests. OFF by default because the tests need a # converted Parakeet GGUF on disk (~2.4 GB) and CI can't ship one. # When ON, the test reads TRANSCRIBE_REAL_PARAKEET_GGUF from the @@ -131,15 +249,61 @@ set(GGML_VULKAN ${TRANSCRIBE_VULKAN} CACHE BOOL "" FORCE) set(GGML_CUDA ${TRANSCRIBE_CUDA} CACHE BOOL "" FORCE) set(GGML_BLAS OFF CACHE BOOL "" FORCE) +# Conservative x86 floor (see the option's comment above): one switch fans +# out to GGML_NATIVE plus the full x86 SIMD tier list. Placed BEFORE +# add_subdirectory(ggml) so ggml's own option() defaults never win — but +# NOT forced: a flag the user already put in the cache (-DGGML_AVX2=ON) is +# left alone, so the floor stays selectively tunable per machine. +if(TRANSCRIBE_X86_CONSERVATIVE) + foreach(_simd_flag + GGML_NATIVE + GGML_SSE42 GGML_AVX GGML_AVX_VNNI GGML_AVX2 GGML_BMI2 + GGML_FMA GGML_F16C + GGML_AVX512 GGML_AVX512_VBMI GGML_AVX512_VNNI GGML_AVX512_BF16) + if(NOT DEFINED CACHE{${_simd_flag}}) + set(${_simd_flag} OFF CACHE BOOL "") + endif() + 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) + set(GGML_OPENMP OFF CACHE BOOL "" FORCE) +endif() + # We don't want ggml's tests / examples leaking into our build tree. They # default to OFF when ggml is added as a subdirectory (GGML_STANDALONE OFF), # but pin them anyway so the contract is explicit. set(GGML_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(GGML_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) -# Build ggml as a static library so the resulting transcribe binary can be -# distributed without chasing libggml.dylib at runtime. -set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +# Library link mode. Default OFF (static) keeps the self-contained CLI/test/ +# bench build with no libtranscribe/libggml to chase at runtime; the Python +# wheel/provider build sets TRANSCRIBE_BUILD_SHARED=ON for a shared +# libtranscribe (+ libggml) the FFI layer can dlopen. Both ggml and +# libtranscribe follow this single switch. Symbol visibility is already hidden +# by default (set above), so a shared lib exports only the TRANSCRIBE_API +# surface. +set(BUILD_SHARED_LIBS ${TRANSCRIBE_BUILD_SHARED} CACHE BOOL "" FORCE) + +# Dynamic backend modules (see the option's comment above). ggml requires +# shared libs for GGML_BACKEND_DL, so gate it on TRANSCRIBE_BUILD_SHARED +# here for a clear configure-time error. +if(TRANSCRIBE_GGML_BACKEND_DL) + if(NOT TRANSCRIBE_BUILD_SHARED) + message(FATAL_ERROR + "TRANSCRIBE_GGML_BACKEND_DL requires TRANSCRIBE_BUILD_SHARED=ON " + "(backend modules are shared libraries loaded at runtime)") + endif() + set(GGML_BACKEND_DL ON CACHE BOOL "" FORCE) + # 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. + set(GGML_NATIVE OFF CACHE BOOL "" FORCE) +endif() # ggml's CMake declares its own warning flags; let it. add_subdirectory(ggml) @@ -162,3 +326,9 @@ endif() if(TRANSCRIBE_BUILD_TOOLS) add_subdirectory(tools) endif() + +# Python provider-wheel packaging (after subdirs: needs the target list and +# ggml's GGML_AVAILABLE_BACKENDS). +if(SKBUILD) + include(cmake/python-wheel-install.cmake) +endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 00000000..2229bfb5 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,61 @@ +{ + "version": 3, + "cmakeMinimumRequired": { "major": 3, "minor": 21, "patch": 0 }, + "configurePresets": [ + { + "name": "wheel-base", + "hidden": true, + "displayName": "Official wheel posture (shared, no vendored OpenMP/BLAS)", + "description": "Shared libtranscribe for the Python provider wheels. OpenMP and non-Apple system BLAS are off so wheel-repair tools (auditwheel/delocate/delvewheel) never vendor an OpenMP/BLAS runtime that would collide with PyTorch/NumPy/MKL in the same process. GGML_NATIVE off so the binary is not tuned to the build machine. NOTE: the official wheels build these same postures through the TRANSCRIBE_WHEEL_LANE overrides in the repo-root pyproject.toml (base posture supplied by the SKBUILD block in CMakeLists.txt) — keep both in sync. The presets remain for hand-built provider directories.", + "binaryDir": "${sourceDir}/build-wheel", + "generator": "Ninja", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "TRANSCRIBE_BUILD_SHARED": "ON", + "TRANSCRIBE_USE_OPENMP": "OFF", + "TRANSCRIBE_USE_SYSTEM_BLAS": "OFF", + "TRANSCRIBE_BUILD_TESTS": "OFF", + "TRANSCRIBE_BUILD_EXAMPLES": "OFF", + "GGML_NATIVE": "OFF" + } + }, + { + "name": "wheel-linux-cpu", + "inherits": "wheel-base", + "displayName": "Linux x86_64 conservative CPU wheel", + "description": "The permanent compatibility floor. TRANSCRIBE_X86_CONSERVATIVE fans out to GGML_NATIVE=OFF plus every x86 SIMD tier OFF (one switch, defined in CMakeLists.txt — GGML_NATIVE=OFF alone does NOT disable the tiers): the resulting binary runs on a baseline x86-64 machine without SIGILL. Performance is the job of the fat-CPU (Strategy B) track, not this wheel.", + "cacheVariables": { + "TRANSCRIBE_X86_CONSERVATIVE": "ON" + } + }, + { + "name": "wheel-macos-metal", + "inherits": "wheel-base", + "displayName": "macOS arm64 Metal wheel", + "description": "Metal ships by default on Apple Silicon, with the shader library embedded so there are no sidecar .metal files to vendor or locate at runtime. Single-artifact (no dynamic backend modules): one platform, one GPU API.", + "cacheVariables": { + "TRANSCRIBE_METAL": "ON", + "GGML_METAL": "ON", + "GGML_METAL_EMBED_LIBRARY": "ON" + } + }, + { + "name": "wheel-linux-cpu-vulkan", + "inherits": "wheel-linux-cpu", + "displayName": "Linux x86_64 default wheel: fat CPU + Vulkan backend modules", + "description": "The default Linux provider (M6.5). GGML_BACKEND_DL builds every backend as a loadable module next to libtranscribe; GGML_CPU_ALL_VARIANTS emits one ggml-cpu- module per x86 ISA tier (runtime feature scoring, llama.cpp-release shape) — the x64 baseline tier is the SIGILL-safe floor, so the per-tier SIMD-off flags inherited from wheel-linux-cpu are inert here (ggml gates them behind NOT GGML_CPU_ALL_VARIANTS) and kept only as the floor if ALL_VARIANTS is disabled. The Vulkan module ships too. On machines without a Vulkan loader/driver the Vulkan module fails to load quietly and CPU keeps working — degradation verified by the provider-dl-vulkan and clean-install CI lanes. Build image needs the Vulkan SDK (glslc).", + "cacheVariables": { + "TRANSCRIBE_GGML_BACKEND_DL": "ON", + "TRANSCRIBE_VULKAN": "ON", + "GGML_VULKAN": "ON", + "GGML_CPU_ALL_VARIANTS": "ON" + } + }, + { + "name": "wheel-windows-cpu-vulkan", + "inherits": "wheel-linux-cpu-vulkan", + "displayName": "Windows x86_64 default wheel: CPU + Vulkan backend modules", + "description": "Same provider shape as wheel-linux-cpu-vulkan on Windows: conservative CPU module + Vulkan module, loaded package-locally (the Python loader calls os.add_dll_directory before CDLL). Build image needs the Vulkan SDK (glslc)." + } + ] +} diff --git a/bindings/python-native-cu12/LICENSE b/bindings/python-native-cu12/LICENSE new file mode 100644 index 00000000..38c7ccc7 --- /dev/null +++ b/bindings/python-native-cu12/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The transcribe.cpp authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bindings/python-native-cu12/LICENSE-ggml b/bindings/python-native-cu12/LICENSE-ggml new file mode 100644 index 00000000..e7dca554 --- /dev/null +++ b/bindings/python-native-cu12/LICENSE-ggml @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023-2026 The ggml authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bindings/python-native-cu12/README.md b/bindings/python-native-cu12/README.md new file mode 100644 index 00000000..d8e32b82 --- /dev/null +++ b/bindings/python-native-cu12/README.md @@ -0,0 +1,36 @@ +# transcribe-cpp-native-cu12 + +CUDA 12 native provider for +[`transcribe-cpp`](https://pypi.org/project/transcribe-cpp/), the Python +bindings for [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp). + +Install via the extra — it is **additive**, alongside the default provider: + +```sh +pip install "transcribe-cpp[cu12]" +``` + +At runtime the best installed provider wins (CUDA outranks Vulkan/CPU); force +one with `TRANSCRIBE_NATIVE_PROVIDER=cu12` or per-model +`Model(..., backend="cuda")`. + +What's inside: `libtranscribe` + ggml backend modules (conservative CPU + +CUDA with fatbins for sm 75/80/86/89/90 + Vulkan). Vulkan is bundled on +purpose: this provider outranks the default one when both are installed, so +without it a `[cu12]` install deployed onto a non-NVIDIA machine (AMD/Intel +GPU) would silently lose Vulkan acceleration. With it, this artifact is a +strict superset of the default provider — CUDA wins on NVIDIA boxes, Vulkan +picks up everywhere else, CPU is the floor. + +The CUDA toolkit is not vendored — cudart/cublas come from the +`nvidia-*-cu12` runtime wheels this package depends on, and `libcuda.so.1` +is your system driver. On a machine without an NVIDIA driver the CUDA module +simply doesn't load and Vulkan/CPU keep working. + +Linux x86_64 and Windows x64 wheels. Building from source with CUDA goes +through the `transcribe-cpp-native` sdist: + +```sh +CMAKE_ARGS="-DTRANSCRIBE_CUDA=ON -DTRANSCRIBE_VULKAN=ON -DTRANSCRIBE_GGML_BACKEND_DL=ON" \ + pip install --no-binary :all: transcribe-cpp-native +``` diff --git a/bindings/python-native-cu12/pyproject.toml b/bindings/python-native-cu12/pyproject.toml new file mode 100644 index 00000000..b065739d --- /dev/null +++ b/bindings/python-native-cu12/pyproject.toml @@ -0,0 +1,94 @@ +# transcribe-cpp-native-cu12: the opt-in CUDA 12 provider for transcribe-cpp. +# +# Same backend-module mechanism as the default provider (see the repo-root +# pyproject.toml): the artifact directory bundles libtranscribe + ggml + a +# conservative CPU module + the ggml-cuda module + the ggml-vulkan module. Vulkan rides along ON PURPOSE: +# provider selection ranks this package above the default one from its +# build-time stamp, so a `transcribe-cpp[cu12]` install deployed onto a +# non-NVIDIA box (AMD/Intel GPU) would otherwise silently lose Vulkan +# acceleration and land on CPU. With both modules bundled this artifact is a +# strict superset of the default Linux/Windows provider: CUDA loads first in +# ggml's load order (NVIDIA boxes pick CUDA), and where the CUDA module +# can't load, Vulkan still can. The CUDA *toolkit* is never vendored: +# cudart/cublas come from the NVIDIA runtime wheels declared below +# (preloaded explicitly by this package's `prepare` hook — the platform +# loader doesn't search site-packages/nvidia/*/lib), and libcuda.so.1 is the +# system driver. Without a driver the cuda module quietly fails to load and +# Vulkan/CPU keep working — the same degradation contract as Vulkan in the +# default wheel. +# +# WHEELS ONLY, Linux x86_64 first. No sdist: this directory cannot carry the +# C++ tree (cmake.source-dir reaches ../.. in a checkout); from-source CUDA +# builds go through the transcribe-cpp-native sdist with +# CMAKE_ARGS="-DTRANSCRIBE_CUDA=ON -DTRANSCRIBE_GGML_BACKEND_DL=ON". +# Built on Modal (scripts/ci/modal_cuda_build.py), not cibuildwheel: the +# manylinux+CUDA image is a cached Modal layer and the GPU runtime smoke +# runs in the same flow. + +[build-system] +requires = ["scikit-build-core>=0.11"] +build-backend = "scikit_build_core.build" + +[project] +name = "transcribe-cpp-native-cu12" +dynamic = ["version"] +description = "CUDA 12 native provider for transcribe-cpp (speech-to-text, ggml)" +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +# Copies of the repo + ggml licenses (PEP 639 paths cannot escape the +# project directory; texts are static). +license-files = ["LICENSE", "LICENSE-ggml"] +authors = [{ name = "The transcribe.cpp authors" }] +keywords = ["transcription", "speech-to-text", "asr", "ggml", "cuda"] +classifiers = [ + "Development Status :: 1 - Planning", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Topic :: Multimedia :: Sound/Audio :: Speech", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +# The NVIDIA runtime wheels the ggml-cuda module links against. The provider's +# prepare hook loads these into the process before any backend module loads. +dependencies = [ + "nvidia-cuda-runtime-cu12>=12.1", + "nvidia-cublas-cu12>=12.1", +] + +[project.urls] +Homepage = "https://github.com/handy-computer/transcribe.cpp" +Repository = "https://github.com/handy-computer/transcribe.cpp" +Issues = "https://github.com/handy-computer/transcribe.cpp/issues" + +[project.entry-points."transcribe_cpp.native"] +cu12 = "transcribe_cpp_native_cu12:descriptor" + +[tool.scikit-build] +# Version from the same single source as everything else. +metadata.version.provider = "scikit_build_core.metadata.regex" +metadata.version.input = "../../include/transcribe.h" +metadata.version.regex = '(?sx) TRANSCRIBE_VERSION_MAJOR \s+ (?P\d+) .*? TRANSCRIBE_VERSION_MINOR \s+ (?P\d+) .*? TRANSCRIBE_VERSION_PATCH \s+ (?P\d+)' +metadata.version.result = "{major}.{minor}.{patch}" + +# The C++ project lives at the repo root; this package builds from a checkout. +cmake.source-dir = "../.." +cmake.version = ">=3.21" +ninja.version = ">=1.11" + +wheel.py-api = "py3" +wheel.install-dir = "transcribe_cpp_native_cu12" +wheel.packages = ["transcribe_cpp_native_cu12"] +install.components = ["wheel"] + +# The full lane posture lives here (no env-selected lanes — this package IS +# the cuda lane): dynamic backend modules, conservative CPU baseline, the +# Vulkan module (superset-of-default rationale in the header comment; build +# image must provide glslc), CUDA fatbins for T4/A100/A10-3090/4090-L4/H100. +cmake.args = [ + "-DTRANSCRIBE_GGML_BACKEND_DL=ON", + "-DTRANSCRIBE_CUDA=ON", + "-DTRANSCRIBE_VULKAN=ON", + # One switch for GGML_NATIVE=OFF + every x86 SIMD tier (CMakeLists.txt). + "-DTRANSCRIBE_X86_CONSERVATIVE=ON", + "-DCMAKE_CUDA_ARCHITECTURES=75;80;86;89;90", +] diff --git a/bindings/python-native-cu12/transcribe_cpp_native_cu12/__init__.py b/bindings/python-native-cu12/transcribe_cpp_native_cu12/__init__.py new file mode 100644 index 00000000..ecb21207 --- /dev/null +++ b/bindings/python-native-cu12/transcribe_cpp_native_cu12/__init__.py @@ -0,0 +1,91 @@ +"""transcribe-cpp-native-cu12: CUDA 12 native artifact for transcribe-cpp. + +No Python API. Bundles libtranscribe + ggml backend modules (CPU + CUDA + +Vulkan — the Vulkan module makes this artifact a strict superset of the +default provider, so a ``[cu12]`` install on a non-NVIDIA box still gets +GPU acceleration instead of silently landing on CPU) in ``_native/`` and +advertises them via the ``transcribe_cpp.native`` entry point. +``_contract.py`` is stamped at build time by CMake +(cmake/python-wheel-install.cmake). + +The CUDA runtime is NOT vendored in the wheel: ``prepare()`` (invoked by the +binding after this provider is *selected*, before any dlopen) loads +cudart/cublas from the NVIDIA runtime wheels — they install under +``site-packages/nvidia/*/lib``, which the platform loader never searches. +``libcuda.so.1`` is the system driver; when absent, the ggml-cuda module +quietly fails to load and CPU keeps working. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +#: Runtime libraries the ggml-cuda module links, in dependency order +#: (cublasLt before cublas), relative to the nvidia namespace package. +#: Linux wheels ship versioned sonames under /lib/; Windows wheels ship +#: DLLs under /bin/ (the layout PyTorch relies on). +if sys.platform == "win32": + _NVIDIA_LIBS = ( + "cuda_runtime/bin/cudart64_12.dll", + "cublas/bin/cublasLt64_12.dll", + "cublas/bin/cublas64_12.dll", + ) +else: + _NVIDIA_LIBS = ( + "cuda_runtime/lib/libcudart.so.12", + "cublas/lib/libcublasLt.so.12", + "cublas/lib/libcublas.so.12", + ) + + +def prepare() -> None: + """Load the NVIDIA runtime libraries into the process so the ggml-cuda + module's imports resolve from the loaded set. + + Linux: dlopen with RTLD_GLOBAL — the module's DT_NEEDED sonames resolve + against the global symbol set. Windows: LoadLibrary by absolute path — + the loader satisfies ggml-cuda.dll's import table from the already-loaded + module list (matched by name), so the preload is the load-bearing step; + os.add_dll_directory on the wheels' bin/ dirs is belt-and-suspenders for + any LOAD_LIBRARY_SEARCH_USER_DIRS resolution. + """ + import ctypes + import os + + try: + import nvidia + except ImportError: + # Runtime wheels absent (e.g. --no-deps install): the cuda module + # will fail to load quietly and CPU still works. + return + for root in map(Path, nvidia.__path__): + for rel in _NVIDIA_LIBS: + lib = root / rel + if not lib.is_file(): + continue + if sys.platform == "win32": + try: + os.add_dll_directory(str(lib.parent)) + except OSError: + pass + try: + ctypes.CDLL(str(lib), mode=ctypes.RTLD_GLOBAL) + except OSError: + # Same posture as a missing driver: degrade, don't break + # the process for the CPU path. + pass + + +def descriptor() -> dict: + """The transcribe_cpp.native provider contract for this package.""" + from . import _contract + + return { + "name": "transcribe-cpp-native-cu12", + "artifact_dir": str(Path(__file__).resolve().parent / "_native"), + "version": _contract.VERSION, + "header_hash": _contract.PUBLIC_HEADER_HASH, + "backends": list(_contract.BACKENDS), + "prepare": prepare, + } diff --git a/bindings/python-native/README.md b/bindings/python-native/README.md new file mode 100644 index 00000000..2759895c --- /dev/null +++ b/bindings/python-native/README.md @@ -0,0 +1,31 @@ +# transcribe-cpp-native + +Prebuilt native library for [`transcribe-cpp`](https://pypi.org/project/transcribe-cpp/), +the Python bindings for [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp) +(local speech-to-text on ggml). + +You normally don't install this directly — `pip install transcribe-cpp` +depends on it. It carries no Python API; it bundles the `libtranscribe` +shared library plus its ggml backend libraries and registers them with the +bindings via the `transcribe_cpp.native` entry point. + +What each artifact ships: + +| Artifact | Backends | +| --- | --- | +| Linux x86_64 wheel | CPU (conservative baseline) + Vulkan backend module | +| Windows x86_64 wheel | CPU (conservative baseline) + Vulkan backend module | +| macOS arm64 wheel | Metal (embedded shaders) + CPU | +| sdist | builds from source; machine-tuned CPU by default, backends via `CMAKE_ARGS` | + +The Vulkan backend ships as a dynamic ggml module: on machines without a +Vulkan loader or driver it simply fails to load and CPU keeps working — no +crash, no import error. Specialized providers (e.g. CUDA) are separate +packages installed alongside this one via extras of `transcribe-cpp`. + +Building from the sdist requires CMake ≥ 3.21, Ninja, and a C++17 compiler; +backend selection follows the repo's CMake options, e.g.: + +```sh +CMAKE_ARGS="-DTRANSCRIBE_VULKAN=ON" pip install --no-binary :all: transcribe-cpp-native +``` diff --git a/bindings/python-native/_contract.py.in b/bindings/python-native/_contract.py.in new file mode 100644 index 00000000..222a07a8 --- /dev/null +++ b/bindings/python-native/_contract.py.in @@ -0,0 +1,15 @@ +"""Build-time provider contract stamp. Generated by CMake — do not edit. + +See cmake/python-wheel-install.cmake for how each value is derived. +""" + +#: Native library version (TRANSCRIBE_VERSION_* macros in include/transcribe.h). +VERSION = "@PROJECT_VERSION@" + +#: Public-header digest the committed FFI layer (_generated.py) was generated +#: from. The binding refuses to load a provider built against a different ABI. +PUBLIC_HEADER_HASH = "@TRANSCRIBE_PUBLIC_HEADER_HASH@" + +#: Backend kinds compiled into this artifact (modules may still degrade to +#: unavailable at runtime, e.g. Vulkan without a loader/driver). +BACKENDS = (@TRANSCRIBE_WHEEL_BACKENDS_PY@,) diff --git a/bindings/python-native/transcribe_cpp_native/__init__.py b/bindings/python-native/transcribe_cpp_native/__init__.py new file mode 100644 index 00000000..fe47efbf --- /dev/null +++ b/bindings/python-native/transcribe_cpp_native/__init__.py @@ -0,0 +1,31 @@ +"""transcribe-cpp-native: prebuilt native artifact for the transcribe-cpp bindings. + +This package contains no Python API. It bundles the native ``transcribe`` +shared library (plus its ggml libraries and any dynamic backend modules) in +``_native/`` and advertises it to the pure-Python ``transcribe-cpp`` package +through the ``transcribe_cpp.native`` entry point declared in pyproject.toml. + +``descriptor()`` returns the provider contract the binding validates before +dlopen — see bindings/python/src/transcribe_cpp/_library.py for the consuming +side. ``_contract.py`` is generated at build time by CMake +(cmake/python-wheel-install.cmake) and stamps the native library version, the +public-header hash the FFI layer was generated from, and the backend kinds +this artifact was built with. +""" + +from __future__ import annotations + +from pathlib import Path + + +def descriptor() -> dict: + """The transcribe_cpp.native provider contract for this package.""" + from . import _contract + + return { + "name": "transcribe-cpp-native", + "artifact_dir": str(Path(__file__).resolve().parent / "_native"), + "version": _contract.VERSION, + "header_hash": _contract.PUBLIC_HEADER_HASH, + "backends": list(_contract.BACKENDS), + } diff --git a/bindings/python/LICENSE b/bindings/python/LICENSE new file mode 100644 index 00000000..38c7ccc7 --- /dev/null +++ b/bindings/python/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The transcribe.cpp authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bindings/python/README.md b/bindings/python/README.md new file mode 100644 index 00000000..9e59b4aa --- /dev/null +++ b/bindings/python/README.md @@ -0,0 +1,100 @@ +# transcribe-cpp + +Python bindings for [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp), +a C/C++ speech-to-text library built on ggml. + +> **Status: in development.** The API below works against a locally built native +> library. Prebuilt wheels (bundled native code, GPU provider packages) are not +> published yet — for now you point the binding at a `libtranscribe` shared +> library you built. Watch the repository for the first wheel release. + +```python +import transcribe_cpp + +with transcribe_cpp.Model("model.gguf") as model: + with model.session() as session: + result = session.run(pcm_float32_16k_mono) + print(result.text) +``` + +`run()` takes 16 kHz mono float32 PCM (buffer-protocol object or sequence). It +does not decode containers or resample — convert first, e.g. +`ffmpeg -i in.wav -ar 16000 -ac 1 out.wav`. With numpy: + +```python +import numpy as np + +pcm = np.asarray(audio, dtype=np.float32) # 1-D, 16 kHz mono in [-1, 1) +# stereo (frames, channels)? downmix first — 2-D input is rejected: +# pcm = audio.mean(axis=1).astype(np.float32) +result = session.run(pcm) +``` + +Streaming models expose incremental transcription with committed/tentative +text views — see `examples/stream_wav.py`: + +```python +with model.session() as session, session.stream() as stream: + for chunk in pcm_chunks: + stream.feed(chunk) + text = stream.text() # .committed (stable) + .tentative + stream.finalize() +``` + +Long transcriptions can be cancelled from another thread with +`session.cancel()` — the run raises `Aborted` with the partial transcript on +`exc.partial_result` (same for `OutputTruncated`). + +## Backends and escape hatches + +`Model(backend=...)` picks the compute device (`"auto"` → best available); +`transcribe_cpp.backends()` lists what registered and +`backend_available(kind)` probes one kind. Environment overrides: + +| Variable | Effect | +|---|---| +| `TRANSCRIBE_BACKEND` | overrides the `backend="auto"` *default* (an explicit `backend=` argument always wins) — the escape hatch for machines whose best-ranked device misbehaves | +| `TRANSCRIBE_NATIVE_PROVIDER` | force a specific installed native provider package (e.g. `cu12`) | +| `TRANSCRIBE_LIBRARY` | load exactly this shared library (developer override) | + +Once wheels are published: `pip install transcribe-cpp` ships CPU plus the +platform accelerator (Metal on macOS arm64, Vulkan on Linux/Windows) with +graceful CPU fallback; `pip install "transcribe-cpp[cu12]"` adds the CUDA 12 +provider (which also bundles Vulkan, so non-NVIDIA machines keep GPU +acceleration). + +## Running from a working tree + +The binding loads the native library at import and verifies its ABI layout and +version before use. Build a shared library and the binding finds it +automatically from the repo, or point `TRANSCRIBE_LIBRARY` at one: + +```bash +cmake -B build-shared -DTRANSCRIBE_BUILD_SHARED=ON +cmake --build build-shared --target transcribe + +cd bindings/python +PYTHONPATH=src uv run --no-project python examples/transcribe_wav.py \ + ../../models/whisper-tiny.en/whisper-tiny.en-Q5_K_M.gguf ../../samples/jfk.wav +``` + +Run the test suite. No-model tests (import, ABI layout, version gate, +status-code/enum agreement, PCM validation, provider selection) always run; +model tests skip when the default whisper-tiny.en + jfk.wav assets are absent +(override with `TRANSCRIBE_SMOKE_MODEL` / `TRANSCRIBE_SMOKE_AUDIO` / +`TRANSCRIBE_SMOKE_STREAMING_MODEL`): + +```bash +cd bindings/python +TRANSCRIBE_LIBRARY=../../build-shared/src/libtranscribe.dylib \ + uv run --extra test pytest +``` + +## Notes + +- One run/stream at a time per `Model` in 0.x: sessions share the model's + compute backend, so serialize runs across sessions (or load one model per + worker). See the `Model` docstring. +- Import package: `transcribe_cpp` +- Distribution: `transcribe-cpp` +- License: MIT diff --git a/bindings/python/_generate/README.md b/bindings/python/_generate/README.md new file mode 100644 index 00000000..9edd372f --- /dev/null +++ b/bindings/python/_generate/README.md @@ -0,0 +1,38 @@ +# FFI generator + +Generates `src/transcribe_cpp/_generated.py` — the low-level ctypes layer — from +`include/transcribe/extensions.h` using libclang. The generated module is +**committed**; it is never hand-edited. + +## Regenerate + +```bash +cd bindings/python +uv run --no-project --with 'libclang==18.1.1' _generate/generate.py +``` + +Run this whenever the public C headers change. libclang is pinned so the output +is deterministic across machines; the freestanding headers (`stdbool.h`, …) come +from the host compiler's resource dir, discovered via `clang -print-resource-dir` +(macOS: `xcrun`). + +## CI gate + +```bash +uv run --no-project --with 'libclang==18.1.1' _generate/generate.py --check +``` + +Exit non-zero if the committed `_generated.py` is out of date. Because the +generator works from the parsed AST, the check is **semantic**: a comment- or +whitespace-only header edit produces no diff, while any real ABI change (a field, +type, enum value, or function signature) does — and then fails CI until the +binding is regenerated. + +## What it emits + +- ctypes `Structure` for every public struct, field-for-field. +- Enum values as module constants. +- `configure(lib)` — `restype`/`argtypes` for every public function. +- `ABI_STRUCT_IDS` and `STRUCT_LAYOUT` (sizes/aligns/offsets), used by + `_abi.verify_layouts()` to check the layer against itself and the loaded + native library at import. diff --git a/bindings/python/_generate/check_version_sync.py b/bindings/python/_generate/check_version_sync.py new file mode 100644 index 00000000..0ed719da --- /dev/null +++ b/bindings/python/_generate/check_version_sync.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Fail if the library version drifts across its three sources of truth. + +The native library version is defined once, in +``include/transcribe.h`` (``TRANSCRIBE_VERSION_{MAJOR,MINOR,PATCH}``); CMake +parses it from there. The Python package repeats it in two more places — +``bindings/python/pyproject.toml`` (``project.version``) and the +``__version__`` in ``bindings/python/src/transcribe_cpp/__init__.py``. The +import-time gate enforces base-version match against the *loaded* library at +runtime; this script is the static, build-time counterpart so a forgotten bump +fails CI before anything is published. + +Comparison is on the PEP 440 *release segment* (``MAJOR.MINOR.PATCH``): the +header is always a clean triple, while the Python side may legitimately carry a +``.postN`` packaging suffix that must still be accepted. + + uv run --no-project bindings/python/_generate/check_version_sync.py + +Exit 0 when all three agree on the base version; 1 on drift; 2 if a version +could not be located (treated as a hard error, not a pass). +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] +HEADER = REPO / "include" / "transcribe.h" +PYPROJECT = REPO / "bindings" / "python" / "pyproject.toml" +INIT = REPO / "bindings" / "python" / "src" / "transcribe_cpp" / "__init__.py" + + +def base_version(version: str) -> str: + """The leading dotted-numeric release segment (suffix stripped).""" + m = re.match(r"\d+(?:\.\d+)*", version.strip()) + return m.group(0) if m else version.strip() + + +def header_version(text: str) -> str | None: + parts = [] + for component in ("MAJOR", "MINOR", "PATCH"): + m = re.search(rf"define\s+TRANSCRIBE_VERSION_{component}\s+(\d+)", text) + if not m: + return None + parts.append(m.group(1)) + return ".".join(parts) + + +def pyproject_version(text: str) -> str | None: + # project.version is a top-level string in [project]; match it directly + # rather than pulling in a TOML parser (tomllib is 3.11+). + m = re.search(r'(?m)^\s*version\s*=\s*"([^"]+)"', text) + return m.group(1) if m else None + + +def init_version(text: str) -> str | None: + m = re.search(r'(?m)^__version__\s*=\s*"([^"]+)"', text) + return m.group(1) if m else None + + +def native_pin_versions(text: str) -> "dict[str, str | None]": + # Every native-provider pin (the hard dependency AND accelerator extras) + # is the pre-1.0 base-version contract at resolver level: + # transcribe-cpp-native[-suffix]==X.Y.Z.* — X.Y.Z must be the same base + # as everything else. (The provider packages themselves can't drift: + # their versions are parsed from the header at build time.) + pins = re.findall( + r'"(transcribe-cpp-native(?:-[a-z0-9]+)*)\s*==\s*([0-9.]+?)\.\*"', text + ) + if not pins: + return {"pyproject.toml (native pin)": None} + return {f"pyproject.toml ({name} pin)": version for name, version in pins} + + +def main() -> int: + pyproject_text = PYPROJECT.read_text() + sources = { + "include/transcribe.h": header_version(HEADER.read_text()), + "pyproject.toml": pyproject_version(pyproject_text), + "__init__.__version__": init_version(INIT.read_text()), + } + sources.update(native_pin_versions(pyproject_text)) + + missing = [name for name, v in sources.items() if v is None] + if missing: + for name in missing: + print(f"error: could not locate the version in {name}", file=sys.stderr) + return 2 + + bases = {name: base_version(v) for name, v in sources.items()} # type: ignore[arg-type] + distinct = set(bases.values()) + if len(distinct) != 1: + print("version drift across sources (base MAJOR.MINOR.PATCH must agree):", + file=sys.stderr) + for name, v in sources.items(): + print(f" {name}: {v} (base {bases[name]})", file=sys.stderr) + return 1 + + base = distinct.pop() + detail = ", ".join(f"{name}={v}" for name, v in sources.items()) + print(f"version sync ok: base {base} ({detail})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bindings/python/_generate/generate.py b/bindings/python/_generate/generate.py new file mode 100644 index 00000000..5cbe09b0 --- /dev/null +++ b/bindings/python/_generate/generate.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""Generate the low-level ctypes FFI layer from the public C headers. + +This parses ``include/transcribe/extensions.h`` with libclang and emits +``src/transcribe_cpp/_generated.py`` — ctypes Structures (field-for-field from +the C structs, with the offsets the C compiler computed), enum constants, +function prototypes, and per-struct ABI metadata. The generated module is +committed; CI regenerates and runs ``git diff --exit-code`` so a header change +that is not reflected in the binding fails the build. + +Because libclang works from the parsed AST (not header text), the output is +semantic: a comment-only or whitespace header edit produces no diff, while any +real ABI change (a field, a type, an enum value, a signature) does. + +Usage: + uv run --no-project --with 'libclang==18.1.1' _generate/generate.py # write + uv run --no-project --with 'libclang==18.1.1' _generate/generate.py --check # CI gate +""" + +from __future__ import annotations + +import argparse +import hashlib +import subprocess +import sys +from pathlib import Path + +import clang.cindex as ci +from clang.cindex import CursorKind, TypeKind + +REPO = Path(__file__).resolve().parents[3] +INCLUDE = REPO / "include" +HEADER = INCLUDE / "transcribe" / "extensions.h" +OUTPUT = REPO / "bindings" / "python" / "src" / "transcribe_cpp" / "_generated.py" + +# Fixed-width / size typedefs map to fixed-width ctypes so the generated layout +# is correct on every target (never c_long, whose width differs LP64 vs LLP64). +_SPELLING_INT = { + "int8_t": "c_int8", "uint8_t": "c_uint8", + "int16_t": "c_int16", "uint16_t": "c_uint16", + "int32_t": "c_int32", "uint32_t": "c_uint32", + "int64_t": "c_int64", "uint64_t": "c_uint64", + "size_t": "c_size_t", "ssize_t": "c_ssize_t", + "intptr_t": "c_ssize_t", "uintptr_t": "c_size_t", +} +_KIND_SCALAR = { + TypeKind.VOID: None, + TypeKind.BOOL: "c_bool", + TypeKind.CHAR_S: "c_char", TypeKind.SCHAR: "c_char", + TypeKind.CHAR_U: "c_ubyte", TypeKind.UCHAR: "c_ubyte", + TypeKind.SHORT: "c_short", TypeKind.USHORT: "c_ushort", + TypeKind.INT: "c_int", TypeKind.UINT: "c_uint", + TypeKind.LONG: "c_long", TypeKind.ULONG: "c_ulong", + TypeKind.LONGLONG: "c_longlong", TypeKind.ULONGLONG: "c_ulonglong", + TypeKind.FLOAT: "c_float", TypeKind.DOUBLE: "c_double", +} + + +def clang_args() -> list[str]: + args = ["-x", "c", "-std=c11", f"-I{INCLUDE}"] + # Freestanding headers (stdbool/stdint/stddef) live in the compiler's + # resource dir; the bundled libclang does not ship them. + try: + resdir = subprocess.check_output( + ["xcrun", "clang", "-print-resource-dir"], text=True, stderr=subprocess.DEVNULL + ).strip() + args.append(f"-isystem{resdir}/include") + sdk = subprocess.check_output( + ["xcrun", "--show-sdk-path"], text=True, stderr=subprocess.DEVNULL + ).strip() + args.append(f"-isysroot{sdk}") + except Exception: + try: + resdir = subprocess.check_output( + ["clang", "-print-resource-dir"], text=True + ).strip() + args.append(f"-isystem{resdir}/include") + except Exception: + pass + return args + + +def in_headers(cursor) -> bool: + f = cursor.location.file + return f is not None and str(INCLUDE) in str(f.name) + + +def int_macro_value(value_tokens): + """Parse an object-like macro's value as an int, or None if it isn't one. + + Captures integer constants (EXT kind FourCCs, version components); skips + string, attribute, and float/expression macros, which don't parse as int. + """ + s = "".join(value_tokens).rstrip("uUlL") + try: + return int(s, 0) + except ValueError: + return None + + +class Surface: + def __init__(self): + self.enum_constants: list[tuple[str, int]] = [] + self.macros: list[tuple[str, int]] = [] # name -> int value + self.structs: dict = {} # name -> cursor + self.struct_order: list[str] = [] + self.functions: dict = {} # name -> cursor + self.abi_ids: dict = {} # struct name -> transcribe_abi_struct id + + +def collect(tu) -> Surface: + s = Surface() + seen_enum = set() + seen_macro = set() + for c in tu.cursor.walk_preorder(): + if not in_headers(c): + continue + if c.kind == CursorKind.ENUM_DECL and c.is_definition(): + for e in c.get_children(): + if e.kind == CursorKind.ENUM_CONSTANT_DECL and e.spelling not in seen_enum: + seen_enum.add(e.spelling) + s.enum_constants.append((e.spelling, e.enum_value)) + elif c.kind == CursorKind.MACRO_DEFINITION and c.spelling.startswith("TRANSCRIBE_"): + if c.spelling in seen_macro: + continue + tokens = [t.spelling for t in c.get_tokens()] + if len(tokens) < 2: + continue # bare guard macro, no value + value = int_macro_value(tokens[1:]) + if value is not None: + seen_macro.add(c.spelling) + s.macros.append((c.spelling, value)) + elif c.kind == CursorKind.STRUCT_DECL and c.is_definition() and c.spelling: + if c.spelling not in s.structs: + s.structs[c.spelling] = c + s.struct_order.append(c.spelling) + elif c.kind == CursorKind.FUNCTION_DECL and c.spelling not in s.functions: + s.functions[c.spelling] = c + + # Map transcribe_abi_struct enum members to struct names: the member + # TRANSCRIBE_ABI_RUN_PARAMS corresponds to struct transcribe_run_params. + for name, value in s.enum_constants: + if name.startswith("TRANSCRIBE_ABI_"): + struct = "transcribe_" + name[len("TRANSCRIBE_ABI_"):].lower() + if struct in s.structs: + s.abi_ids[struct] = value + s.macros.sort(key=lambda kv: kv[0]) # name order for deterministic output + return s + + +def map_type(t, structs: dict) -> str: + """Return a ctypes expression (``_c.``-prefixed) for a clang Type.""" + spelling = t.spelling.replace("const ", "").replace("volatile ", "").strip() + k = t.kind + + if k == TypeKind.POINTER: + pointee = t.get_pointee() + cp = pointee.get_canonical() + if cp.kind == TypeKind.FUNCTIONPROTO: + ret = map_type(cp.get_result(), structs) or "None" + args = [map_type(a, structs) for a in cp.argument_types()] + return f"_c.CFUNCTYPE({', '.join([ret] + args)})" + if cp.kind in (TypeKind.CHAR_S, TypeKind.CHAR_U, TypeKind.SCHAR, TypeKind.UCHAR): + return "_c.c_char_p" + if cp.kind == TypeKind.VOID: + return "_c.c_void_p" + if cp.kind == TypeKind.RECORD: + name = cp.get_declaration().spelling + return f"_c.POINTER({name})" if name in structs else "_c.c_void_p" + inner = map_type(pointee, structs) + return f"_c.POINTER({inner})" if inner and inner != "None" else "_c.c_void_p" + + if spelling in _SPELLING_INT: + return "_c." + _SPELLING_INT[spelling] + if k == TypeKind.ENUM: + return "_c.c_int" + if k in (TypeKind.TYPEDEF, TypeKind.ELABORATED): + return map_type(t.get_canonical(), structs) + if k == TypeKind.RECORD: + return t.get_declaration().spelling + if k == TypeKind.CONSTANTARRAY: + return f"({map_type(t.element_type, structs)} * {t.element_count})" + if k in _KIND_SCALAR: + v = _KIND_SCALAR[k] + return "_c." + v if v else "None" + raise SystemExit(f"unmapped type: {t.spelling!r} kind={k}") + + +def struct_fields(cursor): + return [f for f in cursor.get_children() if f.kind == CursorKind.FIELD_DECL] + + +def order_by_value_deps(structs: dict, order: list[str]) -> list[str]: + """Topologically order structs so a by-value member is defined first.""" + deps = {} + for name in order: + d = set() + for f in struct_fields(structs[name]): + ft = f.type.get_canonical() + if ft.kind == TypeKind.RECORD: + dep = ft.get_declaration().spelling + if dep in structs: + d.add(dep) + elif ft.kind == TypeKind.CONSTANTARRAY: + et = ft.element_type.get_canonical() + if et.kind == TypeKind.RECORD and et.get_declaration().spelling in structs: + d.add(et.get_declaration().spelling) + deps[name] = d + + out, placed = [], set() + while len(out) < len(order): + progress = False + for name in order: + if name in placed: + continue + if deps[name] <= placed: + out.append(name) + placed.add(name) + progress = True + if not progress: # cycle (shouldn't happen for this API) + raise SystemExit(f"by-value struct cycle among {set(order) - placed}") + return out + + +def render(s: Surface, libclang_version: str) -> str: + # The structural payload (everything below the imports) is built first so a + # stable digest can be hashed over it and emitted as PUBLIC_HEADER_HASH. That + # digest is the coarse cross-language ABI tag a native provider echoes back + # at load time: it changes for any real ABI change (a field, type, enum + # value, signature, layout) and stays put for a comment-only header edit, + # exactly like the generator's own drift gate. It does NOT cover the libclang + # version line (that lives in the docstring, above the hashed body). + body: list[str] = [] + b = body.append + + b("# === enum constants ===") + for name, value in s.enum_constants: + b(f"{name} = {value}") + b("") + + b("# === macro constants (integer object-like macros) ===") + for name, value in s.macros: + b(f"{name} = {value}") + b("") + + struct_names = set(s.structs) + b("# === structs ===") + for name in s.struct_order: + b(f"class {name}(_c.Structure):") + b(" pass") + b("") + + layout = {} + for name in order_by_value_deps(s.structs, s.struct_order): + cursor = s.structs[name] + fields, offsets = [], {} + for f in struct_fields(cursor): + fields.append((f.spelling, map_type(f.type, struct_names))) + offsets[f.spelling] = cursor.type.get_offset(f.spelling) // 8 + joined = ", ".join(f'("{fn}", {ct})' for fn, ct in fields) + b(f"{name}._fields_ = [{joined}]") + layout[name] = { + "size": cursor.type.get_size(), + "align": cursor.type.get_align(), + "offsets": offsets, + } + b("") + + b("# === ABI metadata ===") + b("# transcribe_abi_struct id per struct (for the native size/align check).") + b("ABI_STRUCT_IDS = {") + for name in s.struct_order: + if name in s.abi_ids: + b(f" {name!r}: {s.abi_ids[name]},") + b("}") + b("") + b("# C-compiler layout captured at generation (for offset self-check).") + b("STRUCT_LAYOUT = {") + for name in s.struct_order: + lo = layout[name] + b(f" {name!r}: {{'size': {lo['size']}, 'align': {lo['align']}, " + f"'offsets': {lo['offsets']!r}}},") + b("}") + b("") + + b("# === function prototypes ===") + b("def configure(lib):") + b(' """Stamp restype/argtypes onto a loaded CDLL."""') + for name in sorted(s.functions): + fn = s.functions[name] + restype = map_type(fn.result_type, struct_names) + argtypes = [map_type(a.type, struct_names) for a in fn.get_arguments()] + b(f" lib.{name}.restype = {restype}") + b(f" lib.{name}.argtypes = [{', '.join(argtypes)}]") + b("") + + digest = hashlib.sha256("\n".join(body).encode("utf-8")).hexdigest()[:16] + + out = [] + w = out.append + w('"""Low-level ctypes FFI for transcribe.cpp — AUTOGENERATED. DO NOT EDIT.') + w("") + w("Regenerate with:") + w(" uv run --no-project --with 'libclang==18.1.1' \\") + w(" bindings/python/_generate/generate.py") + w("") + w("Source: include/transcribe/extensions.h") + w(f"libclang: {libclang_version}") + w('"""') + w("") + w("import ctypes as _c") + w("") + w("# Stable digest of the ABI surface below (structs, enums, macros, layout,") + w("# prototypes). A native provider package echoes this back so the API") + w("# package can reject an ABI-mismatched provider before dlopen.") + w(f'PUBLIC_HEADER_HASH = "{digest}"') + w("") + out.extend(body) + return "\n".join(out) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--check", action="store_true", + help="exit non-zero if the committed file is out of date") + args = ap.parse_args() + + try: + libclang_version = __import__("importlib.metadata", fromlist=["version"]).version( + "libclang" + ) + except Exception: + libclang_version = "unknown" + + tu = ci.Index.create().parse( + str(HEADER), args=clang_args(), + options=ci.TranslationUnit.PARSE_DETAILED_PROCESSING_RECORD, + ) + errors = [d for d in tu.diagnostics if d.severity >= ci.Diagnostic.Error] + if errors: + for d in errors: + print(f"clang error: {d.spelling} at {d.location}", file=sys.stderr) + return 2 + + text = render(collect(tu), libclang_version) + + if args.check: + current = OUTPUT.read_text() if OUTPUT.exists() else "" + if current != text: + print(f"{OUTPUT} is out of date — regenerate with " + "_generate/generate.py", file=sys.stderr) + return 1 + print(f"{OUTPUT.name} is up to date") + return 0 + + OUTPUT.write_text(text) + print(f"wrote {OUTPUT}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bindings/python/examples/stream_wav.py b/bindings/python/examples/stream_wav.py new file mode 100644 index 00000000..b0371383 --- /dev/null +++ b/bindings/python/examples/stream_wav.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Stream a 16 kHz mono WAV through a streaming model, chunk by chunk. + + uv run examples/stream_wav.py [--chunk-ms 250] + +Demonstrates the streaming surface: ``Session.stream()`` returns a Stream you +``feed()`` PCM chunks into; ``text()`` exposes the two UI-facing views — +``committed`` (append-only, stable: safe to act on) and ``tentative`` (the +volatile suffix that may still be revised). ``display`` is what a live UI +shows. ``finalize()`` flushes the last hypothesis when the audio ends, and +context-manager exit resets the session back to idle. + +The model must advertise streaming (``Model.capabilities.supports_streaming``) +— e.g. moonshine-streaming-tiny; whisper does not stream. ``--realtime`` +sleeps each chunk's duration so the schedule (and any family decode +throttles) behaves like live microphone input. +""" + +from __future__ import annotations + +import argparse +import array +import sys +import time +import wave + +import transcribe_cpp + + +def load_wav_mono16k(path: str) -> array.array: + with wave.open(path, "rb") as w: + if w.getsampwidth() != 2 or w.getframerate() != 16000 or w.getnchannels() != 1: + raise SystemExit( + f"{path}: expected 16 kHz 16-bit mono — resample first, e.g. " + f"ffmpeg -i in.wav -ar 16000 -ac 1 out.wav" + ) + pcm16 = array.array("h") + pcm16.frombytes(w.readframes(w.getnframes())) + if sys.byteorder == "big": + pcm16.byteswap() + return array.array("f", (s / 32768.0 for s in pcm16)) + + +def render(text: "transcribe_cpp.StreamText") -> None: + # One status line: committed text plain, tentative dimmed. \r-rewrite is + # enough for a demo; a real UI keys off StreamUpdate.committed_changed / + # tentative_changed to redraw minimally. + line = f"{text.committed}\x1b[2m{text.tentative}\x1b[0m" + print(f"\r\x1b[K{line}", end="", flush=True) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("model") + ap.add_argument("audio") + ap.add_argument("--chunk-ms", type=int, default=250, + help="feed size in milliseconds (default: 250)") + ap.add_argument("--realtime", action="store_true", + help="sleep each chunk's duration (simulate a live mic)") + ap.add_argument("--language", default=None) + args = ap.parse_args() + + pcm = load_wav_mono16k(args.audio) + chunk = max(1, int(16000 * args.chunk_ms / 1000)) + + with transcribe_cpp.Model(args.model) as model: + caps = model.capabilities + if not caps.supports_streaming: + raise SystemExit( + f"{model.arch}/{model.variant} does not support streaming — " + "use a streaming model (e.g. moonshine-streaming-tiny)" + ) + print(f"model: {model.arch}/{model.variant} on {model.backend} | " + f"feeding {args.chunk_ms} ms chunks" + f"{' at realtime pace' if args.realtime else ''}") + + with model.session() as session: + with session.stream(language=args.language) as stream: + for i in range(0, len(pcm), chunk): + update = stream.feed(pcm[i:i + chunk]) + if update.committed_changed or update.tentative_changed: + render(stream.text()) + if args.realtime: + time.sleep(args.chunk_ms / 1000) + update = stream.finalize() + final = stream.text() + render(final) + print() # land the cursor after the live line + print(f"\nfinal ({update.audio_committed_ms / 1000:.1f}s audio, " + f"revision {stream.revision}):\n{final.committed.strip()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bindings/python/examples/transcribe_wav.py b/bindings/python/examples/transcribe_wav.py new file mode 100644 index 00000000..7c881ed0 --- /dev/null +++ b/bindings/python/examples/transcribe_wav.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Transcribe a 16 kHz mono WAV with the transcribe_cpp bindings. + + uv run examples/transcribe_wav.py [--timestamps segment] + +WAV decoding here uses only the stdlib ``wave`` module — the binding itself +takes float32 PCM and does not decode containers or resample. Point +TRANSCRIBE_LIBRARY at a libtranscribe shared library, or run from a working tree +with a shared build (cmake -DTRANSCRIBE_BUILD_SHARED=ON). +""" + +from __future__ import annotations + +import argparse +import array +import sys +import wave + +import transcribe_cpp + + +def load_wav_mono16k(path: str) -> array.array: + with wave.open(path, "rb") as w: + n_channels = w.getnchannels() + sample_width = w.getsampwidth() + framerate = w.getframerate() + frames = w.readframes(w.getnframes()) + + if sample_width != 2: + raise SystemExit(f"{path}: expected 16-bit PCM, got {sample_width * 8}-bit") + if framerate != 16000: + raise SystemExit( + f"{path}: expected 16 kHz, got {framerate} Hz — resample first, e.g. " + f"ffmpeg -i in.wav -ar 16000 -ac 1 out.wav" + ) + + pcm16 = array.array("h") + pcm16.frombytes(frames) + if sys.byteorder == "big": + pcm16.byteswap() + + if n_channels > 1: # downmix to mono + mono = array.array("h", [0]) * (len(pcm16) // n_channels) + for i in range(len(mono)): + acc = sum(pcm16[i * n_channels + c] for c in range(n_channels)) + mono[i] = int(acc / n_channels) + pcm16 = mono + + return array.array("f", (s / 32768.0 for s in pcm16)) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("model") + ap.add_argument("audio") + ap.add_argument("--backend", default="auto") + ap.add_argument("--timestamps", default="none", + choices=["none", "auto", "segment", "word", "token"]) + ap.add_argument("--language", default=None) + args = ap.parse_args() + + print(f"transcribe_cpp {transcribe_cpp.__version__} | native " + f"{transcribe_cpp.native_version()} ({transcribe_cpp.native_commit()})") + print(f"library: {transcribe_cpp.library_path()}") + + pcm = load_wav_mono16k(args.audio) + + with transcribe_cpp.Model(args.model, backend=args.backend) as model: + caps = model.capabilities + print(f"model: {model.arch}/{model.variant} on {model.backend} | " + f"max_ts={caps.max_timestamp_kind} translate={caps.supports_translate}") + with model.session() as session: + result = session.run( + pcm, timestamps=args.timestamps, language=args.language + ) + + print(f"\nlanguage: {result.language or '(n/a)'} | " + f"timestamps: {result.timestamp_kind} | " + f"encode {result.timings.encode_ms:.0f} ms") + print(f"\n{result.text.strip()}\n") + + if result.segments and result.timestamp_kind != "none": + for seg in result.segments: + print(f" [{seg.t0_ms / 1000:6.2f} -> {seg.t1_ms / 1000:6.2f}] " + f"{seg.text.strip()}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml new file mode 100644 index 00000000..612db5e2 --- /dev/null +++ b/bindings/python/pyproject.toml @@ -0,0 +1,71 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "transcribe-cpp" +version = "0.0.1" +description = "Python bindings for transcribe.cpp" +readme = "README.md" +# 3.8 is EOL (2024-10); 3.9 is the floor. The binding is ctypes-only, so there +# is no per-version compiled extension — one pure-Python package spans the range. +requires-python = ">=3.9" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "The transcribe.cpp authors" }] +keywords = ["transcription", "speech-to-text", "asr", "ggml", "whisper", "parakeet"] +classifiers = [ + "Development Status :: 1 - Planning", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Sound/Audio :: Speech", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] + +# The pure-Python API package hard-depends on the default native provider so +# `pip install transcribe-cpp` transcribes out of the box. The `==X.Y.Z.*` pin +# is the pre-1.0 base-version contract enforced at the resolver level (a .postN +# packaging fix still resolves); the import-time version/header-hash check in +# _library.py is the runtime backstop. check_version_sync.py gates this pin +# against include/transcribe.h. +dependencies = ["transcribe-cpp-native==0.0.1.*"] + +[project.optional-dependencies] +# Opt-in accelerator providers — ADDITIVE: they install alongside the default +# provider and the best one wins at runtime. Same base-version pin contract +# as the hard dependency (gated by check_version_sync.py). +cu12 = ["transcribe-cpp-native-cu12==0.0.1.*"] +# Test-only deps. Run with: uv run --extra test pytest (from bindings/python). +# numpy is here so the numpy PCM-input tests run in every lane instead of +# silently skipping wherever numpy happens to be absent. +test = ["pytest>=7", "numpy"] + +[project.urls] +Homepage = "https://github.com/handy-computer/transcribe.cpp" +Repository = "https://github.com/handy-computer/transcribe.cpp" +Issues = "https://github.com/handy-computer/transcribe.cpp/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/transcribe_cpp"] + +[tool.uv] +# Dev/CI environments load a locally built library (TRANSCRIBE_LIBRARY or the +# build-tree fallback) — they must not pull the native provider from an index +# (it isn't published until release, and a prebuilt artifact would shadow the +# library under test). The provably-false marker removes the dependency for uv +# project operations only; pip installs of built distributions keep the pin. +override-dependencies = [ + "transcribe-cpp-native; python_version < '0'", + "transcribe-cpp-native-cu12; python_version < '0'", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +# 77 is the historical CTest "skipped" code; pytest uses skip markers instead. +addopts = "-ra" diff --git a/bindings/python/src/transcribe_cpp/__init__.py b/bindings/python/src/transcribe_cpp/__init__.py new file mode 100644 index 00000000..b8c5691b --- /dev/null +++ b/bindings/python/src/transcribe_cpp/__init__.py @@ -0,0 +1,1283 @@ +"""Python bindings for transcribe.cpp — a ggml speech-to-text library. + + import transcribe_cpp + + with transcribe_cpp.Model("model.gguf") as model: + with model.session() as session: + result = session.run(pcm_float32_16k_mono) + print(result.text) + +The native library is loaded at import time; its ABI layout and version are +verified against this binding before any model is touched (see +``_abi.verify_layouts`` and the version check below). PCM passed to ``run`` must +be 16 kHz mono float32; resample external audio first, e.g.:: + + ffmpeg -i in.wav -ar 16000 -ac 1 -f f32le out.f32 + +Long-running native calls (model load, run) release the GIL — ctypes does this +for every foreign call — so other Python threads make progress during inference. +""" + +from __future__ import annotations + +import ctypes +import os +import threading +import weakref +from dataclasses import dataclass +from typing import Literal, Sequence, Union + +from . import _abi, _generated +from ._library import _base_version, artifact_dir, load_library, selected_provider +from .errors import ( + AbiError, + Aborted, + BackendError, + InputTooLong, + InvalidArgument, + ModelFileNotFound, + ModelLoadError, + NotImplementedByModel, + OutOfMemory, + OutputTruncated, + TranscribeError, + UnsupportedRequest, + exception_for_status, + raise_for_status, +) + +__version__ = "0.0.1" + +# String-enum types, exported so callers (and type checkers) can name them. +Backend = Literal["auto", "cpu", "metal", "vulkan", "cpu_accel", "cuda"] +KVType = Literal["auto", "f32", "f16"] +Task = Literal["transcribe", "translate"] +Timestamps = Literal["none", "auto", "segment", "word", "token"] +CommitPolicy = Literal["auto", "on_finalize", "stable_prefix"] +Feature = Literal[ + "initial_prompt", "temperature_fallback", "long_form", + "cancellation", "pnc", "itn", +] + +__all__ = [ + "__version__", + "transcribe", + "Model", + "Session", + "Result", + "Segment", + "Word", + "Token", + "Capabilities", + "SessionLimits", + "Timings", + "Stream", + "StreamUpdate", + "StreamText", + "FamilyExtension", + "WhisperRunOptions", + "MoonshineStreamingOptions", + "ParakeetStreamOptions", + "ParakeetBufferedStreamOptions", + "VoxtralRealtimeStreamOptions", + "Backend", + "KVType", + "Task", + "Timestamps", + "CommitPolicy", + "Feature", + "TranscribeError", + "InvalidArgument", + "ModelFileNotFound", + "ModelLoadError", + "NotImplementedByModel", + "OutOfMemory", + "BackendError", + "UnsupportedRequest", + "AbiError", + "Aborted", + "InputTooLong", + "OutputTruncated", + "native_version", + "native_commit", + "library_path", + "native_provider", + "BackendDevice", + "backends", + "backend_available", + "set_log_callback", +] + +# --- native library bootstrap -------------------------------------------- + +_lib, _lib_path = load_library() +_generated.configure(_lib) +_abi.verify_layouts(_lib) + +# _base_version is single-sourced in _library (re-exported here so the import +# gate and tests can reach it as transcribe_cpp._base_version). Pre-1.0 the +# Python package and native library must agree on the base (MAJOR.MINOR.PATCH) +# release segment exactly; a packaging-only fix (0.0.1.post1) keeps the same +# base and so still loads against the 0.0.1 native library. +_native_version = _lib.transcribe_version().decode("ascii") +if _base_version(_native_version) != _base_version(__version__): + raise TranscribeError( + f"transcribe_cpp {__version__} cannot use native library " + f"{_native_version}: pre-1.0 requires a matching base " + f"(MAJOR.MINOR.PATCH) version. Rebuild the native library at the " + "matching version or install a matching native provider." + ) + +# Load ggml backend modules from the artifact directory (package-local; a +# no-op for static/compiled-in builds). In a dynamic-backend build this is +# what registers CPU/Vulkan/... devices, and a Vulkan module on a machine +# without Vulkan simply fails to load while CPU keeps working. Raises only +# when the process ends up with ZERO compute devices. +_artifact = artifact_dir() +if _artifact is not None: + _status = _lib.transcribe_init_backends(os.fspath(_artifact).encode("utf-8")) + if _status != 0: + raise BackendError( + f"no usable compute backend: transcribe_init_backends({_artifact}) " + f"reported {_lib.transcribe_status_string(_status).decode('utf-8', 'replace')}. " + "In a dynamic-backend build the ggml backend modules must sit next " + "to the native library." + ) + +_byref = ctypes.byref + +# Callback function types — must match the generated argtypes for +# transcribe_log_set / transcribe_set_abort_callback. ctypes acquires the GIL +# when C invokes these, and a CFUNCTYPE instance must be kept alive for as long +# as C may call it (a module global for logging; on the Session for abort). +_LOG_CFUNC = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_char_p, ctypes.c_void_p) +_ABORT_CFUNC = ctypes.CFUNCTYPE(ctypes.c_bool, ctypes.c_void_p) + +# Struct aliases onto the generated ctypes layer (the low-level names mirror the +# C tags; alias them for readability here). +_ModelLoadParams = _generated.transcribe_model_load_params +_SessionParams = _generated.transcribe_session_params +_RunParams = _generated.transcribe_run_params +_Capabilities = _generated.transcribe_capabilities +_Timings = _generated.transcribe_timings +_Segment = _generated.transcribe_segment +_Word = _generated.transcribe_word +_Token = _generated.transcribe_token +_StreamParams = _generated.transcribe_stream_params +_StreamUpdate = _generated.transcribe_stream_update +_StreamText = _generated.transcribe_stream_text + +# --- enum maps (values sourced from the generated enum constants) --------- + +_BACKENDS = { + "auto": _generated.TRANSCRIBE_BACKEND_AUTO, + "cpu": _generated.TRANSCRIBE_BACKEND_CPU, + "metal": _generated.TRANSCRIBE_BACKEND_METAL, + "vulkan": _generated.TRANSCRIBE_BACKEND_VULKAN, + "cpu_accel": _generated.TRANSCRIBE_BACKEND_CPU_ACCEL, + "cuda": _generated.TRANSCRIBE_BACKEND_CUDA, +} +_KV_TYPES = { + "auto": _generated.TRANSCRIBE_KV_TYPE_AUTO, + "f32": _generated.TRANSCRIBE_KV_TYPE_F32, + "f16": _generated.TRANSCRIBE_KV_TYPE_F16, +} +_TASKS = { + "transcribe": _generated.TRANSCRIBE_TASK_TRANSCRIBE, + "translate": _generated.TRANSCRIBE_TASK_TRANSLATE, +} +_TIMESTAMPS = { + "none": _generated.TRANSCRIBE_TIMESTAMPS_NONE, + "auto": _generated.TRANSCRIBE_TIMESTAMPS_AUTO, + "segment": _generated.TRANSCRIBE_TIMESTAMPS_SEGMENT, + "word": _generated.TRANSCRIBE_TIMESTAMPS_WORD, + "token": _generated.TRANSCRIBE_TIMESTAMPS_TOKEN, +} +_TIMESTAMP_NAMES = {v: k for k, v in _TIMESTAMPS.items()} +_COMMIT_POLICIES = { + "auto": _generated.TRANSCRIBE_STREAM_COMMIT_AUTO, + "on_finalize": _generated.TRANSCRIBE_STREAM_COMMIT_ON_FINALIZE, + "stable_prefix": _generated.TRANSCRIBE_STREAM_COMMIT_STABLE_PREFIX, +} +_STREAM_STATES = { + _generated.TRANSCRIBE_STREAM_IDLE: "idle", + _generated.TRANSCRIBE_STREAM_ACTIVE: "active", + _generated.TRANSCRIBE_STREAM_FINISHED: "finished", + _generated.TRANSCRIBE_STREAM_FAILED: "failed", +} +_EXT_SLOTS = { + "run": _generated.TRANSCRIBE_EXT_SLOT_RUN, + "stream": _generated.TRANSCRIBE_EXT_SLOT_STREAM, +} +_FEATURES = { + "initial_prompt": _generated.TRANSCRIBE_FEATURE_INITIAL_PROMPT, + "temperature_fallback": _generated.TRANSCRIBE_FEATURE_TEMPERATURE_FALLBACK, + "long_form": _generated.TRANSCRIBE_FEATURE_LONG_FORM, + "cancellation": _generated.TRANSCRIBE_FEATURE_CANCELLATION, + "pnc": _generated.TRANSCRIBE_FEATURE_PNC, + "itn": _generated.TRANSCRIBE_FEATURE_ITN, +} + + +def native_version() -> str: + """Version string of the loaded native library, e.g. ``"0.0.1"``.""" + return _native_version + + +def native_commit() -> str: + """Git commit the native library was built from, or ``"unknown"``.""" + return _lib.transcribe_version_commit().decode("ascii") + + +def library_path() -> str: + """Filesystem path of the loaded native library.""" + return str(_lib_path) + + +def native_provider() -> str | None: + """Name of the installed provider package the native library was loaded + from, or None for a dev-tree / ``TRANSCRIBE_LIBRARY`` load.""" + return selected_provider() + + +@dataclass(frozen=True) +class BackendDevice: + """One registered compute device (owned copies of the C strings).""" + + name: str + description: str + kind: str # "cpu" | "accel" | "metal" | "vulkan" | "cuda" | "sycl" | "gpu" | "unknown" + + +def backends() -> list[BackendDevice]: + """The compute devices registered with the native runtime — what the + process can actually run on, after backend-module loading and graceful + degradation (e.g. a Vulkan module skipped on a machine without Vulkan).""" + devices = [] + for i in range(_lib.transcribe_backend_device_count()): + dev = _generated.transcribe_backend_device() + _lib.transcribe_backend_device_init(_byref(dev)) + _check(_lib.transcribe_get_backend_device(i, _byref(dev)), + f"reading backend device {i}") + devices.append(BackendDevice( + name=_decode(dev.name), + description=_decode(dev.description), + kind=_decode(dev.kind), + )) + return devices + + +def backend_available(backend: Backend) -> bool: + """Whether ``Model(..., backend=...)`` can be satisfied on this machine — + the probe that turns ``backend="vulkan"`` without Vulkan into a clear + answer before any model load.""" + return bool(_lib.transcribe_backend_available( + _enum(_BACKENDS, backend, "backend"))) + + +_log_handler = None +_log_trampoline = None + + +def _log_thunk(level, msg, _userdata): + handler = _log_handler + if handler is not None: + try: + handler(level, msg.decode("utf-8", "replace") if msg else "") + except Exception: + pass # a handler error must never propagate back into C + + +def set_log_callback(handler) -> None: + """Route native log messages to ``handler(level: int, message: str)``; pass + None to silence. + + Install ONCE at startup, before loading models or creating threads (the 0.x + contract for transcribe_log_set). The handler may be invoked from ggml worker + threads, so it must be thread-safe — route to the ``logging`` module or a + queue rather than doing heavy work inline. Levels mirror + TRANSCRIBE_LOG_LEVEL_* (1=info, 2=warn, 3=error, 4=debug).""" + global _log_handler, _log_trampoline + _log_handler = handler + if _log_trampoline is None: + # Install the trampoline once; later calls just swap the Python handler + # behind it, so transcribe_log_set is never re-invoked after threads run. + _log_trampoline = _LOG_CFUNC(_log_thunk) + _lib.transcribe_log_set(_log_trampoline, None) + + +def _status_string(status: int) -> str: + return _lib.transcribe_status_string(status).decode("utf-8", "replace") + + +def _check(status: int, context: str = "") -> None: + raise_for_status(status, _status_string(status), context) + + +def _decode(value) -> str: + return value.decode("utf-8", "replace") if value else "" + + +def _enum(mapping: dict, key: str, what: str) -> int: + try: + return mapping[key] + except KeyError: + raise InvalidArgument( + f"unknown {what} {key!r}; expected one of {sorted(mapping)}" + ) from None + + +PCMLike = Union["ctypes.Array", bytes, bytearray, memoryview, Sequence[float]] + + +def _pcm_to_carray(pcm: PCMLike): + """Copy *pcm* into a ``c_float`` array, returning ``(array, n_samples)``.""" + if isinstance(pcm, ctypes.Array) and pcm._type_ is ctypes.c_float: + n = len(pcm) + if n == 0: + raise InvalidArgument("empty PCM buffer") + return pcm, n + + try: + mv = memoryview(pcm) + except TypeError: + seq = [float(x) for x in pcm] + if not seq: + raise InvalidArgument("empty PCM buffer") + return (ctypes.c_float * len(seq))(*seq), len(seq) + + with mv: + if not mv.contiguous: + raise InvalidArgument("PCM buffer must be C-contiguous") + if mv.ndim != 1: + # A (frames, channels) stereo array is the classic mistake here. + # Accepting it and reading shape[0] samples would transcribe + # silent garbage; reject loudly instead. + raise InvalidArgument( + f"PCM buffer must be 1-D (got shape {mv.shape}); downmix " + "multichannel audio to mono and flatten, e.g. " + "audio.mean(axis=1).astype('float32') for a (frames, " + "channels) numpy array" + ) + if mv.format == "f": + floats = mv + elif mv.itemsize == 1: # raw bytes: interpret as little-endian float32 + raw = mv.cast("B") + if raw.nbytes % 4: + raise InvalidArgument( + "raw PCM byte buffer length is not a multiple of 4 (float32)" + ) + floats = raw.cast("f") + else: + raise InvalidArgument( + f"PCM must be float32 (got buffer format {mv.format!r}); convert " + "with array('f', ...) or numpy.asarray(x, dtype='float32')" + ) + n = floats.shape[0] + if n == 0: + raise InvalidArgument("empty PCM buffer") + return (ctypes.c_float * n).from_buffer_copy(floats), n + + +# --- result value objects ------------------------------------------------- + + +@dataclass(frozen=True) +class Segment: + text: str + t0_ms: int + t1_ms: int + first_word: int + n_words: int + first_token: int + n_tokens: int + + +@dataclass(frozen=True) +class Word: + text: str + t0_ms: int + t1_ms: int + seg_index: int + first_token: int + n_tokens: int + + +@dataclass(frozen=True) +class Token: + text: str + id: int + p: float + t0_ms: int + t1_ms: int + seg_index: int + word_index: int + + +@dataclass(frozen=True) +class Timings: + load_ms: float + mel_ms: float + encode_ms: float + decode_ms: float + + +@dataclass(frozen=True) +class Capabilities: + native_sample_rate: int + languages: tuple[str, ...] + max_timestamp_kind: str + supports_language_detect: bool + supports_translate: bool + supports_streaming: bool + supports_spec_decode: bool + max_audio_ms: int + + +@dataclass(frozen=True) +class SessionLimits: + """Effective per-session limits (model bound narrowed by session params). + + ``effective_max_audio_ms`` is the input ceiling THIS session enforces — + unlike ``Capabilities.max_audio_ms`` it reflects a lowered ``n_ctx``. + 0 means unbounded, matching the capabilities convention. Check it to + size input before a run instead of discovering ``InputTooLong``.""" + + effective_n_ctx: int + effective_max_audio_ms: int + max_kv_bytes: int + + +@dataclass(frozen=True) +class Result: + """A fully materialized transcription. Holds no native pointers: every + string and row was copied out of the session before this object was + returned, so it stays valid after later runs.""" + + text: str + language: str + timestamp_kind: str + segments: tuple[Segment, ...] + words: tuple[Word, ...] + tokens: tuple[Token, ...] + timings: Timings + + +@dataclass(frozen=True) +class StreamUpdate: + """Per-call change metadata returned by Stream.feed()/finalize().""" + + result_changed: bool + is_final: bool + revision: int + input_received_ms: int + audio_committed_ms: int + buffered_ms: int + committed_changed: bool + tentative_changed: bool + + +@dataclass(frozen=True) +class StreamText: + """The UI-facing text views of an active stream (owned copies). + + ``committed`` is append-only and stable; ``tentative`` is the volatile + suffix; ``display`` is what a UI should show. ``full`` is the raw model + hypothesis (may not be ``committed + tentative`` after a revision).""" + + full: str + committed: str + tentative: str + + @property + def display(self) -> str: + return self.committed + self.tentative + + +# --- materialization helpers ---------------------------------------------- +# Build owned value objects by copying out of the ctypes row structs. Shared by +# the single-result and per-utterance (batch) accessor paths. + + +def _segment_from(s) -> Segment: + return Segment( + text=_decode(s.text), t0_ms=s.t0_ms, t1_ms=s.t1_ms, + first_word=s.first_word, n_words=s.n_words, + first_token=s.first_token, n_tokens=s.n_tokens, + ) + + +def _word_from(w) -> Word: + return Word( + text=_decode(w.text), t0_ms=w.t0_ms, t1_ms=w.t1_ms, + seg_index=w.seg_index, first_token=w.first_token, n_tokens=w.n_tokens, + ) + + +def _token_from(t) -> Token: + return Token( + text=_decode(t.text), id=t.id, p=t.p, t0_ms=t.t0_ms, t1_ms=t.t1_ms, + seg_index=t.seg_index, word_index=t.word_index, + ) + + +def _timings_from(tm) -> Timings: + return Timings( + load_ms=tm.load_ms, mel_ms=tm.mel_ms, + encode_ms=tm.encode_ms, decode_ms=tm.decode_ms, + ) + + +def _stream_update_from(u) -> StreamUpdate: + return StreamUpdate( + result_changed=bool(u.result_changed), is_final=bool(u.is_final), + revision=u.revision, input_received_ms=u.input_received_ms, + audio_committed_ms=u.audio_committed_ms, buffered_ms=u.buffered_ms, + committed_changed=bool(u.committed_changed), + tentative_changed=bool(u.tentative_changed), + ) + + +def _build_run_params(task, language, target_language, timestamps, + keep_special_tags, spec_k_drafts): + if not isinstance(spec_k_drafts, int) or spec_k_drafts < -1: + raise InvalidArgument( + f"spec_k_drafts must be -1 (family default), 0 (disabled), or a " + f"positive draft length; got {spec_k_drafts!r}" + ) + params = _RunParams() + _lib.transcribe_run_params_init(_byref(params)) + params.task = _enum(_TASKS, task, "task") + params.timestamps = _enum(_TIMESTAMPS, timestamps, "timestamps") + params.language = language.encode("utf-8") if language else None + params.target_language = target_language.encode("utf-8") if target_language else None + params.keep_special_tags = keep_special_tags + params.spec_k_drafts = spec_k_drafts + return params + + +# --- family extensions ---------------------------------------------------- + + +class FamilyExtension: + """Base for typed family-extension options. + + A family extension carries family-specific knobs alongside a run or stream + on a model that accepts them. Subclasses set the slot, kind, ctypes struct, + and init function, and implement ``_apply()`` to copy their overrides onto + the initialized struct (only fields the caller set override the family + defaults). Pass an instance as ``family=`` to Session.run()/stream(); the + model is probed first and a clear error is raised if it does not accept it. + + Adding another family is one subclass: point it at the generated + ``transcribe__*_ext`` struct, its init function, and its + ``TRANSCRIBE_EXT_KIND_*`` constant.""" + + _slot: str = "" + _kind: int = 0 + _struct = None + _init: str = "" + + def _build(self): + ext = self._struct() + getattr(_lib, self._init)(_byref(ext)) + self._apply(ext) + return ext + + def _apply(self, ext) -> None: + raise NotImplementedError + + +class WhisperRunOptions(FamilyExtension): + """Whisper run-extension options (run slot): initial prompt, temperature + fallback, and decode thresholds. Only fields you set override the family + defaults; the rest keep the values transcribe_whisper_run_ext_init stamps.""" + + _slot = "run" + _kind = _generated.TRANSCRIBE_EXT_KIND_WHISPER_RUN + _struct = _generated.transcribe_whisper_run_ext + _init = "transcribe_whisper_run_ext_init" + + def __init__(self, *, initial_prompt: str | None = None, + condition_on_prev_tokens: bool | None = None, + temperature: float | None = None, + temperature_inc: float | None = None, + compression_ratio_thold: float | None = None, + logprob_thold: float | None = None, + no_speech_thold: float | None = None, + max_prev_context_tokens: int | None = None, + seed: int | None = None, + max_initial_timestamp: float | None = None): + self.initial_prompt = initial_prompt + self.condition_on_prev_tokens = condition_on_prev_tokens + self.temperature = temperature + self.temperature_inc = temperature_inc + self.compression_ratio_thold = compression_ratio_thold + self.logprob_thold = logprob_thold + self.no_speech_thold = no_speech_thold + self.max_prev_context_tokens = max_prev_context_tokens + self.seed = seed + self.max_initial_timestamp = max_initial_timestamp + + def _apply(self, ext) -> None: + if self.initial_prompt is not None: + ext.initial_prompt = self.initial_prompt.encode("utf-8") + if self.condition_on_prev_tokens is not None: + ext.condition_on_prev_tokens = self.condition_on_prev_tokens + if self.temperature is not None: + ext.temperature = self.temperature + if self.temperature_inc is not None: + ext.temperature_inc = self.temperature_inc + if self.compression_ratio_thold is not None: + ext.compression_ratio_thold = self.compression_ratio_thold + if self.logprob_thold is not None: + ext.logprob_thold = self.logprob_thold + if self.no_speech_thold is not None: + ext.no_speech_thold = self.no_speech_thold + if self.max_prev_context_tokens is not None: + ext.max_prev_context_tokens = self.max_prev_context_tokens + if self.seed is not None: + ext.seed = self.seed + if self.max_initial_timestamp is not None: + ext.max_initial_timestamp = self.max_initial_timestamp + + +class MoonshineStreamingOptions(FamilyExtension): + """Moonshine-streaming stream-extension options (stream slot).""" + + _slot = "stream" + _kind = _generated.TRANSCRIBE_EXT_KIND_MOONSHINE_STREAMING_STREAM + _struct = _generated.transcribe_moonshine_streaming_stream_ext + _init = "transcribe_moonshine_streaming_stream_ext_init" + + def __init__(self, *, min_decode_interval_ms: int | None = None): + self.min_decode_interval_ms = min_decode_interval_ms + + def _apply(self, ext) -> None: + if self.min_decode_interval_ms is not None: + ext.min_decode_interval_ms = self.min_decode_interval_ms + + +class ParakeetStreamOptions(FamilyExtension): + """Parakeet cache-aware streaming options (stream slot).""" + + _slot = "stream" + _kind = _generated.TRANSCRIBE_EXT_KIND_PARAKEET_STREAM + _struct = _generated.transcribe_parakeet_stream_ext + _init = "transcribe_parakeet_stream_ext_init" + + def __init__(self, *, att_context_right: int | None = None): + self.att_context_right = att_context_right + + def _apply(self, ext) -> None: + if self.att_context_right is not None: + ext.att_context_right = self.att_context_right + + +class ParakeetBufferedStreamOptions(FamilyExtension): + """Parakeet chunked-attention buffered streaming options (stream slot). + A field left at None keeps the family default (the C sentinel -1).""" + + _slot = "stream" + _kind = _generated.TRANSCRIBE_EXT_KIND_PARAKEET_BUFFERED_STREAM + _struct = _generated.transcribe_parakeet_buffered_stream_ext + _init = "transcribe_parakeet_buffered_stream_ext_init" + + def __init__(self, *, left_ms: int | None = None, + chunk_ms: int | None = None, + right_ms: int | None = None): + self.left_ms = left_ms + self.chunk_ms = chunk_ms + self.right_ms = right_ms + + def _apply(self, ext) -> None: + if self.left_ms is not None: + ext.left_ms = self.left_ms + if self.chunk_ms is not None: + ext.chunk_ms = self.chunk_ms + if self.right_ms is not None: + ext.right_ms = self.right_ms + + +class VoxtralRealtimeStreamOptions(FamilyExtension): + """Voxtral-realtime streaming options (stream slot).""" + + _slot = "stream" + _kind = _generated.TRANSCRIBE_EXT_KIND_VOXTRAL_REALTIME_STREAM + _struct = _generated.transcribe_voxtral_realtime_stream_ext + _init = "transcribe_voxtral_realtime_stream_ext_init" + + def __init__(self, *, num_delay_tokens: int | None = None, + min_decode_interval_ms: int | None = None): + self.num_delay_tokens = num_delay_tokens + self.min_decode_interval_ms = min_decode_interval_ms + + def _apply(self, ext) -> None: + if self.num_delay_tokens is not None: + ext.num_delay_tokens = self.num_delay_tokens + if self.min_decode_interval_ms is not None: + ext.min_decode_interval_ms = self.min_decode_interval_ms + + +# --- high-level handles --------------------------------------------------- + + +class Model: + """A loaded model. Sharing it across threads for queries and session + creation is safe; it must outlive its sessions. + + Known 0.x limitation: at most one run/stream may be IN FLIGHT across all + sessions of a model at a time — sessions share the model's compute + backend, so overlapping runs race. Run sessions serially (a pool behind + a lock is fine), or load one Model per worker for true parallelism. + + ``backend="auto"`` (the default) picks the best available device. The + ``TRANSCRIBE_BACKEND`` environment variable overrides that *default* — + the escape hatch for machines whose best-ranked device misbehaves (e.g. + CI's paravirtualized Metal, a broken GPU driver) without touching code. + An explicit ``backend=`` argument always wins over the environment. + """ + + def __init__(self, path: str | os.PathLike, *, + backend: Backend = "auto", gpu_device: int = 0): + # Live sessions, tracked weakly: close() must free them before the + # model, because transcribe_model_free is only valid once every + # derived session is gone (use-after-free otherwise). Created before + # the load call so the failure path of __del__ finds it. + self._sessions = weakref.WeakSet() + backend_source = "backend" + if backend == "auto" and os.environ.get("TRANSCRIBE_BACKEND"): + backend = os.environ["TRANSCRIBE_BACKEND"] # type: ignore[assignment] + backend_source = "backend (from TRANSCRIBE_BACKEND)" + params = _ModelLoadParams() + _lib.transcribe_model_load_params_init(_byref(params)) + params.backend = _enum(_BACKENDS, backend, backend_source) + params.gpu_device = gpu_device + + handle = ctypes.c_void_p() + status = _lib.transcribe_model_load_file( + os.fspath(path).encode("utf-8"), _byref(params), _byref(handle) + ) + _check(status, f"loading model {os.fspath(path)!r}") + if not handle.value: + raise ModelLoadError(f"model load returned a null handle for {path!r}") + self._handle = handle + + @property + def _h(self) -> ctypes.c_void_p: + if self._handle is None: + raise TranscribeError("model is closed") + return self._handle + + @property + def arch(self) -> str: + return _decode(_lib.transcribe_model_arch_string(self._h)) + + @property + def variant(self) -> str: + return _decode(_lib.transcribe_model_variant_string(self._h)) + + @property + def backend(self) -> str: + return _decode(_lib.transcribe_model_backend(self._h)) + + @property + def capabilities(self) -> Capabilities: + caps = _Capabilities() + _lib.transcribe_capabilities_init(_byref(caps)) + _check( + _lib.transcribe_model_get_capabilities(self._h, _byref(caps)), + "reading capabilities", + ) + languages = [] + if caps.languages and caps.n_languages > 0: + for i in range(caps.n_languages): + languages.append(_decode(caps.languages[i])) + return Capabilities( + native_sample_rate=caps.native_sample_rate, + languages=tuple(languages), + max_timestamp_kind=_TIMESTAMP_NAMES.get(caps.max_timestamp_kind, "unknown"), + supports_language_detect=bool(caps.supports_language_detect), + supports_translate=bool(caps.supports_translate), + supports_streaming=bool(caps.supports_streaming), + supports_spec_decode=bool(caps.supports_spec_decode), + max_audio_ms=caps.max_audio_ms, + ) + + def supports(self, feature: Feature) -> bool: + """Whether the model exposes a behavioral feature (initial prompt, + temperature fallback, long-form, cancellation, pnc, itn).""" + return bool(_lib.transcribe_model_supports( + self._h, _enum(_FEATURES, feature, "feature"))) + + def accepts(self, options: "FamilyExtension") -> bool: + """Whether the model accepts a family extension on its slot.""" + return bool(_lib.transcribe_model_accepts_ext_kind( + self._h, _EXT_SLOTS[options._slot], options._kind)) + + def session(self, *, n_threads: int = 0, kv_type: KVType = "auto", + n_ctx: int = 0) -> "Session": + return Session(self, n_threads=n_threads, kv_type=kv_type, n_ctx=n_ctx) + + def close(self) -> None: + """Free the model. Any session still open on it is closed first — + the C contract forbids freeing a model before its sessions, so this + keeps explicit close()/context-manager exit safe in any order.""" + if getattr(self, "_handle", None) is not None: + for session in list(getattr(self, "_sessions", ()) or ()): + session.close() + _lib.transcribe_model_free(self._handle) + self._handle = None + + def __enter__(self) -> "Model": + return self + + def __exit__(self, *exc) -> None: + self.close() + + def __del__(self): + try: + self.close() + except Exception: + pass + + +class Session: + """A single transcription context bound to one Model. Not thread-safe. + + Sessions of one model must also not RUN concurrently with each other in + 0.x (they share the model's compute backend — see the Model docstring); + interleave or serialize their runs, or use one Model per worker.""" + + def __init__(self, model: Model, *, n_threads: int = 0, kv_type: KVType = "auto", + n_ctx: int = 0): + self._model = model # keep the model alive for the session's lifetime + params = _SessionParams() + _lib.transcribe_session_params_init(_byref(params)) + params.n_threads = n_threads + params.kv_type = _enum(_KV_TYPES, kv_type, "kv_type") + params.n_ctx = n_ctx + + handle = ctypes.c_void_p() + status = _lib.transcribe_session_init(model._h, _byref(params), _byref(handle)) + _check(status, "opening session") + if not handle.value: + raise TranscribeError("session init returned a null handle") + self._handle = handle + + # Cancellation: an abort callback polled at chunk/decode boundaries + # returns True once cancel() is requested, so another thread can abort an + # in-flight run/stream. Bind the Event (not self) into the trampoline to + # avoid a reference cycle that would defer __del__. + self._cancel = threading.Event() + _event = self._cancel + self._abort_trampoline = _ABORT_CFUNC(lambda _ud: _event.is_set()) + _lib.transcribe_set_abort_callback(self._handle, self._abort_trampoline, None) + + # Registered last, once the session is fully constructed: Model.close() + # closes any session still tracked here before freeing the model. + model._sessions.add(self) + + @property + def _h(self) -> ctypes.c_void_p: + if self._handle is None: + raise TranscribeError("session is closed") + return self._handle + + def _resolve_family(self, family: "FamilyExtension", slot: str): + """Validate + probe a family extension and return its built ctypes + struct (which the caller keeps alive and points params.family at).""" + if not isinstance(family, FamilyExtension): + raise InvalidArgument("family must be a FamilyExtension instance") + if family._slot != slot: + raise InvalidArgument( + f"{type(family).__name__} is a {family._slot!r}-slot extension and " + f"cannot be used on the {slot!r} slot") + if not _lib.transcribe_model_accepts_ext_kind( + self._model._h, _EXT_SLOTS[slot], family._kind): + raise UnsupportedRequest( + f"this model does not accept {type(family).__name__} on the " + f"{slot!r} slot") + return family._build() + + def run(self, pcm: PCMLike, *, task: Task = "transcribe", + language: str | None = None, + target_language: str | None = None, + timestamps: Timestamps = "none", + keep_special_tags: bool = False, + spec_k_drafts: int = -1, + family: FamilyExtension | None = None) -> Result: + """Transcribe 16 kHz mono float32 PCM and return a materialized Result. + + ``family`` is an optional family-specific extension (e.g. + WhisperRunOptions) carrying per-run knobs for models that accept it. + ``spec_k_drafts`` tunes speculative decoding on models whose + capabilities advertise ``supports_spec_decode`` (-1 = family default, + 0 = disabled, >0 = draft length; silently ignored elsewhere). + + On ``Aborted`` (via :meth:`cancel`) and ``OutputTruncated`` the + partial transcript is preserved and attached to the exception as + ``partial_result``.""" + self._cancel.clear() + array, n_samples = _pcm_to_carray(pcm) + params = _build_run_params(task, language, target_language, timestamps, + keep_special_tags, spec_k_drafts) + ext = self._resolve_family(family, "run") if family is not None else None + if ext is not None: + params.family = ctypes.cast( + _byref(ext), ctypes.POINTER(_generated.transcribe_ext)) + try: + _check(_lib.transcribe_run(self._h, array, n_samples, _byref(params)), + "transcribe_run") + except (Aborted, OutputTruncated) as exc: + # The C API preserves the partial transcript on the session for + # exactly these two statuses; surface it rather than discard it. + exc.partial_result = self._materialize() + raise + return self._materialize() + + def run_batch(self, pcms: Sequence[PCMLike], *, task: Task = "transcribe", + language: str | None = None, + target_language: str | None = None, + timestamps: Timestamps = "none", + keep_special_tags: bool = False, + spec_k_drafts: int = -1, + family: FamilyExtension | None = None, + return_exceptions: bool = False) -> list[Result | TranscribeError]: + """Transcribe several utterances in one dispatch — one Result each. + + Families with a batched compute path process every utterance in a single + device dispatch (≈2x throughput on an underused GPU); others fall back to + running them in turn, so every model accepts this. + + Failure is PER-UTTERANCE (the C API reports one status per clip; a + :meth:`cancel` surfaces at the batch level but the native side pads + the per-utterance result set, so it is folded into the same view). + Default: raise the first failing utterance's exception, with + ``utterance_index`` set, ``partial_result`` attached where the + native layer preserved a partial transcript for that slot (None + otherwise), and ``batch_results`` carrying the full per-utterance + view (``Result`` or ``TranscribeError`` each) so completed work is + never discarded. With ``return_exceptions=True`` no exception is + raised for utterance failures and that mixed list is returned + directly (the ``asyncio.gather`` convention).""" + self._cancel.clear() + pcms = list(pcms) + if not pcms: + raise InvalidArgument("run_batch requires at least one PCM buffer") + + arrays = [] # keep the per-utterance float arrays alive across the call + ptrs = (ctypes.POINTER(ctypes.c_float) * len(pcms))() + counts = (ctypes.c_int * len(pcms))() + for k, pcm in enumerate(pcms): + arr, n = _pcm_to_carray(pcm) + arrays.append(arr) + ptrs[k] = ctypes.cast(arr, ctypes.POINTER(ctypes.c_float)) + counts[k] = n + + params = _build_run_params(task, language, target_language, timestamps, + keep_special_tags, spec_k_drafts) + ext = self._resolve_family(family, "run") if family is not None else None + if ext is not None: + params.family = ctypes.cast( + _byref(ext), ctypes.POINTER(_generated.transcribe_ext)) + batch_abort = None + try: + _check( + _lib.transcribe_run_batch(self._h, ptrs, counts, len(pcms), _byref(params)), + "transcribe_run_batch", + ) + except Aborted as exc: + # Cancellation surfaces at the BATCH level, but the native side + # pads the per-utterance result set so clips completed before the + # abort survive. Fall through to the per-utterance loop, which + # restores utterance context instead of discarding that work. + batch_abort = exc + + out: list = [] + first_exc = None + for i in range(_lib.transcribe_batch_n_results(self._h)): + status = _lib.transcribe_batch_status(self._h, i) + if status == 0: + out.append(self._materialize(i)) + continue + exc = exception_for_status(status, _status_string(status), + f"utterance {i} in batch") + exc.utterance_index = i + if isinstance(exc, (Aborted, OutputTruncated)): + # Attach the partial transcript only when the native layer + # actually snapshotted one for this slot — some batch paths + # record failed slots as empty, and None is more honest than + # a confidently empty Result. + partial = self._materialize(i) + if partial.text or partial.tokens or partial.segments: + exc.partial_result = partial + out.append(exc) + if first_exc is None: + first_exc = exc + if first_exc is None and batch_abort is not None: + # Anomalous: an abort with no per-utterance trace. Re-raise loud + # (even under return_exceptions) rather than swallow a cancel. + batch_abort.batch_results = out + raise batch_abort + if first_exc is not None and not return_exceptions: + first_exc.batch_results = out + raise first_exc + return out + + def stream(self, *, task: Task = "transcribe", language: str | None = None, + target_language: str | None = None, timestamps: Timestamps = "none", + keep_special_tags: bool = False, commit_policy: CommitPolicy = "auto", + stable_prefix_agreement_n: int = 0, + family: FamilyExtension | None = None) -> Stream: + """Begin streaming on this session and return a Stream to feed audio to. + + Requires a model whose capabilities advertise ``supports_streaming``; + otherwise raises NotImplementedByModel. ``family`` is an optional + family-specific stream extension (e.g. MoonshineStreamingOptions). The + session is single-threaded and runs at most one stream at a time. Use + the Stream as a context manager so it is reset when you are done.""" + self._cancel.clear() + # spec_k_drafts is an offline-decode knob; streaming always uses the + # family default (-1). + run_params = _build_run_params(task, language, target_language, timestamps, + keep_special_tags, -1) + sp = _StreamParams() + _lib.transcribe_stream_params_init(_byref(sp)) + sp.commit_policy = _enum(_COMMIT_POLICIES, commit_policy, "commit_policy") + sp.stable_prefix_agreement_n = stable_prefix_agreement_n + ext = self._resolve_family(family, "stream") if family is not None else None + if ext is not None: + sp.family = ctypes.cast( + _byref(ext), ctypes.POINTER(_generated.transcribe_ext)) + _check( + _lib.transcribe_stream_begin(self._h, _byref(run_params), _byref(sp)), + "transcribe_stream_begin", + ) + # The C contract says everything passed to begin may be freed once it + # returns (strings are copied into session-owned storage). The Stream + # still pins the params structs until reset() as defense in depth — + # it costs nothing and keeps the binding safe even against an older + # or out-of-tree native library that predates that contract. + return Stream(self, _keepalive=(run_params, sp, ext)) + + def _materialize(self, utt: int | None = None) -> Result: + """Copy out one result. utt is None for the single-result accessors, or + an utterance index for the batch accessors (index 0 aliases the single + result after a plain run, so both paths share this code).""" + h = self._h + if utt is None: + n_seg = lambda: _lib.transcribe_n_segments(h) + get_seg = lambda j, out: _lib.transcribe_get_segment(h, j, out) + n_word = lambda: _lib.transcribe_n_words(h) + get_word = lambda j, out: _lib.transcribe_get_word(h, j, out) + n_tok = lambda: _lib.transcribe_n_tokens(h) + get_tok = lambda j, out: _lib.transcribe_get_token(h, j, out) + full_text = _lib.transcribe_full_text(h) + language = _lib.transcribe_detected_language(h) + kind = _lib.transcribe_returned_timestamp_kind(h) + get_tim = lambda out: _lib.transcribe_get_timings(h, out) + else: + n_seg = lambda: _lib.transcribe_batch_n_segments(h, utt) + get_seg = lambda j, out: _lib.transcribe_batch_get_segment(h, utt, j, out) + n_word = lambda: _lib.transcribe_batch_n_words(h, utt) + get_word = lambda j, out: _lib.transcribe_batch_get_word(h, utt, j, out) + n_tok = lambda: _lib.transcribe_batch_n_tokens(h, utt) + get_tok = lambda j, out: _lib.transcribe_batch_get_token(h, utt, j, out) + full_text = _lib.transcribe_batch_full_text(h, utt) + language = _lib.transcribe_batch_detected_language(h, utt) + kind = _lib.transcribe_batch_returned_timestamp_kind(h, utt) + get_tim = lambda out: _lib.transcribe_batch_get_timings(h, utt, out) + + segments = [] + for j in range(n_seg()): + s = _Segment() + _lib.transcribe_segment_init(_byref(s)) + get_seg(j, _byref(s)) + segments.append(_segment_from(s)) + + words = [] + for j in range(n_word()): + w = _Word() + _lib.transcribe_word_init(_byref(w)) + get_word(j, _byref(w)) + words.append(_word_from(w)) + + tokens = [] + for j in range(n_tok()): + tok = _Token() + _lib.transcribe_token_init(_byref(tok)) + get_tok(j, _byref(tok)) + tokens.append(_token_from(tok)) + + tm = _Timings() + _lib.transcribe_timings_init(_byref(tm)) + get_tim(_byref(tm)) + + return Result( + text=_decode(full_text), + language=_decode(language), + timestamp_kind=_TIMESTAMP_NAMES.get(kind, "unknown"), + segments=tuple(segments), + words=tuple(words), + tokens=tuple(tokens), + timings=_timings_from(tm), + ) + + @property + def limits(self) -> SessionLimits: + """Effective limits for THIS session (model bound narrowed by + ``n_ctx``). Check ``effective_max_audio_ms`` to size input up front + instead of discovering ``InputTooLong`` on failure.""" + lm = _generated.transcribe_session_limits() + _lib.transcribe_session_limits_init(_byref(lm)) + _check(_lib.transcribe_session_get_limits(self._h, _byref(lm)), + "reading session limits") + return SessionLimits( + effective_n_ctx=lm.effective_n_ctx, + effective_max_audio_ms=lm.effective_max_audio_ms, + max_kv_bytes=lm.max_kv_bytes, + ) + + def cancel(self) -> None: + """Request cancellation of an in-flight run/stream from another thread. + The active call aborts at the next chunk/decode boundary and raises + ``Aborted`` (with ``partial_result`` attached); ``was_aborted`` then + reports True. The flag is cleared at the start of the next + run/stream.""" + self._cancel.set() + + @property + def was_aborted(self) -> bool: + """True if the most recent call was ended by cancel().""" + return bool(_lib.transcribe_was_aborted(self._h)) + + def close(self) -> None: + if getattr(self, "_handle", None) is not None: + _lib.transcribe_session_free(self._handle) + self._handle = None + + def __enter__(self) -> "Session": + return self + + def __exit__(self, *exc) -> None: + self.close() + + def __del__(self): + try: + self.close() + except Exception: + pass + + +class Stream: + """An active streaming transcription on a Session. + + Feed audio in chunks with ``feed()``, read the committed/tentative text with + ``text()``, and call ``finalize()`` when the audio ends. The session is + returned to idle on context-manager exit (or ``reset()``).""" + + def __init__(self, session: Session, *, _keepalive=None): + self._session = session # keep the session (and its model) alive + # Pins the ctypes params structs (and any family ext) passed to + # transcribe_stream_begin until reset(). The native library copies + # what it needs at begin; this is belt-and-braces for the FFI layer. + self._keepalive = _keepalive + self._active = True + + @property + def _h(self) -> ctypes.c_void_p: + if not self._active: + raise TranscribeError("stream has been reset") + return self._session._h + + def feed(self, pcm: PCMLike) -> StreamUpdate: + """Feed a chunk of 16 kHz mono float32 PCM; returns change metadata.""" + array, n_samples = _pcm_to_carray(pcm) + update = _StreamUpdate() + _lib.transcribe_stream_update_init(_byref(update)) + _check(_lib.transcribe_stream_feed(self._h, array, n_samples, _byref(update)), + "transcribe_stream_feed") + return _stream_update_from(update) + + def finalize(self) -> StreamUpdate: + """Signal end of audio and flush the final hypothesis.""" + update = _StreamUpdate() + _lib.transcribe_stream_update_init(_byref(update)) + _check(_lib.transcribe_stream_finalize(self._h, _byref(update)), + "transcribe_stream_finalize") + return _stream_update_from(update) + + def text(self) -> StreamText: + """Current committed / tentative / full text views (owned copies).""" + txt = _StreamText() + _lib.transcribe_stream_text_init(_byref(txt)) + _check(_lib.transcribe_stream_get_text(self._h, _byref(txt)), + "transcribe_stream_get_text") + return StreamText( + full=_decode(txt.full_text), + committed=_decode(txt.committed_text), + tentative=_decode(txt.tentative_text), + ) + + @property + def state(self) -> str: + """``"idle"`` / ``"active"`` / ``"finished"`` / ``"failed"``.""" + return _STREAM_STATES.get(_lib.transcribe_stream_get_state(self._h), "unknown") + + @property + def revision(self) -> int: + return _lib.transcribe_stream_revision(self._h) + + def reset(self) -> None: + """Return the session to idle, discarding stream state. Idempotent.""" + if self._active: + _lib.transcribe_stream_reset(self._session._h) + self._active = False + self._keepalive = None + + def __enter__(self) -> "Stream": + return self + + def __exit__(self, *exc) -> None: + self.reset() + + +def transcribe( + model: Model | str | os.PathLike, + pcm: PCMLike, + *, + backend: Backend = "auto", + gpu_device: int = 0, + n_threads: int = 0, + kv_type: KVType = "auto", + n_ctx: int = 0, + task: Task = "transcribe", + language: str | None = None, + target_language: str | None = None, + timestamps: Timestamps = "none", + keep_special_tags: bool = False, + spec_k_drafts: int = -1, + family: FamilyExtension | None = None, +) -> Result: + """Transcribe *pcm* in one call and return a materialized Result. + + *model* may be a path (loaded and freed within this call) or an existing + Model (reused and left open). Loading a model is not free, so to transcribe + many clips keep a Model and call ``model.session().run(...)`` yourself; this + helper is for the one-shot case. ``backend`` / ``gpu_device`` apply only when + *model* is a path — they are ignored when an already-loaded Model is passed. + ``family`` / ``spec_k_drafts`` pass through to :meth:`Session.run`. + """ + session_opts = dict(n_threads=n_threads, kv_type=kv_type, n_ctx=n_ctx) + run_opts = dict(task=task, language=language, target_language=target_language, + timestamps=timestamps, keep_special_tags=keep_special_tags, + spec_k_drafts=spec_k_drafts, family=family) + + if isinstance(model, Model): + with model.session(**session_opts) as session: + return session.run(pcm, **run_opts) + + with Model(model, backend=backend, gpu_device=gpu_device) as owned: + with owned.session(**session_opts) as session: + return session.run(pcm, **run_opts) diff --git a/bindings/python/src/transcribe_cpp/_abi.py b/bindings/python/src/transcribe_cpp/_abi.py new file mode 100644 index 00000000..2bb9bfb4 --- /dev/null +++ b/bindings/python/src/transcribe_cpp/_abi.py @@ -0,0 +1,77 @@ +"""Load-time ABI verification of the generated ctypes layer. + +Two independent checks, both required before the high-level API touches a struct: + +1. **Offset self-check** — every generated Structure's ctypes size, alignment, + and per-field offsets must equal the C-compiler layout libclang captured at + generation time (``_generated.STRUCT_LAYOUT``). This catches a wrong type in + the generated module on the running platform. + +2. **Native agreement** — for every struct with a ``transcribe_abi_struct`` id, + ctypes size/alignment must equal what the *loaded* native library reports + (``transcribe_abi_struct_size``/``_align``). This catches a committed module + that is stale relative to the actual ``.dylib``/``.so`` in this process. + +A ctypes layout mismatch corrupts memory silently; turning it into an +ImportError here is the safety net behind the generated FFI. +""" + +from __future__ import annotations + +import ctypes + +from . import _generated +from .errors import AbiError + + +def verify_layouts(lib: ctypes.CDLL) -> None: + """Raise AbiError if the generated layer disagrees with itself or the lib.""" + mismatches: list[str] = [] + + # 1. ctypes layout vs the captured C-compiler layout. + for name, layout in _generated.STRUCT_LAYOUT.items(): + cls = getattr(_generated, name) + if ctypes.sizeof(cls) != layout["size"]: + mismatches.append( + f"{name}: size {ctypes.sizeof(cls)} (ctypes) != {layout['size']} (generated)" + ) + if ctypes.alignment(cls) != layout["align"]: + mismatches.append( + f"{name}: alignment {ctypes.alignment(cls)} (ctypes) != " + f"{layout['align']} (generated)" + ) + for field, offset in layout["offsets"].items(): + actual = getattr(cls, field).offset + if actual != offset: + mismatches.append( + f"{name}.{field}: offset {actual} (ctypes) != {offset} (generated)" + ) + + # 2. ctypes size/alignment vs the loaded native library. + for name, abi_id in _generated.ABI_STRUCT_IDS.items(): + cls = getattr(_generated, name) + native_size = lib.transcribe_abi_struct_size(abi_id) + native_align = lib.transcribe_abi_struct_align(abi_id) + if native_size == 0: + mismatches.append( + f"{name}: native library does not know abi id {abi_id} " + "(library older than this binding)" + ) + continue + if ctypes.sizeof(cls) != native_size: + mismatches.append( + f"{name}: size {ctypes.sizeof(cls)} (binding) != {native_size} (native)" + ) + if ctypes.alignment(cls) != native_align: + mismatches.append( + f"{name}: alignment {ctypes.alignment(cls)} (binding) != " + f"{native_align} (native)" + ) + + if mismatches: + raise AbiError( + "transcribe_cpp ABI layout check failed — the generated binding and " + "native library disagree on struct layout. This is a version/build " + "skew or a stale generated layer; do not run against this library.\n " + + "\n ".join(mismatches) + ) diff --git a/bindings/python/src/transcribe_cpp/_generated.py b/bindings/python/src/transcribe_cpp/_generated.py new file mode 100644 index 00000000..aa5aa8f7 --- /dev/null +++ b/bindings/python/src/transcribe_cpp/_generated.py @@ -0,0 +1,390 @@ +"""Low-level ctypes FFI for transcribe.cpp — AUTOGENERATED. DO NOT EDIT. + +Regenerate with: + uv run --no-project --with 'libclang==18.1.1' \ + bindings/python/_generate/generate.py + +Source: include/transcribe/extensions.h +libclang: 18.1.1 +""" + +import ctypes as _c + +# 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" + +# === enum constants === +TRANSCRIBE_OK = 0 +TRANSCRIBE_ERR_INVALID_ARG = 1 +TRANSCRIBE_ERR_NOT_IMPLEMENTED = 2 +TRANSCRIBE_ERR_FILE_NOT_FOUND = 3 +TRANSCRIBE_ERR_GGUF = 4 +TRANSCRIBE_ERR_UNSUPPORTED_ARCH = 5 +TRANSCRIBE_ERR_UNSUPPORTED_VARIANT = 6 +TRANSCRIBE_ERR_OOM = 7 +TRANSCRIBE_ERR_BACKEND = 8 +TRANSCRIBE_ERR_SAMPLE_RATE = 9 +TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE = 10 +TRANSCRIBE_ERR_UNSUPPORTED_TASK = 11 +TRANSCRIBE_ERR_UNSUPPORTED_TIMESTAMPS = 12 +TRANSCRIBE_ERR_ABORTED = 13 +TRANSCRIBE_ERR_BAD_STRUCT_SIZE = 14 +TRANSCRIBE_ERR_UNSUPPORTED_PNC = 15 +TRANSCRIBE_ERR_UNSUPPORTED_ITN = 16 +TRANSCRIBE_ERR_INPUT_TOO_LONG = 17 +TRANSCRIBE_ERR_OUTPUT_TRUNCATED = 18 +TRANSCRIBE_ABI_MODEL_LOAD_PARAMS = 0 +TRANSCRIBE_ABI_SESSION_PARAMS = 1 +TRANSCRIBE_ABI_RUN_PARAMS = 2 +TRANSCRIBE_ABI_STREAM_PARAMS = 3 +TRANSCRIBE_ABI_CAPABILITIES = 4 +TRANSCRIBE_ABI_TIMINGS = 5 +TRANSCRIBE_ABI_SEGMENT = 6 +TRANSCRIBE_ABI_WORD = 7 +TRANSCRIBE_ABI_TOKEN = 8 +TRANSCRIBE_ABI_STREAM_UPDATE = 9 +TRANSCRIBE_ABI_STREAM_TEXT = 10 +TRANSCRIBE_ABI_SESSION_LIMITS = 11 +TRANSCRIBE_ABI_EXT = 12 +TRANSCRIBE_ABI_BACKEND_DEVICE = 13 +TRANSCRIBE_LOG_LEVEL_NONE = 0 +TRANSCRIBE_LOG_LEVEL_INFO = 1 +TRANSCRIBE_LOG_LEVEL_WARN = 2 +TRANSCRIBE_LOG_LEVEL_ERROR = 3 +TRANSCRIBE_LOG_LEVEL_DEBUG = 4 +TRANSCRIBE_LOG_LEVEL_CONT = 5 +TRANSCRIBE_TASK_TRANSCRIBE = 0 +TRANSCRIBE_TASK_TRANSLATE = 1 +TRANSCRIBE_TIMESTAMPS_NONE = 0 +TRANSCRIBE_TIMESTAMPS_AUTO = 1 +TRANSCRIBE_TIMESTAMPS_SEGMENT = 2 +TRANSCRIBE_TIMESTAMPS_WORD = 3 +TRANSCRIBE_TIMESTAMPS_TOKEN = 4 +TRANSCRIBE_KV_TYPE_AUTO = 0 +TRANSCRIBE_KV_TYPE_F32 = 1 +TRANSCRIBE_KV_TYPE_F16 = 2 +TRANSCRIBE_PNC_MODE_DEFAULT = 0 +TRANSCRIBE_PNC_MODE_OFF = 1 +TRANSCRIBE_PNC_MODE_ON = 2 +TRANSCRIBE_ITN_MODE_DEFAULT = 0 +TRANSCRIBE_ITN_MODE_OFF = 1 +TRANSCRIBE_ITN_MODE_ON = 2 +TRANSCRIBE_EXT_SLOT_RUN = 0 +TRANSCRIBE_EXT_SLOT_STREAM = 1 +TRANSCRIBE_BACKEND_AUTO = 0 +TRANSCRIBE_BACKEND_CPU = 1 +TRANSCRIBE_BACKEND_METAL = 2 +TRANSCRIBE_BACKEND_VULKAN = 3 +TRANSCRIBE_BACKEND_CPU_ACCEL = 4 +TRANSCRIBE_BACKEND_CUDA = 5 +TRANSCRIBE_FEATURE_INITIAL_PROMPT = 0 +TRANSCRIBE_FEATURE_TEMPERATURE_FALLBACK = 1 +TRANSCRIBE_FEATURE_LONG_FORM = 2 +TRANSCRIBE_FEATURE_CANCELLATION = 3 +TRANSCRIBE_FEATURE_PNC = 4 +TRANSCRIBE_FEATURE_ITN = 5 +TRANSCRIBE_STREAM_IDLE = 0 +TRANSCRIBE_STREAM_ACTIVE = 1 +TRANSCRIBE_STREAM_FINISHED = 2 +TRANSCRIBE_STREAM_FAILED = 3 +TRANSCRIBE_STREAM_COMMIT_AUTO = 0 +TRANSCRIBE_STREAM_COMMIT_ON_FINALIZE = 1 +TRANSCRIBE_STREAM_COMMIT_STABLE_PREFIX = 2 +TRANSCRIBE_WHISPER_PROMPT_FIRST_SEGMENT = 0 +TRANSCRIBE_WHISPER_PROMPT_ALL_SEGMENTS = 1 + +# === macro constants (integer object-like macros) === +TRANSCRIBE_EXT_KIND_MOONSHINE_STREAMING_STREAM = 1414746957 +TRANSCRIBE_EXT_KIND_PARAKEET_BUFFERED_STREAM = 1396853584 +TRANSCRIBE_EXT_KIND_PARAKEET_STREAM = 1414744912 +TRANSCRIBE_EXT_KIND_VOXTRAL_REALTIME_STREAM = 1414746710 +TRANSCRIBE_EXT_KIND_WHISPER_RUN = 1314015319 +TRANSCRIBE_VERSION_MAJOR = 0 +TRANSCRIBE_VERSION_MINOR = 0 +TRANSCRIBE_VERSION_PATCH = 1 + +# === structs === +class transcribe_ext(_c.Structure): + pass +class transcribe_backend_device(_c.Structure): + pass +class transcribe_model_load_params(_c.Structure): + pass +class transcribe_session_params(_c.Structure): + pass +class transcribe_run_params(_c.Structure): + pass +class transcribe_capabilities(_c.Structure): + pass +class transcribe_session_limits(_c.Structure): + pass +class transcribe_stream_params(_c.Structure): + pass +class transcribe_stream_update(_c.Structure): + pass +class transcribe_stream_text(_c.Structure): + pass +class transcribe_timings(_c.Structure): + pass +class transcribe_segment(_c.Structure): + pass +class transcribe_word(_c.Structure): + pass +class transcribe_token(_c.Structure): + pass +class transcribe_moonshine_streaming_stream_ext(_c.Structure): + pass +class transcribe_parakeet_stream_ext(_c.Structure): + pass +class transcribe_parakeet_buffered_stream_ext(_c.Structure): + pass +class transcribe_voxtral_realtime_stream_ext(_c.Structure): + pass +class transcribe_whisper_run_ext(_c.Structure): + pass +class transcribe_whisper_chunk_trace(_c.Structure): + pass + +transcribe_ext._fields_ = [("size", _c.c_uint64), ("kind", _c.c_uint32)] +transcribe_backend_device._fields_ = [("struct_size", _c.c_uint64), ("name", _c.c_char_p), ("description", _c.c_char_p), ("kind", _c.c_char_p)] +transcribe_model_load_params._fields_ = [("struct_size", _c.c_uint64), ("backend", _c.c_int), ("gpu_device", _c.c_int)] +transcribe_session_params._fields_ = [("struct_size", _c.c_uint64), ("n_threads", _c.c_int), ("kv_type", _c.c_int), ("n_ctx", _c.c_int32)] +transcribe_run_params._fields_ = [("struct_size", _c.c_uint64), ("task", _c.c_int), ("timestamps", _c.c_int), ("pnc", _c.c_int), ("itn", _c.c_int), ("language", _c.c_char_p), ("target_language", _c.c_char_p), ("keep_special_tags", _c.c_bool), ("family", _c.POINTER(transcribe_ext)), ("spec_k_drafts", _c.c_int32)] +transcribe_capabilities._fields_ = [("struct_size", _c.c_uint64), ("native_sample_rate", _c.c_int32), ("n_languages", _c.c_int), ("languages", _c.POINTER(_c.c_char_p)), ("max_timestamp_kind", _c.c_int), ("supports_language_detect", _c.c_bool), ("supports_translate", _c.c_bool), ("supports_streaming", _c.c_bool), ("supports_spec_decode", _c.c_bool), ("max_audio_ms", _c.c_int64)] +transcribe_session_limits._fields_ = [("struct_size", _c.c_uint64), ("effective_n_ctx", _c.c_int32), ("effective_max_audio_ms", _c.c_int64), ("max_kv_bytes", _c.c_int64)] +transcribe_stream_params._fields_ = [("struct_size", _c.c_uint64), ("family", _c.POINTER(transcribe_ext)), ("commit_policy", _c.c_int), ("stable_prefix_agreement_n", _c.c_uint32)] +transcribe_stream_update._fields_ = [("struct_size", _c.c_uint64), ("result_changed", _c.c_bool), ("is_final", _c.c_bool), ("revision", _c.c_int32), ("input_received_ms", _c.c_int64), ("audio_committed_ms", _c.c_int64), ("buffered_ms", _c.c_int64), ("committed_changed", _c.c_bool), ("tentative_changed", _c.c_bool)] +transcribe_stream_text._fields_ = [("struct_size", _c.c_uint64), ("full_text", _c.c_char_p), ("full_text_bytes", _c.c_uint64), ("committed_text", _c.c_char_p), ("committed_text_bytes", _c.c_uint64), ("tentative_text", _c.c_char_p), ("tentative_text_bytes", _c.c_uint64), ("raw_tentative_start_bytes", _c.c_uint64)] +transcribe_timings._fields_ = [("struct_size", _c.c_uint64), ("load_ms", _c.c_float), ("mel_ms", _c.c_float), ("encode_ms", _c.c_float), ("decode_ms", _c.c_float)] +transcribe_segment._fields_ = [("struct_size", _c.c_uint64), ("t0_ms", _c.c_int64), ("t1_ms", _c.c_int64), ("first_word", _c.c_int), ("n_words", _c.c_int), ("first_token", _c.c_int), ("n_tokens", _c.c_int), ("text", _c.c_char_p)] +transcribe_word._fields_ = [("struct_size", _c.c_uint64), ("t0_ms", _c.c_int64), ("t1_ms", _c.c_int64), ("seg_index", _c.c_int), ("first_token", _c.c_int), ("n_tokens", _c.c_int), ("text", _c.c_char_p)] +transcribe_token._fields_ = [("struct_size", _c.c_uint64), ("id", _c.c_int), ("p", _c.c_float), ("t0_ms", _c.c_int64), ("t1_ms", _c.c_int64), ("seg_index", _c.c_int), ("word_index", _c.c_int), ("text", _c.c_char_p)] +transcribe_moonshine_streaming_stream_ext._fields_ = [("ext", transcribe_ext), ("min_decode_interval_ms", _c.c_int32)] +transcribe_parakeet_stream_ext._fields_ = [("ext", transcribe_ext), ("att_context_right", _c.c_int32)] +transcribe_parakeet_buffered_stream_ext._fields_ = [("ext", transcribe_ext), ("left_ms", _c.c_int32), ("chunk_ms", _c.c_int32), ("right_ms", _c.c_int32)] +transcribe_voxtral_realtime_stream_ext._fields_ = [("ext", transcribe_ext), ("num_delay_tokens", _c.c_int32), ("min_decode_interval_ms", _c.c_int32)] +transcribe_whisper_run_ext._fields_ = [("ext", transcribe_ext), ("initial_prompt", _c.c_char_p), ("prompt_tokens", _c.POINTER(_c.c_int32)), ("n_prompt_tokens", _c.c_size_t), ("prompt_condition", _c.c_int), ("condition_on_prev_tokens", _c.c_bool), ("max_prev_context_tokens", _c.c_int32), ("temperature", _c.c_float), ("temperature_inc", _c.c_float), ("compression_ratio_thold", _c.c_float), ("logprob_thold", _c.c_float), ("no_speech_thold", _c.c_float), ("seed", _c.c_uint32), ("max_initial_timestamp", _c.c_float)] +transcribe_whisper_chunk_trace._fields_ = [("struct_size", _c.c_uint64), ("t0_ms", _c.c_int64), ("t1_ms", _c.c_int64), ("temperature_used", _c.c_float), ("compression_ratio", _c.c_float), ("avg_logprob", _c.c_float), ("no_speech_prob", _c.c_float), ("no_speech_triggered", _c.c_bool), ("n_fallbacks", _c.c_int32)] + +# === ABI metadata === +# transcribe_abi_struct id per struct (for the native size/align check). +ABI_STRUCT_IDS = { + 'transcribe_ext': 12, + 'transcribe_backend_device': 13, + 'transcribe_model_load_params': 0, + 'transcribe_session_params': 1, + 'transcribe_run_params': 2, + 'transcribe_capabilities': 4, + 'transcribe_session_limits': 11, + 'transcribe_stream_params': 3, + 'transcribe_stream_update': 9, + 'transcribe_stream_text': 10, + 'transcribe_timings': 5, + 'transcribe_segment': 6, + 'transcribe_word': 7, + 'transcribe_token': 8, +} + +# C-compiler layout captured at generation (for offset self-check). +STRUCT_LAYOUT = { + 'transcribe_ext': {'size': 16, 'align': 8, 'offsets': {'size': 0, 'kind': 8}}, + 'transcribe_backend_device': {'size': 32, 'align': 8, 'offsets': {'struct_size': 0, 'name': 8, 'description': 16, 'kind': 24}}, + 'transcribe_model_load_params': {'size': 16, 'align': 8, 'offsets': {'struct_size': 0, 'backend': 8, 'gpu_device': 12}}, + 'transcribe_session_params': {'size': 24, 'align': 8, 'offsets': {'struct_size': 0, 'n_threads': 8, 'kv_type': 12, 'n_ctx': 16}}, + 'transcribe_run_params': {'size': 64, 'align': 8, 'offsets': {'struct_size': 0, 'task': 8, 'timestamps': 12, 'pnc': 16, 'itn': 20, 'language': 24, 'target_language': 32, 'keep_special_tags': 40, 'family': 48, 'spec_k_drafts': 56}}, + 'transcribe_capabilities': {'size': 40, 'align': 8, 'offsets': {'struct_size': 0, 'native_sample_rate': 8, 'n_languages': 12, 'languages': 16, 'max_timestamp_kind': 24, 'supports_language_detect': 28, 'supports_translate': 29, 'supports_streaming': 30, 'supports_spec_decode': 31, 'max_audio_ms': 32}}, + 'transcribe_session_limits': {'size': 32, 'align': 8, 'offsets': {'struct_size': 0, 'effective_n_ctx': 8, 'effective_max_audio_ms': 16, 'max_kv_bytes': 24}}, + 'transcribe_stream_params': {'size': 24, 'align': 8, 'offsets': {'struct_size': 0, 'family': 8, 'commit_policy': 16, 'stable_prefix_agreement_n': 20}}, + 'transcribe_stream_update': {'size': 48, 'align': 8, 'offsets': {'struct_size': 0, 'result_changed': 8, 'is_final': 9, 'revision': 12, 'input_received_ms': 16, 'audio_committed_ms': 24, 'buffered_ms': 32, 'committed_changed': 40, 'tentative_changed': 41}}, + 'transcribe_stream_text': {'size': 64, 'align': 8, 'offsets': {'struct_size': 0, 'full_text': 8, 'full_text_bytes': 16, 'committed_text': 24, 'committed_text_bytes': 32, 'tentative_text': 40, 'tentative_text_bytes': 48, 'raw_tentative_start_bytes': 56}}, + 'transcribe_timings': {'size': 24, 'align': 8, 'offsets': {'struct_size': 0, 'load_ms': 8, 'mel_ms': 12, 'encode_ms': 16, 'decode_ms': 20}}, + 'transcribe_segment': {'size': 48, 'align': 8, 'offsets': {'struct_size': 0, 't0_ms': 8, 't1_ms': 16, 'first_word': 24, 'n_words': 28, 'first_token': 32, 'n_tokens': 36, 'text': 40}}, + 'transcribe_word': {'size': 48, 'align': 8, 'offsets': {'struct_size': 0, 't0_ms': 8, 't1_ms': 16, 'seg_index': 24, 'first_token': 28, 'n_tokens': 32, 'text': 40}}, + 'transcribe_token': {'size': 48, 'align': 8, 'offsets': {'struct_size': 0, 'id': 8, 'p': 12, 't0_ms': 16, 't1_ms': 24, 'seg_index': 32, 'word_index': 36, 'text': 40}}, + 'transcribe_moonshine_streaming_stream_ext': {'size': 24, 'align': 8, 'offsets': {'ext': 0, 'min_decode_interval_ms': 16}}, + 'transcribe_parakeet_stream_ext': {'size': 24, 'align': 8, 'offsets': {'ext': 0, 'att_context_right': 16}}, + 'transcribe_parakeet_buffered_stream_ext': {'size': 32, 'align': 8, 'offsets': {'ext': 0, 'left_ms': 16, 'chunk_ms': 20, 'right_ms': 24}}, + 'transcribe_voxtral_realtime_stream_ext': {'size': 24, 'align': 8, 'offsets': {'ext': 0, 'num_delay_tokens': 16, 'min_decode_interval_ms': 20}}, + 'transcribe_whisper_run_ext': {'size': 80, 'align': 8, 'offsets': {'ext': 0, 'initial_prompt': 16, 'prompt_tokens': 24, 'n_prompt_tokens': 32, 'prompt_condition': 40, 'condition_on_prev_tokens': 44, 'max_prev_context_tokens': 48, 'temperature': 52, 'temperature_inc': 56, 'compression_ratio_thold': 60, 'logprob_thold': 64, 'no_speech_thold': 68, 'seed': 72, 'max_initial_timestamp': 76}}, + 'transcribe_whisper_chunk_trace': {'size': 48, 'align': 8, 'offsets': {'struct_size': 0, 't0_ms': 8, 't1_ms': 16, 'temperature_used': 24, 'compression_ratio': 28, 'avg_logprob': 32, 'no_speech_prob': 36, 'no_speech_triggered': 40, 'n_fallbacks': 44}}, +} + +# === function prototypes === +def configure(lib): + """Stamp restype/argtypes onto a loaded CDLL.""" + lib.transcribe_abi_struct_align.restype = _c.c_size_t + lib.transcribe_abi_struct_align.argtypes = [_c.c_int] + lib.transcribe_abi_struct_size.restype = _c.c_size_t + lib.transcribe_abi_struct_size.argtypes = [_c.c_int] + lib.transcribe_backend_available.restype = _c.c_bool + lib.transcribe_backend_available.argtypes = [_c.c_int] + lib.transcribe_backend_device_count.restype = _c.c_int + lib.transcribe_backend_device_count.argtypes = [] + lib.transcribe_backend_device_init.restype = None + lib.transcribe_backend_device_init.argtypes = [_c.POINTER(transcribe_backend_device)] + lib.transcribe_batch_detected_language.restype = _c.c_char_p + lib.transcribe_batch_detected_language.argtypes = [_c.c_void_p, _c.c_int] + lib.transcribe_batch_full_text.restype = _c.c_char_p + lib.transcribe_batch_full_text.argtypes = [_c.c_void_p, _c.c_int] + lib.transcribe_batch_get_segment.restype = _c.c_int + lib.transcribe_batch_get_segment.argtypes = [_c.c_void_p, _c.c_int, _c.c_int, _c.POINTER(transcribe_segment)] + lib.transcribe_batch_get_timings.restype = _c.c_int + lib.transcribe_batch_get_timings.argtypes = [_c.c_void_p, _c.c_int, _c.POINTER(transcribe_timings)] + lib.transcribe_batch_get_token.restype = _c.c_int + lib.transcribe_batch_get_token.argtypes = [_c.c_void_p, _c.c_int, _c.c_int, _c.POINTER(transcribe_token)] + lib.transcribe_batch_get_word.restype = _c.c_int + lib.transcribe_batch_get_word.argtypes = [_c.c_void_p, _c.c_int, _c.c_int, _c.POINTER(transcribe_word)] + lib.transcribe_batch_n_results.restype = _c.c_int + lib.transcribe_batch_n_results.argtypes = [_c.c_void_p] + lib.transcribe_batch_n_segments.restype = _c.c_int + lib.transcribe_batch_n_segments.argtypes = [_c.c_void_p, _c.c_int] + lib.transcribe_batch_n_tokens.restype = _c.c_int + lib.transcribe_batch_n_tokens.argtypes = [_c.c_void_p, _c.c_int] + lib.transcribe_batch_n_words.restype = _c.c_int + lib.transcribe_batch_n_words.argtypes = [_c.c_void_p, _c.c_int] + lib.transcribe_batch_returned_timestamp_kind.restype = _c.c_int + lib.transcribe_batch_returned_timestamp_kind.argtypes = [_c.c_void_p, _c.c_int] + lib.transcribe_batch_status.restype = _c.c_int + lib.transcribe_batch_status.argtypes = [_c.c_void_p, _c.c_int] + lib.transcribe_capabilities_init.restype = None + lib.transcribe_capabilities_init.argtypes = [_c.POINTER(transcribe_capabilities)] + lib.transcribe_close.restype = None + lib.transcribe_close.argtypes = [_c.c_void_p] + lib.transcribe_detected_language.restype = _c.c_char_p + lib.transcribe_detected_language.argtypes = [_c.c_void_p] + lib.transcribe_ext_check.restype = _c.c_int + lib.transcribe_ext_check.argtypes = [_c.POINTER(transcribe_ext), _c.c_uint32, _c.c_uint64] + lib.transcribe_full_text.restype = _c.c_char_p + lib.transcribe_full_text.argtypes = [_c.c_void_p] + lib.transcribe_get_backend_device.restype = _c.c_int + lib.transcribe_get_backend_device.argtypes = [_c.c_int, _c.POINTER(transcribe_backend_device)] + lib.transcribe_get_model.restype = _c.c_void_p + lib.transcribe_get_model.argtypes = [_c.c_void_p] + lib.transcribe_get_segment.restype = _c.c_int + lib.transcribe_get_segment.argtypes = [_c.c_void_p, _c.c_int, _c.POINTER(transcribe_segment)] + lib.transcribe_get_timings.restype = _c.c_int + lib.transcribe_get_timings.argtypes = [_c.c_void_p, _c.POINTER(transcribe_timings)] + lib.transcribe_get_token.restype = _c.c_int + lib.transcribe_get_token.argtypes = [_c.c_void_p, _c.c_int, _c.POINTER(transcribe_token)] + lib.transcribe_get_whisper_chunk_count.restype = _c.c_int + lib.transcribe_get_whisper_chunk_count.argtypes = [_c.c_void_p] + lib.transcribe_get_whisper_chunk_trace.restype = _c.c_int + lib.transcribe_get_whisper_chunk_trace.argtypes = [_c.c_void_p, _c.c_int, _c.POINTER(transcribe_whisper_chunk_trace)] + lib.transcribe_get_word.restype = _c.c_int + 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_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 + lib.transcribe_model_accepts_ext_kind.argtypes = [_c.c_void_p, _c.c_int, _c.c_uint32] + lib.transcribe_model_arch_string.restype = _c.c_char_p + lib.transcribe_model_arch_string.argtypes = [_c.c_void_p] + lib.transcribe_model_backend.restype = _c.c_char_p + lib.transcribe_model_backend.argtypes = [_c.c_void_p] + lib.transcribe_model_free.restype = None + lib.transcribe_model_free.argtypes = [_c.c_void_p] + lib.transcribe_model_get_capabilities.restype = _c.c_int + lib.transcribe_model_get_capabilities.argtypes = [_c.c_void_p, _c.POINTER(transcribe_capabilities)] + lib.transcribe_model_load_file.restype = _c.c_int + lib.transcribe_model_load_file.argtypes = [_c.c_char_p, _c.POINTER(transcribe_model_load_params), _c.POINTER(_c.c_void_p)] + lib.transcribe_model_load_params_init.restype = None + lib.transcribe_model_load_params_init.argtypes = [_c.POINTER(transcribe_model_load_params)] + lib.transcribe_model_supports.restype = _c.c_bool + lib.transcribe_model_supports.argtypes = [_c.c_void_p, _c.c_int] + lib.transcribe_model_variant_string.restype = _c.c_char_p + lib.transcribe_model_variant_string.argtypes = [_c.c_void_p] + lib.transcribe_moonshine_streaming_stream_ext_init.restype = None + lib.transcribe_moonshine_streaming_stream_ext_init.argtypes = [_c.POINTER(transcribe_moonshine_streaming_stream_ext)] + lib.transcribe_n_segments.restype = _c.c_int + lib.transcribe_n_segments.argtypes = [_c.c_void_p] + lib.transcribe_n_tokens.restype = _c.c_int + lib.transcribe_n_tokens.argtypes = [_c.c_void_p] + lib.transcribe_n_words.restype = _c.c_int + lib.transcribe_n_words.argtypes = [_c.c_void_p] + lib.transcribe_open.restype = _c.c_int + lib.transcribe_open.argtypes = [_c.c_char_p, _c.POINTER(transcribe_model_load_params), _c.POINTER(transcribe_session_params), _c.POINTER(_c.c_void_p)] + lib.transcribe_parakeet_buffered_stream_ext_init.restype = None + lib.transcribe_parakeet_buffered_stream_ext_init.argtypes = [_c.POINTER(transcribe_parakeet_buffered_stream_ext)] + lib.transcribe_parakeet_stream_ext_init.restype = None + lib.transcribe_parakeet_stream_ext_init.argtypes = [_c.POINTER(transcribe_parakeet_stream_ext)] + lib.transcribe_print_timings.restype = None + lib.transcribe_print_timings.argtypes = [_c.c_void_p] + lib.transcribe_reset_timings.restype = None + lib.transcribe_reset_timings.argtypes = [_c.c_void_p] + lib.transcribe_returned_timestamp_kind.restype = _c.c_int + lib.transcribe_returned_timestamp_kind.argtypes = [_c.c_void_p] + lib.transcribe_run.restype = _c.c_int + lib.transcribe_run.argtypes = [_c.c_void_p, _c.POINTER(_c.c_float), _c.c_int, _c.POINTER(transcribe_run_params)] + lib.transcribe_run_batch.restype = _c.c_int + lib.transcribe_run_batch.argtypes = [_c.c_void_p, _c.POINTER(_c.POINTER(_c.c_float)), _c.POINTER(_c.c_int), _c.c_int, _c.POINTER(transcribe_run_params)] + lib.transcribe_run_params_init.restype = None + lib.transcribe_run_params_init.argtypes = [_c.POINTER(transcribe_run_params)] + lib.transcribe_segment_init.restype = None + lib.transcribe_segment_init.argtypes = [_c.POINTER(transcribe_segment)] + lib.transcribe_session_free.restype = None + lib.transcribe_session_free.argtypes = [_c.c_void_p] + lib.transcribe_session_get_limits.restype = _c.c_int + lib.transcribe_session_get_limits.argtypes = [_c.c_void_p, _c.POINTER(transcribe_session_limits)] + lib.transcribe_session_init.restype = _c.c_int + lib.transcribe_session_init.argtypes = [_c.c_void_p, _c.POINTER(transcribe_session_params), _c.POINTER(_c.c_void_p)] + lib.transcribe_session_limits_init.restype = None + lib.transcribe_session_limits_init.argtypes = [_c.POINTER(transcribe_session_limits)] + lib.transcribe_session_params_init.restype = None + lib.transcribe_session_params_init.argtypes = [_c.POINTER(transcribe_session_params)] + lib.transcribe_set_abort_callback.restype = None + lib.transcribe_set_abort_callback.argtypes = [_c.c_void_p, _c.CFUNCTYPE(_c.c_bool, _c.c_void_p), _c.c_void_p] + lib.transcribe_status_string.restype = _c.c_char_p + lib.transcribe_status_string.argtypes = [_c.c_int] + lib.transcribe_stream_begin.restype = _c.c_int + lib.transcribe_stream_begin.argtypes = [_c.c_void_p, _c.POINTER(transcribe_run_params), _c.POINTER(transcribe_stream_params)] + lib.transcribe_stream_feed.restype = _c.c_int + lib.transcribe_stream_feed.argtypes = [_c.c_void_p, _c.POINTER(_c.c_float), _c.c_int, _c.POINTER(transcribe_stream_update)] + lib.transcribe_stream_finalize.restype = _c.c_int + lib.transcribe_stream_finalize.argtypes = [_c.c_void_p, _c.POINTER(transcribe_stream_update)] + lib.transcribe_stream_get_state.restype = _c.c_int + lib.transcribe_stream_get_state.argtypes = [_c.c_void_p] + lib.transcribe_stream_get_text.restype = _c.c_int + lib.transcribe_stream_get_text.argtypes = [_c.c_void_p, _c.POINTER(transcribe_stream_text)] + lib.transcribe_stream_last_status.restype = _c.c_int + lib.transcribe_stream_last_status.argtypes = [_c.c_void_p] + lib.transcribe_stream_n_committed_segments.restype = _c.c_int + lib.transcribe_stream_n_committed_segments.argtypes = [_c.c_void_p] + lib.transcribe_stream_n_committed_tokens.restype = _c.c_int + lib.transcribe_stream_n_committed_tokens.argtypes = [_c.c_void_p] + lib.transcribe_stream_n_committed_words.restype = _c.c_int + lib.transcribe_stream_n_committed_words.argtypes = [_c.c_void_p] + lib.transcribe_stream_params_init.restype = None + lib.transcribe_stream_params_init.argtypes = [_c.POINTER(transcribe_stream_params)] + lib.transcribe_stream_reset.restype = None + lib.transcribe_stream_reset.argtypes = [_c.c_void_p] + lib.transcribe_stream_revision.restype = _c.c_int + lib.transcribe_stream_revision.argtypes = [_c.c_void_p] + lib.transcribe_stream_text_init.restype = None + lib.transcribe_stream_text_init.argtypes = [_c.POINTER(transcribe_stream_text)] + lib.transcribe_stream_update_init.restype = None + lib.transcribe_stream_update_init.argtypes = [_c.POINTER(transcribe_stream_update)] + lib.transcribe_timings_init.restype = None + lib.transcribe_timings_init.argtypes = [_c.POINTER(transcribe_timings)] + lib.transcribe_token_init.restype = None + lib.transcribe_token_init.argtypes = [_c.POINTER(transcribe_token)] + lib.transcribe_tokenize.restype = _c.c_int + lib.transcribe_tokenize.argtypes = [_c.c_void_p, _c.c_char_p, _c.POINTER(_c.c_int32), _c.c_size_t] + lib.transcribe_version.restype = _c.c_char_p + lib.transcribe_version.argtypes = [] + lib.transcribe_version_commit.restype = _c.c_char_p + lib.transcribe_version_commit.argtypes = [] + lib.transcribe_voxtral_realtime_stream_ext_init.restype = None + lib.transcribe_voxtral_realtime_stream_ext_init.argtypes = [_c.POINTER(transcribe_voxtral_realtime_stream_ext)] + lib.transcribe_was_aborted.restype = _c.c_bool + lib.transcribe_was_aborted.argtypes = [_c.c_void_p] + lib.transcribe_was_truncated.restype = _c.c_bool + lib.transcribe_was_truncated.argtypes = [_c.c_void_p] + lib.transcribe_whisper_chunk_trace_init.restype = None + lib.transcribe_whisper_chunk_trace_init.argtypes = [_c.POINTER(transcribe_whisper_chunk_trace)] + lib.transcribe_whisper_run_ext_init.restype = None + lib.transcribe_whisper_run_ext_init.argtypes = [_c.POINTER(transcribe_whisper_run_ext)] + lib.transcribe_word_init.restype = None + lib.transcribe_word_init.argtypes = [_c.POINTER(transcribe_word)] diff --git a/bindings/python/src/transcribe_cpp/_library.py b/bindings/python/src/transcribe_cpp/_library.py new file mode 100644 index 00000000..5c299fe1 --- /dev/null +++ b/bindings/python/src/transcribe_cpp/_library.py @@ -0,0 +1,386 @@ +"""Locate and load the native ``transcribe`` shared library. + +Two worlds load the library, and this module is the single choke point for both: + +**Distribution (wheels).** The pure-Python API package carries no native code. +A *provider* package ships the artifact and advertises it through a Python +*entry point* in group ``transcribe_cpp.native``. Two providers exist today: +``transcribe-cpp-native`` (the default — platform wheels bundling CPU+Metal on +macOS arm64, CPU+Vulkan on Linux/Windows) and ``transcribe-cpp-native-cu12`` +(opt-in CUDA 12, installed via the ``transcribe-cpp[cu12]`` extra). The entry +point resolves to a zero-argument callable returning a descriptor (a mapping +or an object) with the contract fields: + + name provider distribution name (e.g. "transcribe-cpp-native-cpu") + library_path absolute path to the shared library to dlopen + (or artifact_dir + the platform filename) + artifact_dir directory holding the library and its sibling ggml libs + version native library version it was built for (base must match ours) + header_hash the _generated.PUBLIC_HEADER_HASH it was built against + backends supported backend kinds, e.g. ["cpu"] / ["metal", "cpu"] + prepare optional zero-argument callable invoked after this provider + is SELECTED and validated, before any dlopen — e.g. the cu12 + provider preloads the NVIDIA runtime-wheel libraries + (site-packages/nvidia/*/lib) that the platform loader would + never find on its own + +Selecting a provider picks which native artifact loads into the process; the +per-model ``backend=`` request is a *separate* axis resolved inside it. Selection +policy: explicit ``provider`` argument → ``TRANSCRIBE_NATIVE_PROVIDER`` env var → +best accelerated (CUDA/Metal, then Vulkan) → CPU. A discovered provider whose +declared version or header hash disagrees with this binding is a hard error +*before* dlopen — pip pins are not enough; this runtime check is the backstop. + +**Development (working tree).** No provider is installed; ``TRANSCRIBE_LIBRARY`` +points at a hand-built library, or one is discovered under the repo's +``build-shared/`` / ``build/`` tree. This path skips the provider contract (the +developer owns the build); the import-time ABI layout check in ``_abi`` is the +correctness backstop either way. +""" + +from __future__ import annotations + +import ctypes +import importlib.metadata +import os +import sys +from pathlib import Path +from typing import Callable, Iterator, Optional + +from . import _generated +from .errors import TranscribeError + +#: Entry-point group a native provider package registers under. +ENTRY_POINT_GROUP = "transcribe_cpp.native" + +#: Backend-kind preference when auto-selecting among installed providers. Higher +#: wins; a provider's rank is the max over the kinds it advertises. +_BACKEND_RANK = {"cuda": 3, "metal": 3, "vulkan": 2, "cpu_accel": 1, "cpu": 1} + +#: Set after a successful load so the package can surface it for diagnostics. +#: None means the library came from the dev-tree / explicit-path fallback. +_selected_provider: Optional[str] = None + +#: Directory holding the native library and (in dynamic-backend builds) its +#: ggml backend modules. The import-time bootstrap passes this to +#: transcribe_init_backends so module loading stays package-local. +_artifact_dir: Optional[Path] = None + + +def selected_provider() -> Optional[str]: + """Name of the provider package the loaded library came from, or None for a + dev-tree / ``TRANSCRIBE_LIBRARY`` load.""" + return _selected_provider + + +def artifact_dir() -> Optional[Path]: + """Directory the native library (and any backend modules) live in.""" + return _artifact_dir + + +def _library_filename() -> str: + if sys.platform == "darwin": + return "libtranscribe.dylib" + if sys.platform == "win32": + return "transcribe.dll" + return "libtranscribe.so" + + +def _base_version(version: str) -> str: + """Leading dotted-numeric release segment, suffix stripped (PEP 440).""" + import re + + m = re.match(r"\d+(?:\.\d+)*", version.strip()) + return m.group(0) if m else version.strip() + + +# --- provider discovery --------------------------------------------------- + + +def _descriptor_field(descriptor, key: str): + """Read a contract field from a mapping- or attribute-style descriptor.""" + if isinstance(descriptor, dict): + return descriptor.get(key) + return getattr(descriptor, key, None) + + +def _iter_native_entry_points(): + """Entry points in ENTRY_POINT_GROUP, across the 3.9 vs 3.10+ APIs.""" + eps = importlib.metadata.entry_points() + if hasattr(eps, "select"): # 3.10+ + return list(eps.select(group=ENTRY_POINT_GROUP)) + return list(eps.get(ENTRY_POINT_GROUP, [])) # 3.9 + + +class _Provider: + """A discovered, normalized provider descriptor.""" + + def __init__(self, ep_name: str, descriptor): + self.ep_name = ep_name + self.name = _descriptor_field(descriptor, "name") or ep_name + self.version = _descriptor_field(descriptor, "version") + self.header_hash = _descriptor_field(descriptor, "header_hash") + self.backends = tuple(_descriptor_field(descriptor, "backends") or ()) + self._library_path = _descriptor_field(descriptor, "library_path") + self._artifact_dir = _descriptor_field(descriptor, "artifact_dir") + self._prepare = _descriptor_field(descriptor, "prepare") + + def prepare(self) -> None: + """Run the provider's post-selection hook (no-op when absent). Errors + here are the provider's fault — surface them with its name.""" + if not callable(self._prepare): + return + try: + self._prepare() + except Exception as exc: + raise TranscribeError( + message=( + f"native provider {self.name!r} failed in its prepare() " + f"hook: {exc}" + ) + ) from exc + + @property + def rank(self) -> int: + return max((_BACKEND_RANK.get(b, 0) for b in self.backends), default=0) + + def matches_request(self, request: str) -> bool: + """Whether an explicit provider request names this provider — by dist + name, by an advertised backend kind, or by the conventional + ``…-native-`` suffix.""" + request = request.strip().lower() + name = (self.name or "").lower() + return ( + request == name + or request == self.ep_name.lower() + or request in {b.lower() for b in self.backends} + or name.endswith(f"-{request}") + or name.endswith(f"-native-{request}") + ) + + def library_path(self) -> Path: + if self._library_path: + return Path(self._library_path) + if self._artifact_dir: + return Path(self._artifact_dir) / _library_filename() + raise TranscribeError( + message=( + f"native provider {self.name!r} supplied neither 'library_path' " + "nor 'artifact_dir' in its entry-point descriptor" + ) + ) + + def validate_contract(self) -> None: + """Hard-fail if the provider disagrees with this binding on ABI or + version. Raises TranscribeError with an actionable message.""" + expected_hash = _generated.PUBLIC_HEADER_HASH + if self.header_hash and self.header_hash != expected_hash: + raise TranscribeError( + message=( + f"native provider {self.name!r} was built against a different " + f"public ABI (header hash {self.header_hash!r}, this binding " + f"expects {expected_hash!r}). Install a provider matching " + "transcribe-cpp, or upgrade/downgrade transcribe-cpp to match " + "the provider." + ) + ) + api_base = _api_version_base() + if self.version and api_base and _base_version(self.version) != api_base: + raise TranscribeError( + message=( + f"native provider {self.name!r} is version {self.version}, but " + f"transcribe-cpp is {api_base}.x: pre-1.0 requires a matching " + "base (MAJOR.MINOR.PATCH). Install matching versions of " + "transcribe-cpp and its native provider." + ) + ) + + +def _api_version_base() -> Optional[str]: + """Base version of the installed API distribution, or None if not installed + as a distribution (a pure source/PYTHONPATH run, where no provider exists).""" + try: + return _base_version(importlib.metadata.version("transcribe-cpp")) + except importlib.metadata.PackageNotFoundError: + return None + + +def _discover_providers() -> list: + providers = [] + for ep in _iter_native_entry_points(): + try: + factory: Callable = ep.load() + descriptor = factory() if callable(factory) else factory + providers.append(_Provider(ep.name, descriptor)) + except Exception as exc: # a broken provider must not hide the others + providers.append(_BrokenProvider(ep.name, exc)) + return providers + + +class _BrokenProvider: + """A provider whose entry point failed to load — kept so selection can name + it in diagnostics rather than silently dropping it.""" + + rank = -1 + backends: tuple = () + + def __init__(self, ep_name: str, error: Exception): + self.ep_name = ep_name + self.name = ep_name + self.error = error + + def matches_request(self, request: str) -> bool: + return request.strip().lower() in (self.ep_name.lower(), self.name.lower()) + + +def _select_provider(providers: list, request: Optional[str]) -> Optional["_Provider"]: + """Apply the selection policy. Returns the chosen provider, or None when no + providers are installed. Raises when an explicit request cannot be honored.""" + healthy = [p for p in providers if isinstance(p, _Provider)] + + if request: + for p in providers: + if p.matches_request(request): + if isinstance(p, _BrokenProvider): + raise TranscribeError( + message=( + f"requested native provider {request!r} failed to " + f"load: {p.error}" + ) + ) + return p + installed = ", ".join(sorted(p.name for p in providers)) or "(none)" + # Suggest a command that actually exists: cu12 is the only extra; + # every other backend kind ships inside the default provider. + if request.strip().lower() in ("cu12", "cuda", "transcribe-cpp-native-cu12"): + hint = 'Install it with: pip install "transcribe-cpp[cu12]".' + else: + hint = ( + "The default provider (pip install transcribe-cpp) bundles " + "cpu plus the platform accelerator (metal on macOS arm64, " + "vulkan on Linux/Windows)." + ) + raise TranscribeError( + message=( + f"requested native provider {request!r} is not installed. " + f"Installed providers: {installed}. {hint}" + ) + ) + + if not providers: + return None + if not healthy: + errors = "; ".join(f"{p.name}: {p.error}" for p in providers) + raise TranscribeError( + message=f"every installed native provider failed to load: {errors}" + ) + # Best accelerated first (CUDA/Metal, then Vulkan, then CPU); deterministic + # tie-break by name so selection is stable across runs. + healthy.sort(key=lambda p: (-p.rank, p.name)) + return healthy[0] + + +# --- developer / bundled fallback candidates ------------------------------ + + +def _ascend_to_repo_root(start: Path) -> Optional[Path]: + """Walk up from *start* to the transcribe.cpp repo root, if we are in one.""" + for parent in (start, *start.parents): + if (parent / "CMakeLists.txt").is_file() and ( + parent / "include" / "transcribe.h" + ).is_file(): + return parent + return None + + +def _fallback_candidate_paths() -> Iterator[Path]: + """In-package bundled artifact, then dev-tree build outputs.""" + name = _library_filename() + + pkg_dir = Path(__file__).resolve().parent + yield pkg_dir / "_native" / name + yield pkg_dir / name + + repo_root = _ascend_to_repo_root(pkg_dir) + if repo_root is not None: + for build_dir in ("build-shared", "build"): + yield repo_root / build_dir / "src" / name + yield repo_root / build_dir / "bin" / name + + +def _cdll(path: Path) -> ctypes.CDLL: + # Windows resolves sibling DLLs (ggml, …) via the DLL search path, not + # rpath; add the library's own directory before loading it. + if sys.platform == "win32": + os.add_dll_directory(str(path.parent)) + return ctypes.CDLL(str(path)) + + +def load_library(provider: Optional[str] = None) -> tuple: + """Return ``(CDLL, path)`` for the native library, or raise TranscribeError. + + Resolution order: + + 1. ``TRANSCRIBE_LIBRARY`` — explicit path (developer escape hatch). + 2. An installed provider package (entry-point discovery + contract check). + 3. A bundled in-package ``_native/`` artifact, then a dev-tree build. + + *provider* (or ``TRANSCRIBE_NATIVE_PROVIDER``) forces a specific installed + provider; otherwise the best accelerated one is chosen, falling back to CPU. + """ + global _selected_provider, _artifact_dir + _selected_provider = None + _artifact_dir = None + + # 1. Explicit path override — bypasses provider discovery entirely. + override = os.environ.get("TRANSCRIBE_LIBRARY") + if override: + path = Path(override) + if not path.is_file(): + raise TranscribeError( + message=f"TRANSCRIBE_LIBRARY points at a missing file: {path}" + ) + _artifact_dir = path.parent + return _cdll(path), path + + # 2. Installed provider packages. + request = provider or os.environ.get("TRANSCRIBE_NATIVE_PROVIDER") + providers = _discover_providers() + chosen = _select_provider(providers, request) + if chosen is not None: + chosen.validate_contract() + path = chosen.library_path() + if not path.is_file(): + raise TranscribeError( + message=( + f"native provider {chosen.name!r} points at a missing " + f"library: {path}" + ) + ) + # Only the selected provider prepares (e.g. preloading NVIDIA runtime + # libs) — discovery alone must stay side-effect free. + chosen.prepare() + cdll = _cdll(path) + _selected_provider = chosen.name + _artifact_dir = ( + Path(chosen._artifact_dir) if chosen._artifact_dir else path.parent + ) + return cdll, path + + # 3. Bundled / dev-tree fallback. + tried: list = [] + for path in _fallback_candidate_paths(): + tried.append(str(path)) + if path.is_file(): + _artifact_dir = path.parent + return _cdll(path), path + + raise TranscribeError( + message=( + "could not locate the native transcribe library. Install the " + "native provider (pip install transcribe-cpp pulls it in), set " + "TRANSCRIBE_LIBRARY to a built library, or build a shared library " + "(cmake -DTRANSCRIBE_BUILD_SHARED=ON). Searched:\n " + + "\n ".join(tried) + ) + ) diff --git a/bindings/python/src/transcribe_cpp/errors.py b/bindings/python/src/transcribe_cpp/errors.py new file mode 100644 index 00000000..18609c8d --- /dev/null +++ b/bindings/python/src/transcribe_cpp/errors.py @@ -0,0 +1,151 @@ +"""Exception hierarchy for transcribe.cpp, mapped from ``transcribe_status``. + +Status values come from the generated FFI layer (``_generated``), which is +produced from ``include/transcribe.h`` and drift-gated in CI — a single +source instead of a hand-maintained mirror. Skew between this binding and +the *loaded native library* is caught at import time by the version and +header-hash gates in ``_library``; the explicit status→exception mapping +below is the part that stays a deliberate, reviewable design decision. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from ._generated import ( + TRANSCRIBE_ERR_ABORTED as ERR_ABORTED, + TRANSCRIBE_ERR_BACKEND as ERR_BACKEND, + TRANSCRIBE_ERR_BAD_STRUCT_SIZE as ERR_BAD_STRUCT_SIZE, + TRANSCRIBE_ERR_FILE_NOT_FOUND as ERR_FILE_NOT_FOUND, + TRANSCRIBE_ERR_GGUF as ERR_GGUF, + TRANSCRIBE_ERR_INPUT_TOO_LONG as ERR_INPUT_TOO_LONG, + TRANSCRIBE_ERR_INVALID_ARG as ERR_INVALID_ARG, + TRANSCRIBE_ERR_NOT_IMPLEMENTED as ERR_NOT_IMPLEMENTED, + TRANSCRIBE_ERR_OOM as ERR_OOM, + TRANSCRIBE_ERR_OUTPUT_TRUNCATED as ERR_OUTPUT_TRUNCATED, + TRANSCRIBE_ERR_SAMPLE_RATE as ERR_SAMPLE_RATE, + TRANSCRIBE_ERR_UNSUPPORTED_ARCH as ERR_UNSUPPORTED_ARCH, + TRANSCRIBE_ERR_UNSUPPORTED_ITN as ERR_UNSUPPORTED_ITN, + TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE as ERR_UNSUPPORTED_LANGUAGE, + TRANSCRIBE_ERR_UNSUPPORTED_PNC as ERR_UNSUPPORTED_PNC, + TRANSCRIBE_ERR_UNSUPPORTED_TASK as ERR_UNSUPPORTED_TASK, + TRANSCRIBE_ERR_UNSUPPORTED_TIMESTAMPS as ERR_UNSUPPORTED_TIMESTAMPS, + TRANSCRIBE_ERR_UNSUPPORTED_VARIANT as ERR_UNSUPPORTED_VARIANT, + TRANSCRIBE_OK as OK, +) + +if TYPE_CHECKING: # avoid a runtime import cycle; Result lives in __init__ + from . import Result + + +class TranscribeError(RuntimeError): + """Base class for every transcribe.cpp error. + + ``status`` is the numeric ``transcribe_status`` (0 for errors raised purely + on the Python side, e.g. library loading or input validation). + ``utterance_index`` is set on per-utterance failures raised out of + ``Session.run_batch`` (None everywhere else). + """ + + utterance_index: Optional[int] = None + + def __init__(self, message: str, status: int = OK): + super().__init__(message) + self.status = status + + +class InvalidArgument(TranscribeError): + pass + + +class NotImplementedByModel(TranscribeError): + pass + + +class ModelFileNotFound(TranscribeError): + pass + + +class ModelLoadError(TranscribeError): + """GGUF parse / unsupported arch / unsupported variant.""" + + +class OutOfMemory(TranscribeError): + pass + + +class BackendError(TranscribeError): + pass + + +class UnsupportedRequest(TranscribeError): + """Task / language / timestamp granularity the model does not support.""" + + +class AbiError(TranscribeError): + """Caller-owned struct layout did not match the library (struct_size).""" + + +class InputTooLong(TranscribeError): + pass + + +class Aborted(TranscribeError): + """The run/stream was ended by ``Session.cancel()``. + + ``partial_result`` holds the materialized transcript of the chunks that + completed before the abort (the C API preserves it on the session), or + None when the abort surfaced outside a result-bearing call. + """ + + partial_result: "Optional[Result]" = None + + +class OutputTruncated(TranscribeError): + """The decode hit the model's generation budget before end-of-stream — + the transcript is incomplete by contract. + + ``partial_result`` holds the materialized partial transcript (always + preserved by the C API for this status), or None when the status + surfaced outside a result-bearing call. + """ + + partial_result: "Optional[Result]" = None + + +_STATUS_TO_EXC = { + ERR_INVALID_ARG: InvalidArgument, + ERR_NOT_IMPLEMENTED: NotImplementedByModel, + ERR_FILE_NOT_FOUND: ModelFileNotFound, + ERR_GGUF: ModelLoadError, + ERR_UNSUPPORTED_ARCH: ModelLoadError, + ERR_UNSUPPORTED_VARIANT: ModelLoadError, + ERR_OOM: OutOfMemory, + ERR_BACKEND: BackendError, + ERR_SAMPLE_RATE: InvalidArgument, + ERR_UNSUPPORTED_LANGUAGE: UnsupportedRequest, + ERR_UNSUPPORTED_TASK: UnsupportedRequest, + ERR_UNSUPPORTED_TIMESTAMPS: UnsupportedRequest, + ERR_ABORTED: Aborted, + ERR_BAD_STRUCT_SIZE: AbiError, + ERR_UNSUPPORTED_PNC: UnsupportedRequest, + ERR_UNSUPPORTED_ITN: UnsupportedRequest, + ERR_INPUT_TOO_LONG: InputTooLong, + ERR_OUTPUT_TRUNCATED: OutputTruncated, +} + + +def exception_for_status( + status: int, status_string: str, context: str = "" +) -> TranscribeError: + """Build (without raising) the mapped exception for a non-OK status.""" + exc_type = _STATUS_TO_EXC.get(status, TranscribeError) + prefix = f"{context}: " if context else "" + return exc_type(f"{prefix}{status_string} (status {status})", status=status) + + +def raise_for_status(status: int, status_string: str, context: str = "") -> None: + """Raise the mapped exception for a non-OK status; return on OK.""" + if status == OK: + return + raise exception_for_status(status, status_string, context) diff --git a/bindings/python/src/transcribe_cpp/py.typed b/bindings/python/src/transcribe_cpp/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/bindings/python/tests/conftest.py b/bindings/python/tests/conftest.py new file mode 100644 index 00000000..c71b1043 --- /dev/null +++ b/bindings/python/tests/conftest.py @@ -0,0 +1,114 @@ +"""Shared fixtures for the transcribe_cpp pytest suite. + +Two tiers of tests: + + - **No-model tests** (test_abi.py) need only an importable package, which + means a loadable native library. Importing ``transcribe_cpp`` already runs + the loader, the ABI layout check, and the version gate; these tests pin that + surface explicitly. They always run in CI's shared-build lane. + + - **Model tests** (test_transcribe.py, test_streaming.py) need a real GGUF and + audio on disk. They ``skip`` (never fail) when the asset is absent, so the + suite is honest on a machine without the multi-GB checkpoints. + +Asset resolution mirrors the old stdlib smoke runner: a default +whisper-tiny.en + samples/jfk.wav in the repo, each overridable by +``TRANSCRIBE_SMOKE_MODEL`` / ``TRANSCRIBE_SMOKE_AUDIO``. +""" + +from __future__ import annotations + +import array +import os +import sys +import wave +from pathlib import Path + +import pytest + +# tests/ -> python/ -> bindings/ -> repo root. +REPO = Path(__file__).resolve().parents[3] +SAMPLES = REPO / "samples" + +DEFAULT_MODEL = REPO / "models/whisper-tiny.en/whisper-tiny.en-Q5_K_M.gguf" +DEFAULT_AUDIO = SAMPLES / "jfk.wav" +STREAMING_MODEL = ( + REPO / "models/moonshine-streaming-tiny/moonshine-streaming-tiny-Q8_0.gguf" +) +# A streaming model with a language-prompt dictionary: its C++ implementation +# re-reads run_params.language on every feed, which is what the params +# copy-out regression tests exercise. +PROMPTED_STREAMING_MODEL = ( + REPO + / "models/nemotron-3.5-asr-streaming-0.6b/nemotron-3.5-asr-streaming-0.6b-Q8_0.gguf" +) + + +def load_wav(path: Path) -> "array.array": + """Read a 16 kHz mono 16-bit WAV into a float32 ``array`` in [-1, 1).""" + with wave.open(str(path), "rb") as w: + assert ( + w.getsampwidth() == 2 + and w.getframerate() == 16000 + and w.getnchannels() == 1 + ), f"{path} must be 16 kHz 16-bit mono" + pcm16 = array.array("h") + pcm16.frombytes(w.readframes(w.getnframes())) + if sys.byteorder == "big": + pcm16.byteswap() + return array.array("f", (s / 32768.0 for s in pcm16)) + + +@pytest.fixture(scope="session") +def transcribe_cpp(): + """The imported package (importing it exercises load + ABI + version gate).""" + import transcribe_cpp + + return transcribe_cpp + + +@pytest.fixture(scope="session") +def model_path() -> Path: + override = os.environ.get("TRANSCRIBE_SMOKE_MODEL") + path = Path(override) if override else DEFAULT_MODEL + if not path.is_file(): + pytest.skip(f"model not present: {path} (set TRANSCRIBE_SMOKE_MODEL)") + return path + + +@pytest.fixture(scope="session") +def audio_path() -> Path: + override = os.environ.get("TRANSCRIBE_SMOKE_AUDIO") + path = Path(override) if override else DEFAULT_AUDIO + if not path.is_file(): + pytest.skip(f"audio not present: {path} (set TRANSCRIBE_SMOKE_AUDIO)") + return path + + +@pytest.fixture(scope="session") +def audio_pcm(audio_path: Path) -> "array.array": + return load_wav(audio_path) + + +@pytest.fixture(scope="session") +def streaming_model_path() -> Path: + override = os.environ.get("TRANSCRIBE_SMOKE_STREAMING_MODEL") + path = Path(override) if override else STREAMING_MODEL + if not path.is_file(): + pytest.skip( + f"streaming model not present: {path} " + "(set TRANSCRIBE_SMOKE_STREAMING_MODEL)" + ) + return path + + +@pytest.fixture(scope="session") +def prompted_streaming_model_path() -> Path: + override = os.environ.get("TRANSCRIBE_SMOKE_PROMPTED_MODEL") + path = Path(override) if override else PROMPTED_STREAMING_MODEL + if not path.is_file(): + pytest.skip( + f"prompted streaming model not present: {path} " + "(set TRANSCRIBE_SMOKE_PROMPTED_MODEL)" + ) + return path diff --git a/bindings/python/tests/test_abi.py b/bindings/python/tests/test_abi.py new file mode 100644 index 00000000..3ec807c1 --- /dev/null +++ b/bindings/python/tests/test_abi.py @@ -0,0 +1,85 @@ +"""No-model tests: ABI layout, version gate, and status-code/enum agreement. + +These run anywhere the native library loads — no GGUF required. Importing +``transcribe_cpp`` already ran the loader, ``_abi.verify_layouts``, and the +version gate; here we pin each surface explicitly so a regression names itself. +""" + +from __future__ import annotations + +import pytest + +import transcribe_cpp as t +from transcribe_cpp import _abi, _generated, errors + + +def test_native_version_matches_binding(): + # The import-time gate compares *base* versions; assert the same here so a + # base mismatch is a named failure rather than a bare ImportError. + assert t._base_version(t.native_version()) == t._base_version(t.__version__) + + +def test_abi_layouts_reverify(): + # Idempotent: import did this once; a second pass must still agree. + _abi.verify_layouts(t._lib) + + +def test_every_abi_struct_known_to_native(): + assert _generated.ABI_STRUCT_IDS, "no ABI struct ids generated" + for name, abi_id in _generated.ABI_STRUCT_IDS.items(): + assert t._lib.transcribe_abi_struct_size(abi_id) > 0, name + + +def test_struct_layout_offsets_present(): + # A guard against an empty/garbage generated layout silently passing. + n_fields = sum(len(lo["offsets"]) for lo in _generated.STRUCT_LAYOUT.values()) + assert len(_generated.STRUCT_LAYOUT) >= 10 + assert n_fields >= 50 + + +@pytest.mark.parametrize( + "version,base", + [ + ("0.0.1", "0.0.1"), + ("0.0.1.post1", "0.0.1"), + ("0.0.1.post42", "0.0.1"), + ("0.1.0.dev3", "0.1.0"), + ("1.2.3rc1", "1.2.3"), + ("1.2.3a4", "1.2.3"), + ("0.0.1+local.build", "0.0.1"), + ("2.0.0", "2.0.0"), + ], +) +def test_base_version_strips_suffixes(version, base): + assert t._base_version(version) == base + + +# --- status-code coverage (M2) --------------------------------------------- +# +# errors.py imports its status constants FROM the generated layer (single +# source — nothing hand-kept to drift). What still needs pinning is COVERAGE: +# every status the native header defines must be aliased in errors.py and +# (for errors) mapped to an exception class, so adding a code natively fails +# CI until the Python layer handles it deliberately. + +_STATUS_NAMES = [n for n in dir(errors) if n == "OK" or n.startswith("ERR_")] + + +def test_status_constants_discovered(): + # transcribe_status has OK + 18 error codes as of this writing; never let + # this collapse to a near-empty set that would make the checks vacuous. + assert len(_STATUS_NAMES) >= 19 + + +def test_every_generated_status_is_aliased_and_mapped(): + # Every TRANSCRIBE_OK / TRANSCRIBE_ERR_* the native library exposes must + # have an errors.py alias AND (for errors) a mapped exception. + py_values = {getattr(errors, n) for n in _STATUS_NAMES} + for gen_name in dir(_generated): + if gen_name == "TRANSCRIBE_OK" or gen_name.startswith("TRANSCRIBE_ERR_"): + value = getattr(_generated, gen_name) + assert value in py_values, f"{gen_name} not aliased in errors.py" + if gen_name != "TRANSCRIBE_OK": + assert value in errors._STATUS_TO_EXC, ( + f"{gen_name} has no mapped exception in errors._STATUS_TO_EXC" + ) diff --git a/bindings/python/tests/test_backends.py b/bindings/python/tests/test_backends.py new file mode 100644 index 00000000..47af44d0 --- /dev/null +++ b/bindings/python/tests/test_backends.py @@ -0,0 +1,77 @@ +"""Backend-module loading and device discovery. + +No model required: importing the package already called +``transcribe_init_backends`` on the artifact directory (a no-op for +compiled-in builds, the module-loading step for dynamic-backend builds). +These pin the discovery surface and the degradation contract on whatever +build configuration the suite runs against. +""" + +from __future__ import annotations + +import pytest + +import transcribe_cpp as t +from transcribe_cpp import _library + + +def test_at_least_one_device_registered(): + devices = t.backends() + assert devices, "no compute devices registered — nothing could run" + for dev in devices: + assert dev.name + assert dev.kind in { + "cpu", "accel", "metal", "vulkan", "cuda", "sycl", "gpu", "unknown" + } + + +def test_cpu_always_available(): + # Every shipped configuration includes a CPU backend (compiled in or as + # the baseline module); a process without one is mispackaged. + assert t.backend_available("cpu") is True + assert t.backend_available("auto") is True + assert any(d.kind == "cpu" for d in t.backends()) + + +def test_unknown_backend_kind_raises(): + with pytest.raises(t.InvalidArgument, match="unknown backend"): + t.backend_available("quantum") + + +def test_available_kinds_match_device_list(): + # The degradation probe: every requestable kind must answer consistently + # with the registered device list, never raise — this is what turns + # backend="vulkan" on a machine without Vulkan into a clear Python-level + # answer. (cpu_accel is satisfied by a CPU device per the C contract.) + kinds = {d.kind for d in t.backends()} + for request, device_kind in (("metal", "metal"), ("vulkan", "vulkan"), + ("cuda", "cuda"), ("cpu", "cpu"), + ("cpu_accel", "cpu")): + assert t.backend_available(request) == (device_kind in kinds), request + + +def test_artifact_dir_is_library_dir(): + # The loader records where the native artifact lives; backend modules + # are loaded from there and nowhere else (package-local contract). + adir = _library.artifact_dir() + assert adir is not None + assert adir == _library._artifact_dir + import pathlib + + assert pathlib.Path(t.library_path()).parent == adir + + +def test_init_backends_rejects_bad_dirs(): + lib = t._lib + # NULL/empty/missing directory: clean status codes, never a crash. + assert lib.transcribe_init_backends(None) != 0 + assert lib.transcribe_init_backends(b"") != 0 + assert lib.transcribe_init_backends(b"/nonexistent-transcribe-dir") != 0 + + +def test_init_backends_idempotent(): + lib = t._lib + adir = str(_library.artifact_dir()).encode("utf-8") + n = lib.transcribe_backend_device_count() + assert lib.transcribe_init_backends(adir) == 0 + assert lib.transcribe_backend_device_count() == n # no re-registration diff --git a/bindings/python/tests/test_errors.py b/bindings/python/tests/test_errors.py new file mode 100644 index 00000000..e3c8affb --- /dev/null +++ b/bindings/python/tests/test_errors.py @@ -0,0 +1,104 @@ +"""Error-mapping coverage: status codes → exception classes, end to end. + +Two layers, both model-free: + + - **Unit**: ``raise_for_status`` / ``exception_for_status`` map every + ``transcribe_status`` to its documented exception subclass with + ``.status`` set, and unknown codes degrade to the base class instead of + KeyError. + - **Integration**: real native calls produce the mapped exceptions + (missing file → ModelFileNotFound, junk bytes → ModelLoadError) — the + paths a user actually hits first. +""" + +from __future__ import annotations + +import pytest + +import transcribe_cpp as t +from transcribe_cpp import errors + + +def test_every_status_maps_to_documented_subclass(): + expected = { + errors.ERR_INVALID_ARG: t.InvalidArgument, + errors.ERR_NOT_IMPLEMENTED: t.NotImplementedByModel, + errors.ERR_FILE_NOT_FOUND: t.ModelFileNotFound, + errors.ERR_GGUF: t.ModelLoadError, + errors.ERR_UNSUPPORTED_ARCH: t.ModelLoadError, + errors.ERR_UNSUPPORTED_VARIANT: t.ModelLoadError, + errors.ERR_OOM: t.OutOfMemory, + errors.ERR_BACKEND: t.BackendError, + errors.ERR_SAMPLE_RATE: t.InvalidArgument, + errors.ERR_UNSUPPORTED_LANGUAGE: t.UnsupportedRequest, + errors.ERR_UNSUPPORTED_TASK: t.UnsupportedRequest, + errors.ERR_UNSUPPORTED_TIMESTAMPS: t.UnsupportedRequest, + errors.ERR_ABORTED: t.Aborted, + errors.ERR_BAD_STRUCT_SIZE: t.AbiError, + errors.ERR_UNSUPPORTED_PNC: t.UnsupportedRequest, + errors.ERR_UNSUPPORTED_ITN: t.UnsupportedRequest, + errors.ERR_INPUT_TOO_LONG: t.InputTooLong, + errors.ERR_OUTPUT_TRUNCATED: t.OutputTruncated, + } + # The mapping table covers every non-OK status the header defines, and + # nothing else (a new C status must be mapped deliberately, not by + # accident). + assert set(errors._STATUS_TO_EXC) == set(expected) + for status, exc_type in expected.items(): + with pytest.raises(exc_type) as ei: + errors.raise_for_status(status, "synthetic", "ctx") + assert ei.value.status == status + assert isinstance(ei.value, t.TranscribeError) + assert "ctx: synthetic" in str(ei.value) + + +def test_ok_does_not_raise(): + assert errors.raise_for_status(errors.OK, "ok") is None + + +def test_unknown_status_degrades_to_base_class(): + exc = errors.exception_for_status(999, "mystery") + assert type(exc) is t.TranscribeError + assert exc.status == 999 + + +def test_exception_for_status_builds_without_raising(): + exc = errors.exception_for_status(errors.ERR_ABORTED, "aborted", "run") + assert isinstance(exc, t.Aborted) + assert exc.partial_result is None # default until a caller attaches one + assert exc.utterance_index is None + + +def test_status_constants_come_from_generated_layer(): + # errors.py aliases the generated constants instead of hand-mirroring + # ints; this pins the alias so a regeneration cannot drift from it. + from transcribe_cpp import _generated + + assert errors.ERR_ABORTED == _generated.TRANSCRIBE_ERR_ABORTED + assert errors.ERR_OUTPUT_TRUNCATED == _generated.TRANSCRIBE_ERR_OUTPUT_TRUNCATED + assert errors.OK == _generated.TRANSCRIBE_OK + + +# --- integration: real native calls raise the mapped classes ---------------- + + +def test_missing_model_file_raises_model_file_not_found(tmp_path): + with pytest.raises(t.ModelFileNotFound) as ei: + t.Model(tmp_path / "definitely-missing.gguf") + assert ei.value.status == errors.ERR_FILE_NOT_FOUND + + +def test_junk_model_file_raises_model_load_error(tmp_path): + junk = tmp_path / "junk.gguf" + junk.write_bytes(b"this is not a gguf file" * 64) + with pytest.raises(t.ModelLoadError): + t.Model(junk) + + +def test_invalid_spec_k_drafts_rejected_before_native_call(): + from transcribe_cpp import _build_run_params + + with pytest.raises(t.InvalidArgument, match="spec_k_drafts"): + _build_run_params("transcribe", None, None, "none", False, -2) + with pytest.raises(t.InvalidArgument, match="spec_k_drafts"): + _build_run_params("transcribe", None, None, "none", False, "many") diff --git a/bindings/python/tests/test_example.py b/bindings/python/tests/test_example.py new file mode 100644 index 00000000..63327553 --- /dev/null +++ b/bindings/python/tests/test_example.py @@ -0,0 +1,130 @@ +"""The shipped examples must actually run. + +``transcribe_wav.py``'s WAV validation happens BEFORE any model load, so the +negative paths (wrong sample rate, with the actionable resample hint) are +model-free; the happy path and the streaming example are model-gated like +every other model test. Each example runs as a subprocess — the way a user +runs it — with TRANSCRIBE_LIBRARY pinned to the library this suite loaded. +""" + +from __future__ import annotations + +import array +import math +import os +import subprocess +import sys +import wave +from pathlib import Path + +import pytest + +import transcribe_cpp as t + +EXAMPLES = Path(__file__).resolve().parents[1] / "examples" + + +def run_example(script: str, *args: str) -> "subprocess.CompletedProcess[str]": + env = os.environ.copy() + env["TRANSCRIBE_LIBRARY"] = t.library_path() + return subprocess.run( + [sys.executable, str(EXAMPLES / script), *args], + capture_output=True, text=True, timeout=300, env=env, + ) + + +def write_wav(path: Path, *, rate: int, channels: int = 1, + seconds: float = 0.25) -> None: + n = int(rate * seconds) + pcm = array.array("h", ( + int(8000 * math.sin(2 * math.pi * 440 * i / rate)) + for i in range(n * channels) + )) + with wave.open(str(path), "wb") as w: + w.setnchannels(channels) + w.setsampwidth(2) + w.setframerate(rate) + w.writeframes(pcm.tobytes()) + + +# --- model-free: validation paths ------------------------------------------- + + +def test_transcribe_wav_rejects_wrong_sample_rate(tmp_path): + bad = tmp_path / "8k.wav" + write_wav(bad, rate=8000) + proc = run_example("transcribe_wav.py", "unused-model.gguf", str(bad)) + assert proc.returncode != 0 + # The error must be actionable: name the rate and how to fix it. + assert "8000" in proc.stderr + assert "resample" in proc.stderr.lower() + assert "ffmpeg" in proc.stderr + + +def test_transcribe_wav_rejects_wrong_sample_width(tmp_path): + bad = tmp_path / "8bit.wav" + n = 4000 + with wave.open(str(bad), "wb") as w: + w.setnchannels(1) + w.setsampwidth(1) # 8-bit + w.setframerate(16000) + w.writeframes(bytes(n)) + proc = run_example("transcribe_wav.py", "unused-model.gguf", str(bad)) + assert proc.returncode != 0 + assert "16-bit" in proc.stderr + + +# --- model-gated: end-to-end ------------------------------------------------- + + +def test_transcribe_wav_happy_path(model_path, audio_path): + proc = run_example("transcribe_wav.py", str(model_path), str(audio_path), + "--timestamps", "segment") + assert proc.returncode == 0, proc.stderr + assert "country" in proc.stdout.lower(), proc.stdout + + +def test_transcribe_wav_downmixes_stereo(model_path, tmp_path, audio_pcm): + # Duplicate the mono canary into 2 interleaved channels: the example's + # downmix path must produce the same transcript as the mono original. + stereo = tmp_path / "stereo.wav" + pcm16 = array.array("h", (max(-32768, min(32767, int(s * 32768.0))) + for s in audio_pcm)) + inter = array.array("h", (0 for _ in range(2 * len(pcm16)))) + inter[0::2] = pcm16 + inter[1::2] = pcm16 + with wave.open(str(stereo), "wb") as w: + w.setnchannels(2) + w.setsampwidth(2) + w.setframerate(16000) + w.writeframes(inter.tobytes()) + proc = run_example("transcribe_wav.py", str(model_path), str(stereo)) + assert proc.returncode == 0, proc.stderr + assert "country" in proc.stdout.lower(), proc.stdout + + +# --- stream_wav.py ------------------------------------------------------------ + + +def test_stream_wav_rejects_non_16k(tmp_path): + bad = tmp_path / "8k.wav" + write_wav(bad, rate=8000) + proc = run_example("stream_wav.py", "unused-model.gguf", str(bad)) + assert proc.returncode != 0 + assert "resample" in proc.stderr.lower() + + +def test_stream_wav_rejects_non_streaming_model(model_path, audio_path): + # whisper (the default canary) does not stream: the example must say so + # clearly instead of tracebacking. + proc = run_example("stream_wav.py", str(model_path), str(audio_path)) + if proc.returncode == 0: + pytest.skip("default canary unexpectedly supports streaming") + assert "does not support streaming" in proc.stderr + + +def test_stream_wav_happy_path(streaming_model_path, audio_path): + proc = run_example("stream_wav.py", str(streaming_model_path), + str(audio_path), "--chunk-ms", "500") + assert proc.returncode == 0, proc.stderr + assert "country" in proc.stdout.lower(), proc.stdout diff --git a/bindings/python/tests/test_family_ext.py b/bindings/python/tests/test_family_ext.py new file mode 100644 index 00000000..e5ba4700 --- /dev/null +++ b/bindings/python/tests/test_family_ext.py @@ -0,0 +1,136 @@ +"""Family-extension coverage: option building (model-free) and the +slot/kind validation + happy path against real models. + +The model-free half drives ``FamilyExtension._build()`` directly: the built +ctypes struct must carry the registered kind, the caller's sizeof, the init +function's defaults for unset fields, and ONLY the explicitly-set overrides. +The model-gated half pins ``Session._resolve_family``'s three rejection modes +and a real whisper ``initial_prompt`` run. +""" + +from __future__ import annotations + +import ctypes + +import pytest + +import transcribe_cpp as t +from transcribe_cpp import _generated + +ALL_OPTION_TYPES = [ + t.WhisperRunOptions, + t.MoonshineStreamingOptions, + t.ParakeetStreamOptions, + t.ParakeetBufferedStreamOptions, + t.VoxtralRealtimeStreamOptions, +] + + +# --- model-free: every subclass builds a correctly-stamped struct ------------ + + +@pytest.mark.parametrize("cls", ALL_OPTION_TYPES) +def test_build_stamps_kind_and_size(cls): + ext = cls()._build() + assert ext.ext.kind == cls._kind + assert ext.ext.size == ctypes.sizeof(cls._struct) + assert cls._slot in ("run", "stream") + + +@pytest.mark.parametrize("cls", ALL_OPTION_TYPES) +def test_build_defaults_equal_init_defaults(cls): + # No overrides set: every byte must equal what the C init function + # stamps — the "only fields you set override" contract's baseline. + built = cls()._build() + fresh = cls._struct() + getattr(t._lib, cls._init)(ctypes.byref(fresh)) + assert bytes(built) == bytes(fresh) + + +def test_whisper_overrides_apply_and_unset_fields_keep_defaults(): + built = t.WhisperRunOptions(temperature=0.7, seed=1234)._build() + fresh = t._generated.transcribe_whisper_run_ext() + t._lib.transcribe_whisper_run_ext_init(ctypes.byref(fresh)) + + assert abs(built.temperature - 0.7) < 1e-6 + assert built.seed == 1234 + # A field left at None keeps the init default. + assert built.no_speech_thold == fresh.no_speech_thold + assert built.condition_on_prev_tokens == fresh.condition_on_prev_tokens + + +def test_whisper_initial_prompt_encodes(): + built = t.WhisperRunOptions(initial_prompt="Glossary: GGUF, ggml")._build() + assert built.initial_prompt == b"Glossary: GGUF, ggml" + + +def test_parakeet_buffered_partial_overrides(): + built = t.ParakeetBufferedStreamOptions(chunk_ms=2000)._build() + fresh = _generated.transcribe_parakeet_buffered_stream_ext() + t._lib.transcribe_parakeet_buffered_stream_ext_init(ctypes.byref(fresh)) + assert built.chunk_ms == 2000 + assert built.left_ms == fresh.left_ms # untouched -> family default + assert built.right_ms == fresh.right_ms + + +# --- model-gated: resolve_family validation + a real extension run ---------- + + +def test_run_rejects_non_extension_object(model_path): + with t.Model(model_path) as model, model.session() as session: + with pytest.raises(t.InvalidArgument, match="FamilyExtension"): + session.run([0.0] * 160, family=object()) + + +def test_run_rejects_stream_slot_extension(model_path): + # A stream-slot extension pointed at the run slot is a caller bug, not + # something to silently ignore. + with t.Model(model_path) as model, model.session() as session: + with pytest.raises(t.InvalidArgument, match="slot"): + session.run([0.0] * 160, family=t.MoonshineStreamingOptions()) + + +def test_run_rejects_extension_model_does_not_accept(streaming_model_path): + # WhisperRunOptions is a run-slot kind, but moonshine-streaming does not + # accept it: UnsupportedRequest, probed BEFORE the native run. + with t.Model(streaming_model_path) as model: + assert not model.accepts(t.WhisperRunOptions()) + with model.session() as session: + with pytest.raises(t.UnsupportedRequest): + session.run([0.0] * 160, family=t.WhisperRunOptions()) + + +def test_model_accepts_probe(model_path): + with t.Model(model_path) as model: + if model.arch != "whisper": + pytest.skip("default canary is expected to be whisper") + assert model.accepts(t.WhisperRunOptions()) is True + assert model.accepts(t.MoonshineStreamingOptions()) is False + + +def test_whisper_initial_prompt_run(model_path, audio_pcm): + with t.Model(model_path) as model: + if not model.supports("initial_prompt"): + pytest.skip("model does not support initial_prompt") + with model.session() as session: + opts = t.WhisperRunOptions(initial_prompt="A speech by JFK.") + result = session.run(audio_pcm, family=opts) + assert "country" in result.text.lower(), result.text + + +def test_run_batch_accepts_family(model_path, audio_pcm): + # B3 plumbed family= through run_batch; pin the happy path. + with t.Model(model_path) as model, model.session() as session: + results = session.run_batch( + [audio_pcm], family=t.WhisperRunOptions(temperature=0.0)) + assert len(results) == 1 + assert "country" in results[0].text.lower() + + +def test_supports_probe_all_features(model_path): + with t.Model(model_path) as model: + for feature in ("initial_prompt", "temperature_fallback", "long_form", + "cancellation", "pnc", "itn"): + assert model.supports(feature) in (True, False) + with pytest.raises(t.InvalidArgument, match="unknown feature"): + model.supports("levitation") diff --git a/bindings/python/tests/test_lifetime.py b/bindings/python/tests/test_lifetime.py new file mode 100644 index 00000000..e094b5f9 --- /dev/null +++ b/bindings/python/tests/test_lifetime.py @@ -0,0 +1,204 @@ +"""Handle-lifetime tests: close ordering, use-after-close, idempotency. + +The C contract says a model may only be freed after every derived session +(`transcribe_model_free` docs). The binding enforces that ordering itself: +``Model.close()`` closes live sessions first, and every closed handle turns +later calls into TranscribeError instead of native use-after-free. +""" + +from __future__ import annotations + +import gc + +import pytest + +import transcribe_cpp as t + + +def test_model_close_closes_open_sessions(model_path): + model = t.Model(model_path) + session = model.session() + model.close() # must close the session first, then free the model + with pytest.raises(t.TranscribeError, match="closed"): + session.run([0.0] * 1600) + + +def test_model_context_exit_with_live_session(model_path, audio_pcm): + # The session is deliberately NOT closed before the model's context + # exits — historically a native use-after-free footgun. + with t.Model(model_path) as model: + session = model.session() + result = session.run(audio_pcm) + assert result.text.strip() + with pytest.raises(t.TranscribeError, match="closed"): + session.run(audio_pcm) + with pytest.raises(t.TranscribeError, match="closed"): + model.session() + + +def test_close_is_idempotent(model_path): + model = t.Model(model_path) + session = model.session() + session.close() + session.close() + model.close() + model.close() + + +def test_session_close_then_model_still_usable(model_path, audio_pcm): + with t.Model(model_path) as model: + session = model.session() + session.close() + # Closing one session must not affect the model or new sessions. + with model.session() as fresh: + assert fresh.run(audio_pcm).text.strip() + + +def test_session_close_with_active_stream(streaming_model_path, audio_pcm): + # Closing the session out from under an active stream must surface as a + # Python error on the next stream call, never a native crash. + with t.Model(streaming_model_path) as model: + session = model.session() + stream = session.stream() + stream.feed(audio_pcm[:16000]) + session.close() + with pytest.raises(t.TranscribeError, match="closed"): + stream.feed(audio_pcm[:16000]) + + +def test_gc_order_session_keeps_model_alive(model_path, audio_pcm): + session = t.Model(model_path).session() # model reference dropped at once + gc.collect() # the session's strong ref must keep the model alive + assert session.run(audio_pcm).text.strip() + session.close() + gc.collect() + + +# --- multi-session use of one model ------------------------------------------ +# +# SUPPORTED in 0.x: many sessions on one model, run serially (from any +# threads). NOT yet supported: overlapping runs across sessions of one model +# — they share the model's compute backend and some family state, so +# concurrent runs race (observed: corrupted whisper decodes on CPU, +# command-buffer failures on Metal). The xfail below pins the limitation so +# the day a per-session backend architecture lands, the XPASS flags it for +# promotion to a hard test. + + +def test_serial_sessions_across_threads(model_path, audio_pcm): + # Two threads, each with its own session, runs SERIALIZED by a lock: + # the supported session-pool pattern. + import threading + + lock = threading.Lock() + results: dict = {} + errors_seen: list = [] + + def worker(tag: str, model: "t.Model") -> None: + try: + with model.session() as session: + with lock: + results[tag] = session.run(audio_pcm).text.lower() + except Exception as exc: # surface, don't deadlock the join + errors_seen.append((tag, exc)) + + with t.Model(model_path) as model: + threads = [threading.Thread(target=worker, args=(f"t{i}", model)) + for i in range(2)] + for th in threads: + th.start() + for th in threads: + th.join(timeout=120) + assert not errors_seen, errors_seen + assert len(results) == 2 + assert all("country" in text for text in results.values()), results + + +# Run INSIDE a subprocess: the known failure mode is NATIVE (corrupted +# decodes, Metal command-buffer failures, potentially a crash or a wedged +# thread). xfail can absorb a Python assert but not a segfault of the test +# runner, and a wedged native thread must never reach interpreter teardown +# in the CI process (freeing the session under an in-flight native call is +# a use-after-free). os._exit on the wedge path skips teardown on purpose. +_CONCURRENT_RUNS_SNIPPET = """\ +import array, os, sys, threading, wave + +import transcribe_cpp as t + +model_path, audio_path = sys.argv[1], sys.argv[2] +with wave.open(audio_path, "rb") as w: + pcm16 = array.array("h") + pcm16.frombytes(w.readframes(w.getnframes())) +pcm = array.array("f", (s / 32768.0 for s in pcm16)) + +results, errors = {}, [] + +def worker(tag, model): + try: + with model.session() as session: + results[tag] = session.run(pcm).text.lower() + except Exception as exc: + errors.append((tag, repr(exc))) + +with t.Model(model_path) as model: + threads = [threading.Thread(target=worker, args=(f"t{i}", model), daemon=True) + for i in range(2)] + for th in threads: + th.start() + for th in threads: + th.join(timeout=120) + if any(th.is_alive() for th in threads): + print("WEDGED: a run never returned", flush=True) + os._exit(3) # never free handles under an in-flight native call + +assert not errors, errors +assert len(results) == 2 and all("country" in v for v in results.values()), results +print("CONCURRENT-OK") +""" + + +@pytest.mark.xfail( + strict=False, + reason="known 0.x limitation: overlapping runs on sessions of one model " + "race on the shared backend/model state (see Model docstring and " + "notes/bindings-review-fixes.md); per-session backends planned", +) +def test_concurrent_sessions_on_shared_model(model_path, audio_path): + import os + import subprocess + import sys + + env = os.environ.copy() + env["TRANSCRIBE_LIBRARY"] = t.library_path() + proc = subprocess.run( + [sys.executable, "-c", _CONCURRENT_RUNS_SNIPPET, + str(model_path), str(audio_path)], + capture_output=True, text=True, timeout=300, env=env, + ) + assert proc.returncode == 0 and "CONCURRENT-OK" in proc.stdout, ( + proc.returncode, proc.stdout[-300:], proc.stderr[-300:]) + + +# --- GC ordering: dropping every reference at once must never crash ---------- + + +def test_gc_drop_model_and_session_together(model_path): + def scope(): + model = t.Model(model_path) + session = model.session() + return None # both refs die on return, collection order is GC's pick + + scope() + gc.collect() + + +def test_gc_drop_with_active_stream(streaming_model_path, audio_pcm): + def scope(): + model = t.Model(streaming_model_path) + session = model.session() + stream = session.stream() + stream.feed(audio_pcm[:16000]) + return None # model + session + ACTIVE stream all dropped together + + scope() + gc.collect() diff --git a/bindings/python/tests/test_pcm.py b/bindings/python/tests/test_pcm.py new file mode 100644 index 00000000..e069f1a5 --- /dev/null +++ b/bindings/python/tests/test_pcm.py @@ -0,0 +1,150 @@ +"""PCM input-validation battery for the FFI boundary. + +The contract: 16 kHz mono float32, as a buffer-protocol object, raw bytes, +or a sequence of floats. Everything else must raise InvalidArgument BEFORE +any native call — a wrong dtype silently reinterpreted as float32 would +produce garbage transcription, not an error, so the gate lives in Python. + +These drive the module-level ``_pcm_to_carray`` helper directly: it is the +single choke point ``run`` / ``run_batch`` / ``Stream.feed`` all share, and +testing it needs no model on disk. +""" + +from __future__ import annotations + +import array +import ctypes + +import pytest + +import transcribe_cpp as t +from transcribe_cpp import InvalidArgument, _pcm_to_carray + + +# --- accepted forms --------------------------------------------------------- + + +def test_float32_array_module(): + arr, n = _pcm_to_carray(array.array("f", [0.0, 0.5, -0.5])) + assert n == 3 + assert abs(arr[1] - 0.5) < 1e-6 + + +def test_raw_bytes_interpreted_as_float32_le(): + raw = array.array("f", [1.0, -1.0]).tobytes() + arr, n = _pcm_to_carray(raw) + assert n == 2 + assert arr[0] == 1.0 and arr[1] == -1.0 + + +def test_bytearray_and_memoryview(): + raw = bytearray(array.array("f", [0.25]).tobytes()) + assert _pcm_to_carray(raw)[1] == 1 + assert _pcm_to_carray(memoryview(raw))[1] == 1 + + +def test_sequence_of_floats(): + arr, n = _pcm_to_carray([0.0, 0.1, 0.2, 0.3]) + assert n == 4 + + +def test_ctypes_float_array_fast_path_no_copy(): + src = (ctypes.c_float * 3)(0.0, 1.0, 2.0) + arr, n = _pcm_to_carray(src) + assert arr is src # documented zero-copy fast path + assert n == 3 + + +# --- rejected forms --------------------------------------------------------- + + +@pytest.mark.parametrize("empty", [[], b"", bytearray(), array.array("f")]) +def test_empty_buffers_rejected(empty): + with pytest.raises(InvalidArgument, match="empty"): + _pcm_to_carray(empty) + + +def test_odd_length_bytes_rejected(): + with pytest.raises(InvalidArgument, match="multiple of 4"): + _pcm_to_carray(b"\x00\x00\x00\x00\x00") + + +def test_float64_buffer_rejected(): + with pytest.raises(InvalidArgument, match="float32"): + _pcm_to_carray(array.array("d", [0.0, 1.0])) + + +def test_int16_buffer_rejected(): + with pytest.raises(InvalidArgument, match="float32"): + _pcm_to_carray(array.array("h", [0, 1, 2])) + + +def test_non_contiguous_buffer_rejected(): + mv = memoryview(array.array("f", [0.0, 1.0, 2.0, 3.0]))[::2] + with pytest.raises(InvalidArgument, match="contiguous"): + _pcm_to_carray(mv) + + +def test_non_iterable_rejected(): + with pytest.raises((InvalidArgument, TypeError)): + _pcm_to_carray(object()) + + +# --- numpy interop (optional dependency; skipped when absent) --------------- + + +def test_numpy_float32_accepted(): + np = pytest.importorskip("numpy") + arr, n = _pcm_to_carray(np.zeros(160, dtype=np.float32)) + assert n == 160 + + +def test_numpy_float64_rejected(): + np = pytest.importorskip("numpy") + with pytest.raises(InvalidArgument, match="float32"): + _pcm_to_carray(np.zeros(160, dtype=np.float64)) + + +def test_numpy_non_contiguous_rejected(): + np = pytest.importorskip("numpy") + with pytest.raises(InvalidArgument, match="contiguous"): + _pcm_to_carray(np.zeros((4, 160), dtype=np.float32)[:, 0]) + + +# --- dimensionality (the silent-stereo-truncation regression) --------------- +# +# A C-contiguous (frames, channels) float32 array used to pass validation and +# transcribe shape[0] SAMPLES — two samples of garbage for a (2, N) stereo +# buffer. Multi-dimensional input is now rejected outright; the caller owns +# the downmix/flatten decision. + + +def test_2d_contiguous_float32_rejected(): + flat = array.array("f", range(6)) + mv = memoryview(flat.tobytes()).cast("f", (2, 3)) + assert mv.contiguous and mv.ndim == 2 # exactly the trap shape + with pytest.raises(InvalidArgument, match="1-D"): + _pcm_to_carray(mv) + + +def test_3d_contiguous_float32_rejected(): + flat = array.array("f", range(8)) + mv = memoryview(flat.tobytes()).cast("f", (2, 2, 2)) + with pytest.raises(InvalidArgument, match="1-D"): + _pcm_to_carray(mv) + + +def test_2d_byte_buffer_rejected(): + # The raw-bytes path must apply the same rule: a 2-D uint8 view is not + # "a byte stream of float32" no matter how it is shaped. + mv = memoryview(bytes(8)).cast("B", (2, 4)) + with pytest.raises(InvalidArgument, match="1-D"): + _pcm_to_carray(mv) + + +def test_numpy_stereo_rejected(): + np = pytest.importorskip("numpy") + stereo = np.zeros((160, 2), dtype=np.float32) # (frames, channels) + assert stereo.flags.c_contiguous + with pytest.raises(InvalidArgument, match="downmix"): + _pcm_to_carray(stereo) diff --git a/bindings/python/tests/test_provider_discovery.py b/bindings/python/tests/test_provider_discovery.py new file mode 100644 index 00000000..563a3620 --- /dev/null +++ b/bindings/python/tests/test_provider_discovery.py @@ -0,0 +1,341 @@ +"""Unit tests for native-provider discovery and selection (_library). + +No real provider package is installed in CI, so these drive the selection and +contract machinery with synthetic descriptors — the logic that decides which +artifact loads and rejects an ABI/version-mismatched provider before dlopen. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import transcribe_cpp as t +from transcribe_cpp import _generated, _library +from transcribe_cpp.errors import TranscribeError + +GOOD_HASH = _generated.PUBLIC_HEADER_HASH +API_BASE = _library._api_version_base() + + +def make(name, backends, *, version=None, header_hash=GOOD_HASH, + library_path="/x/libtranscribe.so", artifact_dir=None): + return _library._Provider( + name, + { + "name": name, + "backends": backends, + "version": version if version is not None else (API_BASE or "0.0.1"), + "header_hash": header_hash, + "library_path": library_path, + "artifact_dir": artifact_dir, + }, + ) + + +# --- descriptor normalization & matching ---------------------------------- + + +def test_descriptor_from_mapping_and_object(): + class Obj: + name = "transcribe-cpp-native-cpu" + backends = ["cpu"] + version = "0.0.1" + header_hash = GOOD_HASH + library_path = "/x/libtranscribe.so" + artifact_dir = None + + p = _library._Provider("cpu", Obj()) + assert p.name == "transcribe-cpp-native-cpu" + assert p.backends == ("cpu",) + assert p.library_path().name.startswith("libtranscribe") + + +def test_library_path_from_artifact_dir(): + p = make("transcribe-cpp-native-cpu", ["cpu"], + library_path=None, artifact_dir="/opt/art") + # Path comparison, not string prefix: Windows renders this with + # backslashes (str(WindowsPath('/opt/art/...')) == '\\opt\\art\\...'). + path = p.library_path() + assert path.parent == Path("/opt/art") + assert path.name in ("libtranscribe.so", "libtranscribe.dylib", "transcribe.dll") + + +def test_library_path_requires_a_source(): + p = make("x", ["cpu"], library_path=None, artifact_dir=None) + with pytest.raises(TranscribeError): + p.library_path() + + +@pytest.mark.parametrize( + "request_str,expected", + [ + ("transcribe-cpp-native-cpu", True), + ("cpu", True), # backend kind and name suffix + ("metal", False), + ("transcribe-cpp-native-metal", False), + ], +) +def test_matches_request(request_str, expected): + p = make("transcribe-cpp-native-cpu", ["cpu"]) + assert p.matches_request(request_str) is expected + + +def test_rank_orders_accelerated_above_cpu(): + assert make("m", ["metal", "cpu"]).rank == 3 + assert make("v", ["vulkan"]).rank == 2 + assert make("c", ["cpu"]).rank == 1 + assert make("u", ["wat"]).rank == 0 + + +# --- selection policy ------------------------------------------------------ + + +def test_select_prefers_accelerated(): + cpu = make("transcribe-cpp-native-cpu", ["cpu"]) + metal = make("transcribe-cpp-native-metal", ["metal", "cpu"]) + assert _library._select_provider([cpu, metal], None) is metal + + +def test_select_is_deterministic_on_ties(): + a = make("transcribe-cpp-native-cpu", ["cpu"]) + b = make("transcribe-cpp-native-cpu2", ["cpu"]) + # Same rank → alphabetical by name, stable regardless of input order. + assert _library._select_provider([a, b], None).name == "transcribe-cpp-native-cpu" + assert _library._select_provider([b, a], None).name == "transcribe-cpp-native-cpu" + + +def test_select_explicit_request(): + cpu = make("transcribe-cpp-native-cpu", ["cpu"]) + metal = make("transcribe-cpp-native-metal", ["metal", "cpu"]) + assert _library._select_provider([cpu, metal], "cpu") is cpu + + +def test_select_none_when_no_providers(): + assert _library._select_provider([], None) is None + + +def test_select_unknown_request_raises(): + cpu = make("transcribe-cpp-native-cpu", ["cpu"]) + with pytest.raises(TranscribeError, match="not installed"): + _library._select_provider([cpu], "vulkan") + + +def test_select_broken_only_raises(): + broken = _library._BrokenProvider("transcribe-cpp-native-cpu", RuntimeError("boom")) + with pytest.raises(TranscribeError, match="failed to load"): + _library._select_provider([broken], None) + + +def test_select_request_for_broken_raises(): + broken = _library._BrokenProvider("transcribe-cpp-native-cpu", RuntimeError("boom")) + with pytest.raises(TranscribeError, match="failed to load"): + _library._select_provider([broken], "transcribe-cpp-native-cpu") + + +# --- prepare hook ------------------------------------------------------------ + + +def test_prepare_hook_runs_when_present(): + calls = [] + p = _library._Provider( + "cu12", + {"name": "transcribe-cpp-native-cu12", "backends": ["cuda", "cpu"], + "library_path": "/x/libtranscribe.so", "prepare": lambda: calls.append(1)}, + ) + p.prepare() + assert calls == [1] + + +def test_prepare_hook_absent_is_noop(): + make("transcribe-cpp-native-cpu", ["cpu"]).prepare() # no raise + + +def test_prepare_hook_failure_names_the_provider(): + def boom(): + raise RuntimeError("nvidia wheels missing") + + p = _library._Provider( + "cu12", + {"name": "transcribe-cpp-native-cu12", "backends": ["cuda"], + "library_path": "/x/libtranscribe.so", "prepare": boom}, + ) + with pytest.raises(TranscribeError, match="transcribe-cpp-native-cu12.*prepare"): + p.prepare() + + +# --- contract validation --------------------------------------------------- + + +def test_contract_ok_for_matching_provider(): + make("transcribe-cpp-native-cpu", ["cpu"]).validate_contract() # no raise + + +def test_contract_rejects_header_hash_mismatch(): + p = make("transcribe-cpp-native-cpu", ["cpu"], header_hash="deadbeefdeadbeef") + with pytest.raises(TranscribeError, match="different\\s+public ABI"): + p.validate_contract() + + +def test_contract_skips_hash_check_when_provider_omits_it(): + # A provider that declares no header hash is trusted on ABI (the import-time + # struct-layout check in _abi is still the deep backstop). + make("transcribe-cpp-native-cpu", ["cpu"], header_hash=None).validate_contract() + + +@pytest.mark.skipif(API_BASE is None, reason="API distribution not installed") +def test_contract_rejects_version_base_mismatch(): + major = int(API_BASE.split(".")[0]) + bumped = f"{major + 1}.0.0" + p = make("transcribe-cpp-native-cpu", ["cpu"], version=bumped) + with pytest.raises(TranscribeError, match="requires a matching"): + p.validate_contract() + + +@pytest.mark.skipif(API_BASE is None, reason="API distribution not installed") +def test_contract_accepts_post_release_provider(): + # A .postN packaging fix on the provider keeps the same base and must load. + p = make("transcribe-cpp-native-cpu", ["cpu"], version=f"{API_BASE}.post3") + p.validate_contract() # no raise + + +def test_provider_state_is_consistent(): + # Two legitimate environments run this suite: the dev tree (no provider + # installed / TRANSCRIBE_LIBRARY override -> native_provider() is None) and + # a wheel-test env where a transcribe-cpp-native* package (default or an + # accelerator variant like -cu12) supplied the library. Pin that the + # reported state matches where the library came from. + provider = t.native_provider() + if provider is None: + assert "transcribe_cpp_native" not in t.library_path() + else: + assert provider.startswith("transcribe-cpp-native"), provider + assert "transcribe_cpp_native" in t.library_path() + + +# --- end to end, through the real entry-point machinery -------------------- +# +# The unit tests above drive selection/contract with synthetic _Provider +# objects. These build an actual installed-distribution shape on disk — a +# module plus a .dist-info advertising the transcribe_cpp.native group — and +# run the full path: importlib.metadata discovery → ep.load() → descriptor → +# selection → contract → ctypes dlopen of the real library this suite loaded. + + +def _install_fake_provider( + site_dir, module_name, *, header_hash=GOOD_HASH, version=API_BASE +): + lib = t.library_path() + dist = module_name.replace("_", "-") + marker = site_dir / f"{module_name}.prepared" + (site_dir / f"{module_name}.py").write_text( + "def _prepare():\n" + f" open({str(marker)!r}, 'w').close()\n" + "\n" + "def descriptor():\n" + " return {\n" + f" 'name': {dist!r},\n" + f" 'library_path': {lib!r},\n" + f" 'version': {version!r},\n" + f" 'header_hash': {header_hash!r},\n" + " 'backends': ['cpu'],\n" + " 'prepare': _prepare,\n" + " }\n" + ) + di = site_dir / f"{module_name}-0.0.1.dist-info" + di.mkdir() + (di / "METADATA").write_text( + f"Metadata-Version: 2.1\nName: {dist}\nVersion: 0.0.1\n" + ) + (di / "entry_points.txt").write_text( + f"[transcribe_cpp.native]\n{module_name} = {module_name}:descriptor\n" + ) + return dist + + +@pytest.fixture +def library_globals_restored(): + """load_library mutates module globals; put them back for later tests.""" + provider, art = _library._selected_provider, _library._artifact_dir + yield + _library._selected_provider = provider + _library._artifact_dir = art + + +def test_entry_point_end_to_end_loads(tmp_path, monkeypatch, library_globals_restored): + dist = _install_fake_provider(tmp_path, "fake_native_provider_ok") + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.delenv("TRANSCRIBE_LIBRARY", raising=False) + + # Explicit request: deterministic even if a real provider package is also + # installed in this environment (auto-selection policy is unit-tested). + cdll, path = _library.load_library(provider=dist) + assert str(path) == t.library_path() + assert _library.selected_provider() == dist + assert _library.artifact_dir() == path.parent + # The dlopen is real: the loaded handle exposes the C API. + assert cdll.transcribe_version is not None + # The selected provider's prepare() hook ran before the dlopen. + assert (tmp_path / "fake_native_provider_ok.prepared").is_file() + + +def test_entry_point_end_to_end_rejects_abi_mismatch( + tmp_path, monkeypatch, library_globals_restored +): + dist = _install_fake_provider( + tmp_path, "fake_native_provider_badhash", header_hash="deadbeefdeadbeef" + ) + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.delenv("TRANSCRIBE_LIBRARY", raising=False) + + # The contract check must refuse before any dlopen happens. + with pytest.raises(TranscribeError, match="public ABI"): + _library.load_library(provider=dist) + + +# --- review-audit additions (2026-06) --------------------------------------- + + +def test_broken_provider_does_not_hide_healthy_on_auto_select(): + # The property the discovery code promises ("a broken provider must not + # hide the others"): with no explicit request, auto-selection skips the + # broken entry and picks the best healthy one. + broken = _library._BrokenProvider("transcribe-cpp-native-zzz", + RuntimeError("import exploded")) + healthy = make("transcribe-cpp-native-cpu", ["cpu"]) + chosen = _library._select_provider([broken, healthy], None) + assert chosen is healthy + + +def test_env_var_selects_provider(monkeypatch, tmp_path, library_globals_restored): + # TRANSCRIBE_NATIVE_PROVIDER must force the named provider even when a + # higher-ranked one is installed. Hermetic: load_library is exercised up + # to the contract check by pointing the chosen provider at a missing + # library file and asserting the error names IT, not the metal one. + cpu = make("transcribe-cpp-native-cpu", ["cpu"], + library_path=str(tmp_path / "missing-cpu.so")) + metal = make("transcribe-cpp-native-metal", ["metal", "cpu"], + library_path=str(tmp_path / "missing-metal.so")) + monkeypatch.setattr(_library, "_discover_providers", lambda: [cpu, metal]) + monkeypatch.delenv("TRANSCRIBE_LIBRARY", raising=False) + monkeypatch.setenv("TRANSCRIBE_NATIVE_PROVIDER", "cpu") + with pytest.raises(TranscribeError, match="transcribe-cpp-native-cpu"): + _library.load_library() + + +def test_transcribe_library_env_missing_file(monkeypatch, tmp_path, library_globals_restored): + monkeypatch.setenv("TRANSCRIBE_LIBRARY", str(tmp_path / "nope.dylib")) + with pytest.raises(TranscribeError, match="missing file"): + _library.load_library() + + +def test_not_installed_hint_suggests_real_commands(): + # The "not installed" error must never suggest a pip extra that does not + # exist (only [cu12] does). + with pytest.raises(TranscribeError) as ei: + _library._select_provider([], "vulkan") + assert "transcribe-cpp[vulkan]" not in str(ei.value) + with pytest.raises(TranscribeError) as ei: + _library._select_provider([], "cu12") + assert 'transcribe-cpp[cu12]' in str(ei.value) diff --git a/bindings/python/tests/test_streaming.py b/bindings/python/tests/test_streaming.py new file mode 100644 index 00000000..6d0b09b1 --- /dev/null +++ b/bindings/python/tests/test_streaming.py @@ -0,0 +1,150 @@ +"""Streaming + cancellation tests. + +The streaming *gate* runs against the default (non-streaming) whisper model and +needs no streaming checkpoint. The real streaming and cancellation tests need +moonshine-streaming-tiny and ``skip`` when it is absent. +""" + +from __future__ import annotations + +import pytest + +import transcribe_cpp as t + + +def test_streaming_gate(model_path): + with t.Model(model_path) as model: + if model.capabilities.supports_streaming: + pytest.skip("default model supports streaming; gate not exercised") + with model.session() as session: + with pytest.raises(t.NotImplementedByModel): + session.stream() + + +def test_streaming_real(streaming_model_path, audio_pcm): + with t.Model(streaming_model_path) as model: + assert model.capabilities.supports_streaming + with model.session() as session, session.stream() as stream: + for i in range(0, len(audio_pcm), 16000): # ~1s chunks + stream.feed(audio_pcm[i : i + 16000]) + update = stream.finalize() + committed = stream.text().committed + revision, state = stream.revision, stream.state + assert update.is_final, update + assert state == "finished", state + assert revision >= 1 + assert "country" in committed.lower(), committed + + +def test_streaming_with_language_hint(prompted_streaming_model_path, audio_pcm): + """Regression: params copy-out contract for streaming. + + Parakeet's prompted streaming re-resolves run_params.language on EVERY + feed. The caller's params storage (here: ctypes structs local to + Session.stream()) dies when stream() returns, so the library must have + copied the string into session-owned storage at begin — a retained + caller pointer is a use-after-free on the first feed. The ASan lane is + the real detector; the gc + junk scribbling below makes stale reads + likelier to also misbehave in a normal build.""" + import gc + + with t.Model(prompted_streaming_model_path) as model: + caps = model.capabilities + assert caps.supports_streaming + assert caps.languages, "prompted model should advertise languages" + lang = "en" if "en" in caps.languages else caps.languages[0] + with model.session() as session: + stream = session.stream(language=lang) + # The params structs built inside stream() are now dead. Collect + # and scribble over the freed allocations before the first feed. + gc.collect() + junk = [b"\x5a" * 256 for _ in range(4096)] + with stream: + for i in range(0, len(audio_pcm), 16000): + stream.feed(audio_pcm[i : i + 16000]) + update = stream.finalize() + committed = stream.text().committed + del junk + assert update.is_final + assert "country" in committed.lower(), committed + + +def test_streaming_language_hint_second_family(streaming_model_path, audio_pcm): + """Same contract on moonshine-streaming: it retains a shallow copy of + run_params for its decode path. No live pointer read today, but the + retained copy must stay safe to carry — this pins it under ASan.""" + with t.Model(streaming_model_path) as model: + caps = model.capabilities + if not caps.languages or "en" not in caps.languages: + pytest.skip("model does not advertise an 'en' language hint") + with model.session() as session, session.stream(language="en") as stream: + for i in range(0, len(audio_pcm), 16000): + stream.feed(audio_pcm[i : i + 16000]) + stream.finalize() + committed = stream.text().committed + assert "country" in committed.lower(), committed + + +def test_cancellation_aborts_pending_feed(streaming_model_path, audio_pcm): + # Pre-set the cancel flag, then feed: the abort callback fires at the + # first poll inside the feed. (True cross-thread mid-run cancellation is + # covered by TestCancellation in test_transcribe.py.) Must surface as + # the dedicated Aborted class, not a generic TranscribeError. + with t.Model(streaming_model_path) as model, model.session() as session: + with session.stream() as stream: + session.cancel() + with pytest.raises(t.Aborted): + stream.feed(audio_pcm) + assert session.was_aborted, "was_aborted should be True after cancel()" + + +# --- stream lifecycle edges --------------------------------------------------- + + +def test_feed_after_finalize_rejected(streaming_model_path, audio_pcm): + with t.Model(streaming_model_path) as model, model.session() as session: + with session.stream() as stream: + stream.feed(audio_pcm[:16000]) + stream.finalize() + assert stream.state == "finished" + with pytest.raises(t.InvalidArgument): + stream.feed(audio_pcm[:16000]) + + +def test_stream_use_after_reset_rejected(streaming_model_path, audio_pcm): + with t.Model(streaming_model_path) as model, model.session() as session: + stream = session.stream() + stream.feed(audio_pcm[:16000]) + stream.reset() + with pytest.raises(t.TranscribeError, match="reset"): + stream.feed(audio_pcm[:16000]) + with pytest.raises(t.TranscribeError, match="reset"): + stream.text() + + +def test_stream_reset_idempotent_and_session_reusable( + streaming_model_path, audio_pcm): + with t.Model(streaming_model_path) as model, model.session() as session: + first = session.stream() + first.feed(audio_pcm[:16000]) + first.reset() + first.reset() # idempotent by contract + + # The session returns to idle: a SECOND stream on it must work + # end-to-end. + with session.stream() as second: + for i in range(0, len(audio_pcm), 16000): + second.feed(audio_pcm[i : i + 16000]) + second.finalize() + committed = second.text().committed + assert "country" in committed.lower(), committed + + +def test_second_stream_while_active_rejected(streaming_model_path, audio_pcm): + # One stream at a time per session: the C dispatcher rejects a begin + # while ACTIVE, and the binding surfaces it as InvalidArgument. + with t.Model(streaming_model_path) as model, model.session() as session: + with session.stream() as stream: + stream.feed(audio_pcm[:16000]) + with pytest.raises(t.InvalidArgument): + session.stream() diff --git a/bindings/python/tests/test_transcribe.py b/bindings/python/tests/test_transcribe.py new file mode 100644 index 00000000..e242bcaa --- /dev/null +++ b/bindings/python/tests/test_transcribe.py @@ -0,0 +1,247 @@ +"""Model-gated transcription tests: run, GIL release, batch, logging. + +Each takes the ``model_path`` / ``audio_pcm`` fixtures, which ``skip`` when the +default whisper-tiny.en + jfk.wav assets are absent (override with +``TRANSCRIBE_SMOKE_MODEL`` / ``TRANSCRIBE_SMOKE_AUDIO``). The content assertions +("country") are specific to jfk.wav and hold for any English ASR model. +""" + +from __future__ import annotations + +import threading +import time + +import pytest + +import transcribe_cpp as t + + +def test_transcription_and_gil(model_path, audio_pcm): + # GIL-release probe: a worker spins in pure Python (GIL-bound). If the native + # run releases the GIL, the worker advances while transcription is in flight. + counter = {"n": 0} + stop = threading.Event() + + def spin(): + while not stop.is_set(): + counter["n"] += 1 + + worker = threading.Thread(target=spin, daemon=True) + worker.start() + time.sleep(0.01) + try: + with t.Model(model_path) as model: + assert model.arch, "model reported an empty arch string" + with model.session() as session: + before = counter["n"] + result = session.run(audio_pcm, timestamps="segment") + during = counter["n"] - before + finally: + stop.set() + worker.join(timeout=1.0) + + text = result.text.strip().lower() + assert text, "empty transcription" + assert "country" in text, f"unexpected transcription: {result.text!r}" + assert result.segments, "no segments materialized" + assert during > 100, f"GIL not released during run (worker advanced {during})" + + +def test_run_batch(model_path, audio_pcm): + with t.Model(model_path) as model, model.session() as session: + results = session.run_batch([audio_pcm, audio_pcm], timestamps="segment") + assert len(results) == 2 + assert all("country" in r.text.lower() for r in results), results + assert all(r.segments for r in results) + + +def test_logging_routes_through_public_sink(model_path, audio_pcm): + received = [] + t.set_log_callback(lambda level, msg: received.append((level, msg))) + try: + with t.Model(model_path) as model, model.session() as session: + session.run(audio_pcm) + # print_timings publishes through the public log sink at INFO level. + t._lib.transcribe_print_timings(session._h) + assert received, "log callback received nothing from the public sink" + finally: + t.set_log_callback(None) + + +# --- TRANSCRIBE_BACKEND default override ------------------------------------ + + +def test_backend_env_invalid_value_rejected(monkeypatch): + # No model needed: the env value is validated before any file access, and + # the error must name the env var so a typo is diagnosable. + monkeypatch.setenv("TRANSCRIBE_BACKEND", "warp9") + with pytest.raises(t.InvalidArgument, match="TRANSCRIBE_BACKEND"): + t.Model("nonexistent.gguf") + + +def test_backend_env_overrides_auto(model_path, audio_pcm, monkeypatch): + monkeypatch.setenv("TRANSCRIBE_BACKEND", "cpu") + with t.Model(model_path) as model: + assert "cpu" in model.backend.lower(), model.backend + with model.session() as session: + text = session.run(audio_pcm).text + assert "country" in text.lower(), text + + +def test_backend_explicit_arg_beats_env(model_path, monkeypatch): + # A deliberately unsatisfiable env value: if the explicit argument didn't + # win, the load would fail (or land on a non-CPU device). + monkeypatch.setenv("TRANSCRIBE_BACKEND", "warp9") + with t.Model(model_path, backend="cpu") as model: + assert "cpu" in model.backend.lower(), model.backend + + +# --- transcribe() one-shot helper -------------------------------------------- + + +class TestOneShot: + def test_path_form(self, model_path, audio_pcm): + result = t.transcribe(model_path, audio_pcm) + assert "country" in result.text.lower(), result.text + + def test_model_form_leaves_model_open(self, model_path, audio_pcm): + with t.Model(model_path) as model: + result = t.transcribe(model, audio_pcm) + assert "country" in result.text.lower() + # The helper must NOT close a caller-owned model: it stays + # usable for another run afterwards. + assert model.arch + again = t.transcribe(model, audio_pcm) + assert "country" in again.text.lower() + + def test_model_form_ignores_backend_arg(self, model_path, audio_pcm): + # backend= applies only to the path form; with a Model it is ignored + # (documented), so an unsatisfiable value must not break the call. + with t.Model(model_path, backend="cpu") as model: + result = t.transcribe(model, audio_pcm, backend="cuda") + assert "country" in result.text.lower() + + +# --- run_batch edges ---------------------------------------------------------- + + +class TestRunBatch: + def test_empty_batch_rejected(self, model_path): + with t.Model(model_path) as model, model.session() as session: + with pytest.raises(t.InvalidArgument, match="at least one"): + session.run_batch([]) + + def test_mixed_lengths(self, model_path, audio_pcm): + with t.Model(model_path) as model, model.session() as session: + results = session.run_batch([audio_pcm, audio_pcm[:16000]]) + assert len(results) == 2 + assert "country" in results[0].text.lower() + assert isinstance(results[1], t.Result) + + def test_return_exceptions_all_success(self, model_path, audio_pcm): + with t.Model(model_path) as model, model.session() as session: + out = session.run_batch([audio_pcm], return_exceptions=True) + assert len(out) == 1 and isinstance(out[0], t.Result) + + def test_mixed_pcm_forms(self, model_path, audio_pcm): + import array as _array + + forms = [audio_pcm, audio_pcm.tobytes(), memoryview(audio_pcm)] + with t.Model(model_path) as model, model.session() as session: + results = session.run_batch(forms) + texts = [r.text.lower() for r in results] + assert all("country" in text for text in texts), texts + + +# --- session limits ----------------------------------------------------------- + + +def test_session_limits_readable(model_path): + with t.Model(model_path) as model: + caps = model.capabilities + with model.session() as session: + limits = session.limits + assert isinstance(limits, t.SessionLimits) + assert limits.effective_n_ctx >= 0 + assert limits.effective_max_audio_ms >= 0 + assert limits.max_kv_bytes >= 0 + # Coherence with the model-level bound: a session at default n_ctx can + # never enforce a TIGHTER audio limit than the model advertises. + if caps.max_audio_ms > 0 and limits.effective_max_audio_ms > 0: + assert limits.effective_max_audio_ms <= caps.max_audio_ms + + +# --- spec_k_drafts ------------------------------------------------------------ + + +def test_spec_k_drafts_disabled_matches_default(model_path, audio_pcm): + # Spec decoding is a lossless performance strategy: disabling it must + # not change the transcript. (On a model without supports_spec_decode + # the field is ignored entirely, which trivially also passes.) + with t.Model(model_path) as model, model.session() as session: + base = session.run(audio_pcm, spec_k_drafts=-1).text + nospec = session.run(audio_pcm, spec_k_drafts=0).text + assert base == nospec + + +# --- cancellation depth --------------------------------------------------------- + + +class TestCancellation: + def test_was_aborted_false_after_clean_run(self, model_path, audio_pcm): + with t.Model(model_path) as model, model.session() as session: + session.run(audio_pcm) + assert session.was_aborted is False + + def test_cross_thread_cancel_in_flight_run(self, model_path, audio_pcm): + # Long input (tiled jfk) so the run is mid-flight when the timer + # fires from another thread. The abort must surface as Aborted with + # the partial transcript attached, and the session must stay usable. + long_pcm = audio_pcm * 12 # ~2.2 minutes of audio + with t.Model(model_path) as model, model.session() as session: + timer = threading.Timer(0.5, session.cancel) + timer.start() + try: + result = session.run(long_pcm) + except t.Aborted as exc: + assert session.was_aborted is True + assert exc.partial_result is not None + assert isinstance(exc.partial_result, t.Result) + else: + # A machine fast enough to finish 2 minutes of audio in + # 0.5 s makes the race unwinnable; don't fail on speed. + pytest.skip(f"run finished before cancel fired: {result.text[:40]!r}") + finally: + timer.cancel() + + # The cancel flag is cleared at the start of the next run. + ok = session.run(audio_pcm) + assert session.was_aborted is False + assert "country" in ok.text.lower() + + def test_cancel_mid_batch_keeps_per_utterance_view(self, model_path, audio_pcm): + # Cancellation surfaces at the BATCH level in C; the binding must + # fold it back into the per-utterance contract (utterance_index + + # batch_results) instead of discarding completed work. + long_pcm = audio_pcm * 12 + with t.Model(model_path) as model, model.session() as session: + timer = threading.Timer(0.5, session.cancel) + timer.start() + try: + session.run_batch([long_pcm, long_pcm]) + except t.Aborted as exc: + assert session.was_aborted is True + assert exc.utterance_index is not None + assert hasattr(exc, "batch_results"), "completed work discarded" + assert len(exc.batch_results) == 2 + assert all(isinstance(r, (t.Result, t.TranscribeError)) + for r in exc.batch_results) + else: + pytest.skip("batch finished before cancel fired") + finally: + timer.cancel() + + # The session stays usable after a cancelled batch. + ok = session.run(audio_pcm) + assert session.was_aborted is False + assert "country" in ok.text.lower() diff --git a/bindings/python/uv.lock b/bindings/python/uv.lock new file mode 100644 index 00000000..5da6ab48 --- /dev/null +++ b/bindings/python/uv.lock @@ -0,0 +1,416 @@ +version = 1 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] + +[manifest] +overrides = [ + { name = "transcribe-cpp-native", marker = "python_full_version < '0'" }, + { name = "transcribe-cpp-native-cu12", marker = "python_full_version < '0'" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "numpy" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245 }, + { url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540 }, + { url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623 }, + { url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774 }, + { url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081 }, + { url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451 }, + { url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572 }, + { url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722 }, + { url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170 }, + { url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558 }, + { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137 }, + { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552 }, + { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957 }, + { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573 }, + { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330 }, + { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895 }, + { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253 }, + { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074 }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640 }, + { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230 }, + { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803 }, + { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835 }, + { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499 }, + { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497 }, + { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158 }, + { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173 }, + { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174 }, + { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701 }, + { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313 }, + { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179 }, + { url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942 }, + { url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512 }, + { url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976 }, + { url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494 }, + { url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596 }, + { url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099 }, + { url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823 }, + { url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424 }, + { url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809 }, + { url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314 }, + { url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288 }, + { url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793 }, + { url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885 }, + { url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784 }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245 }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048 }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542 }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301 }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320 }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050 }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034 }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185 }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149 }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620 }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963 }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743 }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616 }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579 }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005 }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570 }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548 }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521 }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866 }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455 }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348 }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362 }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103 }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382 }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462 }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618 }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511 }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783 }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506 }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190 }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828 }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006 }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765 }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736 }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719 }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072 }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213 }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632 }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532 }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885 }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467 }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144 }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217 }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014 }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935 }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122 }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143 }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260 }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225 }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374 }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391 }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754 }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476 }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666 }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194 }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111 }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159 }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936 }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692 }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164 }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877 }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487 }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945 }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406 }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528 }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119 }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246 }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410 }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240 }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012 }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538 }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706 }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541 }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825 }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687 }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482 }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648 }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902 }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992 }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944 }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392 }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220 }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800 }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600 }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134 }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598 }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272 }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197 }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287 }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763 }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070 }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752 }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024 }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398 }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971 }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532 }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881 }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458 }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559 }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716 }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947 }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197 }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245 }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587 }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226 }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196 }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334 }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678 }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672 }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731 }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805 }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496 }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616 }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145 }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813 }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982 }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908 }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867 }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511 }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064 }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157 }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728 }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374 }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286 }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263 }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704 }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454 }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561 }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824 }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227 }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859 }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204 }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084 }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285 }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924 }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018 }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948 }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341 }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159 }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290 }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141 }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847 }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088 }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866 }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887 }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704 }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628 }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180 }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674 }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976 }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755 }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265 }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726 }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859 }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713 }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084 }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973 }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223 }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973 }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082 }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490 }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263 }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736 }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717 }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461 }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855 }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144 }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683 }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196 }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393 }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, +] + +[[package]] +name = "transcribe-cpp" +version = "0.0.1" +source = { editable = "." } + +[package.optional-dependencies] +test = [ + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", marker = "extra == 'test'" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=7" }, + { name = "transcribe-cpp-native", specifier = "==0.0.1.*" }, + { name = "transcribe-cpp-native-cu12", marker = "extra == 'cu12'", specifier = "==0.0.1.*" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] diff --git a/bindings/rust/.gitignore b/bindings/rust/.gitignore new file mode 100644 index 00000000..96ef6c0b --- /dev/null +++ b/bindings/rust/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/bindings/rust/Cargo.toml b/bindings/rust/Cargo.toml new file mode 100644 index 00000000..dc002af8 --- /dev/null +++ b/bindings/rust/Cargo.toml @@ -0,0 +1,15 @@ +[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/LICENSE b/bindings/rust/LICENSE new file mode 100644 index 00000000..38c7ccc7 --- /dev/null +++ b/bindings/rust/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The transcribe.cpp authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bindings/rust/README.md b/bindings/rust/README.md new file mode 100644 index 00000000..b33088e0 --- /dev/null +++ b/bindings/rust/README.md @@ -0,0 +1,11 @@ +# 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 new file mode 100644 index 00000000..09488f2c --- /dev/null +++ b/bindings/rust/src/lib.rs @@ -0,0 +1,8 @@ +//! 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/swift/.gitignore b/bindings/swift/.gitignore new file mode 100644 index 00000000..06b99482 --- /dev/null +++ b/bindings/swift/.gitignore @@ -0,0 +1,3 @@ +.build +.swiftpm +Package.resolved diff --git a/bindings/swift/LICENSE b/bindings/swift/LICENSE new file mode 100644 index 00000000..38c7ccc7 --- /dev/null +++ b/bindings/swift/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The transcribe.cpp authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bindings/swift/Package.swift b/bindings/swift/Package.swift new file mode 100644 index 00000000..023aa67e --- /dev/null +++ b/bindings/swift/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// Placeholder Swift package for transcribe.cpp bindings. +// +// SwiftPM has no central name registry: packages are identified by their Git +// URL, so the "name" you reserve is really the repository URL plus the product +// name below. To be consumable as a remote dependency, a Package.swift must +// live at the ROOT of a git repository (a subdirectory package can only be used +// as a local `path:` dependency), so this skeleton is expected to move to the +// root of a dedicated bindings repo before publishing. +let package = Package( + name: "transcribe-cpp", + products: [ + .library(name: "TranscribeCpp", targets: ["TranscribeCpp"]), + ], + targets: [ + .target(name: "TranscribeCpp"), + ] +) diff --git a/bindings/swift/Sources/TranscribeCpp/TranscribeCpp.swift b/bindings/swift/Sources/TranscribeCpp/TranscribeCpp.swift new file mode 100644 index 00000000..b23bc51b --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/TranscribeCpp.swift @@ -0,0 +1,11 @@ +/// Swift bindings for transcribe.cpp. +/// +/// This is a placeholder package skeleton for the `transcribe-cpp` Swift +/// bindings while the first-party implementation is developed. It ships no +/// functionality yet. +/// +/// See https://github.com/handy-computer/transcribe.cpp for status. +public enum TranscribeCpp { + /// Version of this placeholder package. + public static let version = "0.0.0" +} diff --git a/bindings/typescript/.gitignore b/bindings/typescript/.gitignore new file mode 100644 index 00000000..69694e4b --- /dev/null +++ b/bindings/typescript/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +*.tgz diff --git a/bindings/typescript/LICENSE b/bindings/typescript/LICENSE new file mode 100644 index 00000000..38c7ccc7 --- /dev/null +++ b/bindings/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The transcribe.cpp authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bindings/typescript/README.md b/bindings/typescript/README.md new file mode 100644 index 00000000..4252e8a0 --- /dev/null +++ b/bindings/typescript/README.md @@ -0,0 +1,17 @@ +# transcribe-cpp + +TypeScript/Node.js bindings for [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp), +a C/C++ speech-to-text library built on ggml. + +> **Status: placeholder.** This release reserves the `transcribe-cpp` name on +> npm while the first-party bindings are developed. It ships no functionality +> yet. Watch the repository for the first functional release. + +```ts +import { version } from "transcribe-cpp"; + +console.log(version); +``` + +- Package: `transcribe-cpp` +- License: MIT diff --git a/bindings/typescript/bun.lock b/bindings/typescript/bun.lock new file mode 100644 index 00000000..4ccd0fe8 --- /dev/null +++ b/bindings/typescript/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "transcribe-cpp", + "devDependencies": { + "typescript": "^5.6.0", + }, + }, + }, + "packages": { + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + } +} diff --git a/bindings/typescript/package.json b/bindings/typescript/package.json new file mode 100644 index 00000000..18ed88bb --- /dev/null +++ b/bindings/typescript/package.json @@ -0,0 +1,37 @@ +{ + "name": "transcribe-cpp", + "version": "0.0.0", + "description": "TypeScript/Node.js bindings for transcribe.cpp (pre-release name reservation placeholder)", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": ["dist", "README.md", "LICENSE"], + "license": "MIT", + "author": "The transcribe.cpp authors", + "homepage": "https://github.com/handy-computer/transcribe.cpp#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/handy-computer/transcribe.cpp.git" + }, + "bugs": { + "url": "https://github.com/handy-computer/transcribe.cpp/issues" + }, + "keywords": ["transcription", "speech-to-text", "asr", "stt", "ggml", "whisper", "parakeet"], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "tsc", + "prepublishOnly": "bun run build" + }, + "devDependencies": { + "typescript": "^5.6.0" + } +} diff --git a/bindings/typescript/src/index.ts b/bindings/typescript/src/index.ts new file mode 100644 index 00000000..52db35ec --- /dev/null +++ b/bindings/typescript/src/index.ts @@ -0,0 +1,11 @@ +/** + * TypeScript/Node.js bindings for transcribe.cpp. + * + * This is a placeholder package that reserves the `transcribe-cpp` name on npm + * while first-party bindings are developed. It ships no functionality yet. + * + * @see https://github.com/handy-computer/transcribe.cpp + */ + +/** Version of this placeholder package. */ +export const version = "0.0.0"; diff --git a/bindings/typescript/tsconfig.json b/bindings/typescript/tsconfig.json new file mode 100644 index 00000000..34837c59 --- /dev/null +++ b/bindings/typescript/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src"] +} diff --git a/cmake/python-wheel-install.cmake b/cmake/python-wheel-install.cmake new file mode 100644 index 00000000..71965458 --- /dev/null +++ b/cmake/python-wheel-install.cmake @@ -0,0 +1,102 @@ +# Install rules for the transcribe-cpp-native Python provider wheel. +# +# Only included when scikit-build-core drives the build (SKBUILD is set, see +# the gate in the top-level CMakeLists.txt). Everything the wheel ships is +# COMPONENT wheel; pyproject.toml restricts packaging to that component, so +# the vendored ggml's default-component install rules (headers, cmake config, +# pkg-config) never leak into the wheel. +# +# The artifact directory is FLAT: every shared library and ggml backend module +# sits next to libtranscribe in transcribe_cpp_native/_native/, resolved via +# $ORIGIN / @loader_path rpaths (Windows uses os.add_dll_directory in the +# binding's loader). transcribe_init_backends() scans exactly this directory +# for backend modules, so module search never escapes the package. + +# --- the library set -------------------------------------------------------- +# ggml maintains GGML_AVAILABLE_BACKENDS (internal cache) as backends register; +# in GGML_BACKEND_DL builds these are MODULE libraries (the provider-directory +# shape), otherwise ordinary shared libraries linked into libggml. +set(_wheel_targets transcribe ggml ggml-base) +foreach(_backend IN LISTS GGML_AVAILABLE_BACKENDS) + if(TARGET ${_backend}) + list(APPEND _wheel_targets ${_backend}) + endif() +endforeach() +list(REMOVE_DUPLICATES _wheel_targets) + +foreach(_tgt IN LISTS _wheel_targets) + # Strip VERSION/SOVERSION decoration inside the wheel: versioned library + # names install as symlink chains (libggml.so -> libggml.so.0 -> ...), and + # wheels cannot carry symlinks — zip would materialize each link as a full + # duplicate copy. Undecorated names also make the SONAME/install-name the + # plain file name, so sibling resolution needs nothing but the rpath. + # Pre-1.0 the binding's exact version/header-hash check is the real + # compatibility gate, not the SONAME. + set_property(TARGET ${_tgt} PROPERTY VERSION) + set_property(TARGET ${_tgt} PROPERTY SOVERSION) + if(APPLE) + set_property(TARGET ${_tgt} PROPERTY INSTALL_RPATH "@loader_path") + else() + set_property(TARGET ${_tgt} PROPERTY INSTALL_RPATH "$ORIGIN") + endif() + install(TARGETS ${_tgt} + # Shared libraries / backend modules (RUNTIME = DLLs on Windows). + LIBRARY DESTINATION _native COMPONENT wheel + RUNTIME DESTINATION _native COMPONENT wheel + # Windows import .lib stubs: link-time only, never packaged. + ARCHIVE DESTINATION _native-dev COMPONENT wheel-dev) +endforeach() + +# --- the build-time contract stamp (_contract.py) --------------------------- +# The binding hard-fails on a provider whose version or public-header hash +# disagrees with it (_library.validate_contract). Version comes from the +# header macros via PROJECT_VERSION; the header hash is the digest the FFI +# generator emitted into the committed _generated.py (drift-gated in CI), so +# wheel and binding are stamped from the same source. +file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/bindings/python/src/transcribe_cpp/_generated.py" + _hash_line REGEX "^PUBLIC_HEADER_HASH = ") +string(REGEX MATCH "\"([0-9a-f]+)\"" _ "${_hash_line}") +set(_public_header_hash "${CMAKE_MATCH_1}") +if(NOT _public_header_hash MATCHES "^[0-9a-f]+$") + message(FATAL_ERROR + "python-wheel-install: failed to parse PUBLIC_HEADER_HASH from " + "bindings/python/src/transcribe_cpp/_generated.py") +endif() + +# Backend kinds this artifact supports, for the descriptor's `backends` field +# (and the binding's accelerated-first provider ranking). Derived from the +# ggml backend target names; CPU ISA variants (ggml-cpu-haswell, ...) all +# collapse to "cpu". +set(_kinds "") +foreach(_backend IN LISTS GGML_AVAILABLE_BACKENDS) + string(REGEX REPLACE "^ggml-" "" _kind "${_backend}") + string(REGEX REPLACE "^cpu-.*$" "cpu" _kind "${_kind}") + list(APPEND _kinds "${_kind}") +endforeach() +list(REMOVE_DUPLICATES _kinds) +# Accelerated kinds first, cpu last — readability only; ranking is the +# binding's job. +set(_backend_kinds "") +foreach(_kind IN ITEMS cuda metal vulkan) + if(_kind IN_LIST _kinds) + list(APPEND _backend_kinds "${_kind}") + list(REMOVE_ITEM _kinds "${_kind}") + endif() +endforeach() +list(REMOVE_ITEM _kinds cpu) +list(APPEND _backend_kinds ${_kinds} cpu) + +list(JOIN _backend_kinds "\", \"" _backends_joined) +set(TRANSCRIBE_WHEEL_BACKENDS_PY "\"${_backends_joined}\"") +set(TRANSCRIBE_PUBLIC_HEADER_HASH "${_public_header_hash}") + +configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/bindings/python-native/_contract.py.in" + "${CMAKE_CURRENT_BINARY_DIR}/python-wheel/_contract.py" + @ONLY) +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/python-wheel/_contract.py" + DESTINATION . COMPONENT wheel) + +message(STATUS + "python wheel: transcribe-cpp-native ${PROJECT_VERSION} " + "(header ${_public_header_hash}, backends: ${_backend_kinds})") diff --git a/include/transcribe.h b/include/transcribe.h index 4412b9c9..8e030989 100644 --- a/include/transcribe.h +++ b/include/transcribe.h @@ -5,8 +5,19 @@ * * Threading: * - transcribe_model_* functions are thread-safe. - * - A loaded model may be shared across any number of threads and used to - * create multiple contexts in parallel. + * - A loaded model may be shared across any number of threads: querying it + * (capabilities, metadata, feature probes) and creating sessions from it + * are safe concurrently. + * - KNOWN 0.x LIMITATION — concurrent COMPUTE is not yet supported: at most + * one transcribe_run / transcribe_run_batch / active stream may be in + * flight across ALL sessions of a given model at a time. Sessions share + * the model's backend instances and some per-family model state, so + * overlapping runs race (observed: corrupted decodes on CPU, command- + * buffer failures on Metal). Callers that want parallel transcription + * today should load one model per worker; a per-session backend + * architecture lifting this restriction is planned. Serialized use of + * many sessions on one model (e.g. a session pool behind a mutex) is + * fully supported. * - A model must outlive every session created from it. * - transcribe_model_free() is only valid after all derived contexts have * been freed and no thread is still using the model. @@ -132,6 +143,34 @@ #include #include +/* ----------------------------------------------------------------------- */ +/* Version */ +/* ----------------------------------------------------------------------- */ +/* + * Single source of truth for the native library version. The top-level + * CMakeLists.txt parses these three macros to set the CMake project version and + * the shared-library VERSION/SOVERSION, and transcribe_version() (declared + * below) returns the same MAJOR.MINOR.PATCH string. Bump the library version + * here. Pre-1.0 the on-disk ABI MAY break between minor releases (see "ABI + * stability" above); the Python binding pins its package version to this value + * exactly and refuses to load a native provider that does not match. + */ +#define TRANSCRIBE_VERSION_MAJOR 0 +#define TRANSCRIBE_VERSION_MINOR 0 +#define TRANSCRIBE_VERSION_PATCH 1 + +#define TRANSCRIBE_VERSION_STRINGIZE_(x) #x +#define TRANSCRIBE_VERSION_STRINGIZE(x) TRANSCRIBE_VERSION_STRINGIZE_(x) +#define TRANSCRIBE_VERSION \ + TRANSCRIBE_VERSION_STRINGIZE(TRANSCRIBE_VERSION_MAJOR) "." \ + TRANSCRIBE_VERSION_STRINGIZE(TRANSCRIBE_VERSION_MINOR) "." \ + TRANSCRIBE_VERSION_STRINGIZE(TRANSCRIBE_VERSION_PATCH) + +/* Monotonic integer form (MAJOR*10000 + MINOR*100 + PATCH) for compile-time + * comparisons, e.g. `#if TRANSCRIBE_VERSION_NUMBER >= 200`. */ +#define TRANSCRIBE_VERSION_NUMBER \ + (TRANSCRIBE_VERSION_MAJOR * 10000 + TRANSCRIBE_VERSION_MINOR * 100 + TRANSCRIBE_VERSION_PATCH) + #ifndef TRANSCRIBE_API # if defined(_WIN32) && !defined(__GNUC__) # ifdef TRANSCRIBE_BUILD @@ -288,15 +327,76 @@ typedef enum { */ TRANSCRIBE_API const char * transcribe_status_string(int status); +/* ----------------------------------------------------------------------- */ +/* Version */ +/* ----------------------------------------------------------------------- */ +/* + * Runtime version of the loaded native library. The numeric components are + * also available at compile time as TRANSCRIBE_VERSION_MAJOR/MINOR/PATCH (and + * the composed string as TRANSCRIBE_VERSION) near the top of this header. + * + * Both return borrowed pointers into static storage: never free them, and + * treat them as valid for the life of the process. + */ +/* "MAJOR.MINOR.PATCH", e.g. "0.1.0". Equals the TRANSCRIBE_VERSION macro the + * caller compiled against; a mismatch means the header and the linked library + * disagree. */ +TRANSCRIBE_API const char * transcribe_version(void); +/* Short git commit the library was built from, or "unknown" when the build + * tree carried no git metadata (e.g. an unpacked source tarball). */ +TRANSCRIBE_API const char * transcribe_version_commit(void); + +/* ----------------------------------------------------------------------- */ +/* ABI metadata */ +/* ----------------------------------------------------------------------- */ +/* + * The native library's sizeof/alignof for the public structs that cross the + * ABI. A binding (Python ctypes, Rust, Swift, ...) declares its own view of + * each struct, then verifies that view against these values before it + * constructs any real instance. This is the safe alternative the "Params" + * section calls for: query the size up front instead of calling a *_init() + * function on a buffer that might be smaller than the library expects. + * + * `which` selects a struct by transcribe_abi_struct. An unknown id (e.g. a + * newer binding asking an older library about a struct it predates) returns 0, + * which the binding MUST treat as "cannot verify," never as "size 0." The enum + * is append-only; do not renumber existing values. + */ +typedef enum { + TRANSCRIBE_ABI_MODEL_LOAD_PARAMS = 0, + TRANSCRIBE_ABI_SESSION_PARAMS = 1, + TRANSCRIBE_ABI_RUN_PARAMS = 2, + TRANSCRIBE_ABI_STREAM_PARAMS = 3, + TRANSCRIBE_ABI_CAPABILITIES = 4, + TRANSCRIBE_ABI_TIMINGS = 5, + TRANSCRIBE_ABI_SEGMENT = 6, + TRANSCRIBE_ABI_WORD = 7, + TRANSCRIBE_ABI_TOKEN = 8, + TRANSCRIBE_ABI_STREAM_UPDATE = 9, + TRANSCRIBE_ABI_STREAM_TEXT = 10, + TRANSCRIBE_ABI_SESSION_LIMITS = 11, + TRANSCRIBE_ABI_EXT = 12, + TRANSCRIBE_ABI_BACKEND_DEVICE = 13, +} transcribe_abi_struct; + +/* sizeof / alignof of the selected public struct, or 0 for an unknown id. + * For every struct that carries a struct_size field, the size returned here + * equals the value its transcribe_*_init() stamps into struct_size. */ +TRANSCRIBE_API size_t transcribe_abi_struct_size(transcribe_abi_struct which); +TRANSCRIBE_API size_t transcribe_abi_struct_align(transcribe_abi_struct which); + /* ----------------------------------------------------------------------- */ /* Logging */ /* ----------------------------------------------------------------------- */ /* - * Numeric values mirror GGML_LOG_LEVEL_* exactly so internal pass-through - * from ggml is a free reinterpret. If ggml ever renumbers, this enum is - * the source of truth for callers and the internal pass-through layer is - * responsible for honoring that contract. + * This enum is the source of truth for callers. ggml's log levels do NOT + * share these numeric values (its DEBUG/INFO/WARN/ERROR ordering differs), + * so the internal pass-through layer maps ggml levels onto this enum + * before any message reaches the installed callback; callers only ever + * see these values. CONT marks a fragment continuing the previous message + * (ggml emits these for progress output); hosts that want one line per + * message may join CONT fragments onto the preceding text. */ typedef enum { TRANSCRIBE_LOG_LEVEL_NONE = 0, @@ -312,7 +412,26 @@ typedef void (*transcribe_log_callback)( const char * msg, void * userdata); -/* Global log sink. Pass NULL cb to disable logging. */ +/* + * Global log sink. Three states: + * + * never called library messages that warrant surfacing go to + * stderr (the dev/CLI default); ggml's internal + * logging keeps its own stderr default. + * cb != NULL every library message AND ggml's internal + * diagnostics (mapped onto transcribe_log_level) + * route to the callback. Caveat: ggml compiles + * its per-module load-failure chatter OUT of + * release builds, so the reliable "why am I not + * on the GPU?" signals are the one-line device + * summary transcribe_init_backends() emits + * through this sink after each fresh scan, and + * the transcribe_backend_* device accessors. + * cb == NULL logging explicitly disabled: library and ggml + * messages are dropped, nothing goes to stderr. + * + * Call once at process startup (see the threading contract above). + */ TRANSCRIBE_API void transcribe_log_set(transcribe_log_callback cb, void * userdata); /* ----------------------------------------------------------------------- */ @@ -576,6 +695,103 @@ typedef enum { TRANSCRIBE_BACKEND_CUDA = 5, } transcribe_backend_request; +/* ----------------------------------------------------------------------- */ +/* Backend modules and device discovery */ +/* ----------------------------------------------------------------------- */ +/* + * In dynamic-backend builds (GGML_BACKEND_DL, selected by the + * TRANSCRIBE_GGML_BACKEND_DL build option), compute backends (CPU, Vulkan, + * CUDA, ...) are separate loadable modules shipped next to the library in a + * provider artifact directory, not compiled into it. A host (a Python + * wheel, a Rust crate, an app bundle) loads them ONCE, before the first + * model load, by pointing the library at that directory. In static builds + * the compiled-in backends are already registered, and this call is a + * harmless no-op for a directory containing no modules. + * + * The search is strictly package-local: only artifact_dir is scanned — + * never the executable directory, the working directory, or any system + * path. (ggml additionally honors the GGML_BACKEND_PATH environment + * variable as an explicit user override naming one out-of-tree backend + * module; an unset environment means a fully package-local load.) + * + * A module whose system dependencies are missing — e.g. the Vulkan module + * on a machine with no Vulkan loader, or with a loader but no driver — + * fails to load quietly and is skipped; the remaining backends keep + * working. That degradation is the designed behavior: ship Vulkan by + * default, fall back to CPU on machines that cannot run it. Use the device + * accessors below to see what actually registered, and + * transcribe_backend_available() to probe one kind. + * + * Idempotent per directory: repeat calls with an already-scanned directory + * return the SAME status as the first scan without re-loading — including + * the TRANSCRIBE_ERR_BACKEND case, which is therefore NOT retryable in + * this process (e.g. installing a driver after the first call requires a + * new process to pick up). Concurrent calls to this function are + * serialized against each other; "thread-safe" extends no further — the + * load mutates the global device registry, so it must complete before any + * thread calls the device accessors below or loads a model. That ordering + * is automatic under the supported usage (call once, before the first + * model load). + * + * Returns: + * TRANSCRIBE_ERR_INVALID_ARG artifact_dir is NULL or empty. + * TRANSCRIBE_ERR_FILE_NOT_FOUND artifact_dir is not an existing directory. + * TRANSCRIBE_ERR_BACKEND after loading, the process has zero + * registered compute devices (a dynamic + * build pointed at a directory with no + * usable modules: nothing could run). + * TRANSCRIBE_OK otherwise. + */ +TRANSCRIBE_API transcribe_status transcribe_init_backends( + const char * artifact_dir); + +/* + * Number of compute devices currently registered with the runtime + * (compiled-in backends plus any modules loaded by + * transcribe_init_backends). A device is something a model can be placed + * on: the CPU, an Apple GPU via Metal, a Vulkan GPU, ... + */ +TRANSCRIBE_API int transcribe_backend_device_count(void); + +/* + * One registered compute device. + * + * name / description / kind are borrowed pointers into runtime-owned + * storage: valid for the life of the process, never freed by the caller. + * + * kind is the library's classification, one of: "cpu", "accel" (a + * host-memory accelerator such as BLAS/AMX), "metal", "vulkan", "cuda", + * "sycl", "gpu" (an unrecognized GPU), or "unknown". + */ +struct transcribe_backend_device { + uint64_t struct_size; /* sizeof(*this); set by _init() */ + const char * name; /* ggml device name, e.g. "Metal" */ + const char * description; /* human-readable, e.g. "Apple M4 Max" */ + const char * kind; /* classified kind string; see above */ +}; + +TRANSCRIBE_API void transcribe_backend_device_init( + struct transcribe_backend_device * p); + +/* + * Fill *out (initialized via transcribe_backend_device_init) with device + * `index` in [0, transcribe_backend_device_count()). + */ +TRANSCRIBE_API transcribe_status transcribe_get_backend_device( + int index, + struct transcribe_backend_device * out); + +/* + * Whether a backend request can be satisfied by some registered device: + * AUTO whenever any device exists; CPU and CPU_ACCEL when a CPU device + * exists; METAL / VULKAN / CUDA when a device of that kind exists. Unknown + * or invalid request values answer false (never an error). This is the + * probe a binding uses to turn `backend="vulkan"` on a machine without + * Vulkan into a clear exception instead of a failed model load. + */ +TRANSCRIBE_API bool transcribe_backend_available( + transcribe_backend_request kind); + /* * Initialization of caller-owned params structs. * @@ -698,6 +914,15 @@ TRANSCRIBE_API void transcribe_session_params_init( * * target_language: target language for translation tasks, or NULL. * + * String-pointer lifetime (language / target_language): caller-owned, and + * the library copies what it needs before the API call returns. This holds + * for transcribe_run / transcribe_run_batch (synchronous) AND for + * transcribe_stream_begin: the dispatcher copies these strings into + * session-owned storage at begin, so the caller may free its params — + * including the strings — the moment begin returns, even though the stream + * keeps running. Bindings rely on this: ctypes/FFI-managed string buffers + * die when the begin wrapper returns. + * * keep_special_tags: keep special vocabulary tags (e.g. <|...|>) in the * returned text fields. Default (false) strips them * for clean transcripts; set true to keep the raw @@ -1718,6 +1943,13 @@ TRANSCRIBE_API transcribe_status transcribe_stream_get_text( * the status in transcribe_stream_last_status. The result snapshot * in this case is whatever the hook wrote before failing — * typically empty. + * + * Params lifetime: everything the caller passes is copied out before this + * function returns. run_params strings (language / target_language) are + * copied into session-owned storage, and family extensions follow their + * documented copy-out contract — so the caller may free both params + * structs and every pointer inside them the moment begin returns, while + * the stream stays ACTIVE. Nothing handed to begin needs to outlive it. */ TRANSCRIBE_API transcribe_status transcribe_stream_begin( struct transcribe_session * session, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..8747ba17 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,223 @@ +# transcribe-cpp-native: the default native provider package for the +# transcribe-cpp Python bindings. +# +# This pyproject lives at the repo root on purpose: the sdist must contain the +# full C++ tree (src/, include/, vendored ggml/) so `pip install` can compile +# from source on platforms without a prebuilt wheel, and an sdist cannot reach +# files outside its project directory. The pure-Python API package +# (transcribe-cpp) lives at bindings/python and depends on this one. +# +# One distribution name, platform-differentiated wheels: +# manylinux / win -> conservative CPU + Vulkan ggml backend modules +# macOS arm64 -> Metal (embedded shaders) + CPU +# sdist -> builds from source; machine-tuned CPU by default +# +# The wheel is py3-none-: the package contains zero compiled Python +# (the binding is ctypes), only the native artifact directory. + +[build-system] +requires = ["scikit-build-core>=0.11"] +build-backend = "scikit_build_core.build" + +[project] +name = "transcribe-cpp-native" +dynamic = ["version"] +description = "Prebuilt native library for transcribe-cpp (speech-to-text, ggml)" +readme = "bindings/python-native/README.md" +requires-python = ">=3.9" +license = "MIT" +# Wheels vendor native code, so third-party license texts ship in the wheel +# (settled decision: license inclusion is part of provider packaging tests). +license-files = ["LICENSE", "ggml/LICENSE"] +authors = [{ name = "The transcribe.cpp authors" }] +keywords = ["transcription", "speech-to-text", "asr", "ggml", "native"] +classifiers = [ + "Development Status :: 1 - Planning", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Topic :: Multimedia :: Sound/Audio :: Speech", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] + +[project.urls] +Homepage = "https://github.com/handy-computer/transcribe.cpp" +Repository = "https://github.com/handy-computer/transcribe.cpp" +Issues = "https://github.com/handy-computer/transcribe.cpp/issues" + +# The provider contract: transcribe_cpp discovers native artifacts through this +# entry-point group and validates version + public-header hash before dlopen +# (bindings/python/src/transcribe_cpp/_library.py). +[project.entry-points."transcribe_cpp.native"] +default = "transcribe_cpp_native:descriptor" + +[tool.scikit-build] +# The version is parsed from the TRANSCRIBE_VERSION_* macros in the public +# header — the same single source CMake and transcribe_version() use — so the +# provider package version cannot drift from the native library it bundles. +metadata.version.provider = "scikit_build_core.metadata.regex" +metadata.version.input = "include/transcribe.h" +metadata.version.regex = '(?sx) TRANSCRIBE_VERSION_MAJOR \s+ (?P\d+) .*? TRANSCRIBE_VERSION_MINOR \s+ (?P\d+) .*? TRANSCRIBE_VERSION_PATCH \s+ (?P\d+)' +metadata.version.result = "{major}.{minor}.{patch}" + +cmake.version = ">=3.21" +ninja.version = ">=1.11" + +# ctypes binding: no compiled Python, one wheel per platform spans all CPythons. +wheel.py-api = "py3" +# CMake's install prefix maps inside the import package, so the artifact +# directory lands at transcribe_cpp_native/_native/. +wheel.install-dir = "transcribe_cpp_native" +wheel.packages = ["bindings/python-native/transcribe_cpp_native"] + +# Package ONLY the wheel component (cmake/python-wheel-install.cmake): the +# vendored ggml's own install rules (headers, cmake config, pkg-config) use the +# default component and must stay out of the wheel. +install.components = ["wheel"] + +# Keep the sdist to the sources a from-scratch build needs (gitignored trees +# are excluded automatically; these prune git-tracked-but-irrelevant weight). +# Converted models, dumps, perf/WER reports, and local build/wheel trees are +# multi-GB and never belong in a distribution; of the committed sample audio +# only jfk.wav ships (the smoke/test default — the rest is porting material). +# Patterns are gitwildmatch: single-segment patterns MUST be root-anchored +# with a leading slash, or they match at any depth — unanchored "canary/" +# silently dropped src/arch/canary/ (the Canary model family) from the sdist +# and broke the from-source build. CI's audit step asserts the contents. +sdist.exclude = [ + "/models/", + "/dumps/", + "/reports/", + "/tmp/", + "/build*/", + "/dist*/", + "/wheelhouse*/", + "/canary/", + "/samples/", + "/.github/", + "/.claude/", + "/bindings/python/dist/", + "**/__pycache__/", + "**/.venv/", +] +sdist.include = ["/samples/jfk.wav"] + +# --- official wheel lanes --------------------------------------------------- +# Selected via the TRANSCRIBE_WHEEL_LANE env var (set by [tool.cibuildwheel] +# below). These mirror the wheel-* CMake presets in CMakePresets.json, which +# remain the way to hand-build the same posture outside Python packaging — +# keep both in sync. The wheel-hygiene base (shared lib, OpenMP/system-BLAS +# off, Metal embedded) is forced by the SKBUILD block in CMakeLists.txt. + +# Default Linux/Windows wheel: fat CPU + Vulkan as dynamic ggml backend +# modules (M6.5). GGML_CPU_ALL_VARIANTS=ON emits one ggml-cpu- module +# per x86 ISA tier (x64 baseline, sse42, sandybridge, haswell, skylakex, +# icelake, alderlake, ...) with runtime feature scoring — the exact shape +# llama.cpp release binaries ship; it requires GGML_BACKEND_DL (set above). +# The x64 baseline variant is the SIGILL-safe floor for any x86-64 machine, +# so the conservative single-build is no longer needed. +# TRANSCRIBE_X86_CONSERVATIVE fans out to GGML_NATIVE=OFF + every per-tier +# GGML_AVX*/GGML_AVX512* flag (one switch, defined in CMakeLists.txt — the +# per-flag lists used to be mirrored here, in the cu12 pyproject, and in +# CMakePresets.json); those flags are inert while ALL_VARIANTS is on (ggml +# gates them behind `if(NOT GGML_CPU_ALL_VARIANTS)`) and remain as the +# explicit floor if a build ever disables ALL_VARIANTS. +# cmake/python-wheel-install.cmake collapses every cpu- kind back to +# "cpu" in the provider descriptor. +[[tool.scikit-build.overrides]] +if.env.TRANSCRIBE_WHEEL_LANE = "^cpu-vulkan$" +cmake.args = [ + "-DTRANSCRIBE_GGML_BACKEND_DL=ON", + "-DTRANSCRIBE_VULKAN=ON", + "-DGGML_CPU_ALL_VARIANTS=ON", + "-DTRANSCRIBE_X86_CONSERVATIVE=ON", +] + +# Default macOS arm64 wheel: Metal (shaders embedded via the SKBUILD block; +# single artifact, no dynamic backend modules — one platform, one GPU API). +[[tool.scikit-build.overrides]] +if.env.TRANSCRIBE_WHEEL_LANE = "^metal$" +cmake.args = ["-DTRANSCRIBE_METAL=ON", "-DGGML_NATIVE=OFF"] + +# x86_64 macOS wheel: CPU only, no Metal. Intel-Mac GPUs are out of scope +# (no fleet hardware to validate Metal compute, and ggml Metal on Intel/AMD +# GPUs is far less proven than on Apple Silicon); Metal or tuned CPU on +# Intel is served by the from-source sdist. Single artifact. TRANSCRIBE_METAL already +# defaults OFF off Apple Silicon, but it is pinned OFF here for intent. +# TRANSCRIBE_X86_CONSERVATIVE turns off every x86 SIMD tier (GGML_NATIVE=OFF +# alone does NOT) so the one artifact runs on any Intel Mac without SIGILL +# across the supported range; fat-CPU variants are a possible perf follow-up +# (would need GGML_BACKEND_DL on macOS, which this single-artifact lane +# intentionally avoids). +[[tool.scikit-build.overrides]] +if.env.TRANSCRIBE_WHEEL_LANE = "^cpu$" +cmake.args = [ + "-DTRANSCRIBE_METAL=OFF", + "-DTRANSCRIBE_X86_CONSERVATIVE=ON", +] + +# --- cibuildwheel ------------------------------------------------------------ +# One wheel per platform: the package is py3-none- (ctypes, no compiled +# Python), so build with a single CPython and skip the rest. Tests always run +# against the REPAIRED wheel in a fresh venv — that is the artifact that +# ships. HF_TOKEN (when present) lets the smoke pull the private canary GGUFs +# for a real transcription. +[tool.cibuildwheel] +build = "cp312-*" +skip = "*-musllinux*" +test-requires = ["pytest>=7", "numpy", "huggingface_hub"] +test-command = "python {project}/scripts/ci/wheel_smoke.py {project}" + +[tool.cibuildwheel.linux] +archs = ["x86_64"] +# manylinux_2_28, not manylinux2014: the documented carve-out fired — the +# Vulkan toolchain cannot exist on glibc 2.17 (and the 2014 images are EOL). +# Torch et al. ship 2_28, so this is the ecosystem floor in practice. +manylinux-x86_64-image = "manylinux_2_28" +environment = { TRANSCRIBE_WHEEL_LANE = "cpu-vulkan" } +environment-pass = [ + "HF_TOKEN", + "VK_TOOLCHAIN_CACHE", + "CI", + # Canary GGUFs are fetched on the HOST (cached; HF rate limits) and read + # inside the container via the /project bind mount. + "TRANSCRIBE_SMOKE_MODEL", + "TRANSCRIBE_SMOKE_STREAMING_MODEL", +] +before-all = "bash {project}/scripts/ci/manylinux-vulkan-toolchain.sh" +# libvulkan stays EXCLUDED from the wheel: the module resolves the system +# loader at runtime and degrades quietly to CPU when there is none — the +# tier-1/tier-2 contract proven in the provider-dl-vulkan CI lane. Everything +# else (libggml*, the string-dlopened backend modules) is already inside the +# wheel, so auditwheel only grafts whitelisted-policy externals. +repair-wheel-command = "auditwheel repair --exclude libvulkan.so.1 -w {dest_dir} {wheel}" + +[tool.cibuildwheel.macos] +# Defaults here are the arm64/Metal lane. The x86_64 CPU-only lane is built by +# a separate CI job (wheel-macos-x86 on a macos-13 Intel runner) that overrides +# CIBW_ARCHS_MACOS=x86_64 and CIBW_ENVIRONMENT_MACOS (TRANSCRIBE_WHEEL_LANE=cpu +# + TRANSCRIBE_SMOKE_BACKEND=cpu) — see .github/workflows/python-wheels.yml. +archs = ["arm64"] +# CC/CXX pinned to Apple's shims: runner images (e.g. Blacksmith macos-15) +# can have mainline LLVM clang first in PATH, and open-source clang errors on +# Apple-SDK CF_ENUM extensions (-Welaborated-enum-base) that Apple clang +# accepts. /usr/bin/clang resolves through xcrun to the active Xcode. +environment = { TRANSCRIBE_WHEEL_LANE = "metal", MACOSX_DEPLOYMENT_TARGET = "11.0", CC = "/usr/bin/clang", CXX = "/usr/bin/clang++" } +# default repair (delocate) — externals are system-only, verified by test. + +[tool.cibuildwheel.windows] +archs = ["AMD64"] +# Ninja, not the Visual Studio generator (scikit-build-core's Windows +# default): substantially faster MSVC builds — this lane compiles ggml plus +# hundreds of Vulkan shader variants. scikit-build-core activates the MSVC +# environment automatically for Ninja. +environment = { TRANSCRIBE_WHEEL_LANE = "cpu-vulkan", CMAKE_GENERATOR = "Ninja" } +before-build = "pip install delvewheel" +# Same loader posture as Linux: vulkan-1.dll is the system's, never vendored. +repair-wheel-command = "delvewheel repair --exclude vulkan-1.dll -w {dest_dir} {wheel}" + +[tool.uv] +# Keep `uv run