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, ¶ms).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, ¶ms).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
-}