# transcribe-cpp
Safe, idiomatic Rust bindings for
[transcribe.cpp](https://github.com/handy-computer/transcribe.cpp), a C/C++
speech-to-text library built on ggml.
> **Status: in development (0.2.0).** Core model, session, run, stream,
> cancellation, backend, and family-extension APIs are implemented and tested.
Upgrading from 0.1? See the
[0.2 migration guide](https://github.com/handy-computer/transcribe.cpp/blob/main/docs/migrating-to-0.2.md),
including the replacement of `ModelOptions::gpu_device` with exact `Device`
handles.
## Install
```sh
cargo add transcribe-cpp
```
The native library is compiled from source by the `transcribe-cpp-sys` crate, so
a first build needs a C++ toolchain and **CMake**. There is no external
compression dependency (the deflate codec is vendored), so no system zlib /
vcpkg setup is required on any platform. No prebuilt download and no environment
variables on the happy path.
## Quickstart
```rust
use transcribe_cpp::{Model, RunOptions};
let mut session = Model::load("model.gguf")?.session()?;
// pcm: 16 kHz mono f32 in [-1, 1]
let result = session.run(&pcm, &RunOptions::default())?;
println!("{}", result.text);
# Ok::<(), transcribe_cpp::Error>(())
```
### Punctuation, capitalization, and text normalization
`RunOptions::pnc` and `RunOptions::itn` default to preserving each model
family's shipped behavior. Probe `model.supports(Feature::Pnc)` or
`Feature::Itn` before selecting `Pnc::Off`/`On` or `Itn::Off`/`On`. The same
`RunOptions` is used by single runs, batches, streams, and `transcribe()`.
```rust
use transcribe_cpp::{Itn, Pnc, RunOptions};
let options = RunOptions { pnc: Pnc::Off, itn: Itn::On, ..Default::default() };
let result = session.run(&pcm, &options)?;
# Ok::<(), transcribe_cpp::Error>(())
```
### Prompting
`RunOptions::vocabulary` (custom terms), `prompt` (context, or the instruction
under `Task::Instruct`) and `prefix` (text the model continues from) take
effect where `model.supports()` reports `Feature::Vocabulary`,
`ContextPrompt`, `Instruct` or `TranscriptPrefix`.
```rust
use transcribe_cpp::RunOptions;
let options = RunOptions { vocabulary: vec!["Kubernetes".into()], ..Default::default() };
let result = session.run(&pcm, &options)?;
# Ok::<(), transcribe_cpp::Error>(())
```
Streaming exposes both UI-stable text and a fully materialized structured
snapshot:
```rust
let mut stream = session.stream(&RunOptions::default(), &Default::default())?;
stream.feed(&chunk)?;
println!("{}", stream.text().committed);
stream.finalize()?;
let transcript = stream.snapshot(); // language, segments, words, tokens, timings
# Ok::<(), transcribe_cpp::Error>(())
```
### Diarization (who spoke when)
A model whose `roles()` contain `Role::Diarize` (e.g. Sortformer) opens a
`DiarizeSession` that returns speaker turns; calls for a role the model lacks
return `Error::UnsupportedRole`.
```rust
use transcribe_cpp::{DiarizeOptions, Model, Role};
let model = Model::load("diarizer.gguf")?;
assert!(model.roles().contains(Role::Diarize));
let mut diarize = model.diarize_session()?;
for turn in diarize.run(&pcm, &DiarizeOptions::default())? {
println!("speaker {}: {}..{} ms", turn.speaker_id, turn.t0_ms, turn.t1_ms);
}
# Ok::<(), transcribe_cpp::Error>(())
```
### Language ID
A model whose `roles()` contain `Role::LangId` (VoxLingua107 ECAPA-TDNN)
opens a `LangIdSession`. Codes are the model's own labels; match them against
an ASR model's `capabilities().languages` yourself. `allowed: None` scores
every label, `Some(vec![])` is `Error::InvalidArgument`, and clips under
`langid_info()?.min_audio_ms` are `Error::InputTooShort`. A low
`allowed_mass` means the speech is probably outside the allowed set.
```rust
use transcribe_cpp::{LangIdOptions, Model};
let model = Model::load("lang-id-voxlingua107-ecapa-Q8_0.gguf")?;
let mut lid = model.langid_session()?;
let opts = LangIdOptions { allowed: Some(vec!["en".into(), "de".into()]) };
let result = lid.run(&pcm, &opts)?;
println!("{:?} (mass {})", result.code(), result.allowed_mass);
# Ok::<(), transcribe_cpp::Error>(())
```
### Voice activity detection
A model whose `roles()` contain `Role::Vad` (Silero VAD; GGUF or the
whisper.cpp `ggml-silero-v5.1.2.bin` / `v6.2.0.bin`) opens a `VadSession`.
`run` scores a clip and returns per-frame probabilities
(`vad_info()?.frame_samples` samples each) plus speech segments in samples;
`stream_feed` / `stream_flush` return the probabilities of the frames each call
completed.
```rust
use transcribe_cpp::{Model, VadOptions};
let model = Model::load("silero-vad-v6.2-F32.gguf")?;
let mut vad = model.vad_session()?;
for s in vad.run(&pcm, &VadOptions::default())?.segments {
println!("speech {}..{}", s.start_sample, s.end_sample);
}
for chunk in pcm.chunks(320) {
let r = vad.stream_feed(chunk)?;
println!("frames {}.. {:?}", r.first_frame, r.probs);
}
vad.stream_flush()?;
# Ok::<(), transcribe_cpp::Error>(())
```
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
Backends are selected with cargo features forwarded to `transcribe-cpp-sys`:
`metal` (default on Apple), `vulkan`, `cuda`, `rocm`, and `openmp`.
On Windows, `vulkan` requires the Vulkan SDK. Deep Cargo output paths are
shortened automatically during the native build; see the
[Windows Vulkan build notes](https://github.com/handy-computer/transcribe.cpp/blob/main/bindings/rust/sys/README.md#windows-vulkan-builds)
for prerequisites and the short `CARGO_TARGET_DIR` fallback.
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`.
## Exact device selection
`devices()` returns process-local `Device` handles. Leave
`ModelOptions::device` as `None` for the backend's automatic policy, or pass
`Some(device)` to select that exact primary device with no fallback. Handles
are not stable across processes; persist `device_id` when it is `Some`,
otherwise `kind` + `name` + `description` (e.g. Metal), and later re-find the
device in `devices()` after backend initialization. In dynamic-backend builds,
finish `init_backends()` or `init_backends_default()` before any thread
enumerates devices, queries backend availability, or loads a model; native
registry mutation is a startup-only operation and must not race those calls.
## Packaging a distributable (`shared` / `dynamic-backends`)
With the **default static** build there is nothing to do — the native code is
baked into your binary. But if you enable `shared` or `dynamic-backends`, your
installer must ship the runtime libraries (and, for `dynamic-backends`, the
backend modules) next to your executable. Bundling for distribution (e.g. a
Tauri/Electron installer) happens at *build* time, so a runtime lookup is the
wrong tool — you need the artifact path while you build.
This crate forwards the native build's output directories to **your** build
script as `DEP_TRANSCRIBE_CPP_*` env vars. The one you usually want is
`DEP_TRANSCRIBE_CPP_RUNTIME_DIR`: a single directory holding the shared objects
to copy beside your exe (the DLLs on Windows, the `.so`/`.dylib` on Unix — no
per-OS logic needed). Also available: `DEP_TRANSCRIBE_CPP_LIB_DIR`,
`_BIN_DIR`, `_MODULE_DIR` (the `dynamic-backends` modules), and `_INCLUDE_DIR`.
The runtime keys are present only in a shared posture; in a static build they
are unset (nothing to bundle).
In your application's `build.rs`:
```rust
use std::{env, fs, path::{Path, PathBuf}};
fn main() {
// Present only for shared / dynamic-backends builds.
let Some(runtime_dir) = env::var_os("DEP_TRANSCRIBE_CPP_RUNTIME_DIR") else {
return; // static build — nothing to ship
};
// A stable folder your bundler references with a STATIC path (e.g.
// tauri.conf.json `"resources": { "transcribe-libs/*": "." }`).
let dest = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("transcribe-libs");
fs::create_dir_all(&dest).unwrap();
// Match by NAME, not extension: Linux versions its libs (libtranscribe.so.0,
// .so.0.0.4) and the loader needs the SONAME, so an extension filter would
// copy only the bare dev symlink and ship a broken installer. `fs::copy`
// dereferences the version symlinks into real files. Returns the count.
fn copy_libs(src: &Path, dest: &Path) -> usize {
let mut n = 0;
for entry in fs::read_dir(src).unwrap_or_else(|e| panic!("read {}: {e}", src.display())) {
let path = entry.unwrap().path();
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
let is_lib = name.ends_with(".dll")
|| name.ends_with(".dylib") // macOS versions before the ext: libfoo.0.dylib
|| name.ends_with(".so")
|| name.contains(".so."); // Linux SONAME/version: libfoo.so.0[.0.4]
if is_lib {
fs::copy(&path, dest.join(name)).unwrap();
n += 1;
}
}
n
}
// HARD-FAIL if a dir was advertised but holds no libraries: better to break
// the build than ship a silently broken installer (missing libs at runtime).
let runtime_dir = PathBuf::from(runtime_dir);
assert!(copy_libs(&runtime_dir, &dest) > 0, "no runtime libraries in {}", runtime_dir.display());
println!("cargo:rerun-if-env-changed=DEP_TRANSCRIBE_CPP_RUNTIME_DIR");
// dynamic-backends only: also ship the loadable compute modules (often a
// different directory than the library). Unset in a plain `shared` build.
println!("cargo:rerun-if-env-changed=DEP_TRANSCRIBE_CPP_MODULE_DIR");
if let Some(module_dir) = env::var_os("DEP_TRANSCRIBE_CPP_MODULE_DIR") {
let module_dir = PathBuf::from(module_dir);
assert!(copy_libs(&module_dir, &dest) > 0, "no backend modules in {}", module_dir.display());
// At runtime, before the first model load, point the library at the
// bundled modules: `init_backends(<dir next to your exe>)`, or
// `init_backends_default()` when they sit right beside the executable.
}
}
```
## Threading
- `Model` is `Send + Sync` and cheap to clone (`Arc`-backed); the native model
is freed only after the last handle and every `Session` drop.
- `Session` is `Send` but not `Sync`; mutating calls take `&mut self`.
- In 0.x the C library allows at most one in-flight run across all sessions of a
model; this crate enforces it with a per-model mutex, so concurrent calls
queue rather than race. `DiarizeSession`s share the same lock. For real
parallelism, use one `Model` per worker.
- License: MIT