mod config;
mod state;
mod app;
mod audio;
mod bridge;
mod input;
mod transcribe;
mod approval;
mod ui;
use anyhow::Result;
use clap::Parser;
use crate::config::{CliArgs, Commands, ModelSize, get_data_dir};
use crate::transcribe::setup::{is_whisper_ready, setup_whisper};
async fn read_line(prompt: &str) -> Result<String> {
use tokio::io::{AsyncBufReadExt, BufReader};
print!("{}", prompt);
use std::io::Write;
std::io::stdout().flush()?;
let stdin = tokio::io::stdin();
let mut reader = BufReader::new(stdin);
let mut line = String::new();
reader.read_line(&mut line).await?;
Ok(line.trim().to_string())
}
async fn prompt_model_choice() -> Result<ModelSize> {
println!("Choose a Whisper model size:");
println!();
println!(" English-only (best for standard accents):");
println!(" 1) tiny.en — fastest, least accurate (~75 MB)");
println!(" 2) base.en — balanced speed and accuracy (~142 MB)");
println!(" 3) small.en — most accurate, slower (~466 MB)");
println!();
println!(" Multilingual (better for accented English):");
println!(" 4) tiny — fastest, least accurate (~75 MB)");
println!(" 5) base — balanced speed and accuracy (~142 MB)");
println!(" 6) small — most accurate, slower (~466 MB)");
loop {
let choice = read_line("Enter choice [1-6] (default: 2): ").await?;
let model = match choice.as_str() {
"1" => ModelSize::TinyEn,
"" | "2" => ModelSize::BaseEn,
"3" => ModelSize::SmallEn,
"4" => ModelSize::Tiny,
"5" => ModelSize::Base,
"6" => ModelSize::Small,
other => {
eprintln!("Invalid choice '{}'. Please enter 1-6.", other);
continue;
}
};
return Ok(model);
}
}
#[tokio::main]
async fn main() {
if std::env::var_os("PIPEWIRE_LOG_LEVEL").is_none() {
std::env::set_var("PIPEWIRE_LOG_LEVEL", "0");
}
if std::env::var_os("JACK_NO_START_SERVER").is_none() {
std::env::set_var("JACK_NO_START_SERVER", "1");
}
if std::env::var_os("JACK_NO_AUDIO_RESERVATION").is_none() {
std::env::set_var("JACK_NO_AUDIO_RESERVATION", "1");
}
if let Err(e) = run().await {
eprintln!("Error: {:#}", e);
std::process::exit(1);
}
}
async fn run() -> Result<()> {
let cli = CliArgs::parse();
match &cli.command {
Some(Commands::Setup { model }) => {
let data_dir = get_data_dir();
let model_size = match model {
Some(m) => m.clone(),
None => prompt_model_choice().await?,
};
println!("Setting up Whisper model: {}", model_size);
setup_whisper(&data_dir, &model_size).await?;
println!("Setup complete.");
}
Some(Commands::Devices) => {
let devices = crate::audio::capture::list_devices()?;
if devices.is_empty() {
println!("No audio input devices found.");
} else {
for device in devices {
println!("{}", device);
}
}
}
Some(Commands::Keys) => {
let names = crate::input::hotkey::list_key_names();
for name in names {
let display = crate::input::hotkey::format_key_name(name);
println!("{:<20} {}", name, display);
}
}
Some(Commands::Run) | None => {
let config = crate::config::AppConfig::load(&cli)?;
if !is_whisper_ready(&config.data_dir, &config.model_size) {
eprintln!(
"Whisper model '{}' is not downloaded yet.",
config.model_size
);
eprintln!(
"The model is required for speech-to-text transcription."
);
let answer = read_line("Download it now? [y/N]: ").await?;
if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
setup_whisper(&config.data_dir, &config.model_size).await?;
} else {
eprintln!(
"Model download skipped. Run 'opencode-voice setup' to download it later."
);
std::process::exit(1);
}
}
let mut app = crate::app::VoiceApp::new(config)?;
app.start().await?;
std::process::exit(0);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_key_name_via_main() {
assert_eq!(
crate::input::hotkey::format_key_name("right_option"),
"Right Option"
);
assert_eq!(
crate::input::hotkey::format_key_name("space"),
"Space"
);
assert_eq!(
crate::input::hotkey::format_key_name("f1"),
"F1"
);
}
#[test]
fn test_list_key_names_non_empty() {
let names = crate::input::hotkey::list_key_names();
assert!(!names.is_empty());
assert!(names.windows(2).all(|w| w[0] <= w[1]));
}
#[test]
fn test_list_devices_does_not_panic() {
let _ = crate::audio::capture::list_devices();
}
#[test]
fn test_get_data_dir_contains_app_name() {
let dir = get_data_dir();
assert!(
dir.to_string_lossy().contains("opencode-voice"),
"data dir should contain 'opencode-voice': {}",
dir.display()
);
}
#[test]
fn test_model_size_display_in_main() {
assert_eq!(ModelSize::TinyEn.to_string(), "tiny.en");
assert_eq!(ModelSize::BaseEn.to_string(), "base.en");
assert_eq!(ModelSize::SmallEn.to_string(), "small.en");
}
}