@ondeinference / onde / commits / ab9e11f

Align vision/whisper surface with development after merge

- Rename VisionModelBuilder -> MultimodalModelBuilder to match the published onde-mistralrs 0.8.2 API used since the development sync. - Drop the whisper module, feature, and transcribe-rs git dependency: onde no longer bundles speech-to-text (apps depend on transcribe-rs directly), and the old dep pinned a `whisper` feature that no longer exists upstream. - cargo fmt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

keypair34 committed Jul 4, 2026 at 19:28 UTC ab9e11fb4521665ed1980b413eb54f6045847402
5 files changed +12 -868
Cargo.toml
+1 -8
@@ -8,7 +8,7 @@ repository = "https://github.com/ondeinference/onde"
8 homepage = "https://ondeinference.com"
9 documentation = "https://docs.rs/onde"
10 readme = "README.md"
11 -keywords = ["inference", "on-device", "llm", "whisper", "chat"]
11 +keywords = ["inference", "on-device", "chat", "llm", "mistral"]
12 categories = ["multimedia::audio", "artificial-intelligence", "api-bindings"]
13
14 [lib]
@@ -23,9 +23,6 @@ crate-type = ["lib", "cdylib", "staticlib"]
23 # -- Feature flags -----------------------------------------------------------
24 [features]
25 default = []
26 -# Enable Whisper-based speech-to-text transcription via transcribe-rs.
27 -# macOS uses Metal GPU acceleration; Android uses CPU-only inference.
28 -whisper = ["dep:transcribe-rs", "dep:hf-hub"]
26
27 # Android: CPU-only GGUF inference via candle; also needs hf-hub and
28 # mistralrs-core for explicit cache path seeding (dirs::home_dir() panics
@@ -94,10 +91,6 @@ tsync = "2"
91 # the uniffi-bindgen binary crate.
92 uniffi = { version = "=0.31.0", features = ["tokio"] }
93
97 -# Optional: Whisper speech-to-text engine (transcribe-rs wraps whisper.cpp).
98 -# The `whisper` feature in transcribe-rs pulls in platform-specific whisper-rs
99 -# (Metal on macOS, Vulkan on Windows/Linux) via its own Cargo.toml cfg gates.
100 -transcribe-rs = { git = "https://github.com/setoelkahfi/transcribe-rs", branch = "feature/onde", features = ["whisper"], optional = true }
94 hf-hub = { version = "0.4", features = ["tokio", "rustls-tls"], default-features = false, optional = true }
95
96 [dependencies.smbcloud-gresiq-sdk]
src/hf_cache.rs
+3 -3
@@ -1008,7 +1008,7 @@ where
1008 use crate::inference::token::hf_token_source;
1009 use mistralrs::{
1010 DiffusionLoaderType, DiffusionModelBuilder, GgufModelBuilder, IsqType, ModelDType,
1011 - TextModelBuilder, VisionModelBuilder,
1011 + MultimodalModelBuilder, TextModelBuilder,
1012 };
1013 use std::sync::{
1014 atomic::{AtomicBool, Ordering},
@@ -1123,14 +1123,14 @@ where
1123 .await
1124 }
1125 GOOGLE_GEMMA_3N_E2B_IT => {
1126 - VisionModelBuilder::new(&model_id)
1126 + MultimodalModelBuilder::new(&model_id)
1127 .with_token_source(hf_token_source())
1128 .with_isq(IsqType::Q4K)
1129 .build()
1130 .await
1131 }
1132 GOOGLE_GEMMA_3N_E4B_IT => {
1133 - VisionModelBuilder::new(&model_id)
1133 + MultimodalModelBuilder::new(&model_id)
1134 .with_token_source(hf_token_source())
1135 .with_dtype(ModelDType::F16)
1136 .build()
src/inference/engine.rs
+5 -4
@@ -297,10 +297,11 @@ impl ChatEngine {
297 // Prefer the constructor-supplied app id; fall back to the
298 // ONDE_APP_ID env var so UniFFI consumers can opt in without a
299 // new constructor signature.
300 - let onde_app_id = self
301 - .onde_app_id
302 - .clone()
303 - .or_else(|| std::env::var("ONDE_APP_ID").ok().filter(|id| !id.is_empty()));
300 + let onde_app_id = self.onde_app_id.clone().or_else(|| {
301 + std::env::var("ONDE_APP_ID")
302 + .ok()
303 + .filter(|id| !id.is_empty())
304 + });
305 let client = crate::pulse::PulseClient::new(environment, edge_id, onde_app_id);
306
307 match &client {
src/lib.rs
+3 -31
@@ -6,11 +6,10 @@
6 //! no latency, no data leaving the device.
7 //!
8 //! Onde wraps [mistral.rs](https://github.com/EricLBuehler/mistral.rs) for
9 -//! LLM and image generation, and [whisper.cpp](https://github.com/ggerganov/whisper.cpp)
10 -//! (via [transcribe-rs](https://github.com/cjpais/transcribe-rs)) for
11 -//! speech-to-text — with a unified API that handles model discovery,
9 +//! LLM and image generation — with a unified API that handles model discovery,
10 //! HuggingFace Hub downloads, cache management, and GPU acceleration
13 -//! across every platform.
11 +//! across every platform. (Speech-to-text moved out of onde: apps depend on
12 +//! `transcribe-rs` directly.)
13 //!
14 //! Built by [Onde Inference](https://ondeinference.com)
15 //!
@@ -22,35 +21,12 @@
21 //! size, display name, org, description) for all supported models.
22 //! - [`inference::token`] — HuggingFace token resolution (build-time literal
23 //! or on-disk cache; required on iOS where the filesystem is sandboxed).
25 -//! - [`whisper`] — Whisper speech-to-text transcription with automatic model
26 -//! download (feature-gated behind `whisper`).
24 //!
25 //! ## Re-exports
26 //!
27 //! `mistralrs`, `hf_hub`, and `mistralrs_core` are re-exported so that apps
28 //! depending on `onde` do not need their own direct dependency on those crates.
29 //! Access them as `onde::mistralrs`, `onde::hf_hub`, and `onde::mistralrs_core`.
33 -//!
34 -//! ## Example
35 -//!
36 -//! ```rust,ignore
37 -//! use onde::whisper::{
38 -//! find_or_download_whisper_model, WhisperEngine,
39 -//! WhisperInferenceParams, TranscriptionEngine,
40 -//! };
41 -//!
42 -//! let model_path = find_or_download_whisper_model(
43 -//! &"./models/whisper".into(),
44 -//! None, // platform-smart default
45 -//! |p| println!("{}: {}", p.model_name, p.downloaded_display),
46 -//! None,
47 -//! )?;
48 -//!
49 -//! let mut engine = WhisperEngine::new();
50 -//! engine.load_model(&model_path)?;
51 -//! let result = engine.transcribe_file("audio.wav", None)?;
52 -//! println!("{}", result.text);
53 -//! ```
30
31 pub mod hf_cache;
32
@@ -82,10 +58,6 @@ pub(crate) fn install_panic_hook_once() {
58
59 uniffi::setup_scaffolding!();
60
85 -// Whisper speech-to-text via transcribe-rs (opt-in via `whisper` feature).
86 -#[cfg(feature = "whisper")]
87 -pub mod whisper;
88 -
61 // Re-export mistralrs for every platform that onde supports.
62 // Apps use `onde::mistralrs::Model` etc. instead of declaring a direct dep.
63 #[cfg(any(
src/whisper.rs deleted
-822
@@ -1,822 +0,0 @@
1 -//! Whisper speech-to-text transcription via `transcribe-rs`.
2 -//!
3 -//! This module re-exports the Whisper engine and associated types from
4 -//! `transcribe-rs` so that callers can depend on `onde` alone for all
5 -//! on-device inference — both LLM/diffusion (via mistral.rs) and
6 -//! speech-to-text (via whisper.cpp / whisper-rs).
7 -//!
8 -//! It also provides [`find_or_download_whisper_model`] which resolves a
9 -//! local Whisper GGML model file — downloading the platform-appropriate
10 -//! default from HuggingFace (`ggerganov/whisper.cpp`) if none is found.
11 -//! The download uses `hf-hub` so it shares the standard HuggingFace cache
12 -//! directory with all other model downloads in the app.
13 -//!
14 -//! # Feature gate
15 -//!
16 -//! This module is only compiled when the `whisper` Cargo feature is enabled:
17 -//!
18 -//! ```toml
19 -//! [dependencies]
20 -//! onde = { path = "…", features = ["whisper"] }
21 -//! ```
22 -//!
23 -//! # Example
24 -//!
25 -//! ```rust,ignore
26 -//! use onde::whisper::{WhisperEngine, WhisperInferenceParams};
27 -//! use onde::whisper::{TranscriptionEngine, TranscriptionSegment};
28 -//!
29 -//! let mut engine = WhisperEngine::new();
30 -//! engine.load_model(&model_path)?;
31 -//!
32 -//! let params = WhisperInferenceParams {
33 -//! language: Some("en".to_string()),
34 -//! ..Default::default()
35 -//! };
36 -//!
37 -//! let result = engine.transcribe_samples(samples, Some(params))?;
38 -//! ```
39 -//!
40 -//! # Auto-download example
41 -//!
42 -//! ```rust,ignore
43 -//! use onde::whisper::{find_or_download_whisper_model, WhisperModelDownloadProgress};
44 -//! use std::path::PathBuf;
45 -//!
46 -//! let model_dir = PathBuf::from("/path/to/models/whisper");
47 -//! let model_path = find_or_download_whisper_model(
48 -//! &model_dir,
49 -//! None, // use platform default model
50 -//! |progress| {
51 -//! println!(
52 -//! "Downloading {} — {}/{}",
53 -//! progress.model_name, progress.downloaded_display, progress.total_display
54 -//! );
55 -//! },
56 -//! )?;
57 -//! ```
58 -
59 -use {
60 - log::{debug, info, warn},
61 - serde::{Deserialize, Serialize},
62 - std::path::{Path, PathBuf},
63 - std::sync::Arc,
64 - tsync::tsync,
65 -};
66 -
67 -// ---------------------------------------------------------------------------
68 -// Re-exports from transcribe-rs
69 -// ---------------------------------------------------------------------------
70 -
71 -// Re-export the Whisper engine and its parameter types.
72 -pub use transcribe_rs::whisper_cpp::{WhisperEngine, WhisperInferenceParams, WhisperLoadParams};
73 -
74 -// Re-export the core transcription trait and result types so callers
75 -// don't need a separate `use transcribe_rs::…` import.
76 -pub use transcribe_rs::{SpeechModel, TranscribeError, TranscriptionResult, TranscriptionSegment};
77 -
78 -// Re-export the audio utility for reading raw WAV samples.
79 -pub use transcribe_rs::audio::read_wav_samples;
80 -
81 -// ---------------------------------------------------------------------------
82 -// Constants
83 -// ---------------------------------------------------------------------------
84 -
85 -/// HuggingFace repository that hosts pre-converted Whisper GGML models.
86 -const HF_WHISPER_REPO: &str = "ggerganov/whisper.cpp";
87 -
88 -/// Whisper model file names, in order of preference (best quality first).
89 -/// [`find_or_download_whisper_model`] tries each in sequence and uses the
90 -/// first one already present on disk before falling back to downloading.
91 -pub const MODEL_CANDIDATES: &[&str] = &[
92 - "ggml-large-v3-turbo.bin",
93 - "ggml-large-v3.bin",
94 - "ggml-medium.bin",
95 - "ggml-medium.en.bin",
96 - "ggml-medium-q5_0.bin",
97 - "ggml-small.bin",
98 - "ggml-small.en.bin",
99 - "ggml-base.bin",
100 - "ggml-base.en.bin",
101 - "ggml-tiny.bin",
102 - "ggml-tiny.en.bin",
103 -];
104 -
105 -// ===========================================================================
106 -// UniFFI — Error type
107 -// ===========================================================================
108 -
109 -/// Errors returned by the UniFFI-exported whisper API.
110 -#[derive(Debug, thiserror::Error, uniffi::Error)]
111 -pub enum WhisperError {
112 - #[error("Model not loaded — call load_model() first")]
113 - ModelNotLoaded,
114 - #[error("Model load failed: {reason}")]
115 - ModelLoadFailed { reason: String },
116 - #[error("Transcription failed: {reason}")]
117 - TranscriptionFailed { reason: String },
118 - #[error("Download failed: {reason}")]
119 - DownloadFailed { reason: String },
120 - #[error("IO error: {reason}")]
121 - Io { reason: String },
122 -}
123 -
124 -// ===========================================================================
125 -// UniFFI — Record types (plain data structs passed across the FFI boundary)
126 -// ===========================================================================
127 -
128 -/// Progress payload emitted during a Whisper model download.
129 -///
130 -/// Callers of [`find_or_download_whisper_model`] receive this through
131 -/// the progress callback and can forward it to the frontend without any
132 -/// framework coupling in this crate.
133 -///
134 -/// Swift/Kotlin consumers receive this as a value type (struct / data class).
135 -#[tsync]
136 -#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
137 -pub struct WhisperModelDownloadProgress {
138 - /// The GGML model file being downloaded, e.g. `"ggml-base.bin"`.
139 - pub model_name: String,
140 - /// Bytes downloaded so far.
141 - pub downloaded_bytes: u64,
142 - /// Human-readable downloaded size, e.g. `"45.2 MB"`.
143 - pub downloaded_display: String,
144 - /// Expected total size in bytes (0 if unknown).
145 - pub total_bytes: u64,
146 - /// Human-readable total size, e.g. `"141.1 MB"`.
147 - pub total_display: String,
148 - /// Download progress as a value between 0.0 and 1.0.
149 - pub progress: f64,
150 - /// Whether the download has completed.
151 - pub done: bool,
152 -}
153 -
154 -/// A single transcription segment with timing information.
155 -///
156 -/// Mirrors `transcribe_rs::TranscriptionSegment` with UniFFI-compatible
157 -/// owned types so it can cross the FFI boundary.
158 -#[derive(Debug, Clone, uniffi::Record)]
159 -pub struct WhisperSegment {
160 - /// Start time in seconds.
161 - pub start_secs: f32,
162 - /// End time in seconds.
163 - pub end_secs: f32,
164 - /// The transcribed text for this segment.
165 - pub text: String,
166 -}
167 -
168 -/// Result of a Whisper transcription.
169 -///
170 -/// Mirrors `transcribe_rs::TranscriptionResult` with UniFFI-compatible
171 -/// owned types. `segments` is always a `Vec` (empty when the engine
172 -/// returns `None`).
173 -#[derive(Debug, Clone, uniffi::Record)]
174 -pub struct WhisperResult {
175 - /// The full transcribed text.
176 - pub text: String,
177 - /// Individual timed segments (empty if the engine did not produce any).
178 - pub segments: Vec<WhisperSegment>,
179 -}
180 -
181 -// ===========================================================================
182 -// UniFFI — Callback interface (Swift/Kotlin implements this to receive progress)
183 -// ===========================================================================
184 -
185 -/// Callback interface that Swift/Kotlin implements to receive download
186 -/// progress updates.
187 -///
188 -/// ```swift
189 -/// class MyProgressListener: WhisperProgressListener {
190 -/// func onProgress(progress: WhisperModelDownloadProgress) {
191 -/// print("Downloaded: \(progress.downloadedDisplay)")
192 -/// }
193 -/// }
194 -/// ```
195 -#[uniffi::export(callback_interface)]
196 -pub trait WhisperProgressListener: Send + Sync {
197 - /// Called periodically during a model download with the current progress.
198 - fn on_progress(&self, progress: WhisperModelDownloadProgress);
199 -}
200 -
201 -// ===========================================================================
202 -// UniFFI — Object wrapper around the foreign `WhisperEngine`
203 -// ===========================================================================
204 -
205 -/// A speech-to-text engine backed by Whisper (via whisper.cpp).
206 -///
207 -/// This is a UniFFI-compatible wrapper around `transcribe_rs::WhisperEngine`.
208 -/// Construct with [`OndeWhisperEngine::new`], load a model with
209 -/// [`load_model`], then transcribe audio with [`transcribe_file`] or
210 -/// [`transcribe_samples`].
211 -///
212 -/// ```swift
213 -/// let engine = OndeWhisperEngine()
214 -/// try engine.loadModel(path: "/path/to/ggml-base.bin")
215 -/// let result = try engine.transcribeFile(path: "/path/to/audio.wav", language: "en")
216 -/// print(result.text)
217 -/// ```
218 -#[derive(uniffi::Object)]
219 -pub struct OndeWhisperEngine {
220 - inner: std::sync::Mutex<Option<WhisperEngine>>,
221 -}
222 -
223 -#[uniffi::export]
224 -impl OndeWhisperEngine {
225 - /// Create a new, unloaded Whisper engine.
226 - #[uniffi::constructor]
227 - pub fn new() -> Arc<Self> {
228 - // The inner Option<WhisperEngine> starts as None; a model must be
229 - // loaded via load_model() before transcription.
230 - Arc::new(Self {
231 - inner: std::sync::Mutex::new(None),
232 - })
233 - }
234 -
235 - /// Load a Whisper GGML model file.
236 - ///
237 - /// `path` is the absolute filesystem path to the `.bin` model file.
238 - /// This must be called before any transcription method.
239 - pub fn load_model(&self, path: String) -> Result<(), WhisperError> {
240 - let model_path = PathBuf::from(&path);
241 - let engine =
242 - WhisperEngine::load(&model_path).map_err(|e| WhisperError::ModelLoadFailed {
243 - reason: e.to_string(),
244 - })?;
245 - let mut guard = self
246 - .inner
247 - .lock()
248 - .map_err(|e| WhisperError::ModelLoadFailed {
249 - reason: format!("Lock poisoned: {e}"),
250 - })?;
251 - *guard = Some(engine);
252 - Ok(())
253 - }
254 -
255 - /// Transcribe an audio file on disk.
256 - ///
257 - /// `path` is the absolute filesystem path to a WAV file (16-bit PCM,
258 - /// 16 kHz mono recommended).
259 - ///
260 - /// `language` is an optional BCP-47 language code (e.g. `"en"`, `"id"`).
261 - /// Pass `None` / `nil` for auto-detection.
262 - pub fn transcribe_file(
263 - &self,
264 - path: String,
265 - language: Option<String>,
266 - ) -> Result<WhisperResult, WhisperError> {
267 - let mut guard = self
268 - .inner
269 - .lock()
270 - .map_err(|e| WhisperError::TranscriptionFailed {
271 - reason: format!("Lock poisoned: {e}"),
272 - })?;
273 -
274 - let engine = guard.as_mut().ok_or(WhisperError::ModelNotLoaded)?;
275 -
276 - let params = WhisperInferenceParams {
277 - language,
278 - ..Default::default()
279 - };
280 -
281 - let file_path = Path::new(&path);
282 - let samples = transcribe_rs::audio::read_wav_samples(file_path).map_err(
283 - |e: transcribe_rs::TranscribeError| WhisperError::TranscriptionFailed {
284 - reason: e.to_string(),
285 - },
286 - )?;
287 - let result = engine.transcribe_with(&samples, &params).map_err(
288 - |e: transcribe_rs::TranscribeError| WhisperError::TranscriptionFailed {
289 - reason: e.to_string(),
290 - },
291 - )?;
292 -
293 - Ok(transcription_result_to_ffi(result))
294 - }
295 -
296 - /// Transcribe raw 16-bit PCM audio samples (16 kHz, mono, f32).
297 - ///
298 - /// `language` is an optional BCP-47 language code (e.g. `"en"`, `"id"`).
299 - /// Pass `None` / `nil` for auto-detection.
300 - pub fn transcribe_samples(
301 - &self,
302 - samples: Vec<f32>,
303 - language: Option<String>,
304 - ) -> Result<WhisperResult, WhisperError> {
305 - let mut guard = self
306 - .inner
307 - .lock()
308 - .map_err(|e| WhisperError::TranscriptionFailed {
309 - reason: format!("Lock poisoned: {e}"),
310 - })?;
311 -
312 - let engine = guard.as_mut().ok_or(WhisperError::ModelNotLoaded)?;
313 -
314 - let params = WhisperInferenceParams {
315 - language,
316 - ..Default::default()
317 - };
318 -
319 - let result = engine.transcribe_with(&samples, &params).map_err(
320 - |e: transcribe_rs::TranscribeError| WhisperError::TranscriptionFailed {
321 - reason: e.to_string(),
322 - },
323 - )?;
324 -
325 - Ok(transcription_result_to_ffi(result))
326 - }
327 -}
328 -
329 -// ===========================================================================
330 -// UniFFI — Exported free functions
331 -// ===========================================================================
332 -
333 -/// Returns the default Whisper model filename for the current platform.
334 -///
335 -/// - **Android (CPU-only)**: `ggml-base.bin` (~142 MB) — best quality/speed
336 -/// trade-off for mobile ARM CPUs.
337 -/// - **macOS / iOS (Metal GPU)**: `ggml-large-v3-turbo.bin` (~1.6 GB) —
338 -/// high quality, fast with GPU acceleration.
339 -/// - **Other platforms**: `ggml-small.bin` (~466 MB) — reasonable default.
340 -#[uniffi::export]
341 -pub fn default_model_for_platform() -> String {
342 - if cfg!(target_os = "android") {
343 - "ggml-base.bin".to_string()
344 - } else if cfg!(target_os = "macos") || cfg!(target_os = "ios") {
345 - "ggml-large-v3-turbo.bin".to_string()
346 - } else {
347 - "ggml-small.bin".to_string()
348 - }
349 -}
350 -
351 -/// Returns the list of Whisper model candidates in order of preference
352 -/// (best quality first).
353 -#[uniffi::export]
354 -pub fn whisper_model_candidates() -> Vec<String> {
355 - MODEL_CANDIDATES.iter().map(|s| s.to_string()).collect()
356 -}
357 -
358 -/// Finds a locally cached Whisper GGML model or downloads one via the
359 -/// HuggingFace hub.
360 -///
361 -/// # Arguments
362 -///
363 -/// - `model_dir` — Legacy local directory to search for manually-placed
364 -/// models. Not used for downloaded models.
365 -/// - `model_name` — Override the model filename to download. Pass `nil`
366 -/// to use the platform default.
367 -/// - `listener` — Callback invoked periodically during the download.
368 -/// Not called at all if a model is already cached.
369 -/// - `app_data_dir` — **Always pass the app's data directory.**
370 -/// The default `~/.cache` path is inaccessible on sandboxed platforms.
371 -/// Pass `nil` only in CLI / test contexts.
372 -///
373 -/// Returns the absolute path to the resolved model file.
374 -#[uniffi::export]
375 -pub fn find_or_download_whisper_model_ffi(
376 - model_dir: String,
377 - model_name: Option<String>,
378 - listener: Box<dyn WhisperProgressListener>,
379 - app_data_dir: Option<String>,
380 -) -> Result<String, WhisperError> {
381 - let model_dir_path = PathBuf::from(&model_dir);
382 - let app_data = app_data_dir.as_deref().map(Path::new);
383 - let name_ref = model_name.as_deref();
384 -
385 - let path = find_or_download_whisper_model(
386 - &model_dir_path,
387 - name_ref,
388 - move |progress| {
389 - listener.on_progress(progress);
390 - },
391 - app_data,
392 - )
393 - .map_err(|reason| WhisperError::DownloadFailed { reason })?;
394 -
395 - path.to_str()
396 - .map(|s| s.to_string())
397 - .ok_or_else(|| WhisperError::Io {
398 - reason: "Model path contains non-UTF-8 characters".to_string(),
399 - })
400 -}
401 -
402 -/// Read raw f32 samples from a WAV file (utility for feeding into
403 -/// `OndeWhisperEngine.transcribe_samples`).
404 -///
405 -/// Returns 16 kHz mono f32 samples suitable for Whisper.
406 -#[uniffi::export]
407 -pub fn read_wav_samples_ffi(path: String) -> Result<Vec<f32>, WhisperError> {
408 - read_wav_samples(Path::new(&path)).map_err(|e| WhisperError::Io {
409 - reason: e.to_string(),
410 - })
411 -}
412 -
413 -// ===========================================================================
414 -// Internal — callback-based API
415 -// ===========================================================================
416 -
417 -/// Resolve the `hf-hub` [`Cache`] instance for the current platform.
418 -///
419 -/// Callers should **always** provide `app_data_dir` (e.g.
420 -/// `app.path().app_data_dir()`). The default `~/.cache/huggingface/hub`
421 -/// is inaccessible on every sandboxed platform:
422 -///
423 -/// - **Android** — `dirs::home_dir()` panics inside the sandbox.
424 -/// - **iOS** — `~/.cache` is outside the container (`os error 1`).
425 -/// - **macOS App Store** — the app is sandboxed; `~/.cache` is
426 -/// inaccessible just like iOS.
427 -///
428 -/// Passing `app_data_dir` on non-sandboxed desktop builds (Linux,
429 -/// Windows, side-loaded macOS) is harmless — the cache simply lives
430 -/// under the app data directory instead of `~/.cache`.
431 -///
432 -/// `None` is accepted as a last-resort fallback for CLI / test contexts;
433 -/// it uses `Cache::default()`.
434 -fn resolve_hf_cache(app_data_dir: Option<&Path>) -> Result<hf_hub::Cache, String> {
435 - if let Some(data_dir) = app_data_dir {
436 - let hf_home = data_dir.join("huggingface");
437 - let hf_cache = hf_home.join("hub");
438 - std::fs::create_dir_all(&hf_cache)
439 - .map_err(|e| format!("Cannot create HF cache dir: {e}"))?;
440 -
441 - // Keep env vars in sync so hf_cache_dir() agrees.
442 - std::env::set_var("HF_HUB_CACHE", &hf_cache);
443 - std::env::set_var("HF_HOME", &hf_home);
444 -
445 - info!("HF cache resolved to app data path: {}", hf_cache.display());
446 - Ok(hf_hub::Cache::new(hf_cache))
447 - } else {
448 - warn!(
449 - "No app_data_dir provided — using Cache::default(). \
450 - This will fail on sandboxed platforms (iOS, Android, macOS App Store)."
451 - );
452 - Ok(hf_hub::Cache::default())
453 - }
454 -}
455 -
456 -/// Try to resolve a Whisper model file from the HF cache without
457 -/// downloading. Returns `Some(path)` if the file is already fully
458 -/// cached, `None` otherwise.
459 -///
460 -/// This mirrors the mistral.rs strategy: the HF cache *is* the model
461 -/// store — no separate `model_dir` copy is needed.
462 -fn resolve_from_hf_cache(model_name: &str, cache: &hf_hub::Cache) -> Option<PathBuf> {
463 - let repo = cache.repo(hf_hub::Repo::model(HF_WHISPER_REPO.to_string()));
464 - // `repo.get(name)` returns `Some(path)` when the blob is fully
465 - // downloaded and the pointer symlink exists.
466 - let path = repo.get(model_name);
467 - if let Some(ref p) = path {
468 - // Double-check: the path must exist and be a real file (following
469 - // symlinks) with a non-zero size.
470 - match std::fs::metadata(p) {
471 - Ok(meta) if meta.len() > 0 => {
472 - debug!(
473 - "HF cache hit for {}: {:?} ({} bytes)",
474 - model_name,
475 - p,
476 - meta.len()
477 - );
478 - }
479 - _ => {
480 - debug!(
481 - "HF cache entry for {} exists but is empty or broken",
482 - model_name
483 - );
484 - return None;
485 - }
486 - }
487 - }
488 - path
489 -}
490 -
491 -/// Finds a locally cached Whisper GGML model or downloads one via the
492 -/// HuggingFace hub.
493 -///
494 -/// # Resolution strategy (mirrors mistral.rs)
495 -///
496 -/// The HuggingFace hub cache is the **primary model store**. Models are
497 -/// downloaded into the cache's content-addressed blob directory and
498 -/// accessed via symlinks — no copy or hard-link into a separate
499 -/// `model_dir` is performed. This avoids "Operation not permitted"
500 -/// errors on sandboxed platforms (iOS, Android) and eliminates double
501 -/// disk usage.
502 -///
503 -/// Search order:
504 -///
505 -/// 1. Check the HF cache for an already-downloaded model (preferred
506 -/// candidate list, best quality first).
507 -/// 2. Check `model_dir` for manually-placed `.bin` files (legacy
508 -/// fallback).
509 -/// 3. Download the platform default (or explicitly requested
510 -/// `model_name`) from `ggerganov/whisper.cpp` on HuggingFace.
511 -///
512 -/// # Arguments
513 -///
514 -/// - `model_dir` — Legacy local directory to search for manually-placed
515 -/// models. Not used for downloaded models.
516 -/// - `model_name` — Override the model filename to download. Pass `None`
517 -/// to use [`default_model_for_platform`].
518 -/// - `on_progress` — Callback invoked periodically during the download.
519 -/// Receives a [`WhisperModelDownloadProgress`] payload. Not called at
520 -/// all if a model is already cached.
521 -/// - `app_data_dir` — **Always pass `Some(app.path().app_data_dir())`.**
522 -/// The default `~/.cache` path is inaccessible on every sandboxed
523 -/// platform (Android, iOS, macOS App Store). On non-sandboxed desktop
524 -/// builds it is harmless — the cache simply lives under the app data
525 -/// directory. `None` is only acceptable in CLI / test contexts.
526 -///
527 -/// # Errors
528 -///
529 -/// Returns a human-readable error string on failure (network, I/O, etc.).
530 -pub fn find_or_download_whisper_model<F>(
531 - model_dir: &Path,
532 - model_name: Option<&str>,
533 - on_progress: F,
534 - app_data_dir: Option<&Path>,
535 -) -> Result<PathBuf, String>
536 -where
537 - F: Fn(WhisperModelDownloadProgress) + Send + Sync + 'static,
538 -{
539 - // ── 1. Build the HF cache once (shared across all lookups) ────────
540 - let cache = resolve_hf_cache(app_data_dir)?;
541 -
542 - // ── 2. Check the HF cache for an already-downloaded model ─────────
543 - //
544 - // Walk the preferred candidate list (best quality first). The first
545 - // cached model wins. This is the primary lookup path — identical to
546 - // how mistral.rs resolves models from cache before triggering a
547 - // download.
548 - for candidate in MODEL_CANDIDATES {
549 - if let Some(path) = resolve_from_hf_cache(candidate, &cache) {
550 - info!("Whisper model found in HF cache: {:?}", path);
551 - return Ok(path);
552 - }
553 - }
554 -
555 - // ── 3. Legacy fallback: check model_dir for manually-placed files ─
556 - if model_dir.exists() {
557 - for candidate in MODEL_CANDIDATES {
558 - let path = model_dir.join(candidate);
559 - if path.exists() && path.is_file() {
560 - info!("Whisper model found in legacy model_dir: {:?}", path);
561 - return Ok(path);
562 - }
563 - }
564 -
565 - // Any .bin file at all.
566 - if let Ok(entries) = std::fs::read_dir(model_dir) {
567 - for entry in entries.flatten() {
568 - let path = entry.path();
569 - if path.is_file() {
570 - if let Some(ext) = path.extension() {
571 - if ext == "bin" {
572 - warn!(
573 - "No preferred Whisper model found; falling back to {:?}",
574 - path
575 - );
576 - return Ok(path);
577 - }
578 - }
579 - }
580 - }
581 - }
582 - }
583 -
584 - // ── 4. Nothing cached — download via hf-hub ───────────────────────
585 -
586 - let target_name = model_name.unwrap_or(&default_model_for_platform_internal());
587 -
588 - info!(
589 - "No Whisper model cached; downloading {} from {}",
590 - target_name, HF_WHISPER_REPO,
591 - );
592 -
593 - let hf_path = download_whisper_model(target_name, on_progress, &cache)?;
594 -
595 - // The returned path lives inside the HF cache's blob store — use it
596 - // directly. No copy / hard-link / symlink into model_dir. This is
597 - // the same strategy mistral.rs uses: the HF cache *is* the model
598 - // directory.
599 - Ok(hf_path)
600 -}
601 -
602 -// ---------------------------------------------------------------------------
603 -// Internal helpers
604 -// ---------------------------------------------------------------------------
605 -
606 -/// Internal version of `default_model_for_platform` that returns `&'static str`
607 -/// (used by the generic callback-based API where we need a string slice).
608 -fn default_model_for_platform_internal() -> &'static str {
609 - if cfg!(target_os = "android") {
610 - "ggml-base.bin"
611 - } else if cfg!(target_os = "macos") || cfg!(target_os = "ios") {
612 - "ggml-large-v3-turbo.bin"
613 - } else {
614 - "ggml-small.bin"
615 - }
616 -}
617 -
618 -/// Convert a `transcribe_rs::TranscriptionResult` into our UniFFI-compatible
619 -/// [`WhisperResult`].
620 -fn transcription_result_to_ffi(result: TranscriptionResult) -> WhisperResult {
621 - WhisperResult {
622 - text: result.text,
623 - segments: result
624 - .segments
625 - .unwrap_or_default()
626 - .into_iter()
627 - .map(|seg| WhisperSegment {
628 - start_secs: seg.start,
629 - end_secs: seg.end,
630 - text: seg.text,
631 - })
632 - .collect(),
633 - }
634 -}
635 -
636 -/// Download a single GGML model file from `ggerganov/whisper.cpp` using the
637 -/// HuggingFace hub synchronous API.
638 -///
639 -/// Accepts a pre-built [`hf_hub::Cache`] (from [`resolve_hf_cache`]) so that
640 -/// the caller controls where files land — essential on sandboxed platforms
641 -/// (iOS, Android).
642 -///
643 -/// hf-hub handles:
644 -/// - Resumable downloads (partial `.incomplete` files are continued).
645 -/// - Content-addressed blob storage with symlinks.
646 -///
647 -/// Returns the absolute path to the downloaded file inside the HF cache.
648 -fn download_whisper_model<F>(
649 - model_name: &str,
650 - on_progress: F,
651 - cache: &hf_hub::Cache,
652 -) -> Result<PathBuf, String>
653 -where
654 - F: Fn(WhisperModelDownloadProgress) + Send + Sync + 'static,
655 -{
656 - use hf_hub::api::sync::ApiBuilder;
657 - use std::sync::{
658 - atomic::{AtomicBool, Ordering},
659 - Arc,
660 - };
661 - use std::time::Duration;
662 -
663 - let api = ApiBuilder::from_cache(cache.clone())
664 - .build()
665 - .map_err(|e| format!("Failed to create HF API: {e}"))?;
666 -
667 - let repo = api.model(HF_WHISPER_REPO.to_string());
668 -
669 - // Emit an initial "starting" progress event.
670 - let name = model_name.to_string();
671 - on_progress(WhisperModelDownloadProgress {
672 - model_name: name.clone(),
673 - downloaded_bytes: 0,
674 - downloaded_display: "0 B".into(),
675 - total_bytes: 0,
676 - total_display: "calculating…".into(),
677 - progress: 0.0,
678 - done: false,
679 - });
680 -
681 - // hf-hub's `repo.get()` is a blocking call that downloads the file.
682 - // We spin up a native monitor thread that polls the HF cache directory
683 - // for the blob size to provide progress updates — the same pattern
684 - // used in `hf_cache::download_model`.
685 - let finished = Arc::new(AtomicBool::new(false));
686 - let finished_clone = Arc::clone(&finished);
687 - let monitor_name = name.clone();
688 -
689 - // Try to figure out the model cache path for monitoring.
690 - let cache_path = crate::hf_cache::model_cache_path(HF_WHISPER_REPO);
691 -
692 - // Approximate expected sizes for common models so the progress bar
693 - // has a meaningful denominator even before the download starts.
694 - let expected_size = approximate_model_size(model_name);
695 -
696 - let monitor_handle = std::thread::spawn(move || loop {
697 - std::thread::sleep(Duration::from_secs(1));
698 -
699 - if finished_clone.load(Ordering::Relaxed) {
700 - break;
701 - }
702 -
703 - let current_bytes = cache_path
704 - .as_ref()
705 - .filter(|p| p.exists())
706 - .map(|p| dir_size_recursive(p))
707 - .unwrap_or(0);
708 -
709 - let progress = if expected_size > 0 {
710 - (current_bytes as f64 / expected_size as f64).min(0.99)
711 - } else {
712 - 0.0
713 - };
714 -
715 - on_progress(WhisperModelDownloadProgress {
716 - model_name: monitor_name.clone(),
717 - downloaded_bytes: current_bytes,
718 - downloaded_display: format_size(current_bytes),
719 - total_bytes: expected_size,
720 - total_display: format_size(expected_size),
721 - progress,
722 - done: false,
723 - });
724 - });
725 -
726 - // Blocking download — hf-hub handles resumption internally.
727 - let result = repo.get(model_name);
728 -
729 - finished.store(true, Ordering::Relaxed);
730 - let _ = monitor_handle.join();
731 -
732 - match result {
733 - Ok(path) => {
734 - info!(
735 - "Whisper model {} downloaded to HF cache: {:?}",
736 - model_name, path
737 - );
738 - // Final progress event.
739 - let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
740 - // We need a new closure-compatible progress emitter here.
741 - // Since the monitor thread consumed `on_progress`, we log instead.
742 - // The caller will see the file appear and can emit its own "done".
743 - info!(
744 - "Whisper model download complete: {} ({})",
745 - model_name,
746 - format_size(file_size)
747 - );
748 - Ok(path)
749 - }
750 - Err(e) => {
751 - let msg = format!(
752 - "Failed to download Whisper model {} from {}: {}",
753 - model_name, HF_WHISPER_REPO, e
754 - );
755 - warn!("{}", msg);
756 - Err(msg)
757 - }
758 - }
759 -}
760 -
761 -/// Approximate expected download size for common Whisper GGML models.
762 -/// These are rough estimates used for progress bar rendering; they don't
763 -/// need to be exact.
764 -fn approximate_model_size(model_name: &str) -> u64 {
765 - match model_name {
766 - "ggml-large-v3-turbo.bin" => 1_620_000_000,
767 - "ggml-large-v3.bin" => 3_090_000_000,
768 - "ggml-large-v2.bin" => 3_090_000_000,
769 - "ggml-large-v1.bin" => 3_090_000_000,
770 - "ggml-medium.bin" => 1_530_000_000,
771 - "ggml-medium.en.bin" => 1_530_000_000,
772 - "ggml-medium-q5_0.bin" => 539_000_000,
773 - "ggml-small.bin" => 466_000_000,
774 - "ggml-small.en.bin" => 466_000_000,
775 - "ggml-base.bin" => 142_000_000,
776 - "ggml-base.en.bin" => 142_000_000,
777 - "ggml-tiny.bin" => 75_000_000,
778 - "ggml-tiny.en.bin" => 75_000_000,
779 - _ => 0,
780 - }
781 -}
782 -
783 -/// Recursively compute the total size of a directory in bytes.
784 -/// Follows symlinks to count the real file size and also counts in-progress
785 -/// `.incomplete` staging files so progress polling works during download.
786 -fn dir_size_recursive(path: &Path) -> u64 {
787 - let mut total: u64 = 0;
788 - if let Ok(entries) = std::fs::read_dir(path) {
789 - for entry in entries.flatten() {
790 - let entry_path = entry.path();
791 - if entry_path.is_dir() {
792 - total += dir_size_recursive(&entry_path);
793 - } else if let Ok(meta) = entry_path.symlink_metadata() {
794 - if meta.is_symlink() {
795 - if let Ok(real_meta) = entry_path.metadata() {
796 - total += real_meta.len();
797 - }
798 - } else {
799 - total += meta.len();
800 - }
801 - }
802 - }
803 - }
804 - total
805 -}
806 -
807 -/// Format a byte count into a human-readable string.
808 -fn format_size(bytes: u64) -> String {
809 - const KB: u64 = 1024;
810 - const MB: u64 = KB * 1024;
811 - const GB: u64 = MB * 1024;
812 -
813 - if bytes >= GB {
814 - format!("{:.2} GB", bytes as f64 / GB as f64)
815 - } else if bytes >= MB {
816 - format!("{:.1} MB", bytes as f64 / MB as f64)
817 - } else if bytes >= KB {
818 - format!("{:.0} KB", bytes as f64 / KB as f64)
819 - } else {
820 - format!("{} B", bytes)
821 - }
822 -}