Files
Lumotia/src-tauri/src/commands/models.rs
jars d6bde52d6e refactor(tauri): use magnotia_core::hardware::vulkan_loader_available
Delete the local duplicate fn and libloading dependency from src-tauri;
import the canonical implementation from magnotia-core::hardware instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 12:08:16 +01:00

692 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::sync::Arc;
use serde::Serialize;
use tauri::Emitter;
use crate::commands::security::ensure_main_window;
use crate::AppState;
use magnotia_core::constants::WHISPER_SAMPLE_RATE;
use magnotia_core::hardware::{self, vulkan_loader_available, CpuFeatures};
use magnotia_core::model_registry::{self, Engine, LanguageSupport, ModelEntry};
use magnotia_core::types::{AudioSamples, ModelId, TranscriptionOptions};
#[cfg(feature = "whisper")]
use magnotia_transcription::load_whisper;
use magnotia_transcription::model_manager;
use magnotia_transcription::{load_parakeet, LocalEngine, Transcriber};
/// Map legacy size strings to ModelId.
fn whisper_model_id(size: &str) -> ModelId {
match size.to_lowercase().as_str() {
"tiny" => ModelId::new("whisper-tiny-en"),
"base" => ModelId::new("whisper-base-en"),
"small" => ModelId::new("whisper-small-en"),
"distil-small" | "distilsmall" => ModelId::new("whisper-distil-small-en"),
"medium" => ModelId::new("whisper-medium-en"),
"distil-large" | "distil-large-v3" | "distillarge" => {
ModelId::new("whisper-distil-large-v3")
}
other => ModelId::new(other),
}
}
fn parakeet_model_id(name: &str) -> ModelId {
match name {
"ctc-int8" => ModelId::new("parakeet-ctc-0.6b-int8"),
other => ModelId::new(other),
}
}
fn engine_for_name(state: &AppState, engine_name: &str) -> Result<Arc<LocalEngine>, String> {
match engine_name {
"whisper" => Ok(state.whisper_engine.clone()),
"parakeet" => Ok(state.parakeet_engine.clone()),
other => Err(format!("Unknown engine: {other}")),
}
}
fn language_support_info(language_support: LanguageSupport) -> LanguageSupportInfo {
match language_support {
LanguageSupport::EnglishOnly => LanguageSupportInfo {
kind: "english-only".into(),
language_count: 1,
},
LanguageSupport::Multilingual(count) => LanguageSupportInfo {
kind: "multilingual".into(),
language_count: count,
},
}
}
fn model_capability(
entry: &'static ModelEntry,
engine: &Arc<LocalEngine>,
) -> ModelRuntimeCapabilities {
let loaded_model_id = engine.loaded_model_id();
ModelRuntimeCapabilities {
id: entry.id.to_string(),
display_name: entry.display_name.to_string(),
downloaded: model_manager::is_downloaded(&entry.id),
loaded: loaded_model_id
.as_ref()
.map(|id| id == &entry.id)
.unwrap_or(false),
language_support: language_support_info(entry.languages),
}
}
pub fn load_model_from_disk(model_id: &ModelId) -> Result<Box<dyn Transcriber + Send>, String> {
let entry =
model_registry::find_model(model_id).ok_or_else(|| format!("Unknown model: {model_id}"))?;
match entry.engine {
#[cfg(feature = "whisper")]
Engine::Whisper => {
let dir = model_manager::model_dir(model_id);
let model_file = entry
.files
.first()
.map(|file| dir.join(file.filename))
.ok_or_else(|| format!("No files registered for model: {model_id}"))?;
if !model_file.exists() {
return Err(format!("Model not downloaded: {model_id}"));
}
load_whisper(&model_file).map_err(|e| e.to_string())
}
#[cfg(not(feature = "whisper"))]
Engine::Whisper => Err(format!(
"Whisper backend not compiled in this build (magnotia built without the \"whisper\" feature); \
cannot load {model_id}"
)),
Engine::Parakeet => {
let dir = model_manager::model_dir(model_id);
if !dir.exists() {
return Err(format!("Model not downloaded: {model_id}"));
}
load_parakeet(&dir).map_err(|e| e.to_string())
}
Engine::Moonshine => Err("Moonshine models are not yet supported in this build".into()),
}
}
pub fn default_model_id_for_engine(engine: &str) -> ModelId {
match engine {
"parakeet" => ModelId::new("parakeet-ctc-0.6b-int8"),
_ => ModelId::new("whisper-base-en"),
}
}
pub async fn ensure_model_loaded(
state: &AppState,
engine_name: &str,
model_id: &str,
concurrent: Option<bool>,
) -> Result<(), String> {
let model_id = ModelId::new(model_id);
let entry = model_registry::find_model(&model_id)
.ok_or_else(|| format!("Unknown model: {model_id}"))?;
let expected_engine = match entry.engine {
Engine::Whisper => "whisper",
Engine::Parakeet => "parakeet",
Engine::Moonshine => "moonshine",
};
if expected_engine != engine_name {
return Err(format!(
"Model {} belongs to {}, not {}",
model_id, expected_engine, engine_name
));
}
if !model_manager::is_downloaded(&model_id) {
return Err(format!("Model not downloaded: {model_id}"));
}
let engine = engine_for_name(state, engine_name)?;
if engine
.loaded_model_id()
.as_ref()
.map(|loaded| loaded == &model_id)
.unwrap_or(false)
{
return Ok(());
}
// Sequential-GPU guard (brief item A.1 #28): if the user opts out
// of concurrent GPU residency, free the LLM before bringing the
// transcription engine on. None / Some(true) leaves the LLM
// untouched (legacy parallel behaviour, safe on multi-GB VRAM
// setups). Inverse guard lives in commands::llm::load_llm_model.
if concurrent == Some(false) && state.llm_engine.is_loaded() {
state.llm_engine.unload().map_err(|e| e.to_string())?;
}
let engine_clone = engine.clone();
let model_id_clone = model_id.clone();
tokio::task::spawn_blocking(move || {
let model = load_model_from_disk(&model_id_clone)?;
engine_clone.load(model, model_id_clone);
Ok::<_, String>(())
})
.await
.map_err(|e| e.to_string())??;
Ok(())
}
/// Spawns a background task that loads the default Whisper model into memory
/// if it is already downloaded. Returns immediately — errors are logged, never panicked.
///
/// Call this once from app setup() after AppState is managed.
pub fn prewarm_default_model(whisper_engine: Arc<LocalEngine>) {
let model_id = default_model_id_for_engine("whisper");
if !model_manager::is_downloaded(&model_id) {
return;
}
if whisper_engine
.loaded_model_id()
.as_ref()
.map(|id| id == &model_id)
.unwrap_or(false)
{
return;
}
tauri::async_runtime::spawn(async move {
let result = tauri::async_runtime::spawn_blocking(move || {
load_model_from_disk(&model_id).map(|model| {
whisper_engine.load(model, model_id);
// Silent warm-up pass: feed one second of silence through
// the freshly-loaded engine. Pre-allocates the Whisper
// context window + warms GPU shader caches so the user's
// first real transcription completes in ≤1.5× steady-state
// latency instead of the ~45s cold-start documented in
// ufal/whisper_streaming #96 and #135. Silence returns
// empty segments — the *work* is the context allocation.
let silence = AudioSamples::mono_16khz(vec![0.0_f32; WHISPER_SAMPLE_RATE as usize]);
let options = TranscriptionOptions::default();
match whisper_engine.transcribe_sync(&silence, &options) {
Ok(_) => eprintln!("[startup] Whisper warm-up inference complete"),
Err(e) => eprintln!("[startup] Whisper warm-up inference failed: {e}"),
}
})
})
.await;
match result {
Ok(Ok(())) => eprintln!("[startup] Whisper model pre-warmed successfully"),
Ok(Err(e)) => eprintln!("[startup] Whisper pre-warm failed: {e}"),
Err(e) => eprintln!("[startup] Whisper pre-warm task panicked: {e}"),
}
});
}
#[tauri::command]
pub async fn prewarm_default_model_cmd(
window: tauri::WebviewWindow,
state: tauri::State<'_, AppState>,
) -> Result<(), String> {
ensure_main_window(&window)?;
prewarm_default_model(state.whisper_engine.clone());
Ok(())
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeCapabilities {
pub accelerators: Vec<String>,
pub engines: Vec<EngineRuntimeCapabilities>,
/// Which compute device Whisper / whisper.cpp actually booted against.
/// Distinct from `accelerators` (which lists what the _build_ supports):
/// a Vulkan-built binary on a CPU-only box reports accelerators=[cpu, vulkan]
/// but activeComputeDevice.kind = "cpu" with a reason.
pub active_compute_device: ActiveComputeDevice,
/// Runtime-detected CPU feature flags. Surfaced so Settings can warn
/// the user that performance will be poor without AVX2 / FMA.
pub cpu_features: CpuFeaturesInfo,
/// True when the detected hardware can sustain Whisper + LLM on the
/// same GPU concurrently (≥16 GB VRAM). Item #28 gates a user-facing
/// toggle on this.
pub parallel_mode_available: bool,
}
/// Serialisable summary of whichever backend whisper.cpp / ggml wired
/// up on this boot. For MVP (Phase A.1) we derive this from
/// compile-time features + a runtime Vulkan loader probe; Phase A.2
/// wires `whisper_print_system_info` for the real answer.
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ActiveComputeDevice {
/// One of "cuda" | "vulkan" | "metal" | "cpu".
pub kind: String,
/// Human-readable label, e.g. "GPU (Vulkan)" or "CPU (fallback)".
pub label: String,
/// Set only when we fell back from a richer backend, explains why
/// (e.g. "Vulkan loader not found"). None on the happy path.
pub reason: Option<String>,
}
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct CpuFeaturesInfo {
pub avx2: bool,
pub avx512f: bool,
pub fma: bool,
pub sse4_2: bool,
pub neon: bool,
/// Shortcut for Settings: false means we fall back to a slow path.
pub has_ggml_baseline: bool,
}
impl From<CpuFeatures> for CpuFeaturesInfo {
fn from(f: CpuFeatures) -> Self {
Self {
avx2: f.avx2,
avx512f: f.avx512f,
fma: f.fma,
sse4_2: f.sse4_2,
neon: f.neon,
has_ggml_baseline: f.has_ggml_baseline(),
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EngineRuntimeCapabilities {
pub id: String,
pub default_model_id: String,
pub loaded_model_id: Option<String>,
pub supports_gpu: bool,
pub models: Vec<ModelRuntimeCapabilities>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelRuntimeCapabilities {
pub id: String,
pub display_name: String,
pub downloaded: bool,
pub loaded: bool,
pub language_support: LanguageSupportInfo,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LanguageSupportInfo {
pub kind: String,
pub language_count: u16,
}
/// Compile-time target signalling used by `compose_accelerators`.
/// Split out so the pure-function behaviour is testable without `cfg!`
/// appearing in the test matrix.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AcceleratorTarget {
Macos,
Other,
}
/// Pure helper: produce the `accelerators` list from the
/// whisper-compiled-in flag, runtime loader availability, and target
/// family. Always starts with `cpu`; appends the platform-appropriate
/// GPU name only when whisper is compiled in AND the Vulkan loader
/// resolves. RB-07 replaced a hard-coded `["cpu", "vulkan"]`.
fn compose_accelerators(
whisper_enabled: bool,
loader_available: bool,
target: AcceleratorTarget,
) -> Vec<String> {
let mut accelerators = vec!["cpu".into()];
if whisper_enabled && loader_available {
let gpu = match target {
AcceleratorTarget::Macos => "metal",
AcceleratorTarget::Other => "vulkan",
};
accelerators.push(gpu.into());
}
accelerators
}
/// Public wrapper around `compose_accelerators` that reads the real
/// `cfg(feature = "whisper")`, runtime loader probe, and target OS.
fn supported_accelerators() -> Vec<String> {
let target = if cfg!(target_os = "macos") {
AcceleratorTarget::Macos
} else {
AcceleratorTarget::Other
};
compose_accelerators(cfg!(feature = "whisper"), vulkan_loader_available(), target)
}
/// Report which backend whisper.cpp was actually able to initialise
/// against. The whisper-rs build here is compiled with the `vulkan`
/// feature unconditionally; on macOS that's still Metal via MoltenVK,
/// on Linux/Windows it's Vulkan. If the Vulkan loader is missing
/// we surface the CPU fallback path explicitly so the UI can warn.
pub fn detect_active_compute_device() -> ActiveComputeDevice {
#[cfg(target_os = "macos")]
{
if vulkan_loader_available() {
return ActiveComputeDevice {
kind: "metal".into(),
label: "GPU (Metal via MoltenVK)".into(),
reason: None,
};
}
return ActiveComputeDevice {
kind: "cpu".into(),
label: "CPU (fallback)".into(),
reason: Some(
"MoltenVK / Vulkan loader not found — install the Vulkan SDK runtime.".into(),
),
};
}
#[cfg(not(target_os = "macos"))]
{
if vulkan_loader_available() {
return ActiveComputeDevice {
kind: "vulkan".into(),
label: "GPU (Vulkan)".into(),
reason: None,
};
}
ActiveComputeDevice {
kind: "cpu".into(),
label: "CPU (fallback)".into(),
reason: Some(
"Vulkan loader not found — install the Vulkan runtime (Windows) or \
libvulkan1 (Linux) to enable GPU acceleration."
.into(),
),
}
}
}
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase", tag = "kind", content = "message")]
pub enum RuntimeWarning {
/// CPU lacks AVX2 / FMA on x86_64 — ggml fallback path will be
/// dramatically slower. User should install a non-AVX2 build or
/// accept the hit.
Avx2Missing(String),
/// Vulkan loader is missing at runtime. Emitted once at startup
/// when `active_compute_device.reason` includes a loader-missing
/// message.
VulkanLoaderMissing(String),
/// CUDA driver mismatch forced a fallback (future-proofed for
/// when we add CUDA-detect; not emitted today).
#[allow(dead_code)]
CudaFallback(String),
}
/// Emit any runtime warnings the frontend should surface as a banner.
/// Called once at setup() after `prewarm_default_model`.
pub fn emit_runtime_warnings(app: &tauri::AppHandle) {
let cpu_features = hardware::probe_cpu_features();
let device = detect_active_compute_device();
if !cpu_features.has_ggml_baseline() {
let _ = app.emit(
"runtime-warning",
RuntimeWarning::Avx2Missing(
"Your CPU is missing AVX2/FMA. Whisper and the local LLM will run on a \
dramatically slower fallback path — expect 510× the latency of a \
2015-or-newer CPU."
.into(),
),
);
}
if device.kind == "cpu" {
if let Some(reason) = device.reason.as_ref() {
let _ = app.emit(
"runtime-warning",
RuntimeWarning::VulkanLoaderMissing(reason.clone()),
);
}
}
}
#[tauri::command]
pub fn get_runtime_capabilities(
state: tauri::State<'_, AppState>,
) -> Result<RuntimeCapabilities, String> {
let whisper = state.whisper_engine.clone();
let parakeet = state.parakeet_engine.clone();
let whisper_models = model_registry::all_models()
.iter()
.filter(|entry| entry.engine == Engine::Whisper)
.map(|entry| model_capability(entry, &whisper))
.collect();
let parakeet_models = model_registry::all_models()
.iter()
.filter(|entry| entry.engine == Engine::Parakeet)
.map(|entry| model_capability(entry, &parakeet))
.collect();
let active_compute_device = detect_active_compute_device();
let cpu_features: CpuFeaturesInfo = hardware::probe_cpu_features().into();
// Accelerator list is now derived from the live build configuration
// and runtime loader probe — see `compose_accelerators`. Parakeet
// (ONNX) stays CPU-only; Whisper advertises `supports_gpu` only
// when it was actually compiled in for this binary.
let whisper_supports_gpu = cfg!(feature = "whisper");
Ok(RuntimeCapabilities {
accelerators: supported_accelerators(),
engines: vec![
EngineRuntimeCapabilities {
id: "whisper".into(),
default_model_id: default_model_id_for_engine("whisper").to_string(),
loaded_model_id: whisper
.loaded_model_id()
.map(|model_id| model_id.to_string()),
supports_gpu: whisper_supports_gpu,
models: whisper_models,
},
EngineRuntimeCapabilities {
id: "parakeet".into(),
default_model_id: default_model_id_for_engine("parakeet").to_string(),
loaded_model_id: parakeet
.loaded_model_id()
.map(|model_id| model_id.to_string()),
supports_gpu: false,
models: parakeet_models,
},
],
active_compute_device,
cpu_features,
// Phase A.1 ships detection of the VRAM budget only via
// hardware::probe_gpu; that currently returns None, so we
// default to sequential. When Phase A.4 (GpuGuard) and the
// real probe_gpu land, flip this based on detected VRAM ≥ 16 GB.
parallel_mode_available: false,
})
}
// --- Whisper model commands ---
#[tauri::command]
pub async fn download_model(
window: tauri::WebviewWindow,
app: tauri::AppHandle,
size: String,
) -> Result<String, String> {
ensure_main_window(&window)?;
let id = whisper_model_id(&size);
let app_clone = app.clone();
model_manager::download(&id, move |progress| {
let _ = app_clone.emit("model-download-progress", &progress);
})
.await
.map_err(|e| e.to_string())?;
Ok(format!("Model {} downloaded", size))
}
#[tauri::command]
pub fn check_model(size: String) -> Result<bool, String> {
let id = whisper_model_id(&size);
Ok(model_manager::is_downloaded(&id))
}
#[tauri::command]
pub fn list_models() -> Result<Vec<String>, String> {
let ids = model_manager::list_downloaded();
Ok(ids
.into_iter()
.filter(|id| id.as_str().starts_with("whisper-"))
.map(|id| match id.as_str() {
"whisper-tiny-en" => "Tiny".to_string(),
"whisper-base-en" => "Base".to_string(),
"whisper-small-en" => "Small".to_string(),
"whisper-distil-small-en" => "Distil-S".to_string(),
"whisper-medium-en" => "Medium".to_string(),
"whisper-distil-large-v3" => "Distil-L".to_string(),
other => other.to_string(),
})
.collect())
}
#[tauri::command]
pub async fn load_model(
window: tauri::WebviewWindow,
state: tauri::State<'_, AppState>,
size: String,
concurrent: Option<bool>,
) -> Result<String, String> {
ensure_main_window(&window)?;
let id = whisper_model_id(&size);
ensure_model_loaded(&state, "whisper", id.as_str(), concurrent).await?;
Ok(format!("Model {} loaded", size))
}
#[tauri::command]
pub fn check_engine(state: tauri::State<AppState>) -> Result<bool, String> {
Ok(state.whisper_engine.is_loaded())
}
// --- Parakeet model commands ---
#[tauri::command]
pub async fn download_parakeet_model(
window: tauri::WebviewWindow,
app: tauri::AppHandle,
name: String,
) -> Result<String, String> {
ensure_main_window(&window)?;
let id = parakeet_model_id(&name);
let app_clone = app.clone();
model_manager::download(&id, move |progress| {
let _ = app_clone.emit("parakeet-download-progress", &progress);
})
.await
.map_err(|e| e.to_string())?;
Ok(format!("Parakeet model {} downloaded", name))
}
#[tauri::command]
pub fn check_parakeet_model(name: String) -> Result<bool, String> {
let id = parakeet_model_id(&name);
Ok(model_manager::is_downloaded(&id))
}
#[tauri::command]
pub fn list_parakeet_models() -> Result<Vec<String>, String> {
let ids = model_manager::list_downloaded();
Ok(ids
.into_iter()
.filter(|id| id.as_str().starts_with("parakeet-"))
.map(|id| match id.as_str() {
"parakeet-ctc-0.6b-int8" => "ctc-int8".to_string(),
other => other.to_string(),
})
.collect())
}
#[tauri::command]
pub async fn load_parakeet_model(
window: tauri::WebviewWindow,
state: tauri::State<'_, AppState>,
name: String,
concurrent: Option<bool>,
) -> Result<String, String> {
ensure_main_window(&window)?;
let id = parakeet_model_id(&name);
ensure_model_loaded(&state, "parakeet", id.as_str(), concurrent).await?;
Ok(format!("Parakeet model {} loaded", name))
}
#[tauri::command]
pub fn check_parakeet_engine(state: tauri::State<'_, AppState>) -> Result<bool, String> {
Ok(state.parakeet_engine.is_loaded())
}
#[cfg(test)]
mod tests {
use super::*;
// RB-07: the accelerator list is now derived from compile flags +
// runtime loader probe + target. These cover the permutations.
//
// Pre-fix behaviour was `vec!["cpu".into(), "vulkan".into()]`
// regardless of whisper feature, loader availability, or platform —
// so a macOS build falsely advertised "vulkan", and a no-whisper
// build falsely advertised a GPU path with no engine to use it.
#[test]
fn cpu_only_when_whisper_disabled() {
assert_eq!(
compose_accelerators(false, true, AcceleratorTarget::Macos),
vec!["cpu".to_string()]
);
assert_eq!(
compose_accelerators(false, true, AcceleratorTarget::Other),
vec!["cpu".to_string()]
);
}
#[test]
fn cpu_only_when_loader_missing() {
assert_eq!(
compose_accelerators(true, false, AcceleratorTarget::Macos),
vec!["cpu".to_string()]
);
assert_eq!(
compose_accelerators(true, false, AcceleratorTarget::Other),
vec!["cpu".to_string()]
);
}
#[test]
fn macos_with_loader_advertises_metal() {
assert_eq!(
compose_accelerators(true, true, AcceleratorTarget::Macos),
vec!["cpu".to_string(), "metal".to_string()]
);
}
#[test]
fn non_macos_with_loader_advertises_vulkan() {
assert_eq!(
compose_accelerators(true, true, AcceleratorTarget::Other),
vec!["cpu".to_string(), "vulkan".to_string()]
);
}
#[test]
fn cpu_is_always_first_entry() {
// Frontend relies on index-0 being the fallback; preserve that
// contract regardless of which GPU extras are added.
for target in [AcceleratorTarget::Macos, AcceleratorTarget::Other] {
let full = compose_accelerators(true, true, target);
assert_eq!(full.first(), Some(&"cpu".to_string()));
}
}
}