mod audio;
mod clipboard;
mod config;
mod hotkey;
mod ipc;
mod service;
mod transcribe;
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::io::{self, Write};
#[derive(Parser)]
#[command(name = "whis")]
#[command(version)]
#[command(about = "Voice-to-text CLI using OpenAI Whisper API")]
#[command(after_help = "Run 'whis' without arguments to record once (press Enter to stop).")]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Listen {
#[arg(short = 'k', long, default_value = "ctrl+shift+r")]
hotkey: String,
},
Stop,
Status,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Some(Commands::Listen { hotkey }) => run_listen(hotkey).await,
Some(Commands::Stop) => run_stop(),
Some(Commands::Status) => run_status(),
None => run_record_once().await,
}
}
async fn run_listen(hotkey_str: String) -> Result<()> {
check_ffmpeg()?;
if ipc::is_service_running() {
eprintln!("Error: whis service is already running.");
eprintln!("Use 'whis stop' to stop the existing service first.");
std::process::exit(1);
}
let hotkey = hotkey::Hotkey::parse(&hotkey_str)?;
let config = load_config()?;
ipc::write_pid_file()?;
let _cleanup = CleanupGuard;
let (hotkey_tx, hotkey_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
if let Err(e) = hotkey::listen_for_hotkey(hotkey, move || {
let _ = hotkey_tx.send(());
}) {
eprintln!("Hotkey error: {e}");
}
});
let service = service::Service::new(config)?;
let service_task = tokio::spawn(async move { service.run(Some(hotkey_rx)).await });
tokio::select! {
result = service_task => {
result?
}
_ = tokio::signal::ctrl_c() => {
println!("\nShutting down...");
Ok(())
}
}
}
fn run_stop() -> Result<()> {
let mut client = ipc::IpcClient::connect()?;
let _ = client.send_message(ipc::IpcMessage::Stop)?;
println!("Service stopped");
Ok(())
}
fn run_status() -> Result<()> {
if !ipc::is_service_running() {
println!("Status: Not running");
println!("Start with: whis listen");
return Ok(());
}
let mut client = ipc::IpcClient::connect()?;
let response = client.send_message(ipc::IpcMessage::Status)?;
match response {
ipc::IpcResponse::Idle => println!("Status: Running (idle)"),
ipc::IpcResponse::Recording => println!("Status: Running (recording)"),
ipc::IpcResponse::Processing => println!("Status: Running (processing)"),
ipc::IpcResponse::Error(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
_ => println!("Status: Running"),
}
Ok(())
}
async fn run_record_once() -> Result<()> {
check_ffmpeg()?;
let config = load_config()?;
let mut recorder = audio::AudioRecorder::new()?;
recorder.start_recording()?;
print!("Recording... (press Enter to stop)");
io::stdout().flush()?;
wait_for_enter()?;
let audio_result = recorder.stop_and_save()?;
let transcription = match audio_result {
audio::AudioResult::Single(audio_data) => {
print!("\rTranscribing... \n");
io::stdout().flush()?;
match transcribe::transcribe_audio(&config.openai_api_key, audio_data) {
Ok(text) => text,
Err(e) => {
eprintln!("Transcription error: {e}");
std::process::exit(1);
}
}
}
audio::AudioResult::Chunked(chunks) => {
print!("\rTranscribing... \n");
io::stdout().flush()?;
match transcribe::parallel_transcribe(&config.openai_api_key, chunks, None).await {
Ok(text) => text,
Err(e) => {
eprintln!("Transcription error: {e}");
std::process::exit(1);
}
}
}
};
clipboard::copy_to_clipboard(&transcription)?;
println!("Copied to clipboard");
Ok(())
}
fn check_ffmpeg() -> Result<()> {
if std::process::Command::new("ffmpeg")
.arg("-version")
.output()
.is_err()
{
eprintln!("Error: FFmpeg is not installed or not in PATH.");
eprintln!("\nwhis requires FFmpeg for audio compression.");
eprintln!("Please install FFmpeg:");
eprintln!(" - Ubuntu/Debian: sudo apt install ffmpeg");
eprintln!(" - macOS: brew install ffmpeg");
eprintln!(" - Or visit: https://ffmpeg.org/download.html\n");
std::process::exit(1);
}
Ok(())
}
fn load_config() -> Result<config::Config> {
match config::Config::from_env() {
Ok(cfg) => Ok(cfg),
Err(e) => {
eprintln!("Error loading configuration: {e}");
eprintln!("\nPlease create a .env file with your OpenAI API key:");
eprintln!(" OPENAI_API_KEY=your-api-key-here\n");
std::process::exit(1);
}
}
}
fn wait_for_enter() -> Result<()> {
let mut input = String::new();
io::stdout().flush()?;
io::stdin().read_line(&mut input)?;
Ok(())
}
struct CleanupGuard;
impl Drop for CleanupGuard {
fn drop(&mut self) {
ipc::remove_pid_file();
}
}