Skip to main content

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}