mod audio;
mod models;
mod output;
mod split;
use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail, ensure};
use indicatif::{MultiProgress, ProgressBar, ProgressFinish, ProgressStyle};
use models::Source;
use wavo::hub::{self, Hub, MODELS};
const USAGE: &str = "\
Usage: wavo COMMAND ...
Commands:
run [MODEL] AUDIO [OPTIONS] transcribe AUDIO with MODEL (parakeet-v3 by default)
pull MODEL... download models
list list downloaded models
rm MODEL delete a downloaded model
bench MODEL AUDIO [-n N] time the model load, the first call and N more calls (10)
Quick start:
wavo AUDIO [OPTIONS] the same as wavo run AUDIO: transcribe with parakeet-v3,
offering to download it first if it is missing
Run options:
--json print the text and its tokens with start times as JSON
--srt print subtitles
--segment SECS split longer audio at pauses into parts of up to SECS
(25 for GigaAM, 30 for Whisper and Moonshine, else 60;
0 for one pass)
-y, --yes download a missing model without asking
MODEL is a name below, its full name or a path to a .gguf file. AUDIO is wav, mp3, m4a, flac
or ogg (Vorbis), downmixed to mono and resampled to 16 kHz.
Models live in the Hugging Face cache (HF_HUB_CACHE or HF_HOME), shared with the hf CLI;
only `wavo pull` and `wavo run` (after asking, or with -y) use the network.
Models:";
const SEGMENT_MS: u32 = 60_000;
fn usage() -> String {
let mut usage = USAGE.to_string();
for m in MODELS {
let full = if m.name == m.full_name { String::new() } else { format!(" ({})", m.full_name) };
usage += &format!("\n {:<18} {} {}{full}", m.name, models::gb(m.size), models::about(m));
}
usage
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
match cli(&args.iter().map(String::as_str).collect::<Vec<_>>()) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
ExitCode::FAILURE
}
}
}
const COMMANDS: &[&str] = &["help", "run", "pull", "list", "ls", "rm", "bench"];
fn cli(args: &[&str]) -> Result<()> {
let help = matches!(args, [] | ["help"]) || args.iter().any(|a| matches!(*a, "-h" | "--help"));
match args {
_ if help => println!("{}", usage()),
["-V" | "--version"] => println!("wavo {}", env!("CARGO_PKG_VERSION")),
["run", args @ ..] => run(args)?,
["pull", names @ ..] if !names.is_empty() => pull(names)?,
["list" | "ls"] => list()?,
["rm", name] => rm(name)?,
["bench", model, audio] => bench(model, audio, 10)?,
["bench", model, audio, "-n", n] => bench(model, audio, n.parse().context("-n")?)?,
[arg, ..] if arg.starts_with('-') || (!COMMANDS.contains(arg) && Path::new(arg).is_file()) => {
run(args)?
}
_ => bail!("unknown command: wavo {}\n\n{}", args.join(" "), usage()),
}
Ok(())
}
#[derive(Clone, Copy, PartialEq)]
enum Download {
Never,
Ask,
Yes,
}
fn model_path(arg: &str, download: Download) -> Result<PathBuf> {
let m = match models::resolve(arg)? {
Source::Path(path) if path.is_file() => return Ok(path),
Source::Path(path) => bail!("{}: no such file", path.display()),
Source::Model(m) => m,
};
let hub = Hub::new();
if let Some(path) = hub.find(m.name) {
return Ok(path);
}
let (name, size) = (m.name, models::gb(m.size));
let question = format!("{name} is not downloaded ({size}). Download it now?");
if download == Download::Yes || download == Download::Ask && ask(&question) {
return pull_one(&hub, m, &MultiProgress::new()).with_context(|| format!("pulling {name}"));
}
bail!("{name} is not in {hub}; run: wavo pull {name} ({size})")
}
fn ask(question: &str) -> bool {
use std::io::{IsTerminal, stdin};
if !(stdin().is_terminal() && std::io::stderr().is_terminal()) {
return false;
}
eprint!("{question} [Y/n] ");
let mut answer = String::new();
if !matches!(stdin().read_line(&mut answer), Ok(1..)) {
eprintln!();
return false;
}
matches!(answer.trim().to_lowercase().as_str(), "" | "y" | "yes")
}
fn load(path: &Path) -> Result<wavo::Model> {
wavo::Model::load(path).with_context(|| format!("loading {}", path.display()))
}
fn run(args: &[&str]) -> Result<()> {
const USAGE: &str = "usage: wavo run [MODEL] AUDIO [--json | --srt] [--segment SECS] [-y]";
let (mut format, mut segment, mut download, mut rest) = (None, None, Download::Ask, Vec::new());
let mut args = args.iter();
while let Some(&arg) = args.next() {
match arg {
"--json" | "--srt" if format.is_none() => format = Some(arg),
"--segment" if segment.is_none() => {
let secs = args.next().and_then(|s| s.parse::<u32>().ok()).filter(|&s| s == 0 || s >= 5);
let secs =
secs.context("--segment takes whole seconds: 0 for one pass, else at least 5")?;
segment = Some(secs.saturating_mul(1000));
}
"-y" | "--yes" => download = Download::Yes,
_ if arg.starts_with("--") => bail!(USAGE),
_ => rest.push(arg),
}
}
let (model, audio) = match *rest {
[arg] if hub::lookup(arg).is_ok() && !Path::new(arg).exists() => {
bail!("no audio file given: wavo run {arg} AUDIO")
}
[audio] => (models::DEFAULT, audio),
[model, audio] => (model, audio),
_ => bail!(USAGE),
};
let meta = std::fs::metadata(audio).with_context(|| format!("reading {audio}"))?;
ensure!(!meta.is_dir(), "{audio}: a folder, not an audio file");
let path = model_path(model, download)?;
let bar = ProgressBar::new_spinner().with_finish(ProgressFinish::AndClear); bar.set_style(ProgressStyle::with_template("{spinner} {msg} {elapsed}")?);
bar.enable_steady_tick(Duration::from_millis(100));
bar.set_message("decoding the audio");
let pcm = audio::read(audio)?;
bar.set_message("loading the model");
let model = load(&path)?;
let max_ms = segment.or(model.max_audio_ms()).unwrap_or(SEGMENT_MS);
let ranges = split::segments(&pcm, if max_ms == 0 { usize::MAX } else { max_ms as usize * 16 });
let n = ranges.len();
let parts = ranges.into_iter().enumerate().map(|(i, r)| {
bar.set_message(format!("transcribing {}/{n}", i + 1));
Ok((r.start, model.transcribe(&pcm[r])?))
});
let (transcript, starts) = split::join(parts.collect::<Result<Vec<_>>>()?);
bar.finish_and_clear();
let (tokens, ms) = (&transcript.tokens, (pcm.len() / 16) as u32);
match format {
Some("--json") => println!("{}", output::json(&transcript)),
Some(_) if matches!(models::architecture(&path).as_deref(), Some("whisper" | "moonshine")) => {
print!("{}", output::srt_segments(tokens, ms))
}
Some(_) => print!("{}", output::srt(tokens, &starts, ms)),
None => println!("{}", transcript.text),
}
Ok(())
}
fn named(arg: &str) -> Result<&'static hub::ModelInfo> {
match models::resolve(arg)? {
Source::Model(m) => Ok(m),
Source::Path(_) => bail!("expected a model name, not a path: {arg}"),
}
}
fn pull(args: &[&str]) -> Result<()> {
let mut models = args.iter().map(|arg| named(arg)).collect::<Result<Vec<_>>>()?;
let mut seen = std::collections::HashSet::new();
models.retain(|m| seen.insert(m.name));
let (hub, bars) = (Hub::new(), MultiProgress::new());
let results: Vec<_> = std::thread::scope(|s| {
let (hub, bars) = (&hub, &bars);
let pulls: Vec<_> = models.iter().map(|m| s.spawn(move || pull_one(hub, m, bars))).collect();
pulls.into_iter().map(|pull| pull.join().unwrap()).collect()
});
for (m, result) in models.iter().zip(&results) {
match result {
Ok(path) => println!("{}", path.display()),
Err(e) => eprintln!("error: pulling {}: {e:#}", m.name),
}
}
let failed = results.iter().filter(|r| r.is_err()).count();
ensure!(failed == 0, "{failed} of {} pulls failed", models.len());
Ok(())
}
fn pull_one(hub: &Hub, m: &hub::ModelInfo, bars: &MultiProgress) -> Result<PathBuf> {
let (file, plain, mb) = (m.file(), bars.is_hidden(), |bytes: u64| bytes as f64 / 1e6);
let style = ProgressStyle::with_template(
"{msg}: {percent:>3}% {decimal_bytes} / {decimal_total_bytes}, {decimal_bytes_per_sec}",
)?;
let (mut bar, mut retries): (Option<(ProgressBar, u64)>, u32) = (None, 0);
let path = hub.pull(m.name, |p| {
if let Some(e) = p.retrying {
retries += 1;
bar = None; bars.suspend(|| eprintln!("retrying {file} ({retries}/{}) after: {e}", hub::RETRIES));
return ControlFlow::Continue(());
}
let (bar, _) = bar.get_or_insert_with(|| {
if plain {
let resume = (p.done > 0).then(|| format!(", resuming at {:.0} MB", mb(p.done)));
eprintln!("downloading {file} ({:.2} GB{})", mb(p.total) / 1e3, resume.unwrap_or_default());
}
let bar = ProgressBar::new(p.total).with_style(style.clone()).with_message(file.clone());
let bar = bars.add(bar.with_finish(ProgressFinish::AndClear));
bar.set_position(p.done);
bar.reset_eta(); (bar, p.done)
});
bar.set_position(p.done);
ControlFlow::Continue(())
})?;
if let Some((bar, start)) = bar {
bar.finish();
let (done, size) = (bar.position(), bar.length().unwrap_or_default());
if plain {
let speed = mb(done - start) / bar.elapsed().as_secs_f64();
let pct = done * 100 / size;
eprintln!("{file}: {pct:3}% {:.0} / {:.0} MB, {speed:.1} MB/s", mb(done), mb(size));
}
}
Ok(path)
}
fn list() -> Result<()> {
let hub = Hub::new();
let found: Vec<_> =
MODELS.iter().filter_map(|m| hub.find(m.name).map(|path| (m, path))).collect();
if found.is_empty() {
eprintln!("no models downloaded; try: wavo pull {}", models::DEFAULT);
} else {
println!("{:<18} {:>7} PATH", "NAME", "SIZE");
}
for (m, path) in found {
println!("{:<18} {} {}", m.name, models::gb(std::fs::metadata(&path)?.len()), path.display());
}
Ok(())
}
fn rm(arg: &str) -> Result<()> {
let m = named(arg)?;
let freed = Hub::new().remove(m.name);
let freed = freed.with_context(|| format!("removing {}", m.name))?;
let freed = freed.with_context(|| format!("{} is not downloaded", m.name))?;
eprintln!("removed {} ({} freed)", m.name, models::gb(freed));
Ok(())
}
fn bench(model: &str, audio: &str, n: usize) -> Result<()> {
ensure!(n > 0, "-n must be at least 1");
let (pcm, path) = (audio::read(audio)?, model_path(model, Download::Never)?);
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1e3;
let t = Instant::now();
let model = load(&path)?;
let load = ms(t);
eprintln!("device: {}", model.device());
let mut calls = (0..=n).map(|_| {
let t = Instant::now();
model.transcribe(&pcm).map(|_| ms(t))
});
let first = calls.next().unwrap()?;
let mut warm = calls.collect::<Result<Vec<_>, _>>()?;
warm.sort_by(f64::total_cmp);
let median = (warm[(n - 1) / 2] + warm[n / 2]) / 2.0;
println!(
"load {load:.1} ms, first {first:.1} ms, warm median {median:.1} ms, min {:.1} ms",
warm[0]
);
Ok(())
}