use clap::{Parser, Subcommand};
use kokoro_tiny::TtsEngine;
use std::io::{self, BufRead};
#[derive(Parser)]
#[command(name = "kokoro-speak")]
#[command(about = "🎤 Minimal TTS for alerts, logs, and announcements", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(short, long, default_value = "0.8")]
volume: f32,
#[arg(short = 'V', long, default_value = "af_sky")]
voice: String,
#[arg(short, long)]
output: Option<String>,
#[arg(short, long)]
list_voices: bool,
}
#[derive(Subcommand)]
enum Commands {
Say {
text: String,
},
Pipe,
Alert {
#[arg(value_enum)]
alert_type: AlertType,
message: Option<String>,
},
Context {
text: String,
#[arg(short, long, default_value = "Context summary:")]
prefix: String,
},
}
#[derive(clap::ValueEnum, Clone)]
enum AlertType {
Success,
Error,
Warning,
Info,
Build,
Test,
Deploy,
Custom,
}
impl AlertType {
fn default_message(&self) -> &str {
match self {
AlertType::Success => "Operation completed successfully!",
AlertType::Error => "Error detected. Please check the logs.",
AlertType::Warning => "Warning: Attention required.",
AlertType::Info => "Information update available.",
AlertType::Build => "Build process complete.",
AlertType::Test => "Test suite finished running.",
AlertType::Deploy => "Deployment status update.",
AlertType::Custom => "Alert triggered.",
}
}
fn voice(&self) -> &str {
match self {
AlertType::Success => "af_bella", AlertType::Error => "am_adam", AlertType::Warning => "bf_emma", AlertType::Info => "af_sky", AlertType::Build => "am_michael", AlertType::Test => "af_nicole", AlertType::Deploy => "am_echo", AlertType::Custom => "af_heart", }
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let cli = Cli::parse();
let mut engine = rt.block_on(TtsEngine::new())
.map_err(|e| format!("Failed to initialize TTS: {}", e))?;
if cli.list_voices {
println!("🎤 Available voices:");
for voice in engine.voices() {
println!(" • {}", voice);
}
return Ok(());
}
let (text, voice) = match cli.command {
Some(Commands::Say { text }) => {
(text, cli.voice)
}
Some(Commands::Pipe) => {
let stdin = io::stdin();
let mut lines = Vec::new();
for line in stdin.lock().lines() {
lines.push(line?);
}
(lines.join(" "), cli.voice)
}
Some(Commands::Alert { alert_type, message }) => {
let text = message.unwrap_or_else(|| alert_type.default_message().to_string());
let voice = alert_type.voice().to_string();
(text, voice)
}
Some(Commands::Context { text, prefix }) => {
let full_text = format!("{} {}", prefix, text);
(full_text, "bf_isabella".to_string())
}
None => {
if atty::is(atty::Stream::Stdin) {
eprintln!("💡 No input provided. Use --help for usage information.");
eprintln!("\nQuick examples:");
eprintln!(" kokoro-speak say \"Hello world!\"");
eprintln!(" echo \"Build complete\" | kokoro-speak pipe");
eprintln!(" kokoro-speak alert success");
eprintln!(" kokoro-speak context \"Found 5 TypeScript files with 200 lines total\"");
return Ok(());
}
let stdin = io::stdin();
let mut lines = Vec::new();
for line in stdin.lock().lines() {
lines.push(line?);
}
(lines.join(" "), cli.voice)
}
};
let audio = engine.synthesize(&text, Some(&voice))
.map_err(|e| format!("Synthesis failed: {}", e))?;
if let Some(output_path) = cli.output {
engine.save_wav(&output_path, &audio)
.map_err(|e| format!("Failed to save audio: {}", e))?;
println!("💾 Saved to: {}", output_path);
} else {
#[cfg(feature = "playback")]
{
println!("🔊 Speaking: \"{}\" [voice: {}, volume: {}]",
if text.len() > 50 {
format!("{}...", &text[..50])
} else {
text.clone()
},
voice,
cli.volume
);
engine.play(&audio, cli.volume)
.map_err(|e| format!("Playback failed: {}", e))?;
}
#[cfg(not(feature = "playback"))]
{
let temp_file = "/tmp/kokoro_output.wav";
engine.save_wav(temp_file, &audio)
.map_err(|e| format!("Failed to save audio: {}", e))?;
println!("💾 Audio saved to: {} (playback feature not enabled)", temp_file);
}
}
Ok(())
}