diff --git a/.github/actions/fetch-canary/action.yml b/.github/actions/fetch-canary/action.yml index 7b21246c..31a99bc0 100644 --- a/.github/actions/fetch-canary/action.yml +++ b/.github/actions/fetch-canary/action.yml @@ -1,13 +1,18 @@ 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). + TRANSCRIBE_SMOKE_STREAMING_MODEL / the two parakeet family canaries. Skips + cleanly when hf-token is empty (forks have no secret): the model tests then + skip, exactly as before this action existed. Always fetches ALL CI canaries + (whisper-tiny + moonshine-streaming ~95 MB, plus the two parakeet 0.6b + family-extension canaries — cache-aware + buffered — at Q4_K_M ~0.95 GB) + 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), which is also why the key carries + a version that bumps whenever the fetched set changes. Voxtral-realtime is NOT + fetched (~2.5 GB, too heavy for CI; its test stays local-only). Requires a + checkout (composite actions resolve from the repo) and uv on PATH (uvx fetches + via huggingface_hub). inputs: hf-token: @@ -29,7 +34,7 @@ runs: uses: actions/cache@v5 with: path: canary - key: canary-models-v1 + key: canary-models-v2 - name: Fetch canary models (cache miss only) if: inputs.hf-token != '' shell: bash @@ -42,9 +47,23 @@ runs: [ -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 + # Parakeet family-extension canaries (cache-aware + buffered streaming). + # Q4_K_M keeps the CI canary set light (~0.95 GB for the pair); the + # family-ext tests are content-lenient (assert non-empty, not accuracy), + # so a lighter quant than the local Q8_0 default is sufficient. + [ -f canary/nemotron-speech-streaming-en-0.6b-Q4_K_M.gguf ] || \ + uvx --from huggingface_hub hf download \ + handy-computer/nemotron-speech-streaming-en-0.6b-gguf \ + nemotron-speech-streaming-en-0.6b-Q4_K_M.gguf --local-dir canary + [ -f canary/parakeet-unified-en-0.6b-Q4_K_M.gguf ] || \ + uvx --from huggingface_hub hf download \ + handy-computer/parakeet-unified-en-0.6b-gguf \ + parakeet-unified-en-0.6b-Q4_K_M.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" + echo "TRANSCRIBE_SMOKE_PARAKEET_STREAM_MODEL=$prefix/canary/nemotron-speech-streaming-en-0.6b-Q4_K_M.gguf" >> "$GITHUB_ENV" + echo "TRANSCRIBE_SMOKE_PARAKEET_BUFFERED_MODEL=$prefix/canary/parakeet-unified-en-0.6b-Q4_K_M.gguf" >> "$GITHUB_ENV" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7dd98fed..3e3f6c49 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,10 +17,11 @@ name: publish # # RELEASE (push tag v0.0.X): same full build, then with the `pypi` # environment (add a required-reviewer rule once the repo is public): -# dist-* → PyPI, cu12 wheels → GitHub release assets for the tag, and -# the wheel-index workflow is dispatched so /whl/cu12 picks them up. -# cu12 → PyPI additionally when the repo variable CU12_ON_PYPI is -# "true" (set it once the PyPI file-size request is granted). +# dist-* → PyPI, cu12 wheels → draft GitHub release assets for the tag, +# Rust crates → crates.io, Swift → the draft release, then the draft is +# published and wheel-index.yml is dispatched so /whl/cu12 picks it up. +# cu12 → PyPI additionally when the repo variable CU12_ON_PYPI is "true" +# (set it once the PyPI file-size request is granted). on: push: @@ -43,10 +44,28 @@ jobs: # 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/') + if: startsWith(github.ref, 'refs/tags/v') uses: ./.github/workflows/cuda-windows.yml secrets: inherit + create-release: + # Tags only. Owns creation of the GitHub Release object. Keep it draft + # until the mandatory publishers have succeeded and assets are verified. + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Create the draft release for the tag + env: + GH_TOKEN: ${{ github.token }} + run: | + set -e + tag="${GITHUB_REF_NAME}" + gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \ + gh release create "$tag" --repo "$GITHUB_REPOSITORY" \ + --title "$tag" --notes "transcribe.cpp $tag" --verify-tag --draft + publish-testpypi: # Rehearsal target (dispatch only — real tags go to PyPI). # Blacksmith, not the Hetzner box: gh-action-pypi-publish is a Docker @@ -164,12 +183,11 @@ jobs: # cu12's primary distribution home: wheels as GitHub release assets, # served to pip through the PEP 503 index on Pages (wheel-index.yml). if: startsWith(github.ref, 'refs/tags/v') - needs: [wheels, cuda-windows] + needs: [create-release, wheels, cuda-windows] runs-on: [self-hosted, Linux, X64, hetzner] timeout-minutes: 30 permissions: - contents: write # create the release + upload assets - actions: write # dispatch wheel-index + contents: write # upload release assets steps: - uses: actions/download-artifact@v8 with: @@ -185,15 +203,12 @@ jobs: pattern: native-* merge-multiple: true path: native-bundles - - name: Create the release for the tag and attach the cu12 wheels + - name: Attach the cu12 wheels env: GH_TOKEN: ${{ github.token }} run: | set -e tag="${GITHUB_REF#refs/tags/}" - gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \ - gh release create "$tag" --repo "$GITHUB_REPOSITORY" \ - --title "$tag" --notes "transcribe.cpp $tag" --verify-tag gh release upload "$tag" cu12/*.whl --repo "$GITHUB_REPOSITORY" --clobber - name: Attach the native bundles (versioned names) env: @@ -210,12 +225,6 @@ jobs: done ls -la upload/ gh release upload "$tag" upload/*.tar.gz --repo "$GITHUB_REPOSITORY" --clobber - - name: Refresh the PEP 503 index (requires Pages enabled on the repo) - env: - GH_TOKEN: ${{ github.token }} - run: | - gh workflow run wheel-index.yml --repo "$GITHUB_REPOSITORY" || \ - echo "::warning::wheel-index dispatch failed — is the workflow on the default branch and Pages enabled?" # ---- Rust crates (crates.io) ------------------------------------------------- # The Rust release path mirrors the Python one: a dispatch REHEARSAL that @@ -272,3 +281,129 @@ jobs: run: cargo publish -p transcribe-cpp-sys - name: Publish transcribe-cpp (the safe wrapper; resolves the just-published sys) run: cargo publish -p transcribe-cpp + + # --------------------------------------------------------------------------- + # Swift binding (TranscribeCpp) — xcframework release. + # + # The Swift package is consumed as a prebuilt static `.xcframework` + # binaryTarget (notes/swift-bindings-plan.md; requirements §5). Releasing it + # means: build the four Apple slices, zip + checksum the xcframework, attach + # the zip as a release asset, then point the mirror repo's Package.swift + # `binaryTarget(url:checksum:)` at it. "Releases are cut from CI, never a + # laptop." macOS runner: the xcframework needs Xcode (libtool/xcodebuild). + # --------------------------------------------------------------------------- + swift-rehearsal: + # The shipped-artifact gate (§4): build the real xcframework and run the + # suite against it (transcribes the canary through the published shape). + if: github.event_name == 'workflow_dispatch' + runs-on: macos-15 + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v8.2.0 + - run: brew install ninja + - name: Build the full xcframework (macOS + iOS device + iOS simulator) + run: scripts/ci/build_xcframework.sh + - name: Package + checksum (proves the release artifact + licenses) + run: scripts/ci/package_xcframework.sh + - uses: ./.github/actions/fetch-canary + with: + hf-token: ${{ secrets.HF_TOKEN }} + - name: swift test against the built xcframework + working-directory: bindings/swift + run: swift test + + swift-release: + # Tags only. Builds the artifact, attaches the zip to the tag's release, and + # emits the checksum. The mirror-repo Package.swift bump is the final step + # (CJ-gated — needs the dedicated SwiftPM repo + a deploy key; see the plan). + if: startsWith(github.ref, 'refs/tags/v') + needs: [create-release] + runs-on: macos-15 + permissions: + contents: write # attach the release asset + steps: + - uses: actions/checkout@v4 + - run: brew install ninja + - name: Build the full xcframework + run: scripts/ci/build_xcframework.sh + - name: Package + checksum + id: pkg + run: | + scripts/ci/package_xcframework.sh | tee pkg.txt + echo "checksum=$(awk '/checksum:/ {print $2}' pkg.txt)" >> "$GITHUB_OUTPUT" + - name: Attach the xcframework zip to the release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -e + tag="${GITHUB_REF_NAME}" + gh release upload "$tag" \ + bindings/swift/build-apple/TranscribeCpp.xcframework.zip \ + --repo "$GITHUB_REPOSITORY" --clobber + - name: Checksum for the mirror repo's Package.swift + run: | + echo "binaryTarget(url: .../TranscribeCpp.xcframework.zip," + echo " checksum: \"${{ steps.pkg.outputs.checksum }}\")" + # TODO(CJ): push the thin Swift sources + the url/checksum-bearing + # Package.swift to the dedicated mirror repo (transcribe-cpp-swift) and tag + # it, so `swift package add` resolves the release. Needs the mirror repo + + # a deploy key secret. Until then, the asset + checksum above are produced + # but not wired into a resolvable SwiftPM tag. + + finalize-release: + # Tags only. Publish the draft GitHub Release only after its hosted assets + # and the core PyPI publish have completed. crates.io has its own approval + # gate and does not host artifacts on this release. + if: startsWith(github.ref, 'refs/tags/v') + needs: [publish-pypi, release-assets, swift-release] + runs-on: ubuntu-latest + permissions: + contents: write # publish the draft release + actions: write # dispatch wheel-index + steps: + - name: Verify assets and publish the release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + ver="${tag#v}" + assets_file="$(mktemp)" + + gh release view "$tag" --repo "$GITHUB_REPOSITORY" \ + --json assets --jq '.assets[].name' | sort > "$assets_file" + + echo "Release assets:" + sed 's/^/ /' "$assets_file" + + require_asset() { + name="$1" + if ! grep -Fxq "$name" "$assets_file"; then + echo "::error::missing release asset: $name" + exit 1 + fi + } + + require_asset "TranscribeCpp.xcframework.zip" + require_asset "transcribe-native-${ver}-linux-x86_64-cpu-vulkan.tar.gz" + require_asset "transcribe-native-${ver}-linux-aarch64-cpu-vulkan.tar.gz" + require_asset "transcribe-native-${ver}-macos-arm64-metal.tar.gz" + require_asset "transcribe-native-${ver}-macos-x86_64-cpu.tar.gz" + require_asset "transcribe-native-${ver}-windows-x86_64-cpu-vulkan.tar.gz" + + cu12_count="$(grep -Ec '^transcribe_cpp_native_cu12-.*\.whl$' "$assets_file" || true)" + if [ "$cu12_count" -lt 2 ]; then + echo "::error::expected at least two cu12 provider wheels, found $cu12_count" + exit 1 + fi + + gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --draft=false + + - name: Refresh the PEP 503 index (requires Pages enabled on the repo) + env: + GH_TOKEN: ${{ github.token }} + run: | + gh workflow run wheel-index.yml --repo "$GITHUB_REPOSITORY" || \ + echo "::warning::wheel-index dispatch failed — is the workflow on the default branch and Pages enabled?" diff --git a/.github/workflows/swift-ci.yml b/.github/workflows/swift-ci.yml new file mode 100644 index 00000000..729b7aab --- /dev/null +++ b/.github/workflows/swift-ci.yml @@ -0,0 +1,118 @@ +name: swift-ci + +# Every-PR gates for the Swift binding (TranscribeCpp). Thin per-binding +# workflow on the shared rails: the binding-agnostic C contracts are certified +# in native-ci.yml; this file adds only what the Swift layer introduces. +# +# - swift-gates: the public-ABI drift gate — the pinned hash in +# ABIHash.swift compared against include/transcribe.abihash +# (scripts/ci/swift_abihash_check.py). No native build; fast, +# runs everywhere. (Swift has no generated FFI layer: the +# Clang importer reads the headers directly, so the gate is a +# pinned constant, not a regen check. Version-sync is the git +# tag + the load-time gate, not check_version_sync.py.) +# - swift-macos: build the macOS slice of the xcframework +# (scripts/ci/build_xcframework.sh), then `swift test` the +# no-model tier against the real binaryTarget — the Swift +# analog of Rust's no_model.rs (version/ABI/device discovery). +# - swift-ios: cross-compile the iOS device + simulator slices +# (build-verify only — no iOS runner executes models here). +# +# Two test tiers (requirements §4): the no-model tests always run; the +# model-gated tier (real transcription/streaming/cancel/family ext) un-skips +# only when the canary GGUFs are fetched (fetch-canary + HF_TOKEN). The model +# tier lands with M4 — this workflow ships the no-model tier first. +# +# Path filters follow native-ci.yml's shape: the binding's own tree plus the +# native paths it compiles from (binding behavior depends on the C side). + +on: + push: + branches: [main] + paths: &paths + - "bindings/swift/**" + - "src/**" + - "include/**" + - "ggml/**" + - "cmake/**" + - "CMakeLists.txt" + - "CMakePresets.json" + - "scripts/ci/build_xcframework.sh" + - "scripts/ci/swift_abihash_check.py" + - ".github/workflows/swift-ci.yml" + pull_request: + paths: *paths + workflow_dispatch: + +concurrency: + group: swift-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + swift-gates: + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v8.2.0 + - name: Public-ABI drift gate (pinned hash vs include/transcribe.abihash) + run: uv run --no-project scripts/ci/swift_abihash_check.py + + swift-macos: + runs-on: [self-hosted, macOS, ARM64] + env: + # The CMake builds inside build_xcframework.sh honor these launcher env + # vars (CMake initializes CMAKE__COMPILER_LAUNCHER from them). + CMAKE_C_COMPILER_LAUNCHER: ccache + CMAKE_CXX_COMPILER_LAUNCHER: ccache + # Present on this repo's runs, empty on forks — fetch-canary skips cleanly + # when empty and the model tier of `swift test` then XCTSkips. + HF_TOKEN: ${{ secrets.HF_TOKEN }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v8.2.0 # fetch-canary fetches via uvx + - name: Install build deps + run: brew install ninja ccache + - uses: actions/cache@v4 + with: + path: ~/Library/Caches/ccache + key: ccache-swift-macos-${{ github.sha }} + restore-keys: ccache-swift-macos- + - name: Build the macOS xcframework slice + run: TRANSCRIBE_XCFRAMEWORK_SLICES="macos" scripts/ci/build_xcframework.sh + # Fetch the canary GGUFs (whisper-tiny + moonshine-streaming-tiny) and + # export TRANSCRIBE_SMOKE_MODEL / _STREAMING_MODEL. Audio falls back to the + # in-repo samples/jfk.wav. Skips cleanly without HF_TOKEN (the model tier + # then XCTSkips — the two-tier scheme, requirements §4). + - uses: ./.github/actions/fetch-canary + with: + hf-token: ${{ secrets.HF_TOKEN }} + - name: swift test (no-model + model tiers) + working-directory: bindings/swift + run: swift test + # The 5 canonical examples (§6) run on every leg under the same skip + # rules as the model tier: each transcribes with the canary or exits 0 + # with a skip note (models/ is gitignored, so forks skip cleanly). + - name: Run the canonical examples + working-directory: bindings/swift + run: | + for example in transcribe-file streaming batch backend-select error-handling; do + echo "== $example ==" + swift run "$example" + done + + swift-ios: + runs-on: [self-hosted, macOS, ARM64] + env: + CMAKE_C_COMPILER_LAUNCHER: ccache + CMAKE_CXX_COMPILER_LAUNCHER: ccache + steps: + - uses: actions/checkout@v4 + - name: Install build deps + run: brew install ninja ccache + - uses: actions/cache@v4 + with: + path: ~/Library/Caches/ccache + key: ccache-swift-ios-${{ github.sha }} + restore-keys: ccache-swift-ios- + - name: Cross-compile the iOS device + simulator slices (build-verify) + run: TRANSCRIBE_XCFRAMEWORK_SLICES="ios-device ios-sim" scripts/ci/build_xcframework.sh diff --git a/bindings/python/README.md b/bindings/python/README.md index 9e59b4aa..3e92202f 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -3,10 +3,8 @@ 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. +> **Status: in development.** Until wheels are published, use a locally built +> `libtranscribe` through repo auto-discovery or `TRANSCRIBE_LIBRARY`. ```python import transcribe_cpp @@ -17,15 +15,14 @@ with transcribe_cpp.Model("model.gguf") as model: 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: +`run()` takes mono 16 kHz float32 PCM (buffer-protocol object or sequence). It +does not decode containers or resample; convert audio before calling it. ```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 = np.asarray(audio, dtype=np.float32) # 1-D, 16 kHz mono +# Downmix stereo first; 2-D input is rejected: # pcm = audio.mean(axis=1).astype(np.float32) result = session.run(pcm) ``` @@ -45,29 +42,26 @@ 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 +## Backends -`Model(backend=...)` picks the compute device (`"auto"` → best available); -`transcribe_cpp.backends()` lists what registered and -`backend_available(kind)` probes one kind. Environment overrides: +`Model(backend=...)` picks the compute device (`"auto"` uses the best +available). `transcribe_cpp.backends()` lists registered backends and +`backend_available(kind)` checks one kind. | 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) | +| `TRANSCRIBE_BACKEND` | overrides the `"auto"` default; explicit `backend=` still wins | +| `TRANSCRIBE_NATIVE_PROVIDER` | forces an installed native provider package, for example `cu12` | +| `TRANSCRIBE_LIBRARY` | loads exactly this shared library | -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). +Planned wheels will bundle CPU plus platform accelerators; +`transcribe-cpp[cu12]` will add the CUDA 12 provider. ## 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: +version before use. Build a shared library, then run from the repo or point +`TRANSCRIBE_LIBRARY` at it: ```bash cmake -B build-shared -DTRANSCRIBE_BUILD_SHARED=ON @@ -78,11 +72,9 @@ 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`): +No-model tests always run; model tests skip unless smoke assets are present. +Override paths with `TRANSCRIBE_SMOKE_MODEL`, `TRANSCRIBE_SMOKE_AUDIO`, and +`TRANSCRIBE_SMOKE_STREAMING_MODEL`. ```bash cd bindings/python diff --git a/bindings/python/tests/conftest.py b/bindings/python/tests/conftest.py index c71b1043..f20a62bc 100644 --- a/bindings/python/tests/conftest.py +++ b/bindings/python/tests/conftest.py @@ -42,6 +42,23 @@ REPO / "models/nemotron-3.5-asr-streaming-0.6b/nemotron-3.5-asr-streaming-0.6b-Q8_0.gguf" ) +# Per-family streaming-extension canaries. These exercise the parakeet/voxtral +# stream-extension happy path (materialize -> accept -> begin -> feed). Not in +# the CI fetch-canary set (parakeet is added once the canary repos exist; +# voxtral is local-only — ~2.5 GB Q4_K_M is too heavy for CI), so each gates on +# its own env var / in-repo GGUF and skips cleanly when absent. +PARAKEET_STREAM_MODEL = ( + REPO + / "models/nemotron-speech-streaming-en-0.6b" + / "nemotron-speech-streaming-en-0.6b-Q8_0.gguf" +) +PARAKEET_BUFFERED_MODEL = ( + REPO / "models/parakeet-unified-en-0.6b/parakeet-unified-en-0.6b-Q8_0.gguf" +) +VOXTRAL_MODEL = ( + REPO + / "models/Voxtral-Mini-4B-Realtime-2602/Voxtral-Mini-4B-Realtime-2602-Q4_K_M.gguf" +) def load_wav(path: Path) -> "array.array": @@ -112,3 +129,31 @@ def prompted_streaming_model_path() -> Path: "(set TRANSCRIBE_SMOKE_PROMPTED_MODEL)" ) return path + + +def _family_model(env_var: str, default: Path) -> Path: + override = os.environ.get(env_var) + path = Path(override) if override else default + if not path.is_file(): + pytest.skip(f"model not present: {path} (set {env_var})") + return path + + +@pytest.fixture(scope="session") +def parakeet_stream_model_path() -> Path: + """Cache-aware parakeet streaming canary (accepts PARAKEET_STREAM).""" + return _family_model("TRANSCRIBE_SMOKE_PARAKEET_STREAM_MODEL", PARAKEET_STREAM_MODEL) + + +@pytest.fixture(scope="session") +def parakeet_buffered_model_path() -> Path: + """Chunked/buffered parakeet streaming canary (accepts PARAKEET_BUFFERED_STREAM).""" + return _family_model( + "TRANSCRIBE_SMOKE_PARAKEET_BUFFERED_MODEL", PARAKEET_BUFFERED_MODEL + ) + + +@pytest.fixture(scope="session") +def voxtral_model_path() -> Path: + """Voxtral realtime streaming canary (accepts VOXTRAL_REALTIME_STREAM).""" + return _family_model("TRANSCRIBE_SMOKE_VOXTRAL_MODEL", VOXTRAL_MODEL) diff --git a/bindings/python/tests/test_family_ext.py b/bindings/python/tests/test_family_ext.py index e5ba4700..5dd94b36 100644 --- a/bindings/python/tests/test_family_ext.py +++ b/bindings/python/tests/test_family_ext.py @@ -134,3 +134,73 @@ def test_supports_probe_all_features(model_path): assert model.supports(feature) in (True, False) with pytest.raises(t.InvalidArgument, match="unknown feature"): model.supports("levitation") + + +# --- model-gated: per-family stream-extension happy path -------------------- +# +# These prove the parakeet/voxtral stream extensions end to end: the typed +# options materialize a kind-tagged struct, the model ACCEPTS the kind on its +# stream slot, ``stream_begin`` consumes it, and a short feed + finalize emits +# text. NOT a transcription-accuracy check (that is the family port's C-level / +# WER job) — a short feed keeps these fast even for the 4B voxtral model, so we +# assert the stream ran and produced non-empty text rather than pinning content. +# Mirrors Swift's FamilyStreamTests. Each gates on its own per-family GGUF and +# skips cleanly when absent (parakeet runs in CI once the canary repos exist; +# voxtral is local-only — ~2.5 GB is too heavy for CI). + +SHORT_FEED_SAMPLES = 2 * 16000 # ~2 s at 16 kHz mono + + +def _drive_short(stream, pcm): + """Feed ~2 s of audio in 100 ms chunks, then finalize and return the update.""" + clip = pcm[:SHORT_FEED_SAMPLES] + for i in range(0, len(clip), 1600): + stream.feed(clip[i : i + 1600]) + return stream.finalize() + + +def test_parakeet_cache_aware_acceptance_discriminates(parakeet_stream_model_path): + # The header's documented discrimination: the cache-aware variant accepts + # PARAKEET_STREAM and rejects PARAKEET_BUFFERED_STREAM. + with t.Model(parakeet_stream_model_path) as model: + assert model.accepts(t.ParakeetStreamOptions()) is True + assert model.accepts(t.ParakeetBufferedStreamOptions()) is False + + +def test_parakeet_cache_aware_streams_with_extension( + parakeet_stream_model_path, audio_pcm): + with t.Model(parakeet_stream_model_path) as model, model.session() as session: + # att_context_right=-1 selects the model's default (max-accuracy) menu entry. + with session.stream( + family=t.ParakeetStreamOptions(att_context_right=-1)) as stream: + update = _drive_short(stream, audio_pcm) + text = stream.text().full + assert update.is_final + assert text.strip(), "cache-aware stream produced no text" + + +def test_parakeet_buffered_streams_with_extension( + parakeet_buffered_model_path, audio_pcm): + with t.Model(parakeet_buffered_model_path) as model: + assert model.accepts(t.ParakeetBufferedStreamOptions()) is True + # Defaults (left/chunk/right = -1) resolve to the model's menu default + # (L=5600/C=1040/R=1040). An explicit override must be an 80 ms multiple + # AND land on a tuple in the training menu, else stream_begin returns + # INVALID_ARG — so the path-proving choice is the default. + with model.session() as session, session.stream( + family=t.ParakeetBufferedStreamOptions()) as stream: + update = _drive_short(stream, audio_pcm) + text = stream.text().full + assert update.is_final + assert text.strip(), "buffered stream produced no text" + + +def test_voxtral_realtime_streams_with_extension(voxtral_model_path, audio_pcm): + with t.Model(voxtral_model_path) as model: + assert model.accepts(t.VoxtralRealtimeStreamOptions()) is True + with model.session() as session, session.stream( + family=t.VoxtralRealtimeStreamOptions(num_delay_tokens=4)) as stream: + update = _drive_short(stream, audio_pcm) + text = stream.text().full + assert update.is_final + assert text.strip(), "voxtral produced no text" diff --git a/bindings/rust/transcribe-cpp/README.md b/bindings/rust/transcribe-cpp/README.md index a12a6b4d..2a782a87 100644 --- a/bindings/rust/transcribe-cpp/README.md +++ b/bindings/rust/transcribe-cpp/README.md @@ -4,11 +4,8 @@ Safe, idiomatic Rust bindings for [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp), a C/C++ speech-to-text library built on ggml. -> **Status: in development (0.0.1).** The full feature surface — model load, -> sessions, `run`/`run_batch`, owned results, error mapping, backend discovery, -> the version/ABI gate, streaming, the five family extensions, cancellation, -> tokenize, and log routing — is implemented and tested against the canary -> models, with the five canonical examples CI-executed on every push. +> **Status: in development (0.0.1).** Core model, session, run, stream, +> cancellation, backend, and family-extension APIs are implemented and tested. ## Install @@ -34,26 +31,29 @@ println!("{}", result.text); # Ok::<(), transcribe_cpp::Error>(()) ``` -The five canonical examples (`cargo run --example transcribe-file`, `streaming`, -`batch`, `backend-select`, `error-handling`) are the same set, under the same -names, in every first-class binding. The raw FFI layer is the -[`transcribe-cpp-sys`](https://crates.io/crates/transcribe-cpp-sys) crate; this -crate is the safe wrapper on top of it. +Runnable examples: + +```sh +cargo run --example transcribe-file +cargo run --example streaming +cargo run --example batch +cargo run --example backend-select +cargo run --example error-handling +``` + +The raw FFI layer is +[`transcribe-cpp-sys`](https://crates.io/crates/transcribe-cpp-sys); this crate +is the safe wrapper. ## Backends -The native library is built from source by `transcribe-cpp-sys` (see its -README). Backends are selected with cargo features — `metal` (default on Apple), -`vulkan`, `cuda`, `openmp` — forwarded to the underlying build. A static, -self-contained link is the default; `shared` links a shared library, and -`dynamic-backends` ships the compute backends as runtime-loaded modules (the -multi-ISA CPU / GPU provider posture; implies `shared`, loaded via -`init_backends_default()` when the modules sit next to `libtranscribe`). These -are advanced packaging modes: a distributed binary must arrange for the runtime -loader to find `libtranscribe`, and either co-locate backend modules with it or -call `init_backends(dir)` with the bundled module directory before loading a -model. Any other CMake flag can be passed through `TRANSCRIBE_CMAKE_ARGS` — see -the `transcribe-cpp-sys` README. +Backends are selected with cargo features forwarded to `transcribe-cpp-sys`: +`metal` (default on Apple), `vulkan`, `cuda`, and `openmp`. + +The default link is static and self-contained. Advanced packaging modes are +available through `shared` and `dynamic-backends`; see the `transcribe-cpp-sys` +README if you need runtime-loaded backend modules or custom +`TRANSCRIBE_CMAKE_ARGS`. ## Threading @@ -64,11 +64,4 @@ the `transcribe-cpp-sys` README. model; this crate enforces it with a per-model mutex, so concurrent calls queue rather than race. For real parallelism, use one `Model` per worker. -## ABI verification - -The per-field struct-layout check the ctypes binding performs is **waived** -here: bindgen takes every struct's layout from a real compiler at generation -time, so the generated FFI cannot disagree with the headers it was built -against. The load-time base-version lock (pre-1.0) is retained. - - License: MIT diff --git a/bindings/rust/transcribe-cpp/tests/common/mod.rs b/bindings/rust/transcribe-cpp/tests/common/mod.rs index cb8983be..4daf2ef7 100644 --- a/bindings/rust/transcribe-cpp/tests/common/mod.rs +++ b/bindings/rust/transcribe-cpp/tests/common/mod.rs @@ -60,6 +60,41 @@ pub fn smoke_streaming_model() -> Option { path.is_file().then_some(path) } +/// A per-family streaming-extension canary: env override or the in-repo GGUF, +/// `None` when neither is present (clean skip). Not in the CI fetch-canary set, +/// so these run locally and skip in CI. +fn family_model(env_var: &str, default_rel: &str) -> Option { + ensure_backends(); + let path = std::env::var_os(env_var) + .map(PathBuf::from) + .unwrap_or_else(|| repo_root().join(default_rel)); + path.is_file().then_some(path) +} + +/// Cache-aware parakeet streaming canary (accepts PARAKEET_STREAM). +pub fn smoke_parakeet_stream_model() -> Option { + family_model( + "TRANSCRIBE_SMOKE_PARAKEET_STREAM_MODEL", + "models/nemotron-speech-streaming-en-0.6b/nemotron-speech-streaming-en-0.6b-Q8_0.gguf", + ) +} + +/// Chunked/buffered parakeet streaming canary (accepts PARAKEET_BUFFERED_STREAM). +pub fn smoke_parakeet_buffered_model() -> Option { + family_model( + "TRANSCRIBE_SMOKE_PARAKEET_BUFFERED_MODEL", + "models/parakeet-unified-en-0.6b/parakeet-unified-en-0.6b-Q8_0.gguf", + ) +} + +/// Voxtral realtime streaming canary (accepts VOXTRAL_REALTIME_STREAM). +pub fn smoke_voxtral_model() -> Option { + family_model( + "TRANSCRIBE_SMOKE_VOXTRAL_MODEL", + "models/Voxtral-Mini-4B-Realtime-2602/Voxtral-Mini-4B-Realtime-2602-Q4_K_M.gguf", + ) +} + /// Both fixtures together; prints a skip note and returns `None` if either is /// missing (so the caller can `return` early — the Rust equivalent of skip). pub fn smoke_fixtures(test: &str) -> Option<(PathBuf, Vec)> { diff --git a/bindings/rust/transcribe-cpp/tests/family.rs b/bindings/rust/transcribe-cpp/tests/family.rs new file mode 100644 index 00000000..067b81c6 --- /dev/null +++ b/bindings/rust/transcribe-cpp/tests/family.rs @@ -0,0 +1,145 @@ +//! Per-family stream-extension happy-path tests (parakeet cache-aware, +//! parakeet buffered, voxtral realtime). Each proves the extension end to end: +//! the typed options materialize a kind-tagged struct, the model ACCEPTS the +//! kind on its stream slot, `stream_begin` consumes it, and a short feed + +//! finalize emits text. NOT a transcription-accuracy check (that is the family +//! port's C-level / WER job) — a short feed keeps these fast even for the 4B +//! voxtral model, so we assert the stream ran and produced non-empty text. +//! +//! Mirrors Swift's `FamilyStreamTests`. Each gates on its own per-family GGUF +//! and skips cleanly when absent (parakeet runs in CI once the canary repos +//! exist; voxtral is local-only — ~2.5 GB is too heavy for CI). The +//! wrong-family reject is already covered by +//! `streaming::stream_family_extension_accepted_or_rejected`. + +mod common; + +use transcribe_cpp::sys::{ + TRANSCRIBE_EXT_KIND_PARAKEET_BUFFERED_STREAM, TRANSCRIBE_EXT_KIND_PARAKEET_STREAM, + TRANSCRIBE_EXT_KIND_VOXTRAL_REALTIME_STREAM, +}; +use transcribe_cpp::{ + ExtSlot, Model, ParakeetBufferedStreamOptions, ParakeetStreamOptions, RunOptions, Stream, + StreamExtension, StreamOptions, VoxtralRealtimeStreamOptions, +}; + +/// Feed the first ~2 s of `pcm` in 100 ms chunks, finalize, and return +/// `(is_final, full_text)` (owned copies). A short feed keeps the test fast and +/// proves consumption without a full transcription. +fn short_feed_text(stream: &mut Stream<'_>, pcm: &[f32]) -> (bool, String) { + let clip = &pcm[..pcm.len().min(2 * 16_000)]; + for frame in clip.chunks(1600) { + stream.feed(frame).expect("feed"); + } + let is_final = stream.finalize().expect("finalize").is_final; + (is_final, stream.text().full) +} + +#[test] +fn parakeet_cache_aware_acceptance_discriminates() { + let Some(model_path) = common::smoke_parakeet_stream_model() else { + eprintln!("skip parakeet_cache_aware_acceptance_discriminates: model absent"); + return; + }; + let model = Model::load(&model_path).unwrap(); + // The header's documented discrimination: the cache-aware variant accepts + // PARAKEET_STREAM and rejects PARAKEET_BUFFERED_STREAM. + assert!( + model.accepts_ext(ExtSlot::Stream, TRANSCRIBE_EXT_KIND_PARAKEET_STREAM), + "cache-aware should accept PARAKEET_STREAM" + ); + assert!( + !model.accepts_ext( + ExtSlot::Stream, + TRANSCRIBE_EXT_KIND_PARAKEET_BUFFERED_STREAM + ), + "cache-aware should reject PARAKEET_BUFFERED_STREAM" + ); +} + +#[test] +fn parakeet_cache_aware_streams_with_extension() { + let (Some(model_path), Some(pcm)) = + (common::smoke_parakeet_stream_model(), common::smoke_audio()) + else { + eprintln!("skip parakeet_cache_aware_streams_with_extension: model/audio absent"); + return; + }; + let mut session = Model::load(&model_path).unwrap().session().unwrap(); + let opts = StreamOptions { + // att_context_right = -1 selects the model's default (max-accuracy) menu entry. + family: Some(StreamExtension::ParakeetStream(ParakeetStreamOptions { + att_context_right: Some(-1), + })), + ..Default::default() + }; + let mut stream = session.stream(&RunOptions::default(), &opts).unwrap(); + let (is_final, text) = short_feed_text(&mut stream, &pcm); + assert!(is_final); + assert!( + !text.trim().is_empty(), + "cache-aware stream produced no text" + ); +} + +#[test] +fn parakeet_buffered_streams_with_extension() { + let (Some(model_path), Some(pcm)) = ( + common::smoke_parakeet_buffered_model(), + common::smoke_audio(), + ) else { + eprintln!("skip parakeet_buffered_streams_with_extension: model/audio absent"); + return; + }; + let model = Model::load(&model_path).unwrap(); + assert!( + model.accepts_ext( + ExtSlot::Stream, + TRANSCRIBE_EXT_KIND_PARAKEET_BUFFERED_STREAM + ), + "buffered model should accept PARAKEET_BUFFERED_STREAM" + ); + // Defaults (left/chunk/right = None -> -1) resolve to the model's menu + // default (L=5600/C=1040/R=1040). An explicit override must be an 80 ms + // multiple AND land on a tuple in the training menu, else stream_begin + // returns INVALID_ARG — so the path-proving choice is the default. + let mut session = model.session().unwrap(); + let opts = StreamOptions { + family: Some(StreamExtension::ParakeetBuffered( + ParakeetBufferedStreamOptions::default(), + )), + ..Default::default() + }; + let mut stream = session.stream(&RunOptions::default(), &opts).unwrap(); + let (is_final, text) = short_feed_text(&mut stream, &pcm); + assert!(is_final); + assert!(!text.trim().is_empty(), "buffered stream produced no text"); +} + +#[test] +fn voxtral_realtime_streams_with_extension() { + let (Some(model_path), Some(pcm)) = (common::smoke_voxtral_model(), common::smoke_audio()) + else { + eprintln!("skip voxtral_realtime_streams_with_extension: model/audio absent"); + return; + }; + let model = Model::load(&model_path).unwrap(); + assert!( + model.accepts_ext(ExtSlot::Stream, TRANSCRIBE_EXT_KIND_VOXTRAL_REALTIME_STREAM), + "voxtral model should accept VOXTRAL_REALTIME_STREAM" + ); + let mut session = model.session().unwrap(); + let opts = StreamOptions { + family: Some(StreamExtension::VoxtralRealtime( + VoxtralRealtimeStreamOptions { + num_delay_tokens: Some(4), + ..Default::default() + }, + )), + ..Default::default() + }; + let mut stream = session.stream(&RunOptions::default(), &opts).unwrap(); + let (is_final, text) = short_feed_text(&mut stream, &pcm); + assert!(is_final); + assert!(!text.trim().is_empty(), "voxtral produced no text"); +} diff --git a/bindings/swift/.gitignore b/bindings/swift/.gitignore index 06b99482..f0e45a96 100644 --- a/bindings/swift/.gitignore +++ b/bindings/swift/.gitignore @@ -1,3 +1,7 @@ .build .swiftpm Package.resolved + +# Locally built native artifact (the release binaryTarget is a remote +# url:+checksum:; dev/CI build it here via scripts/ci/build_xcframework.sh). +build-apple/ diff --git a/bindings/swift/Package.swift b/bindings/swift/Package.swift index 023aa67e..8c611120 100644 --- a/bindings/swift/Package.swift +++ b/bindings/swift/Package.swift @@ -1,20 +1,74 @@ // swift-tools-version: 5.9 import PackageDescription +import Foundation -// Placeholder Swift package for transcribe.cpp bindings. +// transcribe.cpp Swift bindings (`TranscribeCpp`). // -// 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. +// Native code is consumed as a prebuilt `.xcframework` binaryTarget (the +// project's distribution posture for Swift; see notes/swift-bindings-plan.md +// and notes/bindings-requirements.md §5). The xcframework bundles a merged +// static `libtranscribe` (Metal embedded on the slices that support it) plus +// the public C headers + module map, so `import CTranscribe` exposes the full +// C surface to the Swift wrapper. +// +// Resolution of the binaryTarget: +// - DEV/CI: set TRANSCRIBE_XCFRAMEWORK_PATH to a locally built xcframework, +// or rely on the default `build-apple/TranscribeCpp.xcframework` +// (produced by scripts/ci/build_xcframework.sh). +// - RELEASE (mirror repo): a remote `binaryTarget(url:checksum:)` pointing at +// the GitHub release asset is substituted by the publish job. +let xcframeworkPath = Context.environment["TRANSCRIBE_XCFRAMEWORK_PATH"] + ?? "build-apple/TranscribeCpp.xcframework" + let package = Package( name: "transcribe-cpp", + platforms: [ + .macOS(.v13), + .iOS(.v16), + ], products: [ .library(name: "TranscribeCpp", targets: ["TranscribeCpp"]), + // The five canonical, CI-executed examples (requirements §6), identical + // names across every first-class binding. + .executable(name: "transcribe-file", targets: ["transcribe-file"]), + .executable(name: "streaming", targets: ["streaming"]), + .executable(name: "batch", targets: ["batch"]), + .executable(name: "backend-select", targets: ["backend-select"]), + .executable(name: "error-handling", targets: ["error-handling"]), ], targets: [ - .target(name: "TranscribeCpp"), + // The prebuilt native artifact. Module `CTranscribe` (per the bundled + // module.modulemap) is the raw C surface. + .binaryTarget(name: "CTranscribe", path: xcframeworkPath), + + // The idiomatic Swift wrapper. System libs/frameworks that the merged + // static archive does NOT carry are linked here (the canonical set + // comes from lib/transcribe-link.json). Linux Swift is out of scope for + // v1 — these settings are Apple-only by construction. + .target( + name: "TranscribeCpp", + dependencies: ["CTranscribe"], + linkerSettings: [ + .linkedLibrary("c++"), + .linkedLibrary("z"), + .linkedFramework("Accelerate"), + .linkedFramework("Foundation"), + .linkedFramework("Metal"), + .linkedFramework("MetalKit"), + ] + ), + + .testTarget( + name: "TranscribeCppTests", + dependencies: ["TranscribeCpp"] + ), + + // Examples: shared fixture plumbing + one executable per canonical name. + .target(name: "ExampleSupport", dependencies: ["TranscribeCpp"]), + .executableTarget(name: "transcribe-file", dependencies: ["TranscribeCpp", "ExampleSupport"]), + .executableTarget(name: "streaming", dependencies: ["TranscribeCpp", "ExampleSupport"]), + .executableTarget(name: "batch", dependencies: ["TranscribeCpp", "ExampleSupport"]), + .executableTarget(name: "backend-select", dependencies: ["TranscribeCpp", "ExampleSupport"]), + .executableTarget(name: "error-handling", dependencies: ["TranscribeCpp", "ExampleSupport"]), ] ) diff --git a/bindings/swift/README.md b/bindings/swift/README.md new file mode 100644 index 00000000..8c861bfd --- /dev/null +++ b/bindings/swift/README.md @@ -0,0 +1,118 @@ +# TranscribeCpp + +Swift bindings for [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp), +a C/C++ speech-to-text library built on ggml. Native code ships as a prebuilt +`.xcframework` SwiftPM `binaryTarget`, with Metal embedded on supported Apple +slices. + +> Status: in development (0.0.1). Core model, session, run, stream, +> cancellation, backend, and family-extension APIs are implemented and tested. + +## Install + +Apple platforms only: **macOS 13+** and **iOS 16+**. + +For development from this repository, use the package in `bindings/swift`. It +expects `bindings/swift/build-apple/TranscribeCpp.xcframework` by default, or a +custom artifact path through `TRANSCRIBE_XCFRAMEWORK_PATH`. + +The standalone SwiftPM mirror is planned but not published yet: + +```swift +.package(url: "https://github.com/handy-computer/transcribe-cpp-swift.git", from: "0.0.1") +``` + +Until that mirror repo and tag exist, use the release xcframework directly when +you only need the raw C module: + +```swift +.binaryTarget( + name: "CTranscribe", + url: "https://github.com/handy-computer/transcribe.cpp/releases/download/v0.0.1/TranscribeCpp.xcframework.zip", + checksum: "" +) +``` + +The direct binary target exposes `import CTranscribe`; the local and planned +Swift packages expose the wrapper product, `import TranscribeCpp`. + +## Quickstart + +```swift +import TranscribeCpp + +let model = try Model(path: "/path/to/model.gguf") +let session = try model.session() + +// pcm: mono float32 at 16 kHz, in [-1, 1] +let transcript = try session.run(pcm, options: RunOptions(timestamps: .segment)) +print(transcript.text) + +for segment in transcript.segments { + print("[\(segment.t0Ms)-\(segment.t1Ms)ms] \(segment.text)") +} +``` + +`run` is blocking; `try await session.run(pcm)` uses the async convenience +overload and hops the work off the caller's thread. + +Streaming models expose committed/tentative text for UI display: + +```swift +let stream = try session.stream() +for chunk in chunks { // 16 kHz mono float32 frames + let update = try stream.feed(chunk) + if update.committedChanged { print(stream.text.committed) } +} +try stream.finalize() +``` + +Runnable examples live in +`Sources/{transcribe-file,streaming,batch,backend-select,error-handling}`. + +## Backends + +Backends are compiled into the xcframework per Apple slice: + +| Slice | Backend | +| -------------------- | ----------- | +| macOS arm64 | Metal + CPU | +| macOS x86_64 | CPU only | +| iOS device arm64 | Metal + CPU | +| iOS simulator | CPU only | + +Request a backend with `ModelOptions(backend:)`; probe availability with +`Transcribe.backendAvailable(_:)` or inspect `Transcribe.devices()`. + +## Concurrency and lifetime + +- `Model` is shareable. In 0.x, compute is serialized per model, so concurrent + runs queue; load one `Model` per worker for true parallelism. +- `Session` is single-threaded. Use one session from one thread at a time. +- An active `Stream` holds the model's compute lease until `finalize`, `reset`, + or drop. Other runs/streams on that model fail with `TranscribeError.busy`. +- `Transcribe.setLogHandler` is best installed at startup. Repeated calls are + safe; they swap the Swift handler behind one native trampoline. +- On Metal, do not keep models in globals in short-lived programs. Scope models + so ARC releases GPU resources before process exit; the examples use `do { }` + blocks for this reason. + +## Cancellation + +Install a token on a session and cancel it from any thread: + +```swift +let token = CancellationToken() +session.setCancellationToken(token) +token.cancel() +``` + +The active `run`, `runBatch`, or stream feed throws `TranscribeError.aborted` +with any partial transcript preserved. Async `run`/`runBatch` also bridge Swift +task cancellation when no custom token is installed. + +## C, Objective-C, and C++ + +The xcframework also exposes the raw C module as `CTranscribe`. Objective-C and +C++ callers use the bundled C headers directly, for example +`#import `. diff --git a/bindings/swift/Sources/ExampleSupport/ExampleSupport.swift b/bindings/swift/Sources/ExampleSupport/ExampleSupport.swift new file mode 100644 index 00000000..6cf01ca1 --- /dev/null +++ b/bindings/swift/Sources/ExampleSupport/ExampleSupport.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Shared plumbing for the canonical examples (the analog of Rust's +/// `examples/common/mod.rs`). Resolves the canary model/audio from the +/// `TRANSCRIBE_SMOKE_*` env vars or the in-repo defaults, decodes WAV, and +/// provides a clean headless skip so every example runs in CI under the same +/// rules as the model-gated tests (requirements §6). +public enum ExampleSupport { + public static func env(_ key: String) -> String? { + let value = ProcessInfo.processInfo.environment[key] + return (value?.isEmpty == false) ? value : nil + } + + public static func repoRoot() -> URL { + var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0..<10 { + if FileManager.default.fileExists( + atPath: dir.appendingPathComponent("include/transcribe.h").path) { + return dir + } + dir = dir.deletingLastPathComponent() + } + return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + } + + private static func resolve(_ envKey: String, default relativePath: String) -> String? { + if let override = env(envKey) { return override } + let path = repoRoot().appendingPathComponent(relativePath).path + return FileManager.default.fileExists(atPath: path) ? path : nil + } + + public static func modelPath() -> String? { + resolve("TRANSCRIBE_SMOKE_MODEL", default: "models/whisper-tiny.en/whisper-tiny.en-Q5_K_M.gguf") + } + + public static func streamingModelPath() -> String? { + resolve( + "TRANSCRIBE_SMOKE_STREAMING_MODEL", + default: "models/moonshine-streaming-tiny/moonshine-streaming-tiny-Q8_0.gguf") + } + + public static func audioPath() -> String? { + resolve("TRANSCRIBE_SMOKE_AUDIO", default: "samples/jfk.wav") + } + + /// Print a skip note and exit 0 — an example that can't find its fixtures is + /// a clean no-op in CI, never a failure. + public static func skip(_ reason: String) -> Never { + print("skip: \(reason)") + exit(0) + } + + /// Minimal 16-bit PCM WAV decoder → mono float32 in [-1, 1]. + public static func loadWav(_ path: String) throws -> [Float] { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + func u32(_ offset: Int) -> Int { + Int(data[offset]) | Int(data[offset + 1]) << 8 + | Int(data[offset + 2]) << 16 | Int(data[offset + 3]) << 24 + } + var offset = 12 + var dataStart = -1 + var dataLength = 0 + while offset + 8 <= data.count { + let id = String(bytes: data[offset..= 0 else { + throw NSError(domain: "ExampleSupport", code: 1, + userInfo: [NSLocalizedDescriptionKey: "no data chunk in \(path)"]) + } + let end = min(dataStart + dataLength, data.count) + var samples: [Float] = [] + samples.reserveCapacity((end - dataStart) / 2) + var i = dataStart + while i + 1 < end { + let raw = Int16(bitPattern: UInt16(data[i]) | (UInt16(data[i + 1]) << 8)) + samples.append(Float(raw) / 32768.0) + i += 2 + } + return samples + } +} diff --git a/bindings/swift/Sources/TranscribeCpp/ABIHash.swift b/bindings/swift/Sources/TranscribeCpp/ABIHash.swift new file mode 100644 index 00000000..c694e807 --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/ABIHash.swift @@ -0,0 +1,20 @@ +import CTranscribe + +// Public-ABI drift gate (requirements §2). Swift's Clang importer reads the C +// headers directly, so there is no generated FFI layer to regenerate — the gate +// is this PINNED hash, compared in CI against include/transcribe.abihash by +// scripts/ci/swift_abihash_check.py. When the header's ABI changes the neutral +// hash moves, the check goes red, and a maintainer bumps this constant after a +// CONSCIOUS review of what changed (then audits the wrapper for new/changed +// structs, enums, or entry points). The per-field struct-layout check is WAIVED +// because the Clang importer gets layout from a real compiler — same waiver as +// Rust/bindgen; the load-time base-version gate (Transcribe.ensureCompatible) +// remains. +extension Transcribe { + /// sha256/16 of the normalized public FFI surface, pinned to the value in + /// include/transcribe.abihash at the time this binding was last reviewed. + public static let pinnedHeaderHash = "fe9ed398c408e5d9" + + /// The public-ABI digest this binding was reviewed against (16 hex chars). + public static func headerHash() -> String { pinnedHeaderHash } +} diff --git a/bindings/swift/Sources/TranscribeCpp/Backend.swift b/bindings/swift/Sources/TranscribeCpp/Backend.swift new file mode 100644 index 00000000..52a10754 --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/Backend.swift @@ -0,0 +1,71 @@ +import CTranscribe + +/// A backend request. `auto` always succeeds (CPU is the final fallback); +/// `metal`/`vulkan`/`cuda` require that backend to be present in the build. +public enum Backend: Sendable, Equatable { + case auto + case cpu + case cpuAccel + case metal + case vulkan + case cuda + + var cValue: transcribe_backend_request { + switch self { + case .auto: return TRANSCRIBE_BACKEND_AUTO + case .cpu: return TRANSCRIBE_BACKEND_CPU + case .cpuAccel: return TRANSCRIBE_BACKEND_CPU_ACCEL + case .metal: return TRANSCRIBE_BACKEND_METAL + case .vulkan: return TRANSCRIBE_BACKEND_VULKAN + case .cuda: return TRANSCRIBE_BACKEND_CUDA + } + } +} + +/// A registered compute device. +public struct Device: Sendable, Equatable { + /// ggml device name, e.g. "Metal". + public let name: String + /// Human-readable description, e.g. "Apple M4 Max". + public let description: String + /// Classified kind string, e.g. "cpu", "metal", "vulkan", "cuda". + public let kind: String +} + +/// A public ABI struct, for the no-model layout-liveness check. Mirrors the +/// `transcribe_abi_struct` enum (only the members the bindings introspect). +public enum AbiStruct: Sendable { + case modelLoadParams + case sessionParams + case runParams + case streamParams + case capabilities + case timings + case segment + case word + case token + case streamUpdate + case streamText + case sessionLimits + case ext + case backendDevice + + var cValue: transcribe_abi_struct { + switch self { + case .modelLoadParams: return TRANSCRIBE_ABI_MODEL_LOAD_PARAMS + case .sessionParams: return TRANSCRIBE_ABI_SESSION_PARAMS + case .runParams: return TRANSCRIBE_ABI_RUN_PARAMS + case .streamParams: return TRANSCRIBE_ABI_STREAM_PARAMS + case .capabilities: return TRANSCRIBE_ABI_CAPABILITIES + case .timings: return TRANSCRIBE_ABI_TIMINGS + case .segment: return TRANSCRIBE_ABI_SEGMENT + case .word: return TRANSCRIBE_ABI_WORD + case .token: return TRANSCRIBE_ABI_TOKEN + case .streamUpdate: return TRANSCRIBE_ABI_STREAM_UPDATE + case .streamText: return TRANSCRIBE_ABI_STREAM_TEXT + case .sessionLimits: return TRANSCRIBE_ABI_SESSION_LIMITS + case .ext: return TRANSCRIBE_ABI_EXT + case .backendDevice: return TRANSCRIBE_ABI_BACKEND_DEVICE + } + } +} diff --git a/bindings/swift/Sources/TranscribeCpp/Cancellation.swift b/bindings/swift/Sources/TranscribeCpp/Cancellation.swift new file mode 100644 index 00000000..de1fae26 --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/Cancellation.swift @@ -0,0 +1,40 @@ +import CTranscribe +import os + +/// A thread-safe cancellation flag. Install it on a `Session` with +/// `setCancellationToken`; the native abort callback (polled between decode +/// steps / chunks) reads it, so `cancel()` may be called from any thread to +/// abort an in-flight run/stream. The run then throws `.aborted` with the +/// partial transcript preserved. +public final class CancellationToken: @unchecked Sendable { + private let cancelled = OSAllocatedUnfairLock(initialState: false) + + public init() {} + + public func cancel() { cancelled.withLock { $0 = true } } + public func reset() { cancelled.withLock { $0 = false } } + public var isCancelled: Bool { cancelled.withLock { $0 } } +} + +/// C-ABI abort trampoline: reconstitutes the token from the userdata pointer +/// and reports its cancellation state. Fires on the native run thread. +private func abortTrampoline(_ userData: UnsafeMutableRawPointer?) -> Bool { + guard let userData else { return false } + return Unmanaged.fromOpaque(userData).takeUnretainedValue().isCancelled +} + +extension Session { + /// Install a cancellation token. The session keeps a strong reference; the + /// C side holds an unretained pointer through the abort callback userdata. + public func setCancellationToken(_ token: CancellationToken) { + cancelToken = token + let context = Unmanaged.passUnretained(token).toOpaque() + transcribe_set_abort_callback(ptr, abortTrampoline, context) + } + + /// Remove any installed cancellation token. + public func clearCancellationToken() { + transcribe_set_abort_callback(ptr, nil, nil) + cancelToken = nil + } +} diff --git a/bindings/swift/Sources/TranscribeCpp/Convenience.swift b/bindings/swift/Sources/TranscribeCpp/Convenience.swift new file mode 100644 index 00000000..adca3278 --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/Convenience.swift @@ -0,0 +1,15 @@ +import CTranscribe + +extension Transcribe { + /// One-shot helper: load a model, transcribe one utterance, return the + /// result. For repeated use, hold a `Model` and reuse its `Session`. + public static func transcribe( + modelPath: String, + pcm: [Float], + options: RunOptions = .init(), + modelOptions: ModelOptions = .init() + ) throws -> Transcript { + let model = try Model(path: modelPath, options: modelOptions) + return try model.session().run(pcm, options: options) + } +} diff --git a/bindings/swift/Sources/TranscribeCpp/Family.swift b/bindings/swift/Sources/TranscribeCpp/Family.swift new file mode 100644 index 00000000..f0d620da --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/Family.swift @@ -0,0 +1,172 @@ +import CTranscribe + +// Typed family extensions. Each option struct uses Optionals so unset fields +// keep the C `_init` defaults — the library is told only what the caller set +// (the same "init then override" pattern the Rust binding uses). The C structs +// are materialized in a nested closure so their address is stable while the +// `transcribe_ext` pointer is handed to the run/begin call; the library copies +// what it needs before returning. + +// MARK: - Run-slot extensions (whisper) + +public struct WhisperRunOptions: Sendable { + public var initialPrompt: String? + public var conditionOnPrevTokens: Bool? + public var maxPrevContextTokens: Int32? + public var temperature: Float? + public var temperatureInc: Float? + public var compressionRatioThold: Float? + public var logprobThold: Float? + public var noSpeechThold: Float? + public var seed: UInt32? + public var maxInitialTimestamp: Float? + + public init( + initialPrompt: String? = nil, + conditionOnPrevTokens: Bool? = nil, + maxPrevContextTokens: Int32? = nil, + temperature: Float? = nil, + temperatureInc: Float? = nil, + compressionRatioThold: Float? = nil, + logprobThold: Float? = nil, + noSpeechThold: Float? = nil, + seed: UInt32? = nil, + maxInitialTimestamp: Float? = nil + ) { + self.initialPrompt = initialPrompt + self.conditionOnPrevTokens = conditionOnPrevTokens + self.maxPrevContextTokens = maxPrevContextTokens + self.temperature = temperature + self.temperatureInc = temperatureInc + self.compressionRatioThold = compressionRatioThold + self.logprobThold = logprobThold + self.noSpeechThold = noSpeechThold + self.seed = seed + self.maxInitialTimestamp = maxInitialTimestamp + } +} + +public enum RunExtension: Sendable { + case whisper(WhisperRunOptions) + + var kind: UInt32 { + switch self { + case .whisper: return TRANSCRIBE_EXT_KIND_WHISPER_RUN + } + } +} + +/// Materialize the run extension (or pass NULL) and call `body` with a pointer +/// to its embedded `transcribe_ext`, kept alive for the call's duration. +func withRunExtension( + _ ext: RunExtension?, _ body: (UnsafePointer?) throws -> R +) rethrows -> R { + guard let ext else { return try body(nil) } + switch ext { + case .whisper(let o): + var c = transcribe_whisper_run_ext() + transcribe_whisper_run_ext_init(&c) + if let v = o.conditionOnPrevTokens { c.condition_on_prev_tokens = v } + if let v = o.maxPrevContextTokens { c.max_prev_context_tokens = v } + if let v = o.temperature { c.temperature = v } + if let v = o.temperatureInc { c.temperature_inc = v } + if let v = o.compressionRatioThold { c.compression_ratio_thold = v } + if let v = o.logprobThold { c.logprob_thold = v } + if let v = o.noSpeechThold { c.no_speech_thold = v } + if let v = o.seed { c.seed = v } + if let v = o.maxInitialTimestamp { c.max_initial_timestamp = v } + return try withOptionalCString(o.initialPrompt) { prompt in + c.initial_prompt = prompt + return try withUnsafePointer(to: &c.ext) { try body($0) } + } + } +} + +// MARK: - Stream-slot extensions + +public struct MoonshineStreamingOptions: Sendable { + public var minDecodeIntervalMs: Int32? + public init(minDecodeIntervalMs: Int32? = nil) { self.minDecodeIntervalMs = minDecodeIntervalMs } +} + +public struct ParakeetStreamOptions: Sendable { + public var attContextRight: Int32? + public init(attContextRight: Int32? = nil) { self.attContextRight = attContextRight } +} + +public struct ParakeetBufferedStreamOptions: Sendable { + public var leftMs: Int32? + public var chunkMs: Int32? + public var rightMs: Int32? + public init(leftMs: Int32? = nil, chunkMs: Int32? = nil, rightMs: Int32? = nil) { + self.leftMs = leftMs; self.chunkMs = chunkMs; self.rightMs = rightMs + } +} + +public struct VoxtralRealtimeStreamOptions: Sendable { + public var numDelayTokens: Int32? + public var minDecodeIntervalMs: Int32? + public init(numDelayTokens: Int32? = nil, minDecodeIntervalMs: Int32? = nil) { + self.numDelayTokens = numDelayTokens; self.minDecodeIntervalMs = minDecodeIntervalMs + } +} + +public enum StreamExtension: Sendable { + case parakeetStream(ParakeetStreamOptions) + case parakeetBuffered(ParakeetBufferedStreamOptions) + case moonshineStreaming(MoonshineStreamingOptions) + case voxtralRealtime(VoxtralRealtimeStreamOptions) + + var kind: UInt32 { + switch self { + case .parakeetStream: return TRANSCRIBE_EXT_KIND_PARAKEET_STREAM + case .parakeetBuffered: return TRANSCRIBE_EXT_KIND_PARAKEET_BUFFERED_STREAM + case .moonshineStreaming: return TRANSCRIBE_EXT_KIND_MOONSHINE_STREAMING_STREAM + case .voxtralRealtime: return TRANSCRIBE_EXT_KIND_VOXTRAL_REALTIME_STREAM + } + } +} + +func withStreamExtension( + _ ext: StreamExtension?, _ body: (UnsafePointer?) throws -> R +) rethrows -> R { + guard let ext else { return try body(nil) } + switch ext { + case .parakeetStream(let o): + var c = transcribe_parakeet_stream_ext() + transcribe_parakeet_stream_ext_init(&c) + if let v = o.attContextRight { c.att_context_right = v } + return try withUnsafePointer(to: &c.ext) { try body($0) } + case .parakeetBuffered(let o): + var c = transcribe_parakeet_buffered_stream_ext() + transcribe_parakeet_buffered_stream_ext_init(&c) + if let v = o.leftMs { c.left_ms = v } + if let v = o.chunkMs { c.chunk_ms = v } + if let v = o.rightMs { c.right_ms = v } + return try withUnsafePointer(to: &c.ext) { try body($0) } + case .moonshineStreaming(let o): + var c = transcribe_moonshine_streaming_stream_ext() + transcribe_moonshine_streaming_stream_ext_init(&c) + if let v = o.minDecodeIntervalMs { c.min_decode_interval_ms = v } + return try withUnsafePointer(to: &c.ext) { try body($0) } + case .voxtralRealtime(let o): + var c = transcribe_voxtral_realtime_stream_ext() + transcribe_voxtral_realtime_stream_ext_init(&c) + if let v = o.numDelayTokens { c.num_delay_tokens = v } + if let v = o.minDecodeIntervalMs { c.min_decode_interval_ms = v } + return try withUnsafePointer(to: &c.ext) { try body($0) } + } +} + +// MARK: - Acceptance probe + +extension Model { + /// Whether this model accepts the given run extension on the RUN slot. + public func accepts(_ family: RunExtension) -> Bool { + transcribe_model_accepts_ext_kind(ptr, TRANSCRIBE_EXT_SLOT_RUN, family.kind) + } + /// Whether this model accepts the given stream extension on the STREAM slot. + public func accepts(_ family: StreamExtension) -> Bool { + transcribe_model_accepts_ext_kind(ptr, TRANSCRIBE_EXT_SLOT_STREAM, family.kind) + } +} diff --git a/bindings/swift/Sources/TranscribeCpp/Logging.swift b/bindings/swift/Sources/TranscribeCpp/Logging.swift new file mode 100644 index 00000000..cb75f7e2 --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/Logging.swift @@ -0,0 +1,83 @@ +import CTranscribe +import Foundation + +public enum LogLevel: Sendable { + case none, info, warn, error, debug, continuation + + init(_ c: transcribe_log_level) { + switch c { + case TRANSCRIBE_LOG_LEVEL_INFO: self = .info + case TRANSCRIBE_LOG_LEVEL_WARN: self = .warn + case TRANSCRIBE_LOG_LEVEL_ERROR: self = .error + case TRANSCRIBE_LOG_LEVEL_DEBUG: self = .debug + case TRANSCRIBE_LOG_LEVEL_CONT: self = .continuation + default: self = .none + } + } +} + +/// Holds the global Swift log handler. The native sink is process-global and +/// may fire from any thread (including ggml worker threads), so access is +/// lock-guarded. +/// +/// The native `transcribe_log_set` is startup-only (C contract): calling it +/// repeatedly after models/threads exist is unsupported. So the binding installs +/// ONE native trampoline (the first time a handler is set or logging disabled) +/// and thereafter only swaps the Swift handler behind it — `transcribe_log_set` +/// is called at most once per process. +private final class LogState: @unchecked Sendable { + static let shared = LogState() + private let lock = NSLock() + private var handler: (@Sendable (LogLevel, String) -> Void)? + private var trampolineInstalled = false + /// Times `transcribe_log_set` has actually been invoked. Test hook for the + /// "install once" invariant; must never exceed 1. + private(set) var nativeInstallCount = 0 + + /// Swap the Swift handler, installing the native trampoline exactly once. + func set(_ h: (@Sendable (LogLevel, String) -> Void)?) { + lock.lock() + handler = h + let needInstall = !trampolineInstalled + if needInstall { + trampolineInstalled = true + nativeInstallCount += 1 + } + lock.unlock() + // Outside the lock: the C call only stores the callback (it never invokes + // it synchronously), but keep it off the lock the trampoline also takes. + if needInstall { transcribe_log_set(logTrampoline, nil) } + } + func current() -> (@Sendable (LogLevel, String) -> Void)? { + lock.lock(); defer { lock.unlock() } + return handler + } +} + +private func logTrampoline( + _ level: transcribe_log_level, _ msg: UnsafePointer?, _ userData: UnsafeMutableRawPointer? +) { + let text = msg.map { String(cString: $0) } ?? "" + LogState.shared.current()?(LogLevel(level), text) +} + +extension Transcribe { + /// Route native log messages (library + ggml diagnostics) to `handler`. + /// Best called once at startup (before loading models), but safe to call + /// later or repeatedly: the native trampoline is installed once and only the + /// Swift handler is swapped. The handler may be invoked from any thread. + public static func setLogHandler(_ handler: @escaping @Sendable (LogLevel, String) -> Void) { + LogState.shared.set(handler) + } + + /// Disable logging (library and ggml messages are dropped). Swaps the Swift + /// handler to none behind the single installed trampoline; does not re-touch + /// the native sink beyond the one-time install. + public static func disableLogging() { + LogState.shared.set(nil) + } + + /// Times the native `transcribe_log_set` has been invoked this process. + /// Internal test hook for the "install once" invariant. + static var nativeLogSetCount: Int { LogState.shared.nativeInstallCount } +} diff --git a/bindings/swift/Sources/TranscribeCpp/Model.swift b/bindings/swift/Sources/TranscribeCpp/Model.swift new file mode 100644 index 00000000..08ae61cd --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/Model.swift @@ -0,0 +1,90 @@ +import CTranscribe +import Foundation + +/// A loaded model. Safe to share across threads (`@unchecked Sendable`): the C +/// API allows concurrent queries and session creation, and the compute path is +/// serialized by an internal lock (the C "one in-flight run per model" +/// contract — the same per-model mutex + stream lease the Rust binding uses). A +/// `Model` outlives every `Session` derived from it; the session holds a strong +/// reference, so close ordering is automatic under ARC. +public final class Model: @unchecked Sendable { + let ptr: OpaquePointer + /// Serializes the run/feed/finalize compute path across all sessions, and + /// guards `streamActive`. + let runLock = NSLock() + /// The compute lease: `true` while some session holds an ACTIVE stream. + /// The C contract allows at most one in-flight run/stream across ALL + /// sessions of a model, and an active stream spans begin..finalize/reset/ + /// drop — so `run`/`runBatch`/another `stream` are refused with `.busy` + /// while it is held, rather than racing into the documented UB (corrupted + /// decodes on CPU, command-buffer failures on Metal). Always accessed under + /// `runLock`. + var streamActive = false + + /// Load a model from a GGUF file. Runs the pre-1.0 version gate first. + public init(path: String, options: ModelOptions = .init()) throws { + try Transcribe.ensureCompatible() + var params = transcribe_model_load_params() + transcribe_model_load_params_init(¶ms) + params.backend = options.backend.cValue + params.gpu_device = options.gpuDevice + var out: OpaquePointer? + let status = transcribe_model_load_file(path, ¶ms, &out) + try TranscribeError.check(status, context: "loading \(path)") + guard let out else { + throw TranscribeError.modelLoad("loading \(path): null model handle") + } + ptr = out + } + + deinit { transcribe_model_free(ptr) } + + /// Create a transcription session bound to this model. + public func session(_ options: SessionOptions = .init()) throws -> Session { + var params = transcribe_session_params() + transcribe_session_params_init(¶ms) + params.n_threads = options.nThreads + params.kv_type = options.kvType.cValue + params.n_ctx = options.nCtx + var out: OpaquePointer? + let status = transcribe_session_init(ptr, ¶ms, &out) + try TranscribeError.check(status, context: "creating session") + guard let out else { + throw TranscribeError.other(status: 0, message: "null session handle") + } + return Session(model: self, ptr: out) + } + + public var capabilities: Capabilities { + var caps = transcribe_capabilities() + transcribe_capabilities_init(&caps) + _ = transcribe_model_get_capabilities(ptr, &caps) + return Capabilities(caps) + } + + public func supports(_ feature: Feature) -> Bool { + transcribe_model_supports(ptr, feature.cValue) + } + + /// `general.architecture`, e.g. "parakeet". + public var arch: String { String(cString: transcribe_model_arch_string(ptr)) } + /// `stt.variant`, e.g. "tdt-0.6b-v2" (may be empty). + public var variant: String { String(cString: transcribe_model_variant_string(ptr)) } + /// The runtime backend bound to this model, e.g. "metal" / "cpu". + public var backend: String { String(cString: transcribe_model_backend(ptr)) } + + /// Tokenize plain UTF-8 text into the model's vocabulary (no special + /// tokens). Throws `.notImplemented` for vocabularies without an encoder. + public func tokenize(_ text: String) throws -> [Int32] { + var capacity = 256 + while true { + var buffer = [Int32](repeating: 0, count: capacity) + let n = transcribe_tokenize(ptr, text, &buffer, capacity) + if n == Int32.min { + throw TranscribeError.notImplemented("tokenize is unsupported for this model") + } + if n < 0 { capacity = Int(-n); continue } // buffer too small: retry + return Array(buffer[0..` tags in the returned text. + public var keepSpecialTags: Bool + /// Speculative-decode draft length: -1 = family default, 0 = disabled. + public var specKDrafts: Int32 + /// Family-specific run extension (whisper run options); M3. + public var family: RunExtension? + + public init( + task: TranscriptionTask = .transcribe, + timestamps: TimestampKind = .none, + pnc: Pnc = .default, + itn: Itn = .default, + language: String? = nil, + targetLanguage: String? = nil, + keepSpecialTags: Bool = false, + specKDrafts: Int32 = -1, + family: RunExtension? = nil + ) { + self.task = task + self.timestamps = timestamps + self.pnc = pnc + self.itn = itn + self.language = language + self.targetLanguage = targetLanguage + self.keepSpecialTags = keepSpecialTags + self.specKDrafts = specKDrafts + self.family = family + } + + /// Materialize a `transcribe_run_params` and run `body` with a pointer to + /// it. The `language` / `target_language` C strings are kept alive for the + /// duration of `body` (the C side copies them before returning). + func withCParams(_ body: (UnsafePointer) throws -> R) rethrows -> R { + var params = transcribe_run_params() + transcribe_run_params_init(¶ms) + params.task = task.cValue + params.timestamps = timestamps.cValue + params.pnc = pnc.cValue + params.itn = itn.cValue + params.keep_special_tags = keepSpecialTags + params.spec_k_drafts = specKDrafts + return try withOptionalCString(language) { lang in + params.language = lang + return try withOptionalCString(targetLanguage) { tgt in + params.target_language = tgt + return try withRunExtension(family) { ext in + params.family = ext + return try withUnsafePointer(to: ¶ms) { try body($0) } + } + } + } + } +} + +func withOptionalCString( + _ string: String?, _ body: (UnsafePointer?) throws -> R +) rethrows -> R { + if let string { return try string.withCString { try body($0) } } + return try body(nil) +} diff --git a/bindings/swift/Sources/TranscribeCpp/Session.swift b/bindings/swift/Sources/TranscribeCpp/Session.swift new file mode 100644 index 00000000..65c87340 --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/Session.swift @@ -0,0 +1,254 @@ +import CTranscribe +import Foundation + +/// A single-threaded transcription context bound to a `Model`. Not `Sendable`: +/// use one `Session` from one thread at a time (it may move between threads if +/// never used concurrently). The strong `model` reference keeps the model alive +/// for the session's lifetime, so freeing happens in a safe order under ARC. +public final class Session { + // Internal (not private) so `Stream` and the cancellation extension in + // this module can reach the handle, the model (for its run lock), and the + // retained cancel token. + let model: Model + let ptr: OpaquePointer + /// Strong ref to the installed cancellation token (the C side holds an + /// unretained pointer to it via the abort callback userdata). + var cancelToken: CancellationToken? + + init(model: Model, ptr: OpaquePointer) { + self.model = model + self.ptr = ptr + } + + deinit { transcribe_session_free(ptr) } + + /// Effective per-session limits. + public var limits: SessionLimits { + var lim = transcribe_session_limits() + transcribe_session_limits_init(&lim) + _ = transcribe_session_get_limits(ptr, &lim) + return SessionLimits(lim) + } + + /// Whether the most recent run was aborted by an installed cancellation. + public var wasAborted: Bool { transcribe_was_aborted(ptr) } + /// Whether the most recent run stopped at the context cap before EOS. + public var wasTruncated: Bool { transcribe_was_truncated(ptr) } + + /// Per-call timings from the most recent run. + public var timings: Timings { + var t = transcribe_timings(); transcribe_timings_init(&t) + _ = transcribe_get_timings(ptr, &t) + return Timings(t) + } + + /// Pretty-print the current timings to the log sink at INFO (or stderr when + /// no log handler is installed). + public func printTimings() { transcribe_print_timings(ptr) } + + // MARK: - Offline run (blocking) + + /// Transcribe one utterance. `pcm` is mono float32 at 16 kHz in [-1, 1]. + public func run(_ pcm: [Float], options: RunOptions = .init()) throws -> Transcript { + model.runLock.lock() + defer { model.runLock.unlock() } + if model.streamActive { + throw TranscribeError.busy( + "a stream is active on this model; finish or drop it before run()") + } + return try options.withCParams { params in + let status = pcm.withUnsafeBufferPointer { + transcribe_run(ptr, $0.baseAddress, Int32($0.count), params) + } + return try makeTranscript(status, context: "run") + } + } + + /// Transcribe several utterances in one dispatch. Returns one result per + /// input; a malformed or per-utterance-failed input is a `.failure` in its + /// slot (whole-batch faults throw). + public func runBatch( + _ inputs: [[Float]], options: RunOptions = .init() + ) throws -> [Result] { + model.runLock.lock() + defer { model.runLock.unlock() } + if model.streamActive { + throw TranscribeError.busy( + "a stream is active on this model; finish or drop it before runBatch()") + } + return try options.withCParams { params in + let counts = inputs.map { Int32($0.count) } + let status = withPCMPointers(inputs[...], []) { pointers in + pointers.withUnsafeBufferPointer { pp in + counts.withUnsafeBufferPointer { cc in + transcribe_run_batch( + ptr, pp.baseAddress, cc.baseAddress, Int32(inputs.count), params) + } + } + } + if status != TRANSCRIBE_OK && status != TRANSCRIBE_ERR_ABORTED { + throw TranscribeError.make(status, context: "run_batch") + } + let n = Int(transcribe_batch_n_results(ptr)) + return (0.. Transcript { + nonisolated(unsafe) let this = self + return try await withTaskCancellationBridge { try this.run(pcm, options: options) } + } + + /// `runBatch` hopped off the caller's thread/actor onto a background queue, + /// with Swift task cancellation bridged to the native abort. + public func runBatch( + _ inputs: [[Float]], options: RunOptions = .init() + ) async throws -> [Result] { + nonisolated(unsafe) let this = self + return try await withTaskCancellationBridge { try this.runBatch(inputs, options: options) } + } + + /// Hop a blocking call to a background queue and bridge Swift structured- + /// concurrency cancellation to the native abort: if the surrounding `Task` + /// is cancelled, the run aborts and throws `.aborted` (with the partial + /// transcript preserved), matching the synchronous `CancellationToken` path. + /// + /// The bridge installs its own token ONLY when the caller has not installed + /// one — it never clobbers a caller's `CancellationToken` (there is a single + /// native abort slot, so a caller-installed token takes precedence and + /// `Task.cancel()` is then not observed). The bridged token is removed when + /// the call returns, restoring the session's prior (no-token) state. + private func withTaskCancellationBridge( + _ body: @escaping @Sendable () throws -> T + ) async throws -> T { + let bridged = (cancelToken == nil) ? CancellationToken() : nil + if let bridged { setCancellationToken(bridged) } + defer { if bridged != nil { clearCancellationToken() } } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + DispatchQueue.global().async { + cont.resume(with: Result { try body() }) + } + } + } onCancel: { + bridged?.cancel() + } + } + + // MARK: - Result extraction + + private func makeTranscript(_ status: transcribe_status, context: String) throws -> Transcript { + switch status { + case TRANSCRIBE_OK: + return readTranscript() + case TRANSCRIBE_ERR_ABORTED: + throw TranscribeError.aborted( + message: TranscribeError.message(status, context), partial: readTranscript()) + case TRANSCRIBE_ERR_OUTPUT_TRUNCATED: + throw TranscribeError.outputTruncated( + message: TranscribeError.message(status, context), partial: readTranscript()) + default: + throw TranscribeError.make(status, context: context) + } + } + + private func readTranscript() -> Transcript { + var segments: [Segment] = [] + for i in 0.. Transcript { + let idx = Int32(i) + var segments: [Segment] = [] + for j in 0..( + _ inputs: ArraySlice<[Float]>, _ acc: [UnsafePointer?], + _ body: ([UnsafePointer?]) -> R +) -> R { + guard let first = inputs.first else { return body(acc) } + return first.withUnsafeBufferPointer { buffer in + withPCMPointers(inputs.dropFirst(), acc + [buffer.baseAddress], body) + } +} diff --git a/bindings/swift/Sources/TranscribeCpp/Streaming.swift b/bindings/swift/Sources/TranscribeCpp/Streaming.swift new file mode 100644 index 00000000..afa670b7 --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/Streaming.swift @@ -0,0 +1,200 @@ +import CTranscribe + +public enum StreamState: Sendable { + case idle, active, finished, failed + init(_ c: transcribe_stream_state) { + switch c { + case TRANSCRIBE_STREAM_ACTIVE: self = .active + case TRANSCRIBE_STREAM_FINISHED: self = .finished + case TRANSCRIBE_STREAM_FAILED: self = .failed + default: self = .idle + } + } +} + +public enum CommitPolicy: Sendable { + case auto, onFinalize, stablePrefix + var cValue: transcribe_stream_commit_policy { + switch self { + case .auto: return TRANSCRIBE_STREAM_COMMIT_AUTO + case .onFinalize: return TRANSCRIBE_STREAM_COMMIT_ON_FINALIZE + case .stablePrefix: return TRANSCRIBE_STREAM_COMMIT_STABLE_PREFIX + } + } +} + +public struct StreamOptions: Sendable { + public var commitPolicy: CommitPolicy + /// Consecutive agreeing hypotheses before a prefix commits; 0 = default (3). + public var stablePrefixAgreementN: UInt32 + public var family: StreamExtension? + + public init( + commitPolicy: CommitPolicy = .auto, + stablePrefixAgreementN: UInt32 = 0, + family: StreamExtension? = nil + ) { + self.commitPolicy = commitPolicy + self.stablePrefixAgreementN = stablePrefixAgreementN + self.family = family + } + + func withCParams(_ body: (UnsafePointer) throws -> R) rethrows -> R { + var params = transcribe_stream_params() + transcribe_stream_params_init(¶ms) + params.commit_policy = commitPolicy.cValue + params.stable_prefix_agreement_n = stablePrefixAgreementN + return try withStreamExtension(family) { ext in + params.family = ext + return try withUnsafePointer(to: ¶ms) { try body($0) } + } + } +} + +/// UI-stable streaming text. `committed` is append-only and flicker-free; +/// `tentative` is the volatile suffix; `full` is the authoritative raw +/// hypothesis. All copied at the FFI boundary. +public struct StreamText: Sendable, Equatable { + public let full: String + public let committed: String + public let tentative: String + /// Flicker-free display string: `committed + tentative`. + public var display: String { committed + tentative } + + init(_ c: transcribe_stream_text) { + full = c.full_text.map { String(cString: $0) } ?? "" + committed = c.committed_text.map { String(cString: $0) } ?? "" + tentative = c.tentative_text.map { String(cString: $0) } ?? "" + } +} + +/// Per-call change metadata from `feed` / `finalize`. +public struct StreamUpdate: Sendable, Equatable { + public let resultChanged: Bool + public let isFinal: Bool + public let revision: Int32 + public let inputReceivedMs: Int64 + public let audioCommittedMs: Int64 + public let bufferedMs: Int64 + public let committedChanged: Bool + public let tentativeChanged: Bool + + init(_ c: transcribe_stream_update) { + resultChanged = c.result_changed + isFinal = c.is_final + revision = c.revision + inputReceivedMs = c.input_received_ms + audioCommittedMs = c.audio_committed_ms + bufferedMs = c.buffered_ms + committedChanged = c.committed_changed + tentativeChanged = c.tentative_changed + } +} + +/// An active streaming run on a `Session`. Streaming is a mode on the session, +/// so a `Stream` holds its session (keeping it alive) and drives its state. +/// Like the session, it is single-threaded; `feed`/`finalize` serialize on the +/// model lock (the compute path). +public final class Stream { + private let session: Session + /// True while this stream holds the model's compute lease (set at begin, + /// cleared at finalize/reset/deinit). Tracked per-stream so `deinit` never + /// releases a lease a *different* session has since acquired — mirrors the + /// Rust binding's `holds_lease`. Mutated only under `model.runLock`; the + /// `deinit` guard reads it on the deallocating thread, where no other + /// reference to this `Stream` can exist (so no concurrent mutation). + var holdsLease = true + + init(_ session: Session) { self.session = session } + + /// Abandon any unfinalized stream when the handle is dropped: without this, + /// a `Stream` that goes out of scope without `finalize()`/`reset()` would + /// leave the session stuck ACTIVE and the model's compute lease held + /// forever. Reset is idempotent and safe from any state. + deinit { + guard holdsLease else { return } + session.model.runLock.lock() + transcribe_stream_reset(session.ptr) + session.model.streamActive = false + session.model.runLock.unlock() + } + + /// Feed a PCM frame (16 kHz mono float32). Returns per-call change metadata. + public func feed(_ frame: [Float]) throws -> StreamUpdate { + session.model.runLock.lock() + defer { session.model.runLock.unlock() } + var update = transcribe_stream_update() + transcribe_stream_update_init(&update) + let status = frame.withUnsafeBufferPointer { + transcribe_stream_feed(session.ptr, $0.baseAddress, Int32($0.count), &update) + } + try TranscribeError.check(status, context: "stream_feed") + return StreamUpdate(update) + } + + /// Signal end of input; flushes buffered audio and emits remaining text. + /// Either way the stream is no longer active, so the model's compute lease + /// is released here (not deferred to `deinit`) — another session may + /// proceed without waiting for this `Stream` to drop. + @discardableResult + public func finalize() throws -> StreamUpdate { + session.model.runLock.lock() + defer { session.model.runLock.unlock() } + var update = transcribe_stream_update() + transcribe_stream_update_init(&update) + let status = transcribe_stream_finalize(session.ptr, &update) + if holdsLease { session.model.streamActive = false; holdsLease = false } + try TranscribeError.check(status, context: "stream_finalize") + return StreamUpdate(update) + } + + /// Abandon the stream and return the session to idle. Releases the model's + /// compute lease (the stream is no longer active). + @discardableResult + public func reset() -> StreamState { + session.model.runLock.lock() + transcribe_stream_reset(session.ptr) + if holdsLease { session.model.streamActive = false; holdsLease = false } + session.model.runLock.unlock() + return state + } + + public var state: StreamState { StreamState(transcribe_stream_get_state(session.ptr)) } + public var revision: Int32 { transcribe_stream_revision(session.ptr) } + public var lastStatus: Int32 { + Int32(bitPattern: transcribe_stream_last_status(session.ptr).rawValue) + } + + /// The UI-stable text snapshot (committed / tentative / full). + public var text: StreamText { + var t = transcribe_stream_text() + transcribe_stream_text_init(&t) + _ = transcribe_stream_get_text(session.ptr, &t) + return StreamText(t) + } +} + +extension Session { + /// Begin a streaming run. The session must support streaming (else + /// `.notImplemented`) and be idle/finished/failed (not already streaming). + /// Claims the model's compute lease for the whole stream lifetime: a second + /// stream — or an offline run — on ANY session of the same model is refused + /// with `.busy` until this stream finalizes, resets, or is dropped. + public func stream( + _ runOptions: RunOptions = .init(), _ streamOptions: StreamOptions = .init() + ) throws -> Stream { + model.runLock.lock() + defer { model.runLock.unlock() } + if model.streamActive { + throw TranscribeError.busy("a stream is already active on this model") + } + let status = runOptions.withCParams { runParams in + streamOptions.withCParams { streamParams in + transcribe_stream_begin(ptr, runParams, streamParams) + } + } + try TranscribeError.check(status, context: "stream_begin") + model.streamActive = true + return Stream(self) + } +} diff --git a/bindings/swift/Sources/TranscribeCpp/TranscribeCpp.swift b/bindings/swift/Sources/TranscribeCpp/TranscribeCpp.swift index b23bc51b..45b9c4f2 100644 --- a/bindings/swift/Sources/TranscribeCpp/TranscribeCpp.swift +++ b/bindings/swift/Sources/TranscribeCpp/TranscribeCpp.swift @@ -1,11 +1,97 @@ /// 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. +/// `TranscribeCpp` is the idiomatic Swift wrapper over the C API exposed by the +/// `CTranscribe` module (the prebuilt `.xcframework` binaryTarget). Objective-C +/// and C++ consumers can `#import` the bundled `transcribe/extensions.h` +/// directly; this wrapper is the Swift-native surface. /// -/// 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" +/// The full surface is implemented: `Model` / `Session` / `Stream`, offline and +/// streaming transcription, batch, cancellation, logging, and family +/// extensions, plus the no-model surface (version, ABI, device discovery, +/// errors). See `notes/swift-bindings-plan.md` for the capability matrix. +import CTranscribe + +/// Top-level, model-free entry points: identity, ABI introspection, the +/// load-time version gate, and backend discovery. +public enum Transcribe { + /// Version this binding was built against. Pinned here for the pre-1.0 + /// base-version load gate; the version-sync milestone will generate it from + /// `include/transcribe.h` (the single source of truth) rather than hardcode. + public static let compiledVersion = "0.0.1" + + /// `MAJOR.MINOR.PATCH` of the linked native library. + public static func version() -> String { String(cString: transcribe_version()) } + + /// Short git commit the native library was built from, or "unknown". + public static func versionCommit() -> String { + String(cString: transcribe_version_commit()) + } + + /// Human-readable description of a native status code. + public static func statusString(_ status: Int32) -> String { + String(cString: transcribe_status_string(status)) + } + + /// `sizeof` the native library reports for a public ABI struct. Used by the + /// no-model ABI liveness check; a real layout is non-zero. + public static func abiStructSize(_ which: AbiStruct) -> Int { + Int(transcribe_abi_struct_size(which.cValue)) + } + + /// Pre-1.0 load gate: the linked library and this binding must agree on the + /// base `MAJOR.MINOR.PATCH` (a packaging-only post-release suffix still + /// loads). Mirrors the Python/Rust load-time gate. + public static func ensureCompatible() throws { + let have = baseVersion(version()) + let want = baseVersion(compiledVersion) + guard have == want else { + throw TranscribeError.versionMismatch( + "native library \(version()) is incompatible with binding \(compiledVersion)") + } + } + + /// Whether a backend request can be satisfied by some registered device. + /// Never throws: an unavailable or unknown backend answers `false`. + public static func backendAvailable(_ backend: Backend) -> Bool { + transcribe_backend_available(backend.cValue) + } + + /// (Re)scan and register the available compute backends. A no-op for a + /// statically-linked build with backends compiled in (the xcframework + /// posture); emits a one-line device summary through the log sink. Throws + /// if the scan reports an error. + public static func initBackends() throws { + try TranscribeError.check(transcribe_init_backends_default(), context: "init_backends") + } + + /// The compute devices the native library has registered. + public static func devices() -> [Device] { + let count = transcribe_backend_device_count() + var devices: [Device] = [] + devices.reserveCapacity(Int(count)) + for index in 0.. String { + var out = "" + for scalar in version.unicodeScalars { + if scalar == "." || ("0"..."9").contains(scalar) { + out.unicodeScalars.append(scalar) + } else { + break + } + } + return out } diff --git a/bindings/swift/Sources/TranscribeCpp/TranscribeError.swift b/bindings/swift/Sources/TranscribeCpp/TranscribeError.swift new file mode 100644 index 00000000..d42c82f3 --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/TranscribeError.swift @@ -0,0 +1,77 @@ +import CTranscribe + +/// Native errors mapped to Swift's idiom. Every `transcribe_status` becomes a +/// distinct case so callers can pattern-match the failure class. Distinct C +/// failures stay distinct (requirements §3): "no such provider" is not +/// "provider can't satisfy this request". +/// +/// `.aborted` / `.outputTruncated` carry the preserved partial `Transcript` +/// (the C side keeps partial output readable after those statuses); it is `nil` +/// when the error is built outside a run (e.g. by `check`). +/// +/// `.busy` is a binding-level error (no C status maps to it): the C library +/// allows at most one in-flight run/stream across all sessions of a model (the +/// 0.x limitation in `include/transcribe.h`), and the wrapper refuses an +/// overlapping run/stream rather than racing into the documented UB. +public enum TranscribeError: Error { + case invalidArgument(String) + case notImplemented(String) + case modelFileNotFound(String) + case modelLoad(String) + case outOfMemory(String) + case backend(String) + case unsupported(String) + case badStructSize(String) + case inputTooLong(String) + case aborted(message: String, partial: Transcript?) + case outputTruncated(message: String, partial: Transcript?) + case versionMismatch(String) + case busy(String) + case other(status: Int32, message: String) + + /// Status description, prefixed with `context` when given. + static func message(_ status: transcribe_status, _ context: String = "") -> String { + let base = String(cString: transcribe_status_string(Int32(bitPattern: status.rawValue))) + return context.isEmpty ? base : "\(context): \(base)" + } + + /// Build the error for a non-OK status, prefixing `context` when given. + static func make(_ status: transcribe_status, context: String = "") -> TranscribeError { + let raw = Int32(bitPattern: status.rawValue) + let message = message(status, context) + switch status { + case TRANSCRIBE_ERR_INVALID_ARG: + return .invalidArgument(message) + case TRANSCRIBE_ERR_NOT_IMPLEMENTED: + return .notImplemented(message) + case TRANSCRIBE_ERR_FILE_NOT_FOUND: + return .modelFileNotFound(message) + case TRANSCRIBE_ERR_GGUF, TRANSCRIBE_ERR_UNSUPPORTED_ARCH, TRANSCRIBE_ERR_UNSUPPORTED_VARIANT: + return .modelLoad(message) + case TRANSCRIBE_ERR_OOM: + return .outOfMemory(message) + case TRANSCRIBE_ERR_BACKEND: + return .backend(message) + case TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE, TRANSCRIBE_ERR_UNSUPPORTED_TASK, + TRANSCRIBE_ERR_UNSUPPORTED_TIMESTAMPS, TRANSCRIBE_ERR_UNSUPPORTED_PNC, + TRANSCRIBE_ERR_UNSUPPORTED_ITN: + return .unsupported(message) + case TRANSCRIBE_ERR_BAD_STRUCT_SIZE: + return .badStructSize(message) + case TRANSCRIBE_ERR_INPUT_TOO_LONG: + return .inputTooLong(message) + case TRANSCRIBE_ERR_ABORTED: + return .aborted(message: message, partial: nil) + case TRANSCRIBE_ERR_OUTPUT_TRUNCATED: + return .outputTruncated(message: message, partial: nil) + default: + return .other(status: raw, message: message) + } + } + + /// Throw the mapped error unless `status` is `TRANSCRIBE_OK`. + static func check(_ status: transcribe_status, context: String = "") throws { + guard status != TRANSCRIBE_OK else { return } + throw make(status, context: context) + } +} diff --git a/bindings/swift/Sources/TranscribeCpp/Transcript.swift b/bindings/swift/Sources/TranscribeCpp/Transcript.swift new file mode 100644 index 00000000..70a6622b --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/Transcript.swift @@ -0,0 +1,128 @@ +import CTranscribe + +/// A fully-materialized transcription result. All text is copied out of the +/// session at the FFI boundary (docs/bindings.md), so a `Transcript` outlives +/// the next run and the session itself. +public struct Transcript: Sendable, Equatable { + public let text: String + /// Detected language ISO code, or `nil` when the model didn't predict one + /// (English-only model, a caller-supplied hint, or no LID). + public let language: String? + /// The granularity actually returned (may be coarser than requested). + public let timestampKind: TimestampKind + public let segments: [Segment] + public let words: [Word] + public let tokens: [Token] + public let timings: Timings +} + +/// A segment with timing and index ranges into `words` / `tokens`. +public struct Segment: Sendable, Equatable { + public let t0Ms: Int64 + public let t1Ms: Int64 + public let firstWord: Int32 + public let nWords: Int32 + public let firstToken: Int32 + public let nTokens: Int32 + public let text: String + + init(_ c: transcribe_segment) { + t0Ms = c.t0_ms; t1Ms = c.t1_ms + firstWord = c.first_word; nWords = c.n_words + firstToken = c.first_token; nTokens = c.n_tokens + text = c.text.map { String(cString: $0) } ?? "" + } +} + +/// A word with timing, its parent segment index, and a token range. +public struct Word: Sendable, Equatable { + public let t0Ms: Int64 + public let t1Ms: Int64 + public let segIndex: Int32 + public let firstToken: Int32 + public let nTokens: Int32 + public let text: String + + init(_ c: transcribe_word) { + t0Ms = c.t0_ms; t1Ms = c.t1_ms + segIndex = c.seg_index + firstToken = c.first_token; nTokens = c.n_tokens + text = c.text.map { String(cString: $0) } ?? "" + } +} + +/// A single token. `p` is a family-specific confidence hint (NaN when the +/// architecture produces none). +public struct Token: Sendable, Equatable { + public let id: Int32 + public let p: Float + public let t0Ms: Int64 + public let t1Ms: Int64 + public let segIndex: Int32 + public let wordIndex: Int32 + public let text: String + + init(_ c: transcribe_token) { + id = c.id; p = c.p + t0Ms = c.t0_ms; t1Ms = c.t1_ms + segIndex = c.seg_index; wordIndex = c.word_index + text = c.text.map { String(cString: $0) } ?? "" + } +} + +/// Per-call stage timings in milliseconds. Zero means "unknown / not measured". +public struct Timings: Sendable, Equatable { + public let loadMs: Float + public let melMs: Float + public let encodeMs: Float + public let decodeMs: Float + + init(_ c: transcribe_timings) { + loadMs = c.load_ms; melMs = c.mel_ms + encodeMs = c.encode_ms; decodeMs = c.decode_ms + } +} + +/// Immutable semantic properties read from the model at load time. +public struct Capabilities: Sendable, Equatable { + public let nativeSampleRate: Int32 + /// Supported language codes; empty when the model is language-agnostic. + public let languages: [String] + public let maxTimestampKind: TimestampKind + public let supportsLanguageDetect: Bool + public let supportsTranslate: Bool + public let supportsStreaming: Bool + public let supportsSpecDecode: Bool + /// Longest single-run audio in ms; 0 = no practical limit. + public let maxAudioMs: Int64 + + init(_ c: transcribe_capabilities) { + nativeSampleRate = c.native_sample_rate + var langs: [String] = [] + if let arr = c.languages { + for i in 0.. .modelFileNotFound: \(message)") + } +} + +guard let modelPath = ExampleSupport.modelPath() else { + ExampleSupport.skip("set TRANSCRIBE_SMOKE_MODEL to demo the run-time error paths") +} + +// Scope the model/session so ARC frees the native Metal resources before the +// process exits — ggml-metal (macOS 15+) asserts every GPU resource is released +// before teardown, so a top-level `let` that outlives `main` aborts. +do { + let session = try Model(path: modelPath).session() + + // 2. Empty PCM is rejected before any compute. + do { + _ = try session.run([]) + } catch let error as TranscribeError { + if case .invalidArgument(let message) = error { + print("empty pcm -> .invalidArgument: \(message)") + } + } + + // 3. Cancellation: a pre-cancelled token aborts the run, preserving any partial. + if let audioPath = ExampleSupport.audioPath() { + let pcm = try ExampleSupport.loadWav(audioPath) + let long = Array(repeating: pcm, count: 4).flatMap { $0 } + let token = CancellationToken() + token.cancel() + session.setCancellationToken(token) + do { + _ = try session.run(long) + print("run completed before the abort was polled") + } catch let error as TranscribeError { + if case .aborted(let message, let partial) = error { + print("cancelled -> .aborted: \(message) (partial: \(partial?.text.isEmpty == false ? "present" : "empty"))") + } + } + session.clearCancellationToken() + } +} + +print("done") diff --git a/bindings/swift/Sources/streaming/main.swift b/bindings/swift/Sources/streaming/main.swift new file mode 100644 index 00000000..eab88618 --- /dev/null +++ b/bindings/swift/Sources/streaming/main.swift @@ -0,0 +1,34 @@ +// streaming — feed chunks, watch committed (stable) vs tentative (volatile) text. +import ExampleSupport +import TranscribeCpp + +guard let modelPath = ExampleSupport.streamingModelPath(), let audioPath = ExampleSupport.audioPath() +else { + ExampleSupport.skip("set TRANSCRIBE_SMOKE_STREAMING_MODEL + TRANSCRIBE_SMOKE_AUDIO") +} + +let pcm = try ExampleSupport.loadWav(audioPath) + +// Scope the model/session/stream so ARC frees the native Metal resources before +// the process exits — ggml-metal (macOS 15+) asserts every GPU resource is +// released before teardown, so a top-level `let` that outlives `main` aborts. +do { + let session = try Model(path: modelPath).session() + let stream = try session.stream() + + let chunk = 1600 // 100 ms at 16 kHz + var offset = 0 + while offset < pcm.count { + let end = min(offset + chunk, pcm.count) + let update = try stream.feed(Array(pcm[offset.. bridge active + // Bare `Task` must resolve to Swift's concurrency type: the run-mode enum + // is `TranscriptionTask`, so it no longer shadows `_Concurrency.Task`. + let task = Task { try await session.run(long) } + task.cancel() + do { + _ = try await task.value + XCTFail("a cancelled Task must abort the bridged async run") + } catch let error as TranscribeError { + guard case .aborted = error else { + return XCTFail("expected .aborted from a cancelled task, got \(error)") + } + } + XCTAssertTrue(session.wasAborted) + } + + /// The bridge must never clobber a caller-installed token: there is a single + /// native abort slot, so a token the caller installed takes precedence and + /// survives an async run unchanged. (Guards the "only when no caller token" + /// rule — passes pre- and post-bridge; fails if the bridge installs blindly.) + func testAsyncRunPreservesCallerInstalledToken() async throws { + let (path, pcm) = try Fixtures.modelAndAudio() + let session = try Model(path: path).session() + let myToken = CancellationToken() + session.setCancellationToken(myToken) + _ = try await session.run(pcm) + XCTAssertTrue(session.cancelToken === myToken, "bridge must not replace the caller's token") + } + + /// `runBatch` shares the same bridge as `run`. A whole-batch abort surfaces + /// as per-slot `.aborted` failures (the binding does not throw on batch-level + /// ABORTED, per include/transcribe.h) and `wasAborted` records it. + func testAsyncRunBatchBridgesTaskCancellation() async throws { + let (path, pcm) = try Fixtures.modelAndAudio() + let long = Array(repeating: pcm, count: 6).flatMap { $0 } + let session = try Model(path: path).session() + let task = Task { try await session.runBatch([long]) } + task.cancel() + let results = (try? await task.value) ?? [] + XCTAssertTrue(session.wasAborted, "Task.cancel must abort the bridged async runBatch") + if let first = results.first, case .failure(let err) = first { + guard case TranscribeError.aborted = err else { + return XCTFail("expected a per-slot .aborted, got \(err)") + } + } + } + + /// A per-utterance aborted batch slot must carry the preserved (possibly + /// empty) partial transcript, not nil — matching `run` and the Rust/Python + /// batch paths. Pre-cancelling makes the batch abort deterministically. + func testBatchAbortedSlotPreservesPartial() throws { + let (path, pcm) = try Fixtures.modelAndAudio() + let long = Array(repeating: pcm, count: 4).flatMap { $0 } + let session = try Model(path: path).session() + let token = CancellationToken() + token.cancel() + session.setCancellationToken(token) + let results = try session.runBatch([long]) + XCTAssertEqual(results.count, 1) + guard case .failure(let err) = results[0] else { + return XCTFail("expected the aborted slot to be a failure") + } + guard case TranscribeError.aborted(_, let partial) = err else { + return XCTFail("expected .aborted, got \(err)") + } + XCTAssertNotNil(partial, "an aborted batch slot must preserve its partial transcript") + } +} diff --git a/bindings/swift/Tests/TranscribeCppTests/ExtensionTests.swift b/bindings/swift/Tests/TranscribeCppTests/ExtensionTests.swift new file mode 100644 index 00000000..56a3a421 --- /dev/null +++ b/bindings/swift/Tests/TranscribeCppTests/ExtensionTests.swift @@ -0,0 +1,43 @@ +import XCTest + +@testable import TranscribeCpp + +/// Family extensions + utilities. Mirrors Rust's `extensions.rs`. Note: the +/// "stream extension on the run slot" rejection the C API enforces at runtime +/// is enforced at COMPILE time here — `RunOptions.family` only accepts a +/// `RunExtension`, `StreamOptions.family` only a `StreamExtension` — so that +/// case is covered by the type system, not a test. +final class ExtensionTests: XCTestCase { + func testWhisperAcceptsRunExtension() throws { + guard let path = Fixtures.modelPath() else { throw XCTSkip("no canary model") } + let model = try Model(path: path) + XCTAssertTrue(model.accepts(.whisper(WhisperRunOptions()))) + } + + func testWhisperRunWithInitialPrompt() throws { + let (path, pcm) = try Fixtures.modelAndAudio() + let model = try Model(path: path) + let options = RunOptions( + family: .whisper(WhisperRunOptions(initialPrompt: "Ask not", temperature: 0.0))) + let transcript = try model.session().run(pcm, options: options) + XCTAssertTrue(transcript.text.lowercased().contains("country"), transcript.text) + } + + func testWrongFamilyExtensionIsRejected() throws { + guard let path = Fixtures.modelPath() else { throw XCTSkip("no canary model") } + let model = try Model(path: path) + // whisper has no streaming surface, so it accepts no stream extension. + XCTAssertFalse(model.accepts(.parakeetStream(ParakeetStreamOptions()))) + XCTAssertFalse(model.accepts(.moonshineStreaming(MoonshineStreamingOptions()))) + } + + func testTokenizeRoundTripsNonEmpty() throws { + guard let path = Fixtures.modelPath() else { throw XCTSkip("no canary model") } + let model = try Model(path: path) + let tokens = try model.tokenize("ask not what your country can do for you") + XCTAssertFalse(tokens.isEmpty) + // A longer string tokenizes to at least as many tokens as a prefix. + let prefix = try model.tokenize("ask not") + XCTAssertGreaterThanOrEqual(tokens.count, prefix.count) + } +} diff --git a/bindings/swift/Tests/TranscribeCppTests/FamilyStreamTests.swift b/bindings/swift/Tests/TranscribeCppTests/FamilyStreamTests.swift new file mode 100644 index 00000000..b05ea086 --- /dev/null +++ b/bindings/swift/Tests/TranscribeCppTests/FamilyStreamTests.swift @@ -0,0 +1,76 @@ +import XCTest + +@testable import TranscribeCpp + +/// Happy-path model tests for the family stream extensions that the core +/// `ExtensionTests` couldn't reach (no canary): parakeet cache-aware, parakeet +/// buffered, and voxtral realtime. These gate on their own GGUF and XCTSkip +/// when absent (they run locally; the CI canary set doesn't include them). +/// Together with whisper-run and moonshine-streaming, this exercises EVERY +/// shipped extension kind end-to-end: the typed struct is materialized, handed +/// across the FFI, and accepted by a model that actually consumes it. +final class FamilyStreamTests: XCTestCase { + // MARK: parakeet cache-aware (PARAKEET_STREAM) + + func testParakeetCacheAwareAcceptanceDiscriminates() throws { + guard let path = Fixtures.parakeetStreamModelPath() else { + throw XCTSkip("no parakeet cache-aware canary") + } + let model = try Model(path: path) + // The header's documented discrimination: cache-aware accepts STREAM, + // rejects BUFFERED. + XCTAssertTrue(model.accepts(.parakeetStream(ParakeetStreamOptions()))) + XCTAssertFalse(model.accepts(.parakeetBuffered(ParakeetBufferedStreamOptions()))) + } + + func testParakeetCacheAwareStreamsWithExtension() throws { + guard let path = Fixtures.parakeetStreamModelPath(), let audio = Fixtures.audioPath() else { + throw XCTSkip("no parakeet cache-aware canary") + } + let pcm = try Fixtures.loadWav(audio) + let session = try Model(path: path).session() + let stream = try session.stream( + .init(), StreamOptions(family: .parakeetStream(ParakeetStreamOptions(attContextRight: -1)))) + try Fixtures.drive(stream, pcm: pcm) + // Non-empty, not content: the CI parakeet canary is a lenient Q4_K_M + // quant (see .github/actions/fetch-canary), so we assert the stream ran + // and produced text — matching Rust's family.rs contract. + XCTAssertFalse(stream.text.full.isEmpty, "parakeet cache-aware produced no text") + } + + // MARK: parakeet buffered (PARAKEET_BUFFERED_STREAM) + + func testParakeetBufferedStreamsWithExtension() throws { + guard let path = Fixtures.parakeetBufferedModelPath(), let audio = Fixtures.audioPath() else { + throw XCTSkip("no parakeet buffered canary") + } + let model = try Model(path: path) + XCTAssertTrue(model.accepts(.parakeetBuffered(ParakeetBufferedStreamOptions()))) + let pcm = try Fixtures.loadWav(audio) + // Defaults (left/chunk/right = -1) resolve to the model's menu default + // (L=5600/C=1040/R=1040). An explicit override must be an 80 ms multiple + // AND land on a tuple in the model's training menu, else stream_begin + // returns INVALID_ARG — so the safe, path-proving choice is the default. + let stream = try model.session().stream( + .init(), StreamOptions(family: .parakeetBuffered(ParakeetBufferedStreamOptions()))) + try Fixtures.drive(stream, pcm: pcm) + // Non-empty, not content (lenient Q4_K_M CI canary; matches Rust). + XCTAssertFalse(stream.text.full.isEmpty, "buffered stream produced no text") + } + + // MARK: voxtral realtime (VOXTRAL_REALTIME_STREAM) + + func testVoxtralRealtimeAcceptsAndStreams() throws { + guard let path = Fixtures.voxtralRealtimeModelPath(), let audio = Fixtures.audioPath() else { + throw XCTSkip("no voxtral realtime canary") + } + let model = try Model(path: path) + XCTAssertTrue(model.accepts(.voxtralRealtime(VoxtralRealtimeStreamOptions()))) + let pcm = try Fixtures.loadWav(audio) + let stream = try model.session().stream( + .init(), + StreamOptions(family: .voxtralRealtime(VoxtralRealtimeStreamOptions(numDelayTokens: 4)))) + try Fixtures.drive(stream, pcm: pcm) + XCTAssertFalse(stream.text.full.isEmpty, "voxtral produced no text") + } +} diff --git a/bindings/swift/Tests/TranscribeCppTests/LoggingTests.swift b/bindings/swift/Tests/TranscribeCppTests/LoggingTests.swift new file mode 100644 index 00000000..eeb3af99 --- /dev/null +++ b/bindings/swift/Tests/TranscribeCppTests/LoggingTests.swift @@ -0,0 +1,51 @@ +import Foundation +import XCTest + +@testable import TranscribeCpp + +/// Log routing (requirements: native log callback surfaced idiomatically). +/// No-model: `initBackends` emits a device summary through the sink. +final class LoggingTests: XCTestCase { + private final class LogBox: @unchecked Sendable { + private let lock = NSLock() + private var stored: [(LogLevel, String)] = [] + func append(_ level: LogLevel, _ message: String) { + lock.lock(); stored.append((level, message)); lock.unlock() + } + var count: Int { lock.lock(); defer { lock.unlock() }; return stored.count } + } + + func testLogHandlerReceivesNativeMessages() throws { + let (path, pcm) = try Fixtures.modelAndAudio() + let session = try Model(path: path).session() + _ = try session.run(pcm) + let box = LogBox() + Transcribe.setLogHandler { level, message in box.append(level, message) } + defer { Transcribe.disableLogging() } + // printTimings publishes through the sink at INFO (same path Python's + // log-routing test uses). + session.printTimings() + XCTAssertGreaterThan(box.count, 0, "expected printTimings output to route to the handler") + } + + func testDisableLoggingIsSafe() { + Transcribe.setLogHandler { _, _ in } + Transcribe.disableLogging() + // Re-disabling and operating afterwards must not crash. + Transcribe.disableLogging() + XCTAssertGreaterThanOrEqual(Transcribe.devices().count, 1) + } + + /// The native sink is startup-only (C contract): repeated set/disable must + /// swap the Swift handler behind a SINGLE installed trampoline, never + /// re-call `transcribe_log_set`. The count is process-cumulative, so the + /// invariant is "at most once" regardless of what other tests did first. + func testNativeLogSetInstalledAtMostOnce() { + Transcribe.setLogHandler { _, _ in } + Transcribe.disableLogging() + Transcribe.setLogHandler { _, _ in } + Transcribe.disableLogging() + XCTAssertLessThanOrEqual( + Transcribe.nativeLogSetCount, 1, "transcribe_log_set must be installed at most once") + } +} diff --git a/bindings/swift/Tests/TranscribeCppTests/NoModelTests.swift b/bindings/swift/Tests/TranscribeCppTests/NoModelTests.swift new file mode 100644 index 00000000..ed083a62 --- /dev/null +++ b/bindings/swift/Tests/TranscribeCppTests/NoModelTests.swift @@ -0,0 +1,88 @@ +import Foundation +import XCTest + +@testable import TranscribeCpp + +/// No-model tier (requirements §4): import/link, version gate, ABI liveness, +/// and backend discovery. These run always — no canary GGUFs required — and are +/// the Swift analogs of Rust's `no_model.rs` and Python's `test_abi.py` / +/// `test_backends.py`. +final class NoModelTests: XCTestCase { + func testNativeVersionPresent() { + XCTAssertFalse(Transcribe.version().isEmpty) + } + + func testVersionGateAgrees() throws { + // The linked library and the binding agree on the base version. + try Transcribe.ensureCompatible() + XCTAssertEqual(baseVersion(Transcribe.version()), baseVersion(Transcribe.compiledVersion)) + } + + func testAbiStructSizesAreLive() { + // A real layout is non-zero; a garbage/empty one would be 0. + for s in [AbiStruct.runParams, .capabilities, .segment, .sessionLimits] { + XCTAssertGreaterThan(Transcribe.abiStructSize(s), 0, "\(s)") + } + } + + func testHeaderHashIsPinned() { + // 16 hex chars (sha256/16). The value-vs-header drift is gated in CI by + // scripts/ci/swift_abihash_check.py; here we just assert the shape. + let hash = Transcribe.headerHash() + XCTAssertEqual(hash.count, 16) + XCTAssertTrue(hash.allSatisfy { $0.isHexDigit }) + } + + func testStatusStringIsActionable() { + XCTAssertEqual(Transcribe.statusString(0), "ok") + XCTAssertFalse(Transcribe.statusString(3).isEmpty) // ERR_FILE_NOT_FOUND + } + + func testAtLeastOneDevice() { + XCTAssertGreaterThanOrEqual(Transcribe.devices().count, 1) + } + + func testCpuIsAlwaysAvailable() { + XCTAssertTrue(Transcribe.backendAvailable(.cpu)) + XCTAssertTrue(Transcribe.backendAvailable(.auto)) + } + + func testCpuDeviceIsRegistered() { + XCTAssertTrue(Transcribe.devices().contains { $0.kind == "cpu" }) + } + + // Error-mapping integration (no canary needed): the two load-failure + // classes map to distinct cases. Mirrors Rust's no_model error tests and + // Python's test_errors.py integration cases. + func testMissingFileIsModelFileNotFound() { + XCTAssertThrowsError(try Model(path: "/no/such/transcribe-model.gguf")) { error in + guard case TranscribeError.modelFileNotFound = error else { + return XCTFail("expected .modelFileNotFound, got \(error)") + } + } + } + + // The run-mode enum is `TranscriptionTask` (renamed off `Task` so it does + // not shadow Swift's concurrency `Task`). Lock the public name + `task:` + // option here so an accidental rename is caught without a model. + func testTranscriptionTaskOptionRoundTrips() { + let translate = RunOptions(task: .translate) + guard case .translate = translate.task else { + return XCTFail("task option did not round-trip to .translate") + } + let task: TranscriptionTask = .transcribe + guard case .transcribe = task else { return XCTFail("TranscriptionTask.transcribe") } + } + + func testJunkFileIsModelLoadError() throws { + let junk = FileManager.default.temporaryDirectory + .appendingPathComponent("junk-\(UUID().uuidString).gguf") + try Data("not a gguf".utf8).write(to: junk) + defer { try? FileManager.default.removeItem(at: junk) } + XCTAssertThrowsError(try Model(path: junk.path)) { error in + guard case TranscribeError.modelLoad = error else { + return XCTFail("expected .modelLoad, got \(error)") + } + } + } +} diff --git a/bindings/swift/Tests/TranscribeCppTests/StreamingTests.swift b/bindings/swift/Tests/TranscribeCppTests/StreamingTests.swift new file mode 100644 index 00000000..1d224c2c --- /dev/null +++ b/bindings/swift/Tests/TranscribeCppTests/StreamingTests.swift @@ -0,0 +1,169 @@ +import XCTest + +@testable import TranscribeCpp + +/// Streaming tier (moonshine-streaming canary). Mirrors Rust's `streaming.rs` +/// and Python's `test_streaming.py`. +final class StreamingTests: XCTestCase { + func testStreamsJfkCommittedText() throws { + let (path, pcm) = try Fixtures.streamingModelAndAudio() + let session = try Model(path: path).session() + let stream = try session.stream() + try Fixtures.drive(stream, pcm: pcm) + XCTAssertTrue(stream.text.full.lowercased().contains("country"), stream.text.full) + } + + func testOnFinalizePolicyCommitsAtFinalize() throws { + let (path, pcm) = try Fixtures.streamingModelAndAudio() + let session = try Model(path: path).session() + let stream = try session.stream(.init(), StreamOptions(commitPolicy: .onFinalize)) + // Feed everything WITHOUT finalizing: committed stays empty under ON_FINALIZE. + var i = 0 + while i < pcm.count { + let end = min(i + 1600, pcm.count) + _ = try stream.feed(Array(pcm[i.. .busy. + XCTAssertThrowsError(try s2.stream()) { error in + guard case TranscribeError.busy = error else { + return XCTFail("expected .busy for a second stream, got \(error)") + } + } + // An offline run on the same model while a stream is live -> .busy too. + XCTAssertThrowsError(try s2.run(pcm)) { error in + guard case TranscribeError.busy = error else { + return XCTFail("expected .busy for a run mid-stream, got \(error)") + } + } + // runBatch is gated on the same lease. + XCTAssertThrowsError(try s2.runBatch([pcm])) { error in + guard case TranscribeError.busy = error else { + return XCTFail("expected .busy for a runBatch mid-stream, got \(error)") + } + } + + // Releasing the first stream frees the lease; s2 can now stream. + stream1.reset() + XCTAssertFalse(model.streamActive) + let stream2 = try s2.stream() + stream2.reset() + } + + /// 2b: a `Stream` dropped without `finalize()`/`reset()` must reset the + /// session and release the model's compute lease in `deinit` — otherwise + /// the session is wedged ACTIVE forever and the model stays `.busy`. + func testDroppedActiveStreamReleasesLeaseAndResetsSession() throws { + let (path, pcm) = try Fixtures.streamingModelAndAudio() + let model = try Model(path: path) + let session = try model.session() + do { + let stream = try session.stream() + _ = try stream.feed(Array(pcm.prefix(1600))) // ACTIVE, holds the lease + XCTAssertTrue(model.streamActive) + // stream is dropped at the end of this scope WITHOUT finalize/reset + } + XCTAssertFalse(model.streamActive, "a dropped active stream must release the model lease") + // The session is reusable: a fresh stream begins (would throw if the + // session were still ACTIVE). + let again = try session.stream() + XCTAssertEqual(again.state, .active) + again.reset() + } + + /// The lease tracks "a stream is ACTIVE", not "a Stream handle exists": + /// after finalize() or reset() another session may proceed WITHOUT waiting + /// for the first Stream to drop. Mirrors Rust's + /// `compute_lease_frees_at_finalize_and_reset`. + func testFinalizeAndResetReleaseLeaseBeforeDrop() throws { + let (path, pcm) = try Fixtures.streamingModelAndAudio() + let model = try Model(path: path) + let s1 = try model.session() + let s2 = try model.session() + let chunk = Array(pcm.prefix(1600)) + + // finalize() frees the lease even while stream1 is still in scope. + do { + let stream1 = try s1.stream() + _ = try stream1.feed(chunk) + try stream1.finalize() + XCTAssertFalse(model.streamActive) + let stream2 = try s2.stream() // would throw .busy if the lease leaked + stream2.reset() + _ = stream1 // keep stream1 alive past s2.stream() to prove finalize freed it + } + // reset() frees the lease even while stream1 is still in scope. + do { + let stream1 = try s1.stream() + _ = try stream1.feed(chunk) + stream1.reset() + let stream2 = try s2.stream() + stream2.reset() + _ = stream1 + } + } +} diff --git a/bindings/swift/Tests/TranscribeCppTests/TestSupport.swift b/bindings/swift/Tests/TranscribeCppTests/TestSupport.swift new file mode 100644 index 00000000..34dd2e15 --- /dev/null +++ b/bindings/swift/Tests/TranscribeCppTests/TestSupport.swift @@ -0,0 +1,149 @@ +import Foundation +import XCTest + +@testable import TranscribeCpp + +/// Shared model-gated test fixtures. Resolves the canary model + audio from the +/// `TRANSCRIBE_SMOKE_*` env vars (the same names Rust uses) or the in-repo +/// defaults, and skips cleanly (XCTSkip) when neither is present — the model +/// tier of the two-tier scheme (requirements §4). +enum Fixtures { + static func repoRoot() -> URL { + var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0..<10 { + if FileManager.default.fileExists( + atPath: dir.appendingPathComponent("include/transcribe.h").path) { + return dir + } + dir = dir.deletingLastPathComponent() + } + return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + } + + private static func env(_ key: String) -> String? { + let value = ProcessInfo.processInfo.environment[key] + return (value?.isEmpty == false) ? value : nil + } + + static func modelPath() -> String? { + if let override = env("TRANSCRIBE_SMOKE_MODEL") { return override } + let path = repoRoot() + .appendingPathComponent("models/whisper-tiny.en/whisper-tiny.en-Q5_K_M.gguf").path + return FileManager.default.fileExists(atPath: path) ? path : nil + } + + static func audioPath() -> String? { + if let override = env("TRANSCRIBE_SMOKE_AUDIO") { return override } + let path = repoRoot().appendingPathComponent("samples/jfk.wav").path + return FileManager.default.fileExists(atPath: path) ? path : nil + } + + static func streamingModelPath() -> String? { + if let override = env("TRANSCRIBE_SMOKE_STREAMING_MODEL") { return override } + let path = repoRoot() + .appendingPathComponent( + "models/moonshine-streaming-tiny/moonshine-streaming-tiny-Q8_0.gguf").path + return FileManager.default.fileExists(atPath: path) ? path : nil + } + + // Extra family-extension canaries — not in the CI fetch-canary set, so these + // gate on their own env var / in-repo GGUF and XCTSkip when absent (they run + // locally where the GGUFs exist; CI skips them). + private static func familyModel(_ envKey: String, _ relativePath: String) -> String? { + if let override = env(envKey) { return override } + let path = repoRoot().appendingPathComponent(relativePath).path + return FileManager.default.fileExists(atPath: path) ? path : nil + } + + /// Parakeet cache-aware streaming (accepts PARAKEET_STREAM). + static func parakeetStreamModelPath() -> String? { + familyModel( + "TRANSCRIBE_SMOKE_PARAKEET_STREAM_MODEL", + "models/nemotron-speech-streaming-en-0.6b/nemotron-speech-streaming-en-0.6b-Q8_0.gguf") + } + /// Parakeet chunked/buffered streaming (accepts PARAKEET_BUFFERED_STREAM). + static func parakeetBufferedModelPath() -> String? { + familyModel( + "TRANSCRIBE_SMOKE_PARAKEET_BUFFERED_MODEL", + "models/parakeet-unified-en-0.6b/parakeet-unified-en-0.6b-Q8_0.gguf") + } + /// Voxtral realtime streaming (accepts VOXTRAL_REALTIME_STREAM). + static func voxtralRealtimeModelPath() -> String? { + familyModel( + "TRANSCRIBE_SMOKE_VOXTRAL_MODEL", + "models/Voxtral-Mini-4B-Realtime-2602/Voxtral-Mini-4B-Realtime-2602-Q4_K_M.gguf") + } + + /// The model path + decoded PCM, or `XCTSkip` when either is absent. + static func modelAndAudio() throws -> (model: String, pcm: [Float]) { + guard let model = modelPath() else { + throw XCTSkip("no canary model (set TRANSCRIBE_SMOKE_MODEL)") + } + guard let audio = audioPath() else { + throw XCTSkip("no canary audio (set TRANSCRIBE_SMOKE_AUDIO)") + } + return (model, try loadWav(audio)) + } + + static func streamingModelAndAudio() throws -> (model: String, pcm: [Float]) { + guard let model = streamingModelPath() else { + throw XCTSkip("no streaming canary (set TRANSCRIBE_SMOKE_STREAMING_MODEL)") + } + guard let audio = audioPath() else { + throw XCTSkip("no canary audio (set TRANSCRIBE_SMOKE_AUDIO)") + } + return (model, try loadWav(audio)) + } + + /// Feed `pcm` to an active stream in fixed chunks, then finalize. + @discardableResult + static func drive( + _ stream: TranscribeCpp.Stream, pcm: [Float], chunk: Int = 1600 + ) throws -> StreamUpdate { + var i = 0 + while i < pcm.count { + let end = min(i + chunk, pcm.count) + _ = try stream.feed(Array(pcm[i.. [Float] { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + func u32(_ offset: Int) -> Int { + Int(data[offset]) | Int(data[offset + 1]) << 8 + | Int(data[offset + 2]) << 16 | Int(data[offset + 3]) << 24 + } + // Walk chunks after the 12-byte "RIFF....WAVE" header to find "data". + var offset = 12 + var dataStart = -1 + var dataLength = 0 + while offset + 8 <= data.count { + let id = String(bytes: data[offset..= 0 else { + throw NSError(domain: "TestSupport", code: 1, + userInfo: [NSLocalizedDescriptionKey: "no data chunk in \(path)"]) + } + let end = min(dataStart + dataLength, data.count) + var samples: [Float] = [] + samples.reserveCapacity((end - dataStart) / 2) + var i = dataStart + while i + 1 < end { + let raw = Int16(bitPattern: UInt16(data[i]) | (UInt16(data[i + 1]) << 8)) + samples.append(Float(raw) / 32768.0) + i += 2 + } + return samples + } +} diff --git a/bindings/swift/Tests/TranscribeCppTests/TranscribeTests.swift b/bindings/swift/Tests/TranscribeCppTests/TranscribeTests.swift new file mode 100644 index 00000000..69510255 --- /dev/null +++ b/bindings/swift/Tests/TranscribeCppTests/TranscribeTests.swift @@ -0,0 +1,144 @@ +import XCTest + +@testable import TranscribeCpp + +/// Model-gated tier (requirements §4): real transcription against the canary. +/// Mirrors Rust's `transcribe.rs` and Python's `test_transcribe.py`. Each test +/// skips cleanly when the canary fixtures are absent. +final class TranscribeTests: XCTestCase { + func testTranscribesJfkWithText() throws { + let (path, pcm) = try Fixtures.modelAndAudio() + let model = try Model(path: path) + let transcript = try model.session().run(pcm) + XCTAssertTrue(transcript.text.lowercased().contains("country"), transcript.text) + } + + func testRequestedTimestampsPopulateSegments() throws { + let (path, pcm) = try Fixtures.modelAndAudio() + let model = try Model(path: path) + let transcript = try model.session().run(pcm, options: RunOptions(timestamps: .segment)) + XCTAssertFalse(transcript.segments.isEmpty) + XCTAssertNotEqual(transcript.timestampKind, .none) + } + + func testFinerThanSupportedTimestampsIsUnsupported() throws { + let (path, pcm) = try Fixtures.modelAndAudio() + let model = try Model(path: path) + guard let finer = finerThanSupported(model.capabilities.maxTimestampKind) else { + throw XCTSkip("model already supports the finest timestamps") + } + let session = try model.session() + XCTAssertThrowsError(try session.run(pcm, options: RunOptions(timestamps: finer))) { error in + guard case TranscribeError.unsupported = error else { + return XCTFail("expected .unsupported, got \(error)") + } + } + } + + func testEmptyPcmIsInvalidArgument() throws { + guard let path = Fixtures.modelPath() else { throw XCTSkip("no canary model") } + let session = try Model(path: path).session() + XCTAssertThrowsError(try session.run([])) { error in + guard case TranscribeError.invalidArgument = error else { + return XCTFail("expected .invalidArgument, got \(error)") + } + } + } + + func testRunBatchTwoUtterances() throws { + let (path, pcm) = try Fixtures.modelAndAudio() + let model = try Model(path: path) + let results = try model.session().runBatch([pcm, pcm]) + XCTAssertEqual(results.count, 2) + for result in results { + let transcript = try result.get() + XCTAssertTrue(transcript.text.lowercased().contains("country"), transcript.text) + } + } + + func testCapabilitiesAndIdentity() throws { + guard let path = Fixtures.modelPath() else { throw XCTSkip("no canary model") } + let model = try Model(path: path) + XCTAssertFalse(model.arch.isEmpty) + XCTAssertFalse(model.backend.isEmpty) + XCTAssertGreaterThan(model.capabilities.nativeSampleRate, 0) + } + + func testSessionLimitsAreSane() throws { + guard let path = Fixtures.modelPath() else { throw XCTSkip("no canary model") } + let limits = try Model(path: path).session().limits + XCTAssertGreaterThanOrEqual(limits.effectiveNCtx, 0) + XCTAssertGreaterThanOrEqual(limits.maxKvBytes, 0) + } + + func testOneModelManySessions() throws { + let (path, pcm) = try Fixtures.modelAndAudio() + let model = try Model(path: path) + for _ in 0..<2 { + let transcript = try model.session().run(pcm) + XCTAssertTrue(transcript.text.lowercased().contains("country")) + } + } + + func testCloseOrderingSessionOutlivesModelReference() throws { + let (path, pcm) = try Fixtures.modelAndAudio() + // Drop the local Model reference; the Session's strong ref must keep the + // native model alive (close-ordering safety under ARC). + let session: Session = try { + let model = try Model(path: path) + return try model.session() + }() + let transcript = try session.run(pcm) + XCTAssertTrue(transcript.text.lowercased().contains("country")) + } + + func testSharedModelAcrossThreadsSerializes() throws { + let (path, pcm) = try Fixtures.modelAndAudio() + let model = try Model(path: path) + let group = DispatchGroup() + let lock = NSLock() + var hits = 0 + for _ in 0..<2 { + group.enter() + DispatchQueue.global().async { + defer { group.leave() } + // Each thread uses its own session; the per-model lock serializes + // the actual compute. + if let transcript = try? model.session().run(pcm), + transcript.text.lowercased().contains("country") { + lock.lock(); hits += 1; lock.unlock() + } + } + } + group.wait() + XCTAssertEqual(hits, 2) + } + + func testAsyncRun() async throws { + let (path, pcm) = try Fixtures.modelAndAudio() + let model = try Model(path: path) + let transcript = try await model.session().run(pcm) + XCTAssertTrue(transcript.text.lowercased().contains("country"), transcript.text) + } + + func testAsyncRunBatch() async throws { + let (path, pcm) = try Fixtures.modelAndAudio() + let model = try Model(path: path) + let results = try await model.session().runBatch([pcm, pcm]) + XCTAssertEqual(results.count, 2) + for result in results { + XCTAssertTrue(try result.get().text.lowercased().contains("country")) + } + } +} + +/// The granularity strictly finer than `kind`, or nil if already finest. +/// Ordering for the run-params ceiling: none < segment < word < token. +private func finerThanSupported(_ kind: TimestampKind) -> TimestampKind? { + switch kind { + case .none: return .segment + case .segment: return .word + case .word: return .token + case .token, .auto: return nil + } +} diff --git a/scripts/ci/build_xcframework.sh b/scripts/ci/build_xcframework.sh new file mode 100755 index 00000000..849afa38 --- /dev/null +++ b/scripts/ci/build_xcframework.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# +# build_xcframework.sh — produce bindings/swift/build-apple/TranscribeCpp.xcframework +# +# The Swift binding consumes the native library as a prebuilt static +# `.xcframework` binaryTarget (notes/swift-bindings-plan.md; requirements §5). +# This script is the lane that produces it. Adapted from whisper.cpp's +# build-xcframework.sh, retargeted to transcribe.cpp's CMake tree and the +# project's per-slice backend posture. +# +# Slices and backends (decision: Metal only where ggml-metal is reliable): +# macos arm64(Metal) + x86_64(CPU-only) -> universal +# ios-device arm64(Metal) +# ios-sim arm64(CPU-only) + x86_64(CPU-only) -> universal +# +# Per (slice,arch) we build a static libtranscribe + ggml, MERGE all the +# archives into one (collision-safe — see below), then `lipo` arches within a +# slice and hand each slice to `xcodebuild -create-xcframework -library`. +# +# Collision-safe merge: our per-family CMake target emits many same-basename +# objects (model.cpp.o, encoder.cpp.o, ...). `libtool -static` DEDUPES archive +# members by basename and silently drops objects. We instead partial-link +# (`ld -r -all_load`) every archive into ONE relocatable object, then wrap that +# single object — no basename collisions possible. +# +# Usage: +# scripts/ci/build_xcframework.sh +# TRANSCRIBE_XCFRAMEWORK_SLICES="macos" scripts/ci/build_xcframework.sh # subset +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BUILD_ROOT="${REPO_ROOT}/tmp/xcframework" +OUT_DIR="${REPO_ROOT}/bindings/swift/build-apple" +XCFRAMEWORK="${OUT_DIR}/TranscribeCpp.xcframework" + +MACOS_MIN="${MACOS_MIN_OS_VERSION:-13.0}" +IOS_MIN="${IOS_MIN_OS_VERSION:-16.0}" + +SLICES="${TRANSCRIBE_XCFRAMEWORK_SLICES:-macos ios-device ios-sim}" + +# Prefer Ninja, fall back to Unix Makefiles. +if command -v ninja >/dev/null 2>&1; then + GENERATOR="Ninja" +else + GENERATOR="Unix Makefiles" +fi + +log() { printf '\n=== %s ===\n' "$*" >&2; } + +# Deterministic build directory for a (tag, arch) pair. Both build_arch and the +# slice loop derive the path from this — never capture it via stdout (cmake +# writes to stdout and would pollute the value). +arch_bdir() { printf '%s' "${BUILD_ROOT}/$1-$2"; } + +# build_arch +# system_name="" for macOS (host), "iOS" for iOS slices. +build_arch() { + local tag="$1" system_name="$2" sdk="$3" arch="$4" metal="$5" min="$6" + local bdir; bdir="$(arch_bdir "$tag" "$arch")" + rm -rf "$bdir" + + local extra=() + [[ -n "$system_name" ]] && extra+=(-DCMAKE_SYSTEM_NAME="$system_name") + if [[ "$metal" == "ON" ]]; then + extra+=(-DTRANSCRIBE_METAL=ON -DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON) + else + extra+=(-DTRANSCRIBE_METAL=OFF -DGGML_METAL=OFF) + fi + + log "configure ${tag}/${arch} (metal=${metal})" + cmake -B "$bdir" -S "$REPO_ROOT" -G "$GENERATOR" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_OSX_SYSROOT="$sdk" \ + -DCMAKE_OSX_ARCHITECTURES="$arch" \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="$min" \ + -DTRANSCRIBE_BUILD_SHARED=OFF \ + -DTRANSCRIBE_BUILD_TESTS=OFF \ + -DTRANSCRIBE_BUILD_EXAMPLES=OFF \ + -DTRANSCRIBE_BUILD_TOOLS=OFF \ + -DTRANSCRIBE_INSTALL=OFF \ + -DTRANSCRIBE_USE_OPENMP=OFF \ + -DGGML_OPENMP=OFF \ + -DGGML_NATIVE=OFF \ + "${extra[@]}" + + log "build ${tag}/${arch}" + cmake --build "$bdir" --target transcribe --config Release --parallel +} + +# merge_arch +# ld_platform: macos | ios | ios-simulator +merge_arch() { + local bdir="$1" arch="$2" ld_platform="$3" sdk="$4" min="$5" out="$6" + local sdkver + sdkver="$(xcrun --sdk "$sdk" --show-sdk-version)" + + # Every static archive the build produced (ggml-metal only when Metal was on). + local archives + archives="$(find "$bdir" \( -name 'libtranscribe.a' -o -name 'libggml*.a' \) | sort)" + + log "merge ${arch} (${ld_platform})" + # shellcheck disable=SC2086 + xcrun ld -r -arch "$arch" \ + -platform_version "$ld_platform" "$min" "$sdkver" \ + -all_load $archives \ + -o "${bdir}/combined.o" + libtool -static -o "$out" "${bdir}/combined.o" +} + +stage_headers() { + local hdr="$1" + rm -rf "$hdr" && mkdir -p "$hdr/transcribe" + cp "${REPO_ROOT}/include/transcribe.h" "$hdr/" + cp "${REPO_ROOT}/include/transcribe/"*.h "$hdr/transcribe/" + cat > "${hdr}/module.modulemap" <<'EOF' +module CTranscribe { + header "transcribe/extensions.h" + export * +} +EOF +} + +# ---- Build each requested slice into a single (possibly fat) static lib ---- +rm -rf "$BUILD_ROOT" "$XCFRAMEWORK" +mkdir -p "$BUILD_ROOT" "$OUT_DIR" +HEADERS="${BUILD_ROOT}/Headers" +stage_headers "$HEADERS" + +XCARGS=() + +for slice in $SLICES; do + case "$slice" in + # Each slice's final library MUST be named `libtranscribe.a` (SwiftPM + # derives a `-ltranscribe` flag from the static binaryTarget's filename) + # and live in its own directory so create-xcframework can take several. + macos) + sdir="${BUILD_ROOT}/slice-macos"; mkdir -p "$sdir" + build_arch macos "" macosx arm64 ON "$MACOS_MIN" + merge_arch "$(arch_bdir macos arm64)" arm64 macos macosx "$MACOS_MIN" "${BUILD_ROOT}/macos-arm64.a" + build_arch macos "" macosx x86_64 OFF "$MACOS_MIN" + merge_arch "$(arch_bdir macos x86_64)" x86_64 macos macosx "$MACOS_MIN" "${BUILD_ROOT}/macos-x86_64.a" + lipo -create "${BUILD_ROOT}/macos-arm64.a" "${BUILD_ROOT}/macos-x86_64.a" \ + -output "${sdir}/libtranscribe.a" + XCARGS+=(-library "${sdir}/libtranscribe.a" -headers "$HEADERS") + ;; + ios-device) + sdir="${BUILD_ROOT}/slice-ios-device"; mkdir -p "$sdir" + build_arch ios-device iOS iphoneos arm64 ON "$IOS_MIN" + merge_arch "$(arch_bdir ios-device arm64)" arm64 ios iphoneos "$IOS_MIN" "${sdir}/libtranscribe.a" + XCARGS+=(-library "${sdir}/libtranscribe.a" -headers "$HEADERS") + ;; + ios-sim) + sdir="${BUILD_ROOT}/slice-ios-sim"; mkdir -p "$sdir" + build_arch ios-sim iOS iphonesimulator arm64 OFF "$IOS_MIN" + merge_arch "$(arch_bdir ios-sim arm64)" arm64 ios-simulator iphonesimulator "$IOS_MIN" "${BUILD_ROOT}/ios-sim-arm64.a" + build_arch ios-sim iOS iphonesimulator x86_64 OFF "$IOS_MIN" + merge_arch "$(arch_bdir ios-sim x86_64)" x86_64 ios-simulator iphonesimulator "$IOS_MIN" "${BUILD_ROOT}/ios-sim-x86_64.a" + lipo -create "${BUILD_ROOT}/ios-sim-arm64.a" "${BUILD_ROOT}/ios-sim-x86_64.a" \ + -output "${sdir}/libtranscribe.a" + XCARGS+=(-library "${sdir}/libtranscribe.a" -headers "$HEADERS") + ;; + *) + echo "unknown slice: $slice" >&2; exit 2 ;; + esac +done + +log "create xcframework" +xcodebuild -create-xcframework "${XCARGS[@]}" -output "$XCFRAMEWORK" + +log "done -> $XCFRAMEWORK" +find "$XCFRAMEWORK" -maxdepth 1 -mindepth 1 -type d | sort >&2 diff --git a/scripts/ci/package_xcframework.sh b/scripts/ci/package_xcframework.sh new file mode 100755 index 00000000..fbcff47d --- /dev/null +++ b/scripts/ci/package_xcframework.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# +# package_xcframework.sh — zip the built TranscribeCpp.xcframework for release +# and print its SwiftPM binaryTarget checksum. +# +# The release flow (publish.yml, tag-gated) uses this after +# build_xcframework.sh: the zip is uploaded as a GitHub release asset and the +# printed checksum goes into the mirror repo's Package.swift +# `binaryTarget(url:checksum:)`. "Releases are cut from CI, never a laptop" +# (requirements §5). +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OUT_DIR="${REPO_ROOT}/bindings/swift/build-apple" +XCFRAMEWORK="${OUT_DIR}/TranscribeCpp.xcframework" +ZIP="${OUT_DIR}/TranscribeCpp.xcframework.zip" + +if [[ ! -d "$XCFRAMEWORK" ]]; then + echo "error: $XCFRAMEWORK not found — run scripts/ci/build_xcframework.sh first" >&2 + exit 1 +fi + +# Bundle the third-party + project license texts inside the artifact so they +# travel with the binaryTarget zip (requirements §5: vendored native code ships +# its license texts). +cp "${REPO_ROOT}/LICENSE" "${XCFRAMEWORK}/LICENSE" +cp "${REPO_ROOT}/ggml/LICENSE" "${XCFRAMEWORK}/LICENSE.ggml" + +# Deterministic zip from the output dir (store the path as +# "TranscribeCpp.xcframework/..." so SwiftPM unpacks it correctly). +rm -f "$ZIP" +( cd "$OUT_DIR" && /usr/bin/zip -qr -X "$(basename "$ZIP")" "$(basename "$XCFRAMEWORK")" ) + +echo "zip: $ZIP" +echo "size: $(du -h "$ZIP" | cut -f1)" +echo -n "checksum: " +swift package --package-path "${REPO_ROOT}/bindings/swift" compute-checksum "$ZIP" diff --git a/scripts/ci/swift_abihash_check.py b/scripts/ci/swift_abihash_check.py new file mode 100644 index 00000000..94f9368f --- /dev/null +++ b/scripts/ci/swift_abihash_check.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Fail if the Swift binding's pinned public-ABI hash drifts from the header. + +The Swift binding does not generate an FFI layer (the Clang importer reads the C +headers directly), so the drift gate (notes/bindings-requirements.md §2) is a +PINNED constant — ``Transcribe.pinnedHeaderHash`` in +``bindings/swift/Sources/TranscribeCpp/ABIHash.swift`` — checked here against the +neutral ``include/transcribe.abihash`` emitted by the Python generator (the hash +oracle). When the header's ABI changes the neutral hash moves, this check goes +red, and a maintainer bumps the pinned constant after consciously reviewing the +change and auditing the wrapper. + + uv run --no-project scripts/ci/swift_abihash_check.py + +Exit 0 when they agree; 1 on drift; 2 if either value could not be located. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +ABIHASH_FILE = REPO / "include" / "transcribe.abihash" +PIN_FILE = REPO / "bindings" / "swift" / "Sources" / "TranscribeCpp" / "ABIHash.swift" + + +def main() -> int: + if not ABIHASH_FILE.exists(): + print(f"error: missing {ABIHASH_FILE}", file=sys.stderr) + return 2 + if not PIN_FILE.exists(): + print(f"error: missing {PIN_FILE}", file=sys.stderr) + return 2 + + neutral = ABIHASH_FILE.read_text().strip() + m = re.search(r'pinnedHeaderHash\s*=\s*"([0-9a-fA-F]+)"', PIN_FILE.read_text()) + if not m: + print(f"error: could not find pinnedHeaderHash in {PIN_FILE}", file=sys.stderr) + return 2 + pinned = m.group(1) + + if pinned != neutral: + print( + "Swift ABI-hash drift: the public header ABI changed.\n" + f" include/transcribe.abihash : {neutral}\n" + f" ABIHash.swift (pinned) : {pinned}\n" + "Review the header change, audit the wrapper for new/changed structs," + " enums, or entry points, then update pinnedHeaderHash.", + file=sys.stderr, + ) + return 1 + + print(f"swift abihash ok: {neutral}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..cd8e4bab --- /dev/null +++ b/uv.lock @@ -0,0 +1,6 @@ +version = 1 +requires-python = ">=3.9" + +[[package]] +name = "transcribe-cpp-native" +source = { virtual = "." }