Skip to main content

transcribe_cpp/
model.rs

1//! [`Model`] — a loaded GGUF, shareable across threads.
2//!
3//! A `Model` is `Arc`-backed and `Send + Sync`: cloning it is cheap and hands
4//! out another handle to the same native model. The native model is freed when
5//! the last handle AND every [`Session`](crate::Session) /
6//! [`DiarizeSession`](crate::DiarizeSession) derived from it have been dropped
7//! — Rust ownership gives the C "model must outlive its sessions" contract (and
8//! close-ordering safety) for free, in any drop order.
9//!
10//! The `Arc` also carries the per-model compute lock that enforces the C
11//! library's 0.x concurrency limitation: at most one `run` / `run_batch` /
12//! diarize run / active stream may be in flight across ALL sessions of a model.
13//! One-shot runs/batches queue on the lock (serialized); an *active stream*
14//! spans many native calls (`begin`→`feed`*→`finalize`), so the lock also
15//! carries a flag marking a stream in flight — any run/batch/stream that would
16//! overlap it is refused with [`Error::Busy`](crate::Error) rather than allowed
17//! to race.
18
19use std::ffi::CString;
20use std::path::Path;
21use std::sync::{Arc, Mutex};
22
23use transcribe_cpp_sys as sys;
24
25use crate::backend::Device;
26use crate::diarize::{DiarizeInfo, DiarizeSession, DiarizeSessionOptions};
27use crate::error::{check, Error, Result};
28use crate::langid::{LangIdInfo, LangIdSession, LangIdSessionOptions};
29use crate::result::owned_str;
30use crate::session::Session;
31use crate::types::{Backend, ExtSlot, Feature, Roles, TimestampKind};
32use crate::vad::{VadInfo, VadSession, VadSessionOptions};
33use crate::version;
34
35/// Options for loading a model.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ModelOptions {
38    /// Which backend to request. Default [`Backend::Auto`]. An explicit device
39    /// must match a non-Auto backend request.
40    pub backend: Backend,
41    /// Exact process-local compute device. `None` applies the backend's
42    /// automatic policy; `Some` selects that device or fails without fallback.
43    pub device: Option<crate::Device>,
44}
45
46impl Default for ModelOptions {
47    fn default() -> Self {
48        ModelOptions {
49            backend: Backend::Auto,
50            device: None,
51        }
52    }
53}
54
55/// Immutable, model-level capabilities read from GGUF metadata.
56#[derive(Debug, Clone, PartialEq)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58pub struct Capabilities {
59    pub native_sample_rate: i32,
60    /// Supported language codes (empty if the model is language-agnostic).
61    pub languages: Vec<String>,
62    /// Supported translation target language codes (empty if not advertised).
63    pub translate_target_languages: Vec<String>,
64    /// The finest timestamp granularity the model can produce.
65    pub max_timestamp_kind: TimestampKind,
66    pub supports_language_detect: bool,
67    pub supports_translate: bool,
68    pub supports_streaming: bool,
69    pub supports_spec_decode: bool,
70    /// Longest accepted audio in ms (0 = no practical limit).
71    pub max_audio_ms: i64,
72}
73
74/// The native model handle plus the per-model compute lock, shared via `Arc`.
75pub(crate) struct ModelInner {
76    pub(crate) ptr: *mut sys::transcribe_model,
77    /// Serializes the compute path across all sessions of this model. The bool
78    /// is `true` while a stream is ACTIVE (from `begin` until the earliest of
79    /// `finalize` / `reset` / the `Stream` being dropped); a held lock plus a
80    /// `true` flag is how `run`/`run_batch`/`stream` detect and refuse an
81    /// overlapping compute. Only taken through [`ModelInner::with_compute`].
82    /// See the module docs.
83    pub(crate) compute_lock: Mutex<bool>,
84}
85
86impl ModelInner {
87    /// Run `f` under the model's compute lock; every native compute call goes
88    /// through here. With `Some(msg)`, returns `Error::Busy(msg)` instead if a
89    /// stream holds the lease. `f` gets the lease flag to take or release it.
90    pub(crate) fn with_compute<R>(
91        &self,
92        refuse_if_streaming: Option<&str>,
93        f: impl FnOnce(&mut bool) -> R,
94    ) -> Result<R> {
95        let mut lease = self.compute_lock.lock().unwrap_or_else(|e| e.into_inner());
96        if let Some(msg) = refuse_if_streaming {
97            if *lease {
98                return Err(Error::Busy(msg.into()));
99            }
100        }
101        Ok(f(&mut lease))
102    }
103}
104
105// SAFETY: the native model is documented thread-safe for query + session
106// creation; the compute path is serialized by `compute_lock`. The raw pointer
107// is only ever used behind `&ModelInner`, never mutated through aliasing.
108unsafe impl Send for ModelInner {}
109unsafe impl Sync for ModelInner {}
110
111impl Drop for ModelInner {
112    fn drop(&mut self) {
113        // Only reached once every Session (each holding an Arc clone) is gone.
114        unsafe { sys::transcribe_model_free(self.ptr) };
115    }
116}
117
118/// A loaded transcription model.
119#[derive(Clone)]
120pub struct Model {
121    pub(crate) inner: Arc<ModelInner>,
122}
123
124impl std::fmt::Debug for Model {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.debug_struct("Model")
127            .field("arch", &self.arch())
128            .field("variant", &self.variant())
129            .field("backend", &self.backend())
130            .finish()
131    }
132}
133
134impl Model {
135    /// Load a model from disk with default options: a GGUF, or a supported
136    /// whisper.cpp `.bin` (Whisper, or Silero VAD v5.1.2 / v6.2.0). The format
137    /// is detected from the file header, not the extension.
138    pub fn load(path: impl AsRef<Path>) -> Result<Model> {
139        Model::load_with(path, &ModelOptions::default())
140    }
141
142    /// Load a model from disk with explicit options (formats as for [`Model::load`]).
143    pub fn load_with(path: impl AsRef<Path>, options: &ModelOptions) -> Result<Model> {
144        // Pre-1.0 base-version lock against the loaded library (once).
145        version::ensure_compatible()?;
146
147        let path = path.as_ref();
148        let c_path = CString::new(path_bytes(path)?)?;
149
150        let mut params: sys::transcribe_model_load_params = unsafe { std::mem::zeroed() };
151        unsafe { sys::transcribe_model_load_params_init(&mut params) };
152        params.backend = options.backend.to_raw();
153        params.device = options
154            .device
155            .as_ref()
156            .map_or(std::ptr::null_mut(), |device| device.handle);
157
158        let mut out: *mut sys::transcribe_model = std::ptr::null_mut();
159        let status = unsafe { sys::transcribe_model_load_file(c_path.as_ptr(), &params, &mut out) };
160        check_model_load(status, &format!("load {}", path.display()))?;
161        debug_assert!(!out.is_null());
162
163        Ok(Model {
164            inner: Arc::new(ModelInner {
165                ptr: out,
166                compute_lock: Mutex::new(false),
167            }),
168        })
169    }
170
171    /// Open a session bound to this model with default session options.
172    pub fn session(&self) -> Result<Session> {
173        self.session_with(&SessionOptions::default())
174    }
175
176    /// Open a session bound to this model with explicit options.
177    pub fn session_with(&self, options: &SessionOptions) -> Result<Session> {
178        Session::new(self, options)
179    }
180
181    /// Open a diarization session with default options. Errors with
182    /// [`Error::UnsupportedRole`] unless the model serves [`Role::Diarize`](crate::Role).
183    pub fn diarize_session(&self) -> Result<DiarizeSession> {
184        self.diarize_session_with(&DiarizeSessionOptions::default())
185    }
186
187    /// Open a diarization session with explicit options.
188    pub fn diarize_session_with(&self, options: &DiarizeSessionOptions) -> Result<DiarizeSession> {
189        DiarizeSession::new(self, options)
190    }
191
192    /// Static facts about a diarization model. Errors with
193    /// [`Error::UnsupportedRole`] unless the model serves [`Role::Diarize`](crate::Role).
194    pub fn diarize_info(&self) -> Result<DiarizeInfo> {
195        let mut raw: sys::transcribe_diarize_info = unsafe { std::mem::zeroed() };
196        unsafe { sys::transcribe_diarize_info_init(&mut raw) };
197        check(
198            unsafe { sys::transcribe_diarize_get_info(self.inner.ptr, &mut raw) },
199            "diarize info",
200        )?;
201        Ok(DiarizeInfo {
202            sample_rate: raw.sample_rate,
203            max_speakers: raw.max_speakers,
204        })
205    }
206
207    /// Open a language ID session with default options. Errors with
208    /// [`Error::UnsupportedRole`] unless the model serves [`Role::LangId`](crate::Role).
209    pub fn langid_session(&self) -> Result<LangIdSession> {
210        self.langid_session_with(&LangIdSessionOptions::default())
211    }
212
213    /// Open a language ID session with explicit options.
214    pub fn langid_session_with(&self, options: &LangIdSessionOptions) -> Result<LangIdSession> {
215        LangIdSession::new(self, options)
216    }
217
218    /// Static facts about a language ID model. Errors with
219    /// [`Error::UnsupportedRole`] unless the model serves [`Role::LangId`](crate::Role).
220    pub fn langid_info(&self) -> Result<LangIdInfo> {
221        let mut raw: sys::transcribe_langid_info = unsafe { std::mem::zeroed() };
222        unsafe { sys::transcribe_langid_info_init(&mut raw) };
223        check(
224            unsafe { sys::transcribe_langid_get_info(self.inner.ptr, &mut raw) },
225            "langid info",
226        )?;
227        Ok(LangIdInfo {
228            sample_rate: raw.sample_rate,
229            n_labels: raw.n_labels,
230            min_audio_ms: raw.min_audio_ms,
231            max_audio_ms: raw.max_audio_ms,
232        })
233    }
234
235    /// `(code, name)` per label index. Errors with [`Error::UnsupportedRole`]
236    /// unless the model serves [`Role::LangId`](crate::Role).
237    pub fn langid_labels(&self) -> Result<Vec<(String, String)>> {
238        let n = self.langid_info()?.n_labels;
239        Ok((0..n)
240            .map(|i| unsafe {
241                (
242                    owned_str(sys::transcribe_langid_label_code(self.inner.ptr, i)),
243                    owned_str(sys::transcribe_langid_label_name(self.inner.ptr, i)),
244                )
245            })
246            .collect())
247    }
248
249    /// Label index of a code or alias (`"he"` and `"iw"` name the same label);
250    /// `None` when unknown or the model does not serve the LANGID role.
251    pub fn langid_label_index(&self, code: &str) -> Option<i32> {
252        let c = CString::new(code).ok()?;
253        let i = unsafe { sys::transcribe_langid_label_index(self.inner.ptr, c.as_ptr()) };
254        (i >= 0).then_some(i)
255    }
256
257    /// Open a VAD session with default options. Errors with
258    /// [`Error::UnsupportedRole`] unless the model serves [`Role::Vad`](crate::Role).
259    pub fn vad_session(&self) -> Result<VadSession> {
260        self.vad_session_with(&VadSessionOptions::default())
261    }
262
263    /// Open a VAD session with explicit options.
264    pub fn vad_session_with(&self, options: &VadSessionOptions) -> Result<VadSession> {
265        VadSession::new(self, options)
266    }
267
268    /// Static facts about a VAD model. Errors with
269    /// [`Error::UnsupportedRole`] unless the model serves [`Role::Vad`](crate::Role).
270    pub fn vad_info(&self) -> Result<VadInfo> {
271        let mut raw: sys::transcribe_vad_info = unsafe { std::mem::zeroed() };
272        unsafe { sys::transcribe_vad_info_init(&mut raw) };
273        check(
274            unsafe { sys::transcribe_vad_get_info(self.inner.ptr, &mut raw) },
275            "vad info",
276        )?;
277        Ok(VadInfo {
278            sample_rate: raw.sample_rate,
279            frame_samples: raw.frame_samples,
280        })
281    }
282
283    /// The roles (kinds of work) this model serves.
284    pub fn roles(&self) -> Roles {
285        Roles(unsafe { sys::transcribe_model_roles(self.inner.ptr) })
286    }
287
288    /// The model's immutable ASR capabilities. Errors with
289    /// [`Error::UnsupportedRole`] unless the model serves [`Role::Asr`](crate::Role).
290    pub fn capabilities(&self) -> Result<Capabilities> {
291        let mut caps: sys::transcribe_capabilities = unsafe { std::mem::zeroed() };
292        unsafe { sys::transcribe_capabilities_init(&mut caps) };
293        check(
294            unsafe { sys::transcribe_model_get_capabilities(self.inner.ptr, &mut caps) },
295            "capabilities",
296        )?;
297
298        let mut languages = Vec::new();
299        if !caps.languages.is_null() && caps.n_languages > 0 {
300            let slice =
301                unsafe { std::slice::from_raw_parts(caps.languages, caps.n_languages as usize) };
302            for &lang in slice {
303                languages.push(owned_str(lang));
304            }
305        }
306        let mut translate_target_languages = Vec::new();
307        if !caps.translate_target_languages.is_null() && caps.n_translate_target_languages > 0 {
308            let slice = unsafe {
309                std::slice::from_raw_parts(
310                    caps.translate_target_languages,
311                    caps.n_translate_target_languages as usize,
312                )
313            };
314            for &lang in slice {
315                translate_target_languages.push(owned_str(lang));
316            }
317        }
318
319        Ok(Capabilities {
320            native_sample_rate: caps.native_sample_rate,
321            languages,
322            translate_target_languages,
323            max_timestamp_kind: TimestampKind::from_raw(caps.max_timestamp_kind),
324            supports_language_detect: caps.supports_language_detect,
325            supports_translate: caps.supports_translate,
326            supports_streaming: caps.supports_streaming,
327            supports_spec_decode: caps.supports_spec_decode,
328            max_audio_ms: caps.max_audio_ms,
329        })
330    }
331
332    /// Probe a yes/no feature.
333    pub fn supports(&self, feature: Feature) -> bool {
334        unsafe { sys::transcribe_model_supports(self.inner.ptr, feature.to_raw()) }
335    }
336
337    /// Whether this model accepts the given family-extension `kind` in `slot`.
338    pub fn accepts_ext(&self, slot: ExtSlot, kind: u32) -> bool {
339        unsafe { sys::transcribe_model_accepts_ext_kind(self.inner.ptr, slot.to_raw(), kind) }
340    }
341
342    /// The `general.architecture` string, e.g. "parakeet".
343    pub fn arch(&self) -> String {
344        owned_str(unsafe { sys::transcribe_model_arch_string(self.inner.ptr) })
345    }
346
347    /// The `stt.variant` string, e.g. "tdt-0.6b-v2" (may be empty).
348    pub fn variant(&self) -> String {
349        owned_str(unsafe { sys::transcribe_model_variant_string(self.inner.ptr) })
350    }
351
352    /// The runtime backend bound to this model, e.g. "cpu", "metal" — the way
353    /// to detect CPU fallback after requesting a GPU.
354    pub fn backend(&self) -> String {
355        owned_str(unsafe { sys::transcribe_model_backend(self.inner.ptr) })
356    }
357
358    /// The compute [`Device`] this model is running on — the one that owns its
359    /// weights. Its `memory_free` is a live snapshot, so re-call to poll how
360    /// much memory is left on the device after the model loaded. Errors with
361    /// [`Error::Backend`](crate::Error) if the model has no resolved device.
362    pub fn device(&self) -> Result<Device> {
363        let handle = unsafe { sys::transcribe_model_device(self.inner.ptr) };
364        if handle.is_null() {
365            return Err(crate::Error::Backend(
366                "model has no resolved device".to_string(),
367            ));
368        }
369        let mut raw: sys::transcribe_device_info = unsafe { std::mem::zeroed() };
370        unsafe { sys::transcribe_device_info_init(&mut raw) };
371        let status = unsafe { sys::transcribe_device_get_info(handle, &mut raw) };
372        check(status, "device_get_info")?;
373        Ok(Device::from_raw(&raw, handle, None))
374    }
375
376    /// Tokenize plain UTF-8 text into the model's vocabulary (no BOS/EOS, no
377    /// special tags). Errors with [`Error::NotImplemented`](crate::Error) for
378    /// families whose tokenizer has no encode path (e.g. SentencePiece today).
379    pub fn tokenize(&self, text: &str) -> Result<Vec<i32>> {
380        let c_text = CString::new(text)?;
381        // Grow-and-retry on the negative-N "buffer too small" signal.
382        let mut buf = vec![0i32; text.len().max(16)];
383        loop {
384            let n = unsafe {
385                sys::transcribe_tokenize(
386                    self.inner.ptr,
387                    c_text.as_ptr(),
388                    buf.as_mut_ptr(),
389                    buf.len(),
390                )
391            };
392            if n == i32::MIN {
393                return Err(crate::error::Error::NotImplemented(
394                    "model tokenizer has no encode path".to_string(),
395                ));
396            }
397            if n < 0 {
398                buf.resize((-n) as usize, 0);
399                continue;
400            }
401            buf.truncate(n as usize);
402            return Ok(buf);
403        }
404    }
405}
406
407fn check_model_load(status: sys::transcribe_status, context: &str) -> Result<()> {
408    #[cfg(feature = "dynamic-backends")]
409    {
410        if status == sys::transcribe_status::TRANSCRIBE_ERR_BACKEND
411            && crate::backend::device_count() == 0
412        {
413            let code = status.0 as i32;
414            let base = crate::error::status_string(code);
415            return Err(crate::error::Error::Backend(format!(
416                "{context}: {base} (status {code}); dynamic-backends builds \
417                 require backend modules to be loaded before model load. Call \
418                 transcribe_cpp::init_backends_default() when modules are \
419                 bundled next to libtranscribe, or init_backends(dir) for a \
420                 custom provider directory."
421            )));
422        }
423    }
424    check(status, context)
425}
426
427/// Options for creating a session.
428#[derive(Debug, Clone, PartialEq, Eq)]
429#[cfg_attr(
430    feature = "serde",
431    derive(serde::Serialize, serde::Deserialize),
432    serde(default)
433)]
434pub struct SessionOptions {
435    /// CPU threads for ops that run on CPU; 0 = library default.
436    pub n_threads: i32,
437    /// K/V activation precision.
438    pub kv_type: crate::types::KvType,
439    /// Optional decoder context cap in tokens; 0 = model maximum.
440    pub n_ctx: i32,
441}
442
443impl Default for SessionOptions {
444    fn default() -> Self {
445        SessionOptions {
446            n_threads: 0,
447            kv_type: crate::types::KvType::Auto,
448            n_ctx: 0,
449        }
450    }
451}
452
453/// Per-session effective limits.
454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
455#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
456pub struct SessionLimits {
457    /// Decoder context cap in force for this session (0 = unbounded family).
458    pub effective_n_ctx: i32,
459    /// Longest accepted audio in ms (0 = no practical limit).
460    pub effective_max_audio_ms: i64,
461    /// Worst-case decoder KV bytes for one utterance (0 = no decoder KV).
462    pub max_kv_bytes: i64,
463}
464
465/// Convert a filesystem path to the bytes the C API's `const char *` expects.
466/// On Unix paths are arbitrary bytes, passed through verbatim; elsewhere the
467/// API takes UTF-8, so a non-UTF-8 path is rejected rather than lossily mangled
468/// (`to_string_lossy` would silently substitute U+FFFD). Shared with
469/// [`crate::backend::init_backends`].
470#[cfg(unix)]
471pub(crate) fn path_bytes(path: &Path) -> Result<Vec<u8>> {
472    use std::os::unix::ffi::OsStrExt;
473    Ok(path.as_os_str().as_bytes().to_vec())
474}
475
476#[cfg(not(unix))]
477pub(crate) fn path_bytes(path: &Path) -> Result<Vec<u8>> {
478    // The C API takes a UTF-8 `const char *`; require a UTF-8-representable path.
479    path.to_str().map(|s| s.as_bytes().to_vec()).ok_or_else(|| {
480        crate::error::Error::InvalidArgument(format!("non-UTF-8 path: {}", path.display()))
481    })
482}