From 7c3b5e519e6b0e2bcb711e00f2e1e1a2cc0b8ebf Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Tue, 16 Jun 2026 14:06:58 +0800 Subject: [PATCH 1/8] swift bindings --- .github/actions/fetch-canary/action.yml | 35 ++- .github/workflows/publish.yml | 66 ++++++ .github/workflows/swift-ci.yml | 118 ++++++++++ bindings/python/tests/conftest.py | 45 ++++ bindings/python/tests/test_family_ext.py | 70 ++++++ .../rust/transcribe-cpp/tests/common/mod.rs | 35 +++ bindings/rust/transcribe-cpp/tests/family.rs | 133 +++++++++++ bindings/swift/.gitignore | 4 + bindings/swift/Package.swift | 70 +++++- bindings/swift/README.md | 141 ++++++++++++ .../ExampleSupport/ExampleSupport.swift | 88 ++++++++ .../swift/Sources/TranscribeCpp/ABIHash.swift | 20 ++ .../swift/Sources/TranscribeCpp/Backend.swift | 71 ++++++ .../Sources/TranscribeCpp/Cancellation.swift | 40 ++++ .../Sources/TranscribeCpp/Convenience.swift | 15 ++ .../swift/Sources/TranscribeCpp/Family.swift | 172 ++++++++++++++ .../swift/Sources/TranscribeCpp/Logging.swift | 57 +++++ .../swift/Sources/TranscribeCpp/Model.swift | 81 +++++++ .../swift/Sources/TranscribeCpp/Options.swift | 176 +++++++++++++++ .../swift/Sources/TranscribeCpp/Session.swift | 211 ++++++++++++++++++ .../Sources/TranscribeCpp/Streaming.swift | 166 ++++++++++++++ .../Sources/TranscribeCpp/TranscribeCpp.swift | 99 +++++++- .../TranscribeCpp/TranscribeError.swift | 71 ++++++ .../Sources/TranscribeCpp/Transcript.swift | 128 +++++++++++ .../swift/Sources/backend-select/main.swift | 31 +++ bindings/swift/Sources/batch/main.swift | 22 ++ .../swift/Sources/error-handling/main.swift | 46 ++++ bindings/swift/Sources/streaming/main.swift | 28 +++ .../swift/Sources/transcribe-file/main.swift | 22 ++ bindings/swift/THIRD-PARTY-LICENSES.md | 36 +++ .../TranscribeCppTests/CancelTests.swift | 56 +++++ .../TranscribeCppTests/ExtensionTests.swift | 43 ++++ .../FamilyStreamTests.swift | 72 ++++++ .../TranscribeCppTests/LoggingTests.swift | 38 ++++ .../TranscribeCppTests/NoModelTests.swift | 76 +++++++ .../TranscribeCppTests/StreamingTests.swift | 72 ++++++ .../TranscribeCppTests/TestSupport.swift | 149 +++++++++++++ .../TranscribeCppTests/TranscribeTests.swift | 134 +++++++++++ scripts/ci/build_xcframework.sh | 172 ++++++++++++++ scripts/ci/package_xcframework.sh | 38 ++++ scripts/ci/swift_abihash_check.py | 60 +++++ uv.lock | 6 + 42 files changed, 3190 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/swift-ci.yml create mode 100644 bindings/rust/transcribe-cpp/tests/family.rs create mode 100644 bindings/swift/README.md create mode 100644 bindings/swift/Sources/ExampleSupport/ExampleSupport.swift create mode 100644 bindings/swift/Sources/TranscribeCpp/ABIHash.swift create mode 100644 bindings/swift/Sources/TranscribeCpp/Backend.swift create mode 100644 bindings/swift/Sources/TranscribeCpp/Cancellation.swift create mode 100644 bindings/swift/Sources/TranscribeCpp/Convenience.swift create mode 100644 bindings/swift/Sources/TranscribeCpp/Family.swift create mode 100644 bindings/swift/Sources/TranscribeCpp/Logging.swift create mode 100644 bindings/swift/Sources/TranscribeCpp/Model.swift create mode 100644 bindings/swift/Sources/TranscribeCpp/Options.swift create mode 100644 bindings/swift/Sources/TranscribeCpp/Session.swift create mode 100644 bindings/swift/Sources/TranscribeCpp/Streaming.swift create mode 100644 bindings/swift/Sources/TranscribeCpp/TranscribeError.swift create mode 100644 bindings/swift/Sources/TranscribeCpp/Transcript.swift create mode 100644 bindings/swift/Sources/backend-select/main.swift create mode 100644 bindings/swift/Sources/batch/main.swift create mode 100644 bindings/swift/Sources/error-handling/main.swift create mode 100644 bindings/swift/Sources/streaming/main.swift create mode 100644 bindings/swift/Sources/transcribe-file/main.swift create mode 100644 bindings/swift/THIRD-PARTY-LICENSES.md create mode 100644 bindings/swift/Tests/TranscribeCppTests/CancelTests.swift create mode 100644 bindings/swift/Tests/TranscribeCppTests/ExtensionTests.swift create mode 100644 bindings/swift/Tests/TranscribeCppTests/FamilyStreamTests.swift create mode 100644 bindings/swift/Tests/TranscribeCppTests/LoggingTests.swift create mode 100644 bindings/swift/Tests/TranscribeCppTests/NoModelTests.swift create mode 100644 bindings/swift/Tests/TranscribeCppTests/StreamingTests.swift create mode 100644 bindings/swift/Tests/TranscribeCppTests/TestSupport.swift create mode 100644 bindings/swift/Tests/TranscribeCppTests/TranscribeTests.swift create mode 100755 scripts/ci/build_xcframework.sh create mode 100755 scripts/ci/package_xcframework.sh create mode 100644 scripts/ci/swift_abihash_check.py create mode 100644 uv.lock 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..92daa74b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -272,3 +272,69 @@ 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') + 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: | + gh release upload "${GITHUB_REF_NAME}" \ + bindings/swift/build-apple/TranscribeCpp.xcframework.zip --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. diff --git a/.github/workflows/swift-ci.yml b/.github/workflows/swift-ci.yml new file mode 100644 index 00000000..36ac6730 --- /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: ubuntu-latest + 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: macos-15 + 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: macos-15 + 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/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/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..65513219 --- /dev/null +++ b/bindings/rust/transcribe-cpp/tests/family.rs @@ -0,0 +1,133 @@ +//! 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..3d873517 --- /dev/null +++ b/bindings/swift/README.md @@ -0,0 +1,141 @@ +# TranscribeCpp — Swift bindings for transcribe.cpp + +Idiomatic Swift bindings for [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp), +a C/C++ speech-to-text library. Native code ships as a prebuilt, Metal-embedded +`.xcframework` consumed as a SwiftPM `binaryTarget` — no C toolchain, no build +step on the consumer side. + +> Status: in development (0.0.1). Every **core** capability is implemented and +> tested; see `notes/bindings-requirements.md` for the matrix. + +## Install + +Swift Package Manager — add the dependency in Xcode (File → Add Package +Dependencies) or in your `Package.swift`. Apple platforms only: **macOS 13+, +iOS 16+** (device + simulator). + +**Option A — the package (recommended).** Add the dependency and the +`"TranscribeCpp"` product: + +```swift +.package(url: "https://github.com/handy-computer/transcribe-cpp-swift.git", from: "0.0.1") +``` + +**Option B — point directly at the prebuilt xcframework.** No extra package +needed: drop a `binaryTarget(url:checksum:)` into your own `Package.swift`, +pointing at the release asset (the checksum is published with each release). +This is the same pattern whisper.cpp documents. + +```swift +let package = Package( + name: "MyApp", + targets: [ + .executableTarget(name: "MyApp", dependencies: ["CTranscribe"]), + .binaryTarget( + name: "CTranscribe", + url: "https://github.com/handy-computer/transcribe.cpp/releases/download/v0.0.1/TranscribeCpp.xcframework.zip", + checksum: "" + ), + ] +) +``` + +Option B gives you the raw C module (`import CTranscribe`); Option A adds the +idiomatic Swift wrapper (`import TranscribeCpp`) on top of it. + +## 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)") +} +``` + +Async (hops the blocking call off the calling thread/actor): + +```swift +let transcript = try await session.run(pcm) +``` + +Streaming: + +```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) } // flicker-free prefix +} +try stream.finalize() +``` + +See `Sources/{transcribe-file,streaming,batch,backend-select,error-handling}` +for the five runnable examples — the same set, with the same names, every +first-class binding ships. + +## Backend selection + +Backends are compiled into the xcframework per Apple slice: + +| Slice | Backend | +| ------------------------ | ------------- | +| macOS arm64 | Metal + CPU | +| macOS x86_64 (Intel) | CPU only | +| iOS device (arm64) | Metal + CPU | +| iOS simulator | CPU only | + +`Metal` on Intel Macs and the iOS simulator is intentionally omitted — +ggml's Metal backend is tuned for Apple-GPU families; those targets run on CPU. +Request a backend explicitly with `ModelOptions(backend:)`; an unsatisfiable +request fails cleanly from the load path. Probe first with +`Transcribe.backendAvailable(_:)` / `Transcribe.devices()`. + +## Thread-safety contract + +- **`Model`** is `Sendable` (`@unchecked`): share it across threads freely. + Queries and session creation are concurrent; the compute path + (`run`/`runBatch`/stream feed) is serialized by an internal per-model lock, + matching the C library's "one in-flight run per model" rule. Concurrent runs + from many threads queue and execute one at a time. +- **`Session`** is single-threaded: use one from one thread at a time (it may + move between threads if never used concurrently). For parallel transcription, + use one `Session` per thread off a shared `Model`. +- **Callbacks** (cancellation, logging) may fire on native worker threads and + are thread-safe. `Transcribe.setLogHandler` should be called once at startup. +- Resource cleanup is automatic under ARC (`deinit` frees the handles); a + `Session`'s strong reference to its `Model` keeps close ordering safe. + +## Objective-C and C++ consumers + +The Swift wrapper is Swift-native (value-type results, typed `throws`, +associated-value errors) and does not bridge to Objective-C. ObjC and C++ +callers use the **C API directly** — the xcframework bundles the public headers, +so `#import ` exposes the full C surface. +One artifact serves both audiences. + +## ABI verification + +The per-field struct-layout check that the ctypes (Python) binding performs at +load time is **waived** here: Swift's Clang importer reads the C headers with a +real compiler, so struct layout is correct by construction. What remains: + +- A **load-time base-version gate** (`Transcribe.ensureCompatible`, run on every + `Model.load`): the linked library and this package must agree on the base + `MAJOR.MINOR.PATCH`. +- A **public-ABI drift gate** in CI: a pinned hash + (`Transcribe.pinnedHeaderHash`) is checked against `include/transcribe.abihash` + so a header ABI change turns the build red for conscious review. + +## Native artifact notes + +The macOS slices contain the same native bytes as the project's desktop +distribution. The **iOS slices are a separate cross-compile** with no desktop +twin — they share the source and the version, but not the byte-for-byte +artifact. 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..5957af4c --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/Logging.swift @@ -0,0 +1,57 @@ +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. +private final class LogState: @unchecked Sendable { + static let shared = LogState() + private let lock = NSLock() + private var handler: (@Sendable (LogLevel, String) -> Void)? + + func set(_ h: (@Sendable (LogLevel, String) -> Void)?) { + lock.lock(); handler = h; lock.unlock() + } + 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`. + /// Per the C contract, call once at process startup, before loading models + /// or creating sessions. The handler may be invoked from any thread. + public static func setLogHandler(_ handler: @escaping @Sendable (LogLevel, String) -> Void) { + LogState.shared.set(handler) + transcribe_log_set(logTrampoline, nil) + } + + /// Disable logging entirely (library and ggml messages are dropped). + public static func disableLogging() { + LogState.shared.set(nil) + transcribe_log_set(nil, nil) + } +} diff --git a/bindings/swift/Sources/TranscribeCpp/Model.swift b/bindings/swift/Sources/TranscribeCpp/Model.swift new file mode 100644 index 00000000..63dca037 --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/Model.swift @@ -0,0 +1,81 @@ +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 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. + let runLock = NSLock() + + /// 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: Task = .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..0e6c043a --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/Session.swift @@ -0,0 +1,211 @@ +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() } + 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() } + 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 withCheckedThrowingContinuation { continuation in + DispatchQueue.global().async { + continuation.resume(with: Result { try this.run(pcm, options: options) }) + } + } + } + + /// `runBatch` hopped off the caller's thread/actor onto a background queue. + public func runBatch( + _ inputs: [[Float]], options: RunOptions = .init() + ) async throws -> [Result] { + nonisolated(unsafe) let this = self + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global().async { + continuation.resume(with: Result { try this.runBatch(inputs, options: options) }) + } + } + } + + // 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..3d31de89 --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/Streaming.swift @@ -0,0 +1,166 @@ +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 + + init(_ session: Session) { self.session = session } + + /// 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. + @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) + try TranscribeError.check(status, context: "stream_finalize") + return StreamUpdate(update) + } + + /// Abandon the stream and return the session to idle. + @discardableResult + public func reset() -> StreamState { + transcribe_stream_reset(session.ptr) + 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). + public func stream( + _ runOptions: RunOptions = .init(), _ streamOptions: StreamOptions = .init() + ) throws -> Stream { + model.runLock.lock() + defer { model.runLock.unlock() } + let status = runOptions.withCParams { runParams in + streamOptions.withCParams { streamParams in + transcribe_stream_begin(ptr, runParams, streamParams) + } + } + try TranscribeError.check(status, context: "stream_begin") + return Stream(self) + } +} diff --git a/bindings/swift/Sources/TranscribeCpp/TranscribeCpp.swift b/bindings/swift/Sources/TranscribeCpp/TranscribeCpp.swift index b23bc51b..c7734ace 100644 --- a/bindings/swift/Sources/TranscribeCpp/TranscribeCpp.swift +++ b/bindings/swift/Sources/TranscribeCpp/TranscribeCpp.swift @@ -1,11 +1,96 @@ /// 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" +/// Status: M1 walking skeleton — the FFI/import/link plumbing is proven and the +/// no-model surface (version, ABI, device discovery, errors) is wired. Model / +/// Session / Stream land in M2–M3 (see notes/swift-bindings-plan.md). +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..7fcc1fdd --- /dev/null +++ b/bindings/swift/Sources/TranscribeCpp/TranscribeError.swift @@ -0,0 +1,71 @@ +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`). +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 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") +} +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..0e627de6 --- /dev/null +++ b/bindings/swift/Sources/streaming/main.swift @@ -0,0 +1,28 @@ +// 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) +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.. 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..e85804ec --- /dev/null +++ b/bindings/swift/Tests/TranscribeCppTests/TranscribeTests.swift @@ -0,0 +1,134 @@ +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) + } +} + +/// 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 = "." } From 59707931376fac2299866f8e01c798bf1087289c Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Tue, 16 Jun 2026 15:03:43 +0800 Subject: [PATCH 2/8] rust: rustfmt tests/family.rs The committed family.rs was not `cargo fmt --all --check` clean, failing the rust-gates rustfmt step on PR #30. No semantic change. Co-Authored-By: Claude Opus 4.8 (1M context) --- bindings/rust/transcribe-cpp/tests/family.rs | 32 ++++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/bindings/rust/transcribe-cpp/tests/family.rs b/bindings/rust/transcribe-cpp/tests/family.rs index 65513219..067b81c6 100644 --- a/bindings/rust/transcribe-cpp/tests/family.rs +++ b/bindings/rust/transcribe-cpp/tests/family.rs @@ -49,7 +49,10 @@ fn parakeet_cache_aware_acceptance_discriminates() { "cache-aware should accept PARAKEET_STREAM" ); assert!( - !model.accepts_ext(ExtSlot::Stream, TRANSCRIBE_EXT_KIND_PARAKEET_BUFFERED_STREAM), + !model.accepts_ext( + ExtSlot::Stream, + TRANSCRIBE_EXT_KIND_PARAKEET_BUFFERED_STREAM + ), "cache-aware should reject PARAKEET_BUFFERED_STREAM" ); } @@ -73,20 +76,27 @@ fn parakeet_cache_aware_streams_with_extension() { 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"); + 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 { + 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), + 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 @@ -120,10 +130,12 @@ fn voxtral_realtime_streams_with_extension() { ); let mut session = model.session().unwrap(); let opts = StreamOptions { - family: Some(StreamExtension::VoxtralRealtime(VoxtralRealtimeStreamOptions { - num_delay_tokens: Some(4), - ..Default::default() - })), + family: Some(StreamExtension::VoxtralRealtime( + VoxtralRealtimeStreamOptions { + num_delay_tokens: Some(4), + ..Default::default() + }, + )), ..Default::default() }; let mut stream = session.stream(&RunOptions::default(), &opts).unwrap(); From 8144e913b9e4b35b19f8fb26fc143ffe4d1fb972 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Tue, 16 Jun 2026 15:03:43 +0800 Subject: [PATCH 3/8] ci(swift): run on self-hosted macOS + Blacksmith The swift-{gates,macos,ios} jobs ran on GitHub-hosted runners (ubuntu-latest / macos-15) and were blocked by the account's GitHub Actions spending limit ("the job was not started because recent account payments have failed..."), the same posture the rest of the repo already avoids. Move them onto the existing fleet: the abihash gate to Blacksmith (blacksmith-2vcpu-ubuntu-2404) and the macOS build/test + iOS cross-compile to the self-hosted M4 Mac mini ([self-hosted, macOS, ARM64]). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/swift-ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/swift-ci.yml b/.github/workflows/swift-ci.yml index 36ac6730..729b7aab 100644 --- a/.github/workflows/swift-ci.yml +++ b/.github/workflows/swift-ci.yml @@ -50,7 +50,7 @@ concurrency: jobs: swift-gates: - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v8.2.0 @@ -58,7 +58,7 @@ jobs: run: uv run --no-project scripts/ci/swift_abihash_check.py swift-macos: - runs-on: macos-15 + 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). @@ -101,7 +101,7 @@ jobs: done swift-ios: - runs-on: macos-15 + runs-on: [self-hosted, macOS, ARM64] env: CMAKE_C_COMPILER_LAUNCHER: ccache CMAKE_CXX_COMPILER_LAUNCHER: ccache From e9aa7187ad44a8dda9ec21e17d56395b03d9c572 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Tue, 16 Jun 2026 16:19:54 +0800 Subject: [PATCH 4/8] examples(swift): scope models so Metal resources free before exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS 15+ ggml-metal uses residency sets and asserts every GPU resource is released before its device teardown (ggml-metal-device.m:612). The examples held the model/session in top-level `let` bindings, which outlive `main`, so the model was still alive at process exit and the assert aborted with SIGABRT (exit 134) — after transcribing correctly. `swift test` passes because tests scope their models in methods (freed mid-process); only the examples leaked to exit. Wrap each example's model/session/stream in a `do {}` so ARC frees the native resources before the process exits. backend-select is unaffected (CPU-only, no residency sets). Verified locally on macOS 26.5.1 / real Metal: all five exit 0, no abort, transcription unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- bindings/swift/Sources/batch/main.swift | 26 +++++++---- .../swift/Sources/error-handling/main.swift | 46 +++++++++++-------- bindings/swift/Sources/streaming/main.swift | 36 +++++++++------ .../swift/Sources/transcribe-file/main.swift | 27 +++++++---- 4 files changed, 80 insertions(+), 55 deletions(-) diff --git a/bindings/swift/Sources/batch/main.swift b/bindings/swift/Sources/batch/main.swift index b9796873..43bc7015 100644 --- a/bindings/swift/Sources/batch/main.swift +++ b/bindings/swift/Sources/batch/main.swift @@ -7,16 +7,22 @@ guard let modelPath = ExampleSupport.modelPath(), let audioPath = ExampleSupport } let pcm = try ExampleSupport.loadWav(audioPath) -let session = try Model(path: modelPath).session() -// Two utterances here; one bad input would surface as a `.failure` in its slot -// without sinking the others. -let results = try session.runBatch([pcm, pcm]) -for (i, result) in results.enumerated() { - switch result { - case .success(let transcript): - print("[\(i)] \(transcript.text)") - case .failure(let error): - print("[\(i)] error: \(error)") +// 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() + + // Two utterances here; one bad input would surface as a `.failure` in its + // slot without sinking the others. + let results = try session.runBatch([pcm, pcm]) + for (i, result) in results.enumerated() { + switch result { + case .success(let transcript): + print("[\(i)] \(transcript.text)") + case .failure(let error): + print("[\(i)] error: \(error)") + } } } diff --git a/bindings/swift/Sources/error-handling/main.swift b/bindings/swift/Sources/error-handling/main.swift index 3af1d3bc..776ee17e 100644 --- a/bindings/swift/Sources/error-handling/main.swift +++ b/bindings/swift/Sources/error-handling/main.swift @@ -14,33 +14,39 @@ do { guard let modelPath = ExampleSupport.modelPath() else { ExampleSupport.skip("set TRANSCRIBE_SMOKE_MODEL to demo the run-time error paths") } -let session = try Model(path: modelPath).session() -// 2. Empty PCM is rejected before any compute. +// 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 { - _ = try session.run([]) -} catch let error as TranscribeError { - if case .invalidArgument(let message) = error { - print("empty pcm -> .invalidArgument: \(message)") - } -} + let session = try Model(path: modelPath).session() -// 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) + // 2. Empty PCM is rejected before any compute. do { - _ = try session.run(long) - print("run completed before the abort was polled") + _ = try session.run([]) } catch let error as TranscribeError { - if case .aborted(let message, let partial) = error { - print("cancelled -> .aborted: \(message) (partial: \(partial?.text.isEmpty == false ? "present" : "empty"))") + 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() } - session.clearCancellationToken() } print("done") diff --git a/bindings/swift/Sources/streaming/main.swift b/bindings/swift/Sources/streaming/main.swift index 0e627de6..eab88618 100644 --- a/bindings/swift/Sources/streaming/main.swift +++ b/bindings/swift/Sources/streaming/main.swift @@ -8,21 +8,27 @@ else { } let pcm = try ExampleSupport.loadWav(audioPath) -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.. Date: Tue, 16 Jun 2026 17:04:55 +0800 Subject: [PATCH 5/8] docs(swift): note Metal model-release-before-exit gotcha Document the macOS 15+/iOS 18+ ggml-metal residency-set teardown: a Model held in a top-level/global `let` that outlives `main` is still alive at the backend's process-exit teardown, which asserts all GPU resources were released first and aborts. Normal object lifetimes are unaffected; the examples scope their work in a `do {}` block. We keep residency sets on (real perf benefit, PR ggml-org/ llama.cpp#11427) and ggml unpatched; this is the documented contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- bindings/swift/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bindings/swift/README.md b/bindings/swift/README.md index 3d873517..5c160d1c 100644 --- a/bindings/swift/README.md +++ b/bindings/swift/README.md @@ -111,6 +111,15 @@ request fails cleanly from the load path. Probe first with are thread-safe. `Transcribe.setLogHandler` should be called once at startup. - Resource cleanup is automatic under ARC (`deinit` frees the handles); a `Session`'s strong reference to its `Model` keeps close ordering safe. +- **Release `Model`s before the process exits on Metal.** On macOS 15+ / iOS 18+, + ggml's Metal backend keeps model GPU memory resident and asserts every GPU + resource is released before its own teardown at process exit. A `Model` parked + in a top-level/global `let` that outlives `main` is therefore still alive when + that teardown runs and aborts the process *after* your work completes. Normal + object lifetimes are fine — this only bites the "global model, then exit" + pattern. In a short-lived program (CLI, script), scope the model so ARC frees + it before exit; the bundled examples wrap their work in a `do { … }` block for + exactly this reason. ## Objective-C and C++ consumers From 29d01c67c5746bce396d306772d2047d7dd34922 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Tue, 16 Jun 2026 18:23:52 +0800 Subject: [PATCH 6/8] clean up swift async --- bindings/swift/README.md | 40 +++++++- .../swift/Sources/TranscribeCpp/Model.swift | 15 ++- .../swift/Sources/TranscribeCpp/Options.swift | 9 +- .../swift/Sources/TranscribeCpp/Session.swift | 50 ++++++++-- .../Sources/TranscribeCpp/Streaming.swift | 36 ++++++- .../Sources/TranscribeCpp/TranscribeCpp.swift | 7 +- .../TranscribeCpp/TranscribeError.swift | 6 ++ .../swift/Sources/backend-select/main.swift | 26 +++-- .../TranscribeCppTests/CancelTests.swift | 56 +++++++++++ .../TranscribeCppTests/NoModelTests.swift | 12 +++ .../TranscribeCppTests/StreamingTests.swift | 97 +++++++++++++++++++ .../TranscribeCppTests/TranscribeTests.swift | 10 ++ 12 files changed, 333 insertions(+), 31 deletions(-) diff --git a/bindings/swift/README.md b/bindings/swift/README.md index 5c160d1c..6fa9cb43 100644 --- a/bindings/swift/README.md +++ b/bindings/swift/README.md @@ -104,9 +104,16 @@ request fails cleanly from the load path. Probe first with (`run`/`runBatch`/stream feed) is serialized by an internal per-model lock, matching the C library's "one in-flight run per model" rule. Concurrent runs from many threads queue and execute one at a time. +- **An active stream holds the model's compute lease for its whole lifetime** + (begin → `finalize`/`reset`/drop). While a stream is live, a `run`, + `runBatch`, or second `stream` on *any* session of the same model is refused + with `TranscribeError.busy` rather than racing into undefined behavior — the + C "one in-flight run per model" rule covers streams, not just offline runs. + Finish (or drop) the stream before issuing other compute on that model. - **`Session`** is single-threaded: use one from one thread at a time (it may move between threads if never used concurrently). For parallel transcription, - use one `Session` per thread off a shared `Model`. + use one `Session` per thread off a shared `Model` (serialized, one in flight + at a time); for true parallelism, load one `Model` per worker. - **Callbacks** (cancellation, logging) may fire on native worker threads and are thread-safe. `Transcribe.setLogHandler` should be called once at startup. - Resource cleanup is automatic under ARC (`deinit` frees the handles); a @@ -121,6 +128,37 @@ request fails cleanly from the load path. Probe first with it before exit; the bundled examples wrap their work in a `do { … }` block for exactly this reason. +## Cancellation + +Install a `CancellationToken` on a session and call `cancel()` from any thread +to abort an in-flight `run`/`runBatch`/stream `feed`. The aborted call throws +`TranscribeError.aborted` with the partial transcript preserved. This is the +primary, thread-agnostic mechanism, and the same model the Rust and Python +bindings use. + +```swift +let token = CancellationToken() +session.setCancellationToken(token) +// later, from anywhere — a Stop button, a timeout: +token.cancel() +``` + +The `async` `run`/`runBatch` additionally bridge Swift structured concurrency: +cancelling the surrounding `Task` aborts the run (it throws `.aborted`, again +with the partial). This auto-bridge is active **only when you have not installed +your own token** — the native abort has a single slot, so a token you install +takes precedence and `Task.cancel()` is then not observed. Pick one mechanism +per session. + +```swift +let task = Task { try await session.run(pcm) } // no token installed +// …user navigates away / times out: +task.cancel() // run aborts, throws .aborted +``` + +(The run-mode enum is named `TranscriptionTask`, not `Task`, so it never +shadows Swift's `Task` in files that `import TranscribeCpp`.) + ## Objective-C and C++ consumers The Swift wrapper is Swift-native (value-type results, typed `throws`, diff --git a/bindings/swift/Sources/TranscribeCpp/Model.swift b/bindings/swift/Sources/TranscribeCpp/Model.swift index 63dca037..08ae61cd 100644 --- a/bindings/swift/Sources/TranscribeCpp/Model.swift +++ b/bindings/swift/Sources/TranscribeCpp/Model.swift @@ -4,13 +4,22 @@ 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 the Rust binding uses). A `Model` -/// outlives every `Session` derived from it; the session holds a strong +/// 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. + /// 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 { diff --git a/bindings/swift/Sources/TranscribeCpp/Options.swift b/bindings/swift/Sources/TranscribeCpp/Options.swift index 7c8de972..a76e64cf 100644 --- a/bindings/swift/Sources/TranscribeCpp/Options.swift +++ b/bindings/swift/Sources/TranscribeCpp/Options.swift @@ -2,7 +2,10 @@ import CTranscribe // MARK: - Enums -public enum Task: Sendable { +/// The run mode: plain transcription or speech translation. Named +/// `TranscriptionTask` (not `Task`) so it does not shadow Swift's +/// `_Concurrency.Task` in files that `import TranscribeCpp`. +public enum TranscriptionTask: Sendable { case transcribe, translate var cValue: transcribe_task { self == .transcribe ? TRANSCRIBE_TASK_TRANSCRIBE : TRANSCRIBE_TASK_TRANSLATE @@ -106,7 +109,7 @@ public struct SessionOptions: Sendable { } public struct RunOptions: Sendable { - public var task: Task + public var task: TranscriptionTask public var timestamps: TimestampKind public var pnc: Pnc public var itn: Itn @@ -122,7 +125,7 @@ public struct RunOptions: Sendable { public var family: RunExtension? public init( - task: Task = .transcribe, + task: TranscriptionTask = .transcribe, timestamps: TimestampKind = .none, pnc: Pnc = .default, itn: Itn = .default, diff --git a/bindings/swift/Sources/TranscribeCpp/Session.swift b/bindings/swift/Sources/TranscribeCpp/Session.swift index 0e6c043a..ff8d1312 100644 --- a/bindings/swift/Sources/TranscribeCpp/Session.swift +++ b/bindings/swift/Sources/TranscribeCpp/Session.swift @@ -52,6 +52,10 @@ public final class Session { 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) @@ -68,6 +72,10 @@ public final class Session { ) 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 @@ -93,7 +101,9 @@ public final class Session { // MARK: - Offline run (async convenience) - /// `run` hopped off the caller's thread/actor onto a background queue. + /// `run` hopped off the caller's thread/actor onto a background queue, with + /// Swift task cancellation bridged to the native abort (see + /// `withTaskCancellationBridge`). /// /// The `nonisolated(unsafe)` capture is sound: the continuation resumes /// exactly once, the work runs serialized under the model lock, and the @@ -102,22 +112,42 @@ public final class Session { /// contract this type documents. public func run(_ pcm: [Float], options: RunOptions = .init()) async throws -> Transcript { nonisolated(unsafe) let this = self - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - continuation.resume(with: Result { try this.run(pcm, options: options) }) - } - } + return try await withTaskCancellationBridge { try this.run(pcm, options: options) } } - /// `runBatch` hopped off the caller's thread/actor onto a background queue. + /// `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 withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - continuation.resume(with: Result { try this.runBatch(inputs, options: options) }) + 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() } } diff --git a/bindings/swift/Sources/TranscribeCpp/Streaming.swift b/bindings/swift/Sources/TranscribeCpp/Streaming.swift index 3d31de89..afa670b7 100644 --- a/bindings/swift/Sources/TranscribeCpp/Streaming.swift +++ b/bindings/swift/Sources/TranscribeCpp/Streaming.swift @@ -97,9 +97,28 @@ public struct StreamUpdate: Sendable, Equatable { /// 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() @@ -114,6 +133,9 @@ public final class Stream { } /// 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() @@ -121,14 +143,19 @@ public final class Stream { 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. + /// 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 } @@ -150,17 +177,24 @@ public final class Stream { 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 c7734ace..45b9c4f2 100644 --- a/bindings/swift/Sources/TranscribeCpp/TranscribeCpp.swift +++ b/bindings/swift/Sources/TranscribeCpp/TranscribeCpp.swift @@ -5,9 +5,10 @@ /// and C++ consumers can `#import` the bundled `transcribe/extensions.h` /// directly; this wrapper is the Swift-native surface. /// -/// Status: M1 walking skeleton — the FFI/import/link plumbing is proven and the -/// no-model surface (version, ABI, device discovery, errors) is wired. Model / -/// Session / Stream land in M2–M3 (see notes/swift-bindings-plan.md). +/// 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 diff --git a/bindings/swift/Sources/TranscribeCpp/TranscribeError.swift b/bindings/swift/Sources/TranscribeCpp/TranscribeError.swift index 7fcc1fdd..d42c82f3 100644 --- a/bindings/swift/Sources/TranscribeCpp/TranscribeError.swift +++ b/bindings/swift/Sources/TranscribeCpp/TranscribeError.swift @@ -8,6 +8,11 @@ import CTranscribe /// `.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) @@ -21,6 +26,7 @@ public enum TranscribeError: Error { 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. diff --git a/bindings/swift/Sources/backend-select/main.swift b/bindings/swift/Sources/backend-select/main.swift index c70e8aa0..308d67c3 100644 --- a/bindings/swift/Sources/backend-select/main.swift +++ b/bindings/swift/Sources/backend-select/main.swift @@ -16,16 +16,22 @@ guard let modelPath = ExampleSupport.modelPath() else { ExampleSupport.skip("set TRANSCRIBE_SMOKE_MODEL to demo explicit backend selection") } -// CPU always succeeds. -let onCpu = try Model(path: modelPath, options: ModelOptions(backend: .cpu)) -print("\nloaded with backend=cpu, bound to: \(onCpu.backend)") +// Scope the model so ARC frees it before exit, consistent with the other +// examples (the Metal residency teardown — see transcribe-file). This load is +// CPU-only so the assert wouldn't fire, but keeping the shape uniform avoids +// "why doesn't this one scope?" confusion. +do { + // CPU always succeeds. + let onCpu = try Model(path: modelPath, options: ModelOptions(backend: .cpu)) + print("\nloaded with backend=cpu, bound to: \(onCpu.backend)") -// A request that can't be satisfied fails cleanly from the load path. -if !Transcribe.backendAvailable(.cuda) { - do { - _ = try Model(path: modelPath, options: ModelOptions(backend: .cuda)) - print("unexpected: cuda load succeeded") - } catch { - print("backend=cuda not available, failed cleanly: \(error)") + // A request that can't be satisfied fails cleanly from the load path. + if !Transcribe.backendAvailable(.cuda) { + do { + _ = try Model(path: modelPath, options: ModelOptions(backend: .cuda)) + print("unexpected: cuda load succeeded") + } catch { + print("backend=cuda not available, failed cleanly: \(error)") + } } } diff --git a/bindings/swift/Tests/TranscribeCppTests/CancelTests.swift b/bindings/swift/Tests/TranscribeCppTests/CancelTests.swift index 76473d12..4e14c379 100644 --- a/bindings/swift/Tests/TranscribeCppTests/CancelTests.swift +++ b/bindings/swift/Tests/TranscribeCppTests/CancelTests.swift @@ -53,4 +53,60 @@ final class CancelTests: XCTestCase { _ = partial // partial transcript may be present } } + + // MARK: - Swift task cancellation bridge (async) + + /// The `async` run bridges Swift structured concurrency: cancelling the + /// surrounding `Task` must abort the in-flight run (throwing `.aborted`) + /// when the caller has NOT installed their own token. A long clip ensures + /// the native abort poll trips well before the run could finish. + func testAsyncRunBridgesTaskCancellation() async throws { + let (path, pcm) = try Fixtures.modelAndAudio() + let long = Array(repeating: pcm, count: 6).flatMap { $0 } + let session = try Model(path: path).session() // no caller token -> 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)") + } + } + } } diff --git a/bindings/swift/Tests/TranscribeCppTests/NoModelTests.swift b/bindings/swift/Tests/TranscribeCppTests/NoModelTests.swift index 028f4971..ed083a62 100644 --- a/bindings/swift/Tests/TranscribeCppTests/NoModelTests.swift +++ b/bindings/swift/Tests/TranscribeCppTests/NoModelTests.swift @@ -62,6 +62,18 @@ final class NoModelTests: XCTestCase { } } + // 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") diff --git a/bindings/swift/Tests/TranscribeCppTests/StreamingTests.swift b/bindings/swift/Tests/TranscribeCppTests/StreamingTests.swift index 6a4146e5..1d224c2c 100644 --- a/bindings/swift/Tests/TranscribeCppTests/StreamingTests.swift +++ b/bindings/swift/Tests/TranscribeCppTests/StreamingTests.swift @@ -69,4 +69,101 @@ final class StreamingTests: XCTestCase { try Fixtures.drive(stream, pcm: pcm) XCTAssertFalse(stream.text.full.isEmpty) } + + // MARK: - Compute lease (C contract: one in-flight run/stream per model) + + /// Regression for the streaming compute lease. The C library allows at most + /// one in-flight run/stream across ALL sessions of a model, and an active + /// stream spans begin..finalize/reset/drop. Before the lease, a second + /// stream — or an offline run — on another session of the same model began + /// concurrently and raced into the documented UB (corrupted decodes / Metal + /// command-buffer failures); now it is refused with `.busy`. Mirrors Rust's + /// `concurrent_compute_on_one_model_is_refused`. + func testConcurrentComputeOnOneModelIsRefused() throws { + let (path, pcm) = try Fixtures.streamingModelAndAudio() + let model = try Model(path: path) + let s1 = try model.session() + let s2 = try model.session() + + let stream1 = try s1.stream() + _ = try stream1.feed(Array(pcm.prefix(1600))) // s1's stream is ACTIVE + + // A second stream on the same model while the first is live -> .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/TranscribeTests.swift b/bindings/swift/Tests/TranscribeCppTests/TranscribeTests.swift index e85804ec..69510255 100644 --- a/bindings/swift/Tests/TranscribeCppTests/TranscribeTests.swift +++ b/bindings/swift/Tests/TranscribeCppTests/TranscribeTests.swift @@ -120,6 +120,16 @@ final class TranscribeTests: XCTestCase { 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. From bd30fce42622f8e6eacc756def40eb2290e581da Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Tue, 16 Jun 2026 19:54:43 +0800 Subject: [PATCH 7/8] clean up some readmes and other notes --- .github/workflows/publish.yml | 11 +- bindings/python/README.md | 48 ++--- bindings/rust/transcribe-cpp/README.md | 51 ++--- bindings/swift/README.md | 190 ++++++------------ .../swift/Sources/TranscribeCpp/Logging.swift | 38 +++- .../swift/Sources/TranscribeCpp/Session.swift | 19 +- .../TranscribeCppTests/CancelTests.swift | 21 ++ .../FamilyStreamTests.swift | 8 +- .../TranscribeCppTests/LoggingTests.swift | 13 ++ 9 files changed, 200 insertions(+), 199 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 92daa74b..993ce47c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -327,7 +327,16 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh release upload "${GITHUB_REF_NAME}" \ + set -e + tag="${GITHUB_REF_NAME}" + # This job has no ordering dependency on release-assets (which also + # creates the tag's release), so create it here if it does not exist + # yet. `|| true` tolerates a concurrent create from the other job (a + # 422 "already exists"); the upload below then succeeds either way. + 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 || true + gh release upload "$tag" \ bindings/swift/build-apple/TranscribeCpp.xcframework.zip --clobber - name: Checksum for the mirror repo's Package.swift run: | 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/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/swift/README.md b/bindings/swift/README.md index 6fa9cb43..8c861bfd 100644 --- a/bindings/swift/README.md +++ b/bindings/swift/README.md @@ -1,47 +1,40 @@ -# TranscribeCpp — Swift bindings for transcribe.cpp +# TranscribeCpp -Idiomatic Swift bindings for [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp), -a C/C++ speech-to-text library. Native code ships as a prebuilt, Metal-embedded -`.xcframework` consumed as a SwiftPM `binaryTarget` — no C toolchain, no build -step on the consumer side. +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). Every **core** capability is implemented and -> tested; see `notes/bindings-requirements.md` for the matrix. +> Status: in development (0.0.1). Core model, session, run, stream, +> cancellation, backend, and family-extension APIs are implemented and tested. ## Install -Swift Package Manager — add the dependency in Xcode (File → Add Package -Dependencies) or in your `Package.swift`. Apple platforms only: **macOS 13+, -iOS 16+** (device + simulator). +Apple platforms only: **macOS 13+** and **iOS 16+**. -**Option A — the package (recommended).** Add the dependency and the -`"TranscribeCpp"` product: +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") ``` -**Option B — point directly at the prebuilt xcframework.** No extra package -needed: drop a `binaryTarget(url:checksum:)` into your own `Package.swift`, -pointing at the release asset (the checksum is published with each release). -This is the same pattern whisper.cpp documents. +Until that mirror repo and tag exist, use the release xcframework directly when +you only need the raw C module: ```swift -let package = Package( - name: "MyApp", - targets: [ - .executableTarget(name: "MyApp", dependencies: ["CTranscribe"]), - .binaryTarget( - name: "CTranscribe", - url: "https://github.com/handy-computer/transcribe.cpp/releases/download/v0.0.1/TranscribeCpp.xcframework.zip", - checksum: "" - ), - ] +.binaryTarget( + name: "CTranscribe", + url: "https://github.com/handy-computer/transcribe.cpp/releases/download/v0.0.1/TranscribeCpp.xcframework.zip", + checksum: "" ) ``` -Option B gives you the raw C module (`import CTranscribe`); Option A adds the -idiomatic Swift wrapper (`import TranscribeCpp`) on top of it. +The direct binary target exposes `import CTranscribe`; the local and planned +Swift packages expose the wrapper product, `import TranscribeCpp`. ## Quickstart @@ -54,135 +47,72 @@ 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)") + print("[\(segment.t0Ms)-\(segment.t1Ms)ms] \(segment.text)") } ``` -Async (hops the blocking call off the calling thread/actor): +`run` is blocking; `try await session.run(pcm)` uses the async convenience +overload and hops the work off the caller's thread. -```swift -let transcript = try await session.run(pcm) -``` - -Streaming: +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) } // flicker-free prefix + if update.committedChanged { print(stream.text.committed) } } try stream.finalize() ``` -See `Sources/{transcribe-file,streaming,batch,backend-select,error-handling}` -for the five runnable examples — the same set, with the same names, every -first-class binding ships. +Runnable examples live in +`Sources/{transcribe-file,streaming,batch,backend-select,error-handling}`. -## Backend selection +## Backends Backends are compiled into the xcframework per Apple slice: -| Slice | Backend | -| ------------------------ | ------------- | -| macOS arm64 | Metal + CPU | -| macOS x86_64 (Intel) | CPU only | -| iOS device (arm64) | Metal + CPU | -| iOS simulator | CPU only | - -`Metal` on Intel Macs and the iOS simulator is intentionally omitted — -ggml's Metal backend is tuned for Apple-GPU families; those targets run on CPU. -Request a backend explicitly with `ModelOptions(backend:)`; an unsatisfiable -request fails cleanly from the load path. Probe first with -`Transcribe.backendAvailable(_:)` / `Transcribe.devices()`. - -## Thread-safety contract - -- **`Model`** is `Sendable` (`@unchecked`): share it across threads freely. - Queries and session creation are concurrent; the compute path - (`run`/`runBatch`/stream feed) is serialized by an internal per-model lock, - matching the C library's "one in-flight run per model" rule. Concurrent runs - from many threads queue and execute one at a time. -- **An active stream holds the model's compute lease for its whole lifetime** - (begin → `finalize`/`reset`/drop). While a stream is live, a `run`, - `runBatch`, or second `stream` on *any* session of the same model is refused - with `TranscribeError.busy` rather than racing into undefined behavior — the - C "one in-flight run per model" rule covers streams, not just offline runs. - Finish (or drop) the stream before issuing other compute on that model. -- **`Session`** is single-threaded: use one from one thread at a time (it may - move between threads if never used concurrently). For parallel transcription, - use one `Session` per thread off a shared `Model` (serialized, one in flight - at a time); for true parallelism, load one `Model` per worker. -- **Callbacks** (cancellation, logging) may fire on native worker threads and - are thread-safe. `Transcribe.setLogHandler` should be called once at startup. -- Resource cleanup is automatic under ARC (`deinit` frees the handles); a - `Session`'s strong reference to its `Model` keeps close ordering safe. -- **Release `Model`s before the process exits on Metal.** On macOS 15+ / iOS 18+, - ggml's Metal backend keeps model GPU memory resident and asserts every GPU - resource is released before its own teardown at process exit. A `Model` parked - in a top-level/global `let` that outlives `main` is therefore still alive when - that teardown runs and aborts the process *after* your work completes. Normal - object lifetimes are fine — this only bites the "global model, then exit" - pattern. In a short-lived program (CLI, script), scope the model so ARC frees - it before exit; the bundled examples wrap their work in a `do { … }` block for - exactly this reason. +| 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 `CancellationToken` on a session and call `cancel()` from any thread -to abort an in-flight `run`/`runBatch`/stream `feed`. The aborted call throws -`TranscribeError.aborted` with the partial transcript preserved. This is the -primary, thread-agnostic mechanism, and the same model the Rust and Python -bindings use. +Install a token on a session and cancel it from any thread: ```swift let token = CancellationToken() session.setCancellationToken(token) -// later, from anywhere — a Stop button, a timeout: token.cancel() ``` -The `async` `run`/`runBatch` additionally bridge Swift structured concurrency: -cancelling the surrounding `Task` aborts the run (it throws `.aborted`, again -with the partial). This auto-bridge is active **only when you have not installed -your own token** — the native abort has a single slot, so a token you install -takes precedence and `Task.cancel()` is then not observed. Pick one mechanism -per session. - -```swift -let task = Task { try await session.run(pcm) } // no token installed -// …user navigates away / times out: -task.cancel() // run aborts, throws .aborted -``` - -(The run-mode enum is named `TranscriptionTask`, not `Task`, so it never -shadows Swift's `Task` in files that `import TranscribeCpp`.) - -## Objective-C and C++ consumers - -The Swift wrapper is Swift-native (value-type results, typed `throws`, -associated-value errors) and does not bridge to Objective-C. ObjC and C++ -callers use the **C API directly** — the xcframework bundles the public headers, -so `#import ` exposes the full C surface. -One artifact serves both audiences. - -## ABI verification - -The per-field struct-layout check that the ctypes (Python) binding performs at -load time is **waived** here: Swift's Clang importer reads the C headers with a -real compiler, so struct layout is correct by construction. What remains: - -- A **load-time base-version gate** (`Transcribe.ensureCompatible`, run on every - `Model.load`): the linked library and this package must agree on the base - `MAJOR.MINOR.PATCH`. -- A **public-ABI drift gate** in CI: a pinned hash - (`Transcribe.pinnedHeaderHash`) is checked against `include/transcribe.abihash` - so a header ABI change turns the build red for conscious review. +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. -## Native artifact notes +## C, Objective-C, and C++ -The macOS slices contain the same native bytes as the project's desktop -distribution. The **iOS slices are a separate cross-compile** with no desktop -twin — they share the source and the version, but not the byte-for-byte -artifact. +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/TranscribeCpp/Logging.swift b/bindings/swift/Sources/TranscribeCpp/Logging.swift index 5957af4c..cb75f7e2 100644 --- a/bindings/swift/Sources/TranscribeCpp/Logging.swift +++ b/bindings/swift/Sources/TranscribeCpp/Logging.swift @@ -19,13 +19,34 @@ public enum LogLevel: Sendable { /// 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; lock.unlock() + 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() } @@ -42,16 +63,21 @@ private func logTrampoline( extension Transcribe { /// Route native log messages (library + ggml diagnostics) to `handler`. - /// Per the C contract, call once at process startup, before loading models - /// or creating sessions. The handler may be invoked from any thread. + /// 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) - transcribe_log_set(logTrampoline, nil) } - /// Disable logging entirely (library and ggml messages are dropped). + /// 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) - transcribe_log_set(nil, 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/Session.swift b/bindings/swift/Sources/TranscribeCpp/Session.swift index ff8d1312..65c87340 100644 --- a/bindings/swift/Sources/TranscribeCpp/Session.swift +++ b/bindings/swift/Sources/TranscribeCpp/Session.swift @@ -92,9 +92,22 @@ public final class Session { let n = Int(transcribe_batch_n_results(ptr)) return (0.. Date: Tue, 16 Jun 2026 20:41:36 +0800 Subject: [PATCH 8/8] update release process --- .github/workflows/publish.yml | 112 ++++++++++++++++++++++++++-------- 1 file changed, 86 insertions(+), 26 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 993ce47c..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 @@ -310,6 +319,7 @@ jobs: # 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 @@ -329,15 +339,9 @@ jobs: run: | set -e tag="${GITHUB_REF_NAME}" - # This job has no ordering dependency on release-assets (which also - # creates the tag's release), so create it here if it does not exist - # yet. `|| true` tolerates a concurrent create from the other job (a - # 422 "already exists"); the upload below then succeeds either way. - 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 || true gh release upload "$tag" \ - bindings/swift/build-apple/TranscribeCpp.xcframework.zip --clobber + 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," @@ -347,3 +351,59 @@ jobs: # 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?"