wavo/lib.rs
1// Copyright (c) vladkens | MIT License | https://github.com/vladkens/wavo
2// The hub links resolve to the module with the `hub` feature and to docs.rs without it; with the
3// feature they name what rustdoc would find anyway, hence the allow.
4#![allow(rustdoc::redundant_explicit_links)]
5#![cfg_attr(
6 feature = "hub",
7 doc = "[hub-mod]: crate::hub\n[hub-models]: crate::hub::MODELS\n[hub-hub]: crate::hub::Hub\n[hub-pull]: crate::hub::Hub::pull"
8)]
9#![cfg_attr(
10 not(feature = "hub"),
11 doc = "[hub-mod]: https://docs.rs/wavo/latest/wavo/hub/\n[hub-models]: https://docs.rs/wavo/latest/wavo/hub/static.MODELS.html\n[hub-hub]: https://docs.rs/wavo/latest/wavo/hub/struct.Hub.html\n[hub-pull]: https://docs.rs/wavo/latest/wavo/hub/struct.Hub.html#method.pull"
12)]
13//! Speech-to-text on the GPU, in pure Rust.
14//!
15//! wavo runs the open ASR models that [transcribe.cpp] publishes as GGUF files on Hugging Face:
16//! NVIDIA's Parakeet, Sber's GigaAM, OpenAI's Whisper and Useful Sensors' Moonshine, with the same
17//! text and timestamps as transcribe.cpp. The encoder runs on the GPU through wgpu (Metal on Apple
18//! Silicon, Vulkan on Linux and Windows, DX12 on Windows without a Vulkan driver) with wavo's own
19//! kernels; the audio frontend runs on the CPU, and so does the decoder except Whisper's and
20//! Moonshine's. It builds with a plain `cargo build`: no C++ toolchain, Python or ONNX Runtime.
21//!
22//! [transcribe.cpp]: https://github.com/handy-computer/transcribe.cpp
23//!
24//! # Features
25//!
26//! - `cli` (default): the `wavo` command-line tool. Leave it out when you use wavo as a library:
27//! `cargo add wavo --no-default-features`.
28//! - `hub`: the [`hub`][hub-mod] module, which downloads and manages models in the Hugging Face
29//! cache. It adds an HTTP client (`ureq` with rustls, whose `ring` compiles some C); without it
30//! the library is pure Rust and never uses the network.
31//!
32//! # Models
33//!
34//! [`Model::load`] takes the Q8_0 GGUF file of one of these models. With the `hub` feature,
35//! [`hub::MODELS`][hub-models] lists them, and [`hub::Hub`][hub-hub] downloads them by short or
36//! full name.
37//!
38//! | Name | Full name | Size | Languages | Output |
39//! |---|---|---|---|---|
40//! | `parakeet-v3` | `parakeet-tdt-0.6b-v3` | 0.74 GB | 25 European languages | cased, punctuated |
41//! | `parakeet-v2` | `parakeet-tdt-0.6b-v2` | 0.73 GB | English | cased, punctuated |
42//! | `gigaam-v3` | `gigaam-v3-e2e-rnnt` | 0.27 GB | Russian | cased, punctuated |
43//! | `gigaam-v3-e2e-ctc` | `gigaam-v3-e2e-ctc` | 0.27 GB | Russian | cased, punctuated |
44//! | `gigaam-v3-rnnt` | `gigaam-v3-rnnt` | 0.27 GB | Russian | lowercase, no punctuation |
45//! | `gigaam-v3-ctc` | `gigaam-v3-ctc` | 0.27 GB | Russian | lowercase, no punctuation |
46//! | `whisper-turbo` | `whisper-large-v3-turbo` | 0.89 GB | 100 languages | cased, punctuated |
47//! | `whisper-tiny` | `whisper-tiny` | 0.05 GB | 99 languages | cased, punctuated |
48//! | `whisper-base` | `whisper-base` | 0.08 GB | 99 languages | cased, punctuated |
49//! | `whisper-small` | `whisper-small` | 0.27 GB | 99 languages | cased, punctuated |
50//! | `whisper-medium` | `whisper-medium` | 0.83 GB | 99 languages | cased, punctuated |
51//! | `whisper-large-v3` | `whisper-large-v3` | 1.67 GB | 100 languages | cased, punctuated |
52//! | `whisper-tiny.en` | `whisper-tiny.en` | 0.05 GB | English | cased, punctuated |
53//! | `whisper-base.en` | `whisper-base.en` | 0.08 GB | English | cased, punctuated |
54//! | `whisper-small.en` | `whisper-small.en` | 0.27 GB | English | cased, punctuated |
55//! | `whisper-medium.en` | `whisper-medium.en` | 0.83 GB | English | cased, punctuated |
56//! | `whisper-large` | `whisper-large` | 1.67 GB | 99 languages | cased, punctuated |
57//! | `whisper-large-v2` | `whisper-large-v2` | 1.67 GB | 99 languages | cased, punctuated |
58//! | `breeze-asr-25` | `Breeze-ASR-25` | 1.67 GB | Taiwanese Mandarin and English | cased, punctuated |
59//! | `moonshine-tiny` | `moonshine-tiny` | 0.04 GB | English | cased, punctuated |
60//! | `moonshine-base` | `moonshine-base` | 0.08 GB | English | cased, punctuated |
61//!
62//! A model `<full name>` lives in the Hugging Face repository `handy-computer/<full name>-gguf`
63//! as `<full name>-Q8_0.gguf`.
64//!
65//! # Transcribe
66//!
67//! [`Model::transcribe`] takes 16 kHz mono samples as `f32` in [-1, 1], and returns the text and
68//! its tokens with start times in milliseconds:
69//!
70//! ```no_run
71//! # fn main() -> wavo::Result<()> {
72//! let model = wavo::Model::load("parakeet-tdt-0.6b-v3-Q8_0.gguf")?;
73//! let pcm = vec![0.0f32; 16_000]; // one second of silence: 16 kHz mono in [-1, 1]
74//! let transcript = model.transcribe(&pcm)?;
75//!
76//! println!("{}", transcript.text);
77//!
78//! for token in &transcript.tokens {
79//! println!("{:>6} ms {}", token.start_ms, token.piece);
80//! }
81//! # Ok(())
82//! # }
83//! ```
84//!
85//! Load a model once and call `transcribe` as often as needed; loading takes about 0.1–0.2 s on
86//! an Apple M2. [`Model::device`] names the GPU and API it runs on, for a log line or a settings
87//! screen.
88//!
89//! # Download a model
90//!
91//! With `cargo add wavo --no-default-features --features hub`, [`hub::Hub`][hub-hub] finds a model
92//! in the Hugging Face cache that `hf` and `huggingface_hub` share, and downloads it when missing:
93//!
94//! ```no_run
95//! # #[cfg(feature = "hub")]
96//! # fn main() -> wavo::Result<()> {
97//! use std::ops::ControlFlow;
98//!
99//! let hub = wavo::hub::Hub::new(); // or Hub::at(dir) for a folder of your app's own
100//! let path = match hub.find("gigaam-v3") {
101//! Some(path) => path, // no network
102//! None => hub.pull("gigaam-v3", |_| ControlFlow::Continue(()))?,
103//! };
104//!
105//! let model = wavo::Model::load(path)?;
106//! # Ok(())
107//! # }
108//! # #[cfg(not(feature = "hub"))]
109//! # fn main() {}
110//! ```
111//!
112//! # Download on a thread, with progress and cancel
113//!
114//! [`Hub::pull`][hub-pull] blocks until the file is complete, and the library starts no
115//! threads of its own: run it on a thread, with [`std::thread::spawn`] as here, or with tokio's
116//! `spawn_blocking`. Its callback sees the bytes done and the total, and returns
117//! [`ControlFlow::Break`](std::ops::ControlFlow::Break) to cancel; the partial file stays in the
118//! cache, and the next pull resumes it.
119//!
120//! ```no_run
121//! # #[cfg(feature = "hub")]
122//! # fn main() {
123//! use std::ops::ControlFlow;
124//! use std::sync::Arc;
125//! use std::sync::atomic::{AtomicBool, Ordering};
126//!
127//! let cancel = Arc::new(AtomicBool::new(false));
128//! let stop = Arc::clone(&cancel);
129//! let download = std::thread::spawn(move || {
130//! wavo::hub::Hub::new().pull("parakeet-v3", |p| {
131//! if let Some(e) = p.retrying {
132//! eprintln!("\nretrying after: {e}");
133//! }
134//! eprint!("\r{} / {} MB", p.done / 1_000_000, p.total / 1_000_000);
135//! if stop.load(Ordering::Relaxed) { ControlFlow::Break(()) } else { ControlFlow::Continue(()) }
136//! })
137//! });
138//! // A cancel button elsewhere runs: cancel.store(true, Ordering::Relaxed);
139//! match download.join().unwrap() {
140//! Ok(path) => println!("\ndownloaded {}", path.display()),
141//! Err(wavo::Error::Cancelled) => println!("\ncancelled; the next pull resumes"),
142//! Err(e) => eprintln!("\ndownload failed: {e}"),
143//! }
144//! # }
145//! # #[cfg(not(feature = "hub"))]
146//! # fn main() {}
147//! ```
148//!
149//! # Left to the caller
150//!
151//! - **Decoding and resampling.** `transcribe` takes 16 kHz mono `f32` only. Decode other formats,
152//! downmix to mono and resample yourself; the `wavo` CLI uses `symphonia` and `rubato`.
153//! - **Long audio.** `transcribe` takes audio of any length (Whisper runs its own 30 s windows),
154//! but accuracy drops past [`Model::max_audio_ms`]. Split longer audio at pauses, transcribe
155//! the pieces and shift their tokens' `start_ms` by each piece's offset, as `wavo run` does
156//! in [`src/cli/split.rs`](https://github.com/vladkens/wavo/blob/main/src/cli/split.rs): it
157//! cuts at the quietest moments by frame energy.
158//! - **Voice activity detection and streaming.** Neither is built in: pass whole recordings or the
159//! pieces your own VAD cuts.
160#![cfg_attr(docsrs, feature(doc_cfg))]
161#![warn(missing_docs)]
162
163mod conformer;
164mod cpu;
165mod error;
166mod fft;
167mod gguf;
168mod gigaam;
169mod gpu;
170#[cfg(feature = "hub")]
171#[cfg_attr(docsrs, doc(cfg(feature = "hub")))]
172pub mod hub;
173mod moonshine;
174mod parakeet;
175mod transformer;
176mod whisper;
177
178use std::path::Path;
179
180use error::bail;
181pub use error::{Error, Result};
182pub use gpu::{Api, Device, DeviceKind};
183
184/// A speech-to-text model loaded on the GPU.
185///
186/// Load it once with [`Model::load`], then call [`Model::transcribe`] for each recording. It is
187/// `Send` and `Sync`: share it between threads, and concurrent calls take turns on its GPU
188/// buffers.
189pub struct Model(Family);
190
191enum Family {
192 Gigaam(Box<gigaam::Gigaam>),
193 Parakeet(Box<parakeet::Parakeet>),
194 Whisper(Box<whisper::Whisper>),
195 Moonshine(Box<moonshine::Moonshine>),
196}
197
198/// The text of a recording and the tokens it is made of.
199#[derive(Debug, Clone, Default, PartialEq)]
200#[non_exhaustive]
201pub struct Transcript {
202 /// The whole text; empty when nothing was recognized.
203 pub text: String,
204 /// The tokens in order: the model's vocabulary pieces the text is made of, with start times.
205 pub tokens: Vec<Token>,
206}
207
208/// One token of a [`Transcript`].
209#[derive(Debug, Clone, Default, PartialEq)]
210#[non_exhaustive]
211pub struct Token {
212 /// Id in the model's vocabulary.
213 pub id: u32,
214 /// Vocabulary piece: a SentencePiece piece (`▁` marks a word start), a character, or for
215 /// Whisper and Moonshine's byte tokens the text the token completes (its bytes may end inside a
216 /// character).
217 pub piece: String,
218 /// Start time in milliseconds from the start of the audio: the encoder frame where the token
219 /// was emitted times the frame length (40 ms for GigaAM, 80 ms for Parakeet); for Whisper the
220 /// start of its segment; 0 for Moonshine, which has no timestamps.
221 pub start_ms: u32,
222}
223
224impl Model {
225 /// Loads a model from its GGUF file and uploads its weights to the GPU.
226 ///
227 /// The file is one of the Q8_0 GGUFs transcribe.cpp publishes (see the
228 /// [model table](crate#models)); the family comes from its `general.architecture`.
229 ///
230 /// # Errors
231 ///
232 /// - [`Error::Io`]: the file can't be read.
233 /// - [`Error::Model`]: not a GGUF, an architecture other than `gigaam`, `parakeet`, `whisper` and
234 /// `moonshine`, or a missing or mismatched tensor or metadata key.
235 /// - [`Error::Adapter`], [`Error::Device`]: no GPU, or not one the model can run on.
236 ///
237 /// # Examples
238 ///
239 /// ```no_run
240 /// let model = wavo::Model::load("gigaam-v3-e2e-rnnt-Q8_0.gguf")?;
241 /// # Ok::<(), wavo::Error>(())
242 /// ```
243 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
244 let g = gguf::Gguf::open(path.as_ref())?;
245 Ok(Self(match g.get::<&str>("general.architecture")? {
246 "gigaam" => Family::Gigaam(Box::new(gigaam::Gigaam::load(&g)?)),
247 "parakeet" => Family::Parakeet(Box::new(parakeet::Parakeet::load(&g)?)),
248 "whisper" => Family::Whisper(Box::new(whisper::Whisper::load(&g)?)),
249 "moonshine" => Family::Moonshine(Box::new(moonshine::Moonshine::load(&g)?)),
250 arch => bail!(
251 "general.architecture is {arch:?}, only gigaam, parakeet, whisper and moonshine are supported"
252 ),
253 }))
254 }
255
256 /// Transcribes 16 kHz mono PCM samples in [-1, 1].
257 ///
258 /// Any length runs, but past [`max_audio_ms`](Self::max_audio_ms) accuracy drops: split long
259 /// audio first (see [Left to the caller](crate#left-to-the-caller)). Audio too short for one
260 /// encoder frame gives an empty transcript.
261 ///
262 /// # Errors
263 ///
264 /// The GPU failed: [`Error::Gpu`], [`Error::Poll`], [`Error::Readback`] or [`Error::Map`].
265 ///
266 /// # Examples
267 ///
268 /// ```no_run
269 /// # let model = wavo::Model::load("gigaam-v3-e2e-rnnt-Q8_0.gguf")?;
270 /// # let pcm = vec![0.0f32; 16_000];
271 /// let transcript = model.transcribe(&pcm)?;
272 /// let words = transcript.text.split(' ').count();
273 /// # Ok::<(), wavo::Error>(())
274 /// ```
275 pub fn transcribe(&self, pcm: &[f32]) -> Result<Transcript> {
276 match &self.0 {
277 Family::Gigaam(m) => m.transcribe(pcm),
278 Family::Parakeet(m) => m.transcribe(pcm),
279 Family::Whisper(m) => m.transcribe(pcm),
280 Family::Moonshine(m) => m.transcribe(pcm),
281 }
282 }
283
284 /// The longest audio, in milliseconds, the model was trained on: 25 000 for GigaAM, 30 000 for
285 /// Whisper and Moonshine. Longer audio still runs, but accuracy degrades, so split it into
286 /// segments up to this length. `None` for Parakeet, which has no such window.
287 pub fn max_audio_ms(&self) -> Option<u32> {
288 match &self.0 {
289 // No GGUF key holds GigaAM's window; transcribe.cpp hardcodes the same 25 s.
290 Family::Gigaam(_) => Some(25_000),
291 // Its window; `transcribe` still takes any length in 30 s windows, as transcribe.cpp does.
292 Family::Whisper(_) => Some(30_000),
293 // transcribe.cpp advises 48.5 s; 30 s keeps fast speech under the 194-token cap.
294 Family::Moonshine(_) => Some(30_000),
295 Family::Parakeet(_) => None,
296 }
297 }
298
299 /// The GPU the model runs on, and the API it reaches it through, as chosen at load.
300 ///
301 /// [`Model::load`] looks for an adapter through Vulkan and Metal first (Metal on macOS, Vulkan
302 /// on Linux and Windows), and through DX12 only when neither has one (Windows without a Vulkan
303 /// driver). It prefers the high-performance adapter, usually the discrete one on a laptop with
304 /// two GPUs, though the driver decides. wgpu's `WGPU_BACKEND` environment variable (`vulkan`,
305 /// `metal` or `dx12`) overrides the API. The adapter may be a
306 /// [`SoftwareGpu`](DeviceKind::SoftwareGpu), such as WARP or llvmpipe, when no hardware driver
307 /// is installed.
308 ///
309 /// One model runs on one device for its lifetime. To use several GPUs, load several models;
310 /// choosing the device is not supported yet.
311 ///
312 /// # Examples
313 ///
314 /// ```no_run
315 /// let model = wavo::Model::load("gigaam-v3-e2e-rnnt-Q8_0.gguf")?;
316 /// println!("running on {}", model.device()); // running on Apple M2 (Metal, integrated)
317 /// if model.device().kind == wavo::DeviceKind::SoftwareGpu {
318 /// eprintln!("no GPU driver found: transcription will be slow");
319 /// }
320 /// # Ok::<(), wavo::Error>(())
321 /// ```
322 pub fn device(&self) -> &Device {
323 match &self.0 {
324 Family::Gigaam(m) => m.gpu.info(),
325 Family::Parakeet(m) => m.gpu.info(),
326 Family::Whisper(m) => m.gpu.info(),
327 Family::Moonshine(m) => m.gpu.info(),
328 }
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 #[test]
335 fn model_and_device_are_send_and_sync() {
336 fn check<T: Send + Sync>() {}
337 check::<super::Model>();
338 check::<super::Device>();
339 }
340}