From a6731189f935b2140b8987c8d26489904f8bc4df Mon Sep 17 00:00:00 2001 From: xqqmdy Date: Sat, 14 Nov 2026 23:44:55 +0800 Subject: [PATCH 1/2] feat: make whisper-cli a selectable backend --- config.example.toml | 12 ++-- docs/architecture.md | 6 +- docs/install.md | 33 +++++++++ docs/troubleshooting.md | 19 ++++++ src/config.rs | 29 ++++++++ src/doctor.rs | 60 +++++++++++------ src/pipeline.rs | 37 +++++----- src/setup.rs | 146 +++++++++++++++++++++++++--------------- 8 files changed, 250 insertions(+), 92 deletions(-) diff --git a/config.example.toml b/config.example.toml index 165eed0..610d064 100644 --- a/config.example.toml +++ b/config.example.toml @@ -12,13 +12,17 @@ feedback = true typing_delay_ms = 0 [recognition] -# The managed runtime and model installed by `whispers setup`. +# "sherpa-onnx" runs the managed streaming runtime installed by +# `whispers setup`. "whisper-cli" runs the external whisper.cpp CLI on the +# finished recording, with no streaming preview. Install whisper-cpp plus a GPU +# backend first (on Arch: `pacman -S whisper-cpp ggml-vulkan`); the plain +# `ggml` package is CPU only and makes large models unusably slow. runtime = "sherpa-onnx" -# Low-latency text shown in the OSD while speaking. +# Low-latency text shown in the OSD while speaking (sherpa-onnx only). model = "nemotron-3.5-asr-streaming-0.6b-560ms-int8" -# Multilingual full-recording pass used for delivered text. +# Multilingual full-recording pass used for delivered text (sherpa-onnx only). final_model = "nemo-parakeet-tdt-0.6b-v3-int8" -# Existing whisper.cpp model used only if managed ASR is unavailable. +# whisper.cpp model used by `whisper-cli` and as the managed ASR fallback. fallback_model = "ggml-large-v3-turbo.bin" [enhancement] diff --git a/docs/architecture.md b/docs/architecture.md index 5b2f9bf..6fa7529 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -49,10 +49,14 @@ limited to terminal surfaces. ## Privacy and failure policy Audio is held in memory for the active dictation and is not retained. Temporary -fallback WAV files are deleted immediately after `whisper-cli` exits. +WAV files used by `whisper-cli` are deleted immediately after it exits. Transcripts are not logged. Setup downloads public artifacts; the dictation pipeline makes no network requests. +Setting `[recognition].runtime = "whisper-cli"` skips the managed sherpa-onnx +stage entirely: the recording is transcribed by the external whisper.cpp CLI +after it stops, so there are no streaming previews. + Enhancement output is rejected if it is empty, invalid, excessively expanded, or removes protected symbols or numbers. Final ASR falls back first to the streaming result, then to an existing local whisper.cpp model; enhancement falls diff --git a/docs/install.md b/docs/install.md index 76c3eae..dc718ce 100644 --- a/docs/install.md +++ b/docs/install.md @@ -45,6 +45,39 @@ separate process, so the interface adds no GUI toolkit to the Rust dependency graph. The OSD follows dictation through listening, transcription, enhancement, and delivery without taking keyboard focus. +## Whisper.cpp backend + +Recognition can run on the external whisper.cpp CLI instead of the managed +sherpa-onnx runtime: + +```toml +[recognition] +runtime = "whisper-cli" +fallback_model = "/absolute/path/to/ggml-large-v3-turbo.bin" +``` + +The CLI transcribes the finished recording, so this mode has no partial +previews. `whispers setup` skips the sherpa-onnx downloads in this mode and +verifies that `whisper-cli` and the configured model exist. + +The distro `whisper-cpp` package ships only the CPU `ggml` backend, which takes +seconds per sentence with larger models. Install a GPU backend as well. On Arch +Linux: + +```sh +sudo pacman -S whisper-cpp ggml-vulkan +``` + +`ggml-vulkan` provides `/usr/lib/ggml/libggml-vulkan.so`, which `whisper-cli` +loads automatically. Verify on one short recording: + +```sh +whisper-cli -m /path/to/ggml-large-v3-turbo.bin -f sample.wav -nt +``` + +The log must contain `using Vulkan0 backend`. Without a GPU backend the same +model is roughly an order of magnitude slower. + ## Setup choices Normal network setup: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 23e6e35..d826023 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -26,6 +26,25 @@ pw-cat --record --raw --format f32 --rate 16000 --channels 1 >/dev/null Set `[audio].device` only when the default PipeWire source is not the microphone you want. +## Whisper.cpp shows no partial text + +Expected when `[recognition].runtime = "whisper-cli"`. The external CLI runs +after the recording stops, so the OSD only shows listening and transcribing. +Switch to `sherpa-onnx` to get the live preview. + +## Whisper.cpp is slow or the GPU stays idle + +The backend package, not whispers, selects the device: + +```sh +pacman -Q whisper-cpp ggml-vulkan +whisper-cli -m /path/to/ggml-large-v3-turbo.bin -f sample.wav -nt +``` + +The log must print `using Vulkan0 backend`. With only `ggml` installed the CLI +falls back to CPU and a large model takes seconds per sentence. `whispers +doctor` reports the configured CLI and model path. + ## Text was copied instead of typed Whispers refuses to type into a different window than the one where dictation diff --git a/src/config.rs b/src/config.rs index 676387b..e0ceac8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,9 +36,12 @@ pub struct DeliveryConfig { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(default)] pub struct RecognitionConfig { + /// `sherpa-onnx` uses the managed streaming engine. `whisper-cli` runs the + /// external whisper.cpp binary on the completed recording instead. pub runtime: String, pub model: String, pub final_model: String, + /// whisper.cpp model used by `whisper-cli` and as the managed fallback. pub fallback_model: String, } @@ -84,6 +87,17 @@ impl Default for RecognitionConfig { } } +impl RecognitionConfig { + /// True when recognition runs through the external `whisper-cli` binary. + /// That path transcribes the finished recording and has no partial results. + pub fn uses_whisper_cli(&self) -> bool { + matches!( + self.runtime.trim().to_ascii_lowercase().as_str(), + "whisper-cli" | "whisper_cli" | "whisper" + ) + } +} + impl Default for EnhancementConfig { fn default() -> Self { Self { @@ -143,6 +157,21 @@ impl Config { mod tests { use super::*; + #[test] + fn whisper_cli_runtime_is_recognized() { + let mut config = Config::default(); + assert!(!config.recognition.uses_whisper_cli()); + + config.recognition.runtime = "whisper-cli".into(); + assert!(config.recognition.uses_whisper_cli()); + + config.recognition.runtime = " Whisper_CLI ".into(); + assert!(config.recognition.uses_whisper_cli()); + + config.recognition.runtime = "sherpa-onnx".into(); + assert!(!config.recognition.uses_whisper_cli()); + } + #[test] fn existing_unversioned_configuration_requires_migration() { let path = std::env::temp_dir().join(format!( diff --git a/src/doctor.rs b/src/doctor.rs index eab8367..ae2e115 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -54,24 +54,31 @@ pub fn run() -> Report { None } }; - match paths::data_dir().and_then(|path| setup::validate_asr_installation(&path)) { - Ok(()) => checks.push(pass( - "managed ASR", - "sherpa-onnx, Nemotron preview, and multilingual Parakeet v3 final ASR are installed", - )), - Err(error) => checks.push(fail("managed ASR", error.to_string())), - } - if let Ok(library) = paths::sherpa_library() { - // SAFETY: Doctor only asks the dynamic loader to validate this pinned shared object. - match unsafe { libloading::Library::new(&library) } { - Ok(handle) => { - drop(handle); - checks.push(pass("ASR loader", "sherpa-onnx shared library loads")); - } - Err(error) => checks.push(fail( - "ASR loader", - format!("{}: {error}", library.display()), + let whisper_cli = config + .as_ref() + .is_some_and(|config| config.recognition.uses_whisper_cli()); + if whisper_cli { + command_check(&mut checks, "whisper ASR", "whisper-cli", true); + } else { + match paths::data_dir().and_then(|path| setup::validate_asr_installation(&path)) { + Ok(()) => checks.push(pass( + "managed ASR", + "sherpa-onnx, Nemotron preview, and multilingual Parakeet v3 final ASR are installed", )), + Err(error) => checks.push(fail("managed ASR", error.to_string())), + } + if let Ok(library) = paths::sherpa_library() { + // SAFETY: Doctor only asks the dynamic loader to validate this pinned shared object. + match unsafe { libloading::Library::new(&library) } { + Ok(handle) => { + drop(handle); + checks.push(pass("ASR loader", "sherpa-onnx shared library loads")); + } + Err(error) => checks.push(fail( + "ASR loader", + format!("{}: {error}", library.display()), + )), + } } } command_check(&mut checks, "audio capture", "pw-cat", true); @@ -87,7 +94,9 @@ pub fn run() -> Report { osd_check(&mut checks); command_check(&mut checks, "notification fallback", "notify-send", false); command_check(&mut checks, "audio feedback", "pw-play", false); - command_check(&mut checks, "fallback ASR", "whisper-cli", false); + if !whisper_cli { + command_check(&mut checks, "fallback ASR", "whisper-cli", false); + } if env::var_os("XDG_RUNTIME_DIR").is_some() { checks.push(pass("runtime directory", "XDG_RUNTIME_DIR is set")); } else { @@ -102,11 +111,23 @@ pub fn run() -> Report { )); } if let Some(config) = config { + let whisper_cli = config.recognition.uses_whisper_cli(); match paths::fallback_model(&config.recognition.fallback_model) { Ok(path) if path.is_file() => checks.push(pass( - "fallback model", + if whisper_cli { + "whisper model" + } else { + "fallback model" + }, format!("{} is available", path.display()), )), + Ok(path) if whisper_cli => checks.push(fail( + "whisper model", + format!( + "{} is missing; whisper-cli cannot transcribe", + path.display() + ), + )), Ok(path) => checks.push(warn( "fallback model", format!( @@ -114,6 +135,7 @@ pub fn run() -> Report { path.display() ), )), + Err(error) if whisper_cli => checks.push(fail("whisper model", error.to_string())), Err(error) => checks.push(warn("fallback model", error.to_string())), } enhancement_checks(&mut checks, &config); diff --git a/src/pipeline.rs b/src/pipeline.rs index 4775f07..0bd100c 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -74,26 +74,33 @@ impl Pipeline for LocalPipeline { // Capture begins before the potentially cold ASR handshake. A reader- // acknowledged boundary discards startup audio before the listening state // is announced, so the ready signal cannot contaminate recognition. - let streaming = self.start_engine(); + let streaming = !self.config.recognition.uses_whisper_cli() && self.start_engine(); recording.discard_buffered(&receiver)?; - let engine = Arc::clone(&self.engine); - let feeder = thread::spawn(move || { - while let Ok(samples) = receiver.recv() { - let Ok(mut engine) = engine.lock() else { - break; - }; - let Some(client) = engine.as_mut() else { - break; - }; - if client.push_audio(&samples).is_err() { - break; + let feeder = if streaming { + let engine = Arc::clone(&self.engine); + Some(thread::spawn(move || { + while let Ok(samples) = receiver.recv() { + let Ok(mut engine) = engine.lock() else { + break; + }; + let Some(client) = engine.as_mut() else { + break; + }; + if client.push_audio(&samples).is_err() { + break; + } } - } - }); + })) + } else { + // The recording keeps its own copy of the audio, so the receiver + // can be dropped when whisper-cli transcribes the finished file. + drop(receiver); + None + }; desktop::show_listening(); Ok(Box::new(LocalDictation { recording: Some(recording), - feeder: Some(feeder), + feeder, engine: Arc::clone(&self.engine), target, config: self.config.clone(), diff --git a/src/setup.rs b/src/setup.rs index 1455506..5559713 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -70,12 +70,17 @@ pub fn run(offline_pack: Option<&Path>, install_service: bool) -> Result Result<()> { Ok(()) } -fn install_from_network(data_dir: &Path, install_enhancement: bool) -> Result<()> { +fn install_from_network( + data_dir: &Path, + install_enhancement: bool, + install_asr: bool, +) -> Result<()> { let downloads = data_dir.join("downloads"); fs::create_dir_all(&downloads)?; - install_archive( - Asset { - name: "sherpa-runtime", - extension: "tar.bz2", - extract_flag: "-xjf", - url: RUNTIME_URL, - sha256: RUNTIME_SHA256, - archive_root: RUNTIME_ROOT, - }, - &downloads, - &data_dir.join("runtimes/sherpa-onnx/current"), - )?; - install_archive( - Asset { - name: "nemotron-model", - extension: "tar.bz2", - extract_flag: "-xjf", - url: MODEL_URL, - sha256: MODEL_SHA256, - archive_root: MODEL_ROOT, - }, - &downloads, - &data_dir.join("models").join(MODEL_NAME), - )?; - install_archive( - Asset { - name: "parakeet-v3-final-model", - extension: "tar.bz2", - extract_flag: "-xjf", - url: FINAL_MODEL_URL, - sha256: FINAL_MODEL_SHA256, - archive_root: FINAL_MODEL_ROOT, - }, - &downloads, - &data_dir.join("models").join(DEFAULT_FINAL_MODEL), - )?; + if install_asr { + install_archive( + Asset { + name: "sherpa-runtime", + extension: "tar.bz2", + extract_flag: "-xjf", + url: RUNTIME_URL, + sha256: RUNTIME_SHA256, + archive_root: RUNTIME_ROOT, + }, + &downloads, + &data_dir.join("runtimes/sherpa-onnx/current"), + )?; + install_archive( + Asset { + name: "nemotron-model", + extension: "tar.bz2", + extract_flag: "-xjf", + url: MODEL_URL, + sha256: MODEL_SHA256, + archive_root: MODEL_ROOT, + }, + &downloads, + &data_dir.join("models").join(MODEL_NAME), + )?; + install_archive( + Asset { + name: "parakeet-v3-final-model", + extension: "tar.bz2", + extract_flag: "-xjf", + url: FINAL_MODEL_URL, + sha256: FINAL_MODEL_SHA256, + archive_root: FINAL_MODEL_ROOT, + }, + &downloads, + &data_dir.join("models").join(DEFAULT_FINAL_MODEL), + )?; + } if install_enhancement { install_archive( Asset { @@ -359,24 +370,31 @@ fn install_file(url: &str, sha256: &str, download: &Path, target: &Path) -> Resu Ok(()) } -fn install_from_pack(pack: &Path, data_dir: &Path, install_enhancement: bool) -> Result<()> { +fn install_from_pack( + pack: &Path, + data_dir: &Path, + install_enhancement: bool, + install_asr: bool, +) -> Result<()> { if !pack.is_dir() { return Err(Error::message( "--runtime-pack must name an unpacked runtime directory", )); } - copy_install( - &pack.join("runtimes/sherpa-onnx/current"), - &data_dir.join("runtimes/sherpa-onnx/current"), - )?; - copy_install( - &pack.join("models").join(MODEL_NAME), - &data_dir.join("models").join(MODEL_NAME), - )?; - copy_install( - &pack.join("models").join(DEFAULT_FINAL_MODEL), - &data_dir.join("models").join(DEFAULT_FINAL_MODEL), - )?; + if install_asr { + copy_install( + &pack.join("runtimes/sherpa-onnx/current"), + &data_dir.join("runtimes/sherpa-onnx/current"), + )?; + copy_install( + &pack.join("models").join(MODEL_NAME), + &data_dir.join("models").join(MODEL_NAME), + )?; + copy_install( + &pack.join("models").join(DEFAULT_FINAL_MODEL), + &data_dir.join("models").join(DEFAULT_FINAL_MODEL), + )?; + } if install_enhancement { copy_install( &pack.join(paths::MANAGED_LLAMA_RUNTIME), @@ -496,6 +514,28 @@ pub fn validate_asr_installation(data_dir: &Path) -> Result<()> { Ok(()) } +fn validate_whisper_cli_installation(config: &Config) -> Result<()> { + let available = Command::new("whisper-cli") + .arg("--help") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + if !available { + return Err(Error::message( + "[recognition].runtime = \"whisper-cli\" requires whisper-cli in PATH", + )); + } + let model = paths::fallback_model(&config.recognition.fallback_model)?; + if !model.is_file() { + return Err(Error::message(format!( + "whisper-cli model is missing: {}", + model.display() + ))); + } + Ok(()) +} + pub fn validate_enhancement_installation(data_dir: &Path) -> Result<()> { for path in [ data_dir From c7ea1d0dc36cd7312bddd4870ef036f5c3bc5698 Mon Sep 17 00:00:00 2001 From: xqqmdy Date: Sun, 15 Nov 2026 00:28:57 +0800 Subject: [PATCH 2/2] feat: let setup choose the recognition backend --- README.md | 5 ++- config.example.toml | 12 ++--- docs/cli.md | 6 ++- docs/install.md | 38 ++++++++++++---- src/cli.rs | 35 ++++++++++++++- src/config.rs | 38 ++++++++++++++-- src/main.rs | 3 +- src/setup.rs | 106 +++++++++++++++++++++++++++++++++++++++++--- 8 files changed, 213 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 5fc8010..40773ce 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,9 @@ separators too. The Rust application is a control plane with seven direct dependencies. It does not compile or vendor whisper.cpp, llama.cpp, CUDA, an async runtime, an audio stack, or a GUI toolkit. `whispers setup` downloads checksum-pinned sherpa-onnx, -Nemotron, Parakeet, llama.cpp, and Qwen artifacts. Inference runtimes can -therefore be +Nemotron, Parakeet, llama.cpp, and Qwen artifacts; `whispers setup --backend +whisper` installs the pinned GGML model and uses the external whisper.cpp CLI +instead of the managed recognizer. Inference runtimes can therefore be upgraded independently without turning every application build into an ML toolchain build. diff --git a/config.example.toml b/config.example.toml index 610d064..22aa3ac 100644 --- a/config.example.toml +++ b/config.example.toml @@ -12,17 +12,13 @@ feedback = true typing_delay_ms = 0 [recognition] -# "sherpa-onnx" runs the managed streaming runtime installed by -# `whispers setup`. "whisper-cli" runs the external whisper.cpp CLI on the -# finished recording, with no streaming preview. Install whisper-cpp plus a GPU -# backend first (on Arch: `pacman -S whisper-cpp ggml-vulkan`); the plain -# `ggml` package is CPU only and makes large models unusably slow. +# Managed recognizer: "sherpa-onnx" (default) or "whisper-cli". runtime = "sherpa-onnx" -# Low-latency text shown in the OSD while speaking (sherpa-onnx only). +# Low-latency text shown in the OSD while speaking. model = "nemotron-3.5-asr-streaming-0.6b-560ms-int8" -# Multilingual full-recording pass used for delivered text (sherpa-onnx only). +# Multilingual full-recording pass used for delivered text. final_model = "nemo-parakeet-tdt-0.6b-v3-int8" -# whisper.cpp model used by `whisper-cli` and as the managed ASR fallback. +# whisper.cpp model used by "whisper-cli" and as the managed ASR fallback. fallback_model = "ggml-large-v3-turbo.bin" [enhancement] diff --git a/docs/cli.md b/docs/cli.md index 54d9c9f..1478294 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -50,8 +50,12 @@ from the delivered text. ## Setup ```sh -whispers setup [--runtime-pack DIR] [--no-service] +whispers setup [--runtime-pack DIR] [--no-service] [--backend sherpa|whisper] ``` Network setup verifies SHA-256 before atomically installing each asset. An offline pack is copied without following symbolic links or special files. + +`--backend whisper` sets `[recognition].runtime = "whisper-cli"`, skips the +sherpa-onnx downloads and installs the pinned GGML model. The default +`--backend sherpa` installs the managed runtime. diff --git a/docs/install.md b/docs/install.md index dc718ce..d047751 100644 --- a/docs/install.md +++ b/docs/install.md @@ -48,21 +48,30 @@ and delivery without taking keyboard focus. ## Whisper.cpp backend Recognition can run on the external whisper.cpp CLI instead of the managed -sherpa-onnx runtime: +sherpa-onnx runtime. Select it during installation: + +```sh +whispers setup --backend whisper +``` + +Setup sets `[recognition].runtime = "whisper-cli"`, skips the sherpa-onnx +downloads, and installs the pinned `ggml-large-v3-turbo.bin` into the data +directory. The equivalent configuration is: ```toml [recognition] runtime = "whisper-cli" -fallback_model = "/absolute/path/to/ggml-large-v3-turbo.bin" +fallback_model = "ggml-large-v3-turbo.bin" ``` -The CLI transcribes the finished recording, so this mode has no partial -previews. `whispers setup` skips the sherpa-onnx downloads in this mode and -verifies that `whisper-cli` and the configured model exist. +`fallback_model` is resolved relative to the data directory +(`${XDG_DATA_HOME:-~/.local/share}/whispers`); use an absolute path only for a +model stored elsewhere. The CLI transcribes the finished recording, so this mode +has no partial previews. -The distro `whisper-cpp` package ships only the CPU `ggml` backend, which takes -seconds per sentence with larger models. Install a GPU backend as well. On Arch -Linux: +Setup requires `whisper-cli` in PATH. The distro `whisper-cpp` package ships only +the CPU `ggml` backend, which takes seconds per sentence with larger models. +Install a GPU backend as well. On Arch Linux: ```sh sudo pacman -S whisper-cpp ggml-vulkan @@ -86,6 +95,16 @@ Normal network setup: whispers setup ``` +Pick the recognition backend during installation: + +```sh +whispers setup --backend whisper +``` + +`--backend sherpa` is the default and installs the managed runtime. Choosing +whisper requires `whisper-cli` in PATH and installs the GGML model instead; see +[Whisper.cpp backend](#whispercpp-backend). + Install without enabling the user service: ```sh @@ -110,6 +129,9 @@ pack/ whispers setup --runtime-pack ./pack ``` +`whispers setup --backend whisper --runtime-pack` uses an existing model and +fails when it is missing, because runtime packs do not carry GGML models. + Setup writes configuration version 2, imports the old dictionary and technical glossary when present, and copies the old configuration to a timestamped backup. It never deletes legacy models or configuration. diff --git a/src/cli.rs b/src/cli.rs index 6132db9..c0672ae 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use lexopt::prelude::*; +use crate::config::Backend; use crate::{Error, Result}; #[derive(Clone, Debug, Eq, PartialEq)] @@ -13,6 +14,7 @@ pub enum Command { Setup { offline_pack: Option, no_service: bool, + backend: Option, }, Doctor, Vocabulary(VocabularyCommand), @@ -55,16 +57,26 @@ where Value(value) if value == "setup" => { let mut offline_pack = None; let mut no_service = false; + let mut backend = None; while let Some(argument) = parser.next()? { match argument { Long("runtime-pack") => offline_pack = Some(parser.value()?.into()), Long("no-service") => no_service = true, + Long("backend") => { + let value = parser.value()?.string()?; + backend = Some(Backend::parse(&value).ok_or_else(|| { + Error::message(format!( + "unknown backend: {value} (use sherpa or whisper)" + )) + })?); + } _ => return Err(argument.unexpected().into()), } } Command::Setup { offline_pack, no_service, + backend, } } Value(value) if value == "vocabulary" => parse_vocabulary(&mut parser)?, @@ -121,7 +133,7 @@ Usage: whispers toggle Start or stop dictation whispers cancel Cancel the active dictation whispers status Show service state - whispers setup [--runtime-pack DIR] [--no-service] + whispers setup [--runtime-pack DIR] [--no-service] [--backend sherpa|whisper] whispers doctor whispers vocabulary list whispers vocabulary add TERM --alias SPOKEN [--alias SPOKEN ...] @@ -137,6 +149,27 @@ mod tests { assert_eq!(parse(Vec::::new()).ok(), Some(Command::Toggle)); } + #[test] + fn parses_setup_backend() { + assert_eq!( + parse(["setup", "--backend", "whisper"]).ok(), + Some(Command::Setup { + offline_pack: None, + no_service: false, + backend: Some(Backend::WhisperCli), + }) + ); + assert_eq!( + parse(["setup", "--no-service", "--backend", "sherpa"]).ok(), + Some(Command::Setup { + offline_pack: None, + no_service: true, + backend: Some(Backend::SherpaOnnx), + }) + ); + assert!(parse(["setup", "--backend", "cloud"]).is_err()); + } + #[test] fn parses_vocabulary_aliases() { let command = parse([ diff --git a/src/config.rs b/src/config.rs index e0ceac8..bb05751 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,6 +33,29 @@ pub struct DeliveryConfig { pub typing_delay_ms: u64, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Backend { + SherpaOnnx, + WhisperCli, +} + +impl Backend { + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "sherpa" | "sherpa-onnx" | "sherpa_onnx" => Some(Self::SherpaOnnx), + "whisper" | "whisper-cli" | "whisper_cli" => Some(Self::WhisperCli), + _ => None, + } + } + + pub const fn runtime(self) -> &'static str { + match self { + Self::SherpaOnnx => "sherpa-onnx", + Self::WhisperCli => "whisper-cli", + } + } +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(default)] pub struct RecognitionConfig { @@ -91,10 +114,7 @@ impl RecognitionConfig { /// True when recognition runs through the external `whisper-cli` binary. /// That path transcribes the finished recording and has no partial results. pub fn uses_whisper_cli(&self) -> bool { - matches!( - self.runtime.trim().to_ascii_lowercase().as_str(), - "whisper-cli" | "whisper_cli" | "whisper" - ) + Backend::parse(&self.runtime) == Some(Backend::WhisperCli) } } @@ -157,6 +177,16 @@ impl Config { mod tests { use super::*; + #[test] + fn backend_names_are_parsed() { + assert_eq!(Backend::parse("sherpa"), Some(Backend::SherpaOnnx)); + assert_eq!(Backend::parse(" Sherpa_Onnx "), Some(Backend::SherpaOnnx)); + assert_eq!(Backend::parse("whisper"), Some(Backend::WhisperCli)); + assert_eq!(Backend::parse("Whisper_CLI"), Some(Backend::WhisperCli)); + assert_eq!(Backend::parse("cloud"), None); + assert_eq!(Backend::WhisperCli.runtime(), "whisper-cli"); + } + #[test] fn whisper_cli_runtime_is_recognized() { let mut config = Config::default(); diff --git a/src/main.rs b/src/main.rs index ffa7d85..09d75ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,8 +19,9 @@ fn main() -> ExitCode { Ok(cli::Command::Setup { offline_pack, no_service, + backend, }) => { - result(setup::run(offline_pack.as_deref(), !no_service).map(|report| { + result(setup::run(offline_pack.as_deref(), !no_service, backend).map(|report| { println!("runtime and model are ready"); if report.migrated_config { println!("migrated the legacy configuration (the original was backed up)"); diff --git a/src/setup.rs b/src/setup.rs index 5559713..3f7ab99 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -5,7 +5,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde::Deserialize; -use crate::config::{CONFIG_VERSION, Config, DEFAULT_FINAL_MODEL, LEGACY_ENGLISH_FINAL_MODEL}; +use crate::config::{ + Backend, CONFIG_VERSION, Config, DEFAULT_FINAL_MODEL, LEGACY_ENGLISH_FINAL_MODEL, +}; use crate::vocabulary::Vocabulary; use crate::{Error, Result, paths}; @@ -19,6 +21,10 @@ const MODEL_NAME: &str = "nemotron-3.5-asr-streaming-0.6b-560ms-int8"; const FINAL_MODEL_URL: &str = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2"; const FINAL_MODEL_SHA256: &str = "1495e91b59d7fc53ddca91f3431758ec99f8fa253fdb0e1e851be0f72b14b0f9"; const FINAL_MODEL_ROOT: &str = "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"; +const WHISPER_MODEL_FILE: &str = "ggml-large-v3-turbo.bin"; +const WHISPER_MODEL_URL: &str = "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-large-v3-turbo.bin"; +const WHISPER_MODEL_SHA256: &str = + "1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69"; const LLAMA_URL: &str = "https://github.com/ggml-org/llama.cpp/releases/download/b10258/llama-b10258-bin-ubuntu-vulkan-x64.tar.gz"; const LLAMA_SHA256: &str = "21e2ca510aa05f5814adfab82e4cb2d31641596aa9a8780ae05132c731ebe75a"; const LLAMA_ROOT: &str = "llama-b10258"; @@ -43,7 +49,11 @@ struct LegacyVocabularyPaths { glossary: PathBuf, } -pub fn run(offline_pack: Option<&Path>, install_service: bool) -> Result { +pub fn run( + offline_pack: Option<&Path>, + install_service: bool, + backend: Option, +) -> Result { if std::env::consts::OS != "linux" || std::env::consts::ARCH != "x86_64" { return Err(Error::message( "managed runtime setup currently supports Linux x86_64 only", @@ -69,8 +79,11 @@ pub fn run(offline_pack: Option<&Path>, install_service: bool) -> Result, install_service: bool) -> Result Result<()> { Ok(()) } +fn install_whisper_model(config: &Config, data_dir: &Path, allow_network: bool) -> Result<()> { + let model = paths::fallback_model(&config.recognition.fallback_model)?; + let pinned = config.recognition.fallback_model == WHISPER_MODEL_FILE; + ensure_whisper_model(&model, pinned, data_dir, allow_network) +} + +fn ensure_whisper_model( + model: &Path, + pinned: bool, + data_dir: &Path, + allow_network: bool, +) -> Result<()> { + // A file the user already placed wins over the pinned asset: the checksum + // guards the download, and setup never overwrites or removes user models. + if model.is_file() { + return Ok(()); + } + if !pinned { + return Err(Error::message(format!( + "whisper model is missing: {} (point [recognition].fallback_model at an existing file)", + model.display() + ))); + } + if !allow_network { + return Err(Error::message(format!( + "whisper model is missing: {} (runtime packs cannot provide it)", + model.display() + ))); + } + let downloads = data_dir.join("downloads"); + fs::create_dir_all(&downloads)?; + install_file( + WHISPER_MODEL_URL, + WHISPER_MODEL_SHA256, + &downloads.join(WHISPER_MODEL_FILE), + model, + ) +} + fn validate_whisper_cli_installation(config: &Config) -> Result<()> { let available = Command::new("whisper-cli") .arg("--help") @@ -523,7 +580,7 @@ fn validate_whisper_cli_installation(config: &Config) -> Result<()> { .is_ok_and(|status| status.success()); if !available { return Err(Error::message( - "[recognition].runtime = \"whisper-cli\" requires whisper-cli in PATH", + "[recognition].runtime = \"whisper-cli\" requires whisper-cli in PATH (install whisper-cpp and a GPU backend such as ggml-vulkan)", )); } let model = paths::fallback_model(&config.recognition.fallback_model)?; @@ -864,6 +921,45 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn accepts_an_existing_pinned_whisper_model_without_a_checksum() { + let root = std::env::temp_dir().join(format!( + "whispers-whisper-model-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + assert!(fs::create_dir_all(&root).is_ok()); + let model = root.join(WHISPER_MODEL_FILE); + + assert!(ensure_whisper_model(&model, true, &root, false).is_err()); + assert!(fs::write(&model, b"a locally converted model").is_ok()); + assert!(ensure_whisper_model(&model, true, &root, false).is_ok()); + assert!(ensure_whisper_model(&model, true, &root, true).is_ok()); + assert_eq!( + fs::read(&model).ok().as_deref(), + Some(b"a locally converted model".as_slice()) + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn accepts_an_existing_custom_whisper_model_without_a_checksum() { + let root = std::env::temp_dir().join(format!( + "whispers-custom-whisper-model-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + assert!(fs::create_dir_all(&root).is_ok()); + let model = root.join("ggml-other.bin"); + + assert!(ensure_whisper_model(&model, false, &root, false).is_err()); + assert!(fs::write(&model, b"anything").is_ok()); + assert!(ensure_whisper_model(&model, false, &root, false).is_ok()); + + let _ = fs::remove_dir_all(root); + } + #[test] fn imports_both_legacy_vocabulary_formats() { let root = std::env::temp_dir().join(format!(