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