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) derived from it have
6//! been dropped — Rust ownership gives the C "model must outlive its sessions"
7//! contract (and close-ordering safety) for free, in any drop order.
8//!
9//! The `Arc` also carries the per-model compute lock that enforces the C
10//! library's 0.x concurrency limitation: at most one `run` / `run_batch` /
11//! active stream may be in flight across ALL sessions of a model. One-shot
12//! runs/batches queue on the lock (serialized); an *active stream* spans many
13//! native calls (`begin`→`feed`*→`finalize`), so the lock also carries a flag
14//! marking a stream in flight — any run/batch/stream that would overlap it is
15//! refused with [`Error::Busy`](crate::Error) rather than allowed to race.
16
17use std::ffi::CString;
18use std::path::Path;
19use std::sync::{Arc, Mutex};
20
21use transcribe_cpp_sys as sys;
22
23use crate::backend::Device;
24use crate::error::{check, Result};
25use crate::result::owned_str;
26use crate::session::Session;
27use crate::types::{Backend, ExtSlot, Feature, TimestampKind};
28use crate::version;
29
30/// Options for loading a model.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ModelOptions {
33    /// Which backend to request. Default [`Backend::Auto`]. An explicit device
34    /// must match a non-Auto backend request.
35    pub backend: Backend,
36    /// Exact process-local compute device. `None` applies the backend's
37    /// automatic policy; `Some` selects that device or fails without fallback.
38    pub device: Option<crate::Device>,
39}
40
41impl Default for ModelOptions {
42    fn default() -> Self {
43        ModelOptions {
44            backend: Backend::Auto,
45            device: None,
46        }
47    }
48}
49
50/// Immutable, model-level capabilities read from GGUF metadata.
51#[derive(Debug, Clone, PartialEq)]
52#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
53pub struct Capabilities {
54    pub native_sample_rate: i32,
55    /// Supported language codes (empty if the model is language-agnostic).
56    pub languages: Vec<String>,
57    /// Supported translation target language codes (empty if not advertised).
58    pub translate_target_languages: Vec<String>,
59    /// The finest timestamp granularity the model can produce.
60    pub max_timestamp_kind: TimestampKind,
61    pub supports_language_detect: bool,
62    pub supports_translate: bool,
63    pub supports_streaming: bool,
64    pub supports_spec_decode: bool,
65    /// Longest accepted audio in ms (0 = no practical limit).
66    pub max_audio_ms: i64,
67}
68
69/// The native model handle plus the per-model compute lock, shared via `Arc`.
70pub(crate) struct ModelInner {
71    pub(crate) ptr: *mut sys::transcribe_model,
72    /// Serializes the compute path across all sessions of this model. The bool
73    /// is `true` while a stream is ACTIVE (from `begin` until the earliest of
74    /// `finalize` / `reset` / the `Stream` being dropped); a held lock plus a
75    /// `true` flag is how `run`/`run_batch`/`stream` detect and refuse an
76    /// overlapping compute. See the module docs.
77    pub(crate) compute_lock: Mutex<bool>,
78}
79
80// SAFETY: the native model is documented thread-safe for query + session
81// creation; the compute path is serialized by `compute_lock`. The raw pointer
82// is only ever used behind `&ModelInner`, never mutated through aliasing.
83unsafe impl Send for ModelInner {}
84unsafe impl Sync for ModelInner {}
85
86impl Drop for ModelInner {
87    fn drop(&mut self) {
88        // Only reached once every Session (each holding an Arc clone) is gone.
89        unsafe { sys::transcribe_model_free(self.ptr) };
90    }
91}
92
93/// A loaded transcription model.
94#[derive(Clone)]
95pub struct Model {
96    pub(crate) inner: Arc<ModelInner>,
97}
98
99impl std::fmt::Debug for Model {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("Model")
102            .field("arch", &self.arch())
103            .field("variant", &self.variant())
104            .field("backend", &self.backend())
105            .finish()
106    }
107}
108
109impl Model {
110    /// Load a GGUF model from disk with default options.
111    pub fn load(path: impl AsRef<Path>) -> Result<Model> {
112        Model::load_with(path, &ModelOptions::default())
113    }
114
115    /// Load a GGUF model from disk with explicit options.
116    pub fn load_with(path: impl AsRef<Path>, options: &ModelOptions) -> Result<Model> {
117        // Pre-1.0 base-version lock against the loaded library (once).
118        version::ensure_compatible()?;
119
120        let path = path.as_ref();
121        let c_path = CString::new(path_bytes(path)?)?;
122
123        let mut params: sys::transcribe_model_load_params = unsafe { std::mem::zeroed() };
124        unsafe { sys::transcribe_model_load_params_init(&mut params) };
125        params.backend = options.backend.to_raw();
126        params.device = options
127            .device
128            .as_ref()
129            .map_or(std::ptr::null_mut(), |device| device.handle);
130
131        let mut out: *mut sys::transcribe_model = std::ptr::null_mut();
132        let status = unsafe { sys::transcribe_model_load_file(c_path.as_ptr(), &params, &mut out) };
133        check_model_load(status, &format!("load {}", path.display()))?;
134        debug_assert!(!out.is_null());
135
136        Ok(Model {
137            inner: Arc::new(ModelInner {
138                ptr: out,
139                compute_lock: Mutex::new(false),
140            }),
141        })
142    }
143
144    /// Open a session bound to this model with default session options.
145    pub fn session(&self) -> Result<Session> {
146        self.session_with(&SessionOptions::default())
147    }
148
149    /// Open a session bound to this model with explicit options.
150    pub fn session_with(&self, options: &SessionOptions) -> Result<Session> {
151        Session::new(self, options)
152    }
153
154    /// The model's immutable capabilities.
155    pub fn capabilities(&self) -> Capabilities {
156        let mut caps: sys::transcribe_capabilities = unsafe { std::mem::zeroed() };
157        unsafe { sys::transcribe_capabilities_init(&mut caps) };
158        // A NULL/struct-size fault cannot happen here (we own a valid model
159        // and an init'd struct), so a non-OK status leaves zeroed defaults.
160        let _ = unsafe { sys::transcribe_model_get_capabilities(self.inner.ptr, &mut caps) };
161
162        let mut languages = Vec::new();
163        if !caps.languages.is_null() && caps.n_languages > 0 {
164            let slice =
165                unsafe { std::slice::from_raw_parts(caps.languages, caps.n_languages as usize) };
166            for &lang in slice {
167                languages.push(owned_str(lang));
168            }
169        }
170        let mut translate_target_languages = Vec::new();
171        if !caps.translate_target_languages.is_null() && caps.n_translate_target_languages > 0 {
172            let slice = unsafe {
173                std::slice::from_raw_parts(
174                    caps.translate_target_languages,
175                    caps.n_translate_target_languages as usize,
176                )
177            };
178            for &lang in slice {
179                translate_target_languages.push(owned_str(lang));
180            }
181        }
182
183        Capabilities {
184            native_sample_rate: caps.native_sample_rate,
185            languages,
186            translate_target_languages,
187            max_timestamp_kind: TimestampKind::from_raw(caps.max_timestamp_kind),
188            supports_language_detect: caps.supports_language_detect,
189            supports_translate: caps.supports_translate,
190            supports_streaming: caps.supports_streaming,
191            supports_spec_decode: caps.supports_spec_decode,
192            max_audio_ms: caps.max_audio_ms,
193        }
194    }
195
196    /// Probe a yes/no feature.
197    pub fn supports(&self, feature: Feature) -> bool {
198        unsafe { sys::transcribe_model_supports(self.inner.ptr, feature.to_raw()) }
199    }
200
201    /// Whether this model accepts the given family-extension `kind` in `slot`.
202    pub fn accepts_ext(&self, slot: ExtSlot, kind: u32) -> bool {
203        unsafe { sys::transcribe_model_accepts_ext_kind(self.inner.ptr, slot.to_raw(), kind) }
204    }
205
206    /// The `general.architecture` string, e.g. "parakeet".
207    pub fn arch(&self) -> String {
208        owned_str(unsafe { sys::transcribe_model_arch_string(self.inner.ptr) })
209    }
210
211    /// The `stt.variant` string, e.g. "tdt-0.6b-v2" (may be empty).
212    pub fn variant(&self) -> String {
213        owned_str(unsafe { sys::transcribe_model_variant_string(self.inner.ptr) })
214    }
215
216    /// The runtime backend bound to this model, e.g. "cpu", "metal" — the way
217    /// to detect CPU fallback after requesting a GPU.
218    pub fn backend(&self) -> String {
219        owned_str(unsafe { sys::transcribe_model_backend(self.inner.ptr) })
220    }
221
222    /// The compute [`Device`] this model is running on — the one that owns its
223    /// weights. Its `memory_free` is a live snapshot, so re-call to poll how
224    /// much memory is left on the device after the model loaded. Errors with
225    /// [`Error::Backend`](crate::Error) if the model has no resolved device.
226    pub fn device(&self) -> Result<Device> {
227        let handle = unsafe { sys::transcribe_model_device(self.inner.ptr) };
228        if handle.is_null() {
229            return Err(crate::Error::Backend(
230                "model has no resolved device".to_string(),
231            ));
232        }
233        let mut raw: sys::transcribe_device_info = unsafe { std::mem::zeroed() };
234        unsafe { sys::transcribe_device_info_init(&mut raw) };
235        let status = unsafe { sys::transcribe_device_get_info(handle, &mut raw) };
236        check(status, "device_get_info")?;
237        Ok(Device::from_raw(&raw, handle, None))
238    }
239
240    /// Tokenize plain UTF-8 text into the model's vocabulary (no BOS/EOS, no
241    /// special tags). Errors with [`Error::NotImplemented`](crate::Error) for
242    /// families whose tokenizer has no encode path (e.g. SentencePiece today).
243    pub fn tokenize(&self, text: &str) -> Result<Vec<i32>> {
244        let c_text = CString::new(text)?;
245        // Grow-and-retry on the negative-N "buffer too small" signal.
246        let mut buf = vec![0i32; text.len().max(16)];
247        loop {
248            let n = unsafe {
249                sys::transcribe_tokenize(
250                    self.inner.ptr,
251                    c_text.as_ptr(),
252                    buf.as_mut_ptr(),
253                    buf.len(),
254                )
255            };
256            if n == i32::MIN {
257                return Err(crate::error::Error::NotImplemented(
258                    "model tokenizer has no encode path".to_string(),
259                ));
260            }
261            if n < 0 {
262                buf.resize((-n) as usize, 0);
263                continue;
264            }
265            buf.truncate(n as usize);
266            return Ok(buf);
267        }
268    }
269}
270
271fn check_model_load(status: sys::transcribe_status, context: &str) -> Result<()> {
272    #[cfg(feature = "dynamic-backends")]
273    {
274        if status == sys::transcribe_status::TRANSCRIBE_ERR_BACKEND
275            && crate::backend::device_count() == 0
276        {
277            let code = status.0 as i32;
278            let base = crate::error::status_string(code);
279            return Err(crate::error::Error::Backend(format!(
280                "{context}: {base} (status {code}); dynamic-backends builds \
281                 require backend modules to be loaded before model load. Call \
282                 transcribe_cpp::init_backends_default() when modules are \
283                 bundled next to libtranscribe, or init_backends(dir) for a \
284                 custom provider directory."
285            )));
286        }
287    }
288    check(status, context)
289}
290
291/// Options for creating a session.
292#[derive(Debug, Clone, PartialEq, Eq)]
293#[cfg_attr(
294    feature = "serde",
295    derive(serde::Serialize, serde::Deserialize),
296    serde(default)
297)]
298pub struct SessionOptions {
299    /// CPU threads for ops that run on CPU; 0 = library default.
300    pub n_threads: i32,
301    /// K/V activation precision.
302    pub kv_type: crate::types::KvType,
303    /// Optional decoder context cap in tokens; 0 = model maximum.
304    pub n_ctx: i32,
305}
306
307impl Default for SessionOptions {
308    fn default() -> Self {
309        SessionOptions {
310            n_threads: 0,
311            kv_type: crate::types::KvType::Auto,
312            n_ctx: 0,
313        }
314    }
315}
316
317/// Per-session effective limits.
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
320pub struct SessionLimits {
321    /// Decoder context cap in force for this session (0 = unbounded family).
322    pub effective_n_ctx: i32,
323    /// Longest accepted audio in ms (0 = no practical limit).
324    pub effective_max_audio_ms: i64,
325    /// Worst-case decoder KV bytes for one utterance (0 = no decoder KV).
326    pub max_kv_bytes: i64,
327}
328
329/// Convert a filesystem path to the bytes the C API's `const char *` expects.
330/// On Unix paths are arbitrary bytes, passed through verbatim; elsewhere the
331/// API takes UTF-8, so a non-UTF-8 path is rejected rather than lossily mangled
332/// (`to_string_lossy` would silently substitute U+FFFD). Shared with
333/// [`crate::backend::init_backends`].
334#[cfg(unix)]
335pub(crate) fn path_bytes(path: &Path) -> Result<Vec<u8>> {
336    use std::os::unix::ffi::OsStrExt;
337    Ok(path.as_os_str().as_bytes().to_vec())
338}
339
340#[cfg(not(unix))]
341pub(crate) fn path_bytes(path: &Path) -> Result<Vec<u8>> {
342    // The C API takes a UTF-8 `const char *`; require a UTF-8-representable path.
343    path.to_str().map(|s| s.as_bytes().to_vec()).ok_or_else(|| {
344        crate::error::Error::InvalidArgument(format!("non-UTF-8 path: {}", path.display()))
345    })
346}