diff --git a/src/audio.rs b/src/audio.rs index 78b7ee4..85a1e19 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -7,10 +7,16 @@ use anyhow::{Context, Result}; /// Play a WAV file and block until playback finishes. pub fn play_wav_blocking(path: &Path) -> Result<()> { + play_audio_blocking(path) +} + +/// Play an audio file (WAV, MP3, OGG, FLAC) and block until playback finishes. +pub fn play_audio_blocking(path: &Path) -> Result<()> { let (_stream, stream_handle) = rodio::OutputStream::try_default().context("Failed to open audio output device")?; - let file = File::open(path).context("Failed to open WAV file")?; - let source = rodio::Decoder::new(BufReader::new(file)).context("Failed to decode WAV file")?; + let file = File::open(path).context("Failed to open audio file")?; + let source = + rodio::Decoder::new(BufReader::new(file)).context("Failed to decode audio file")?; let sink = rodio::Sink::try_new(&stream_handle).context("Failed to create audio sink")?; sink.append(source); sink.sleep_until_end(); diff --git a/src/config.rs b/src/config.rs index 33babf8..c29c636 100644 --- a/src/config.rs +++ b/src/config.rs @@ -88,3 +88,7 @@ pub fn db_path() -> PathBuf { pub fn clones_dir() -> PathBuf { config_dir().join("clones") } + +pub fn packs_dir() -> PathBuf { + config_dir().join("packs") +} diff --git a/src/db.rs b/src/db.rs index f208d0c..9170df4 100644 --- a/src/db.rs +++ b/src/db.rs @@ -12,6 +12,7 @@ pub struct Preferences { pub gender: Option, pub style: Option, pub model: Option, + pub pack: Option, } #[derive(Debug, Clone)] @@ -79,6 +80,13 @@ fn migrate(conn: &Connection) -> Result<()> { created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now')) );", )?; + + // Add pack column if it doesn't exist (migration for existing DBs) + let has_pack = conn.prepare("SELECT pack FROM preferences LIMIT 0").is_ok(); + if !has_pack { + conn.execute_batch("ALTER TABLE preferences ADD COLUMN pack TEXT;")?; + } + Ok(()) } @@ -86,7 +94,7 @@ fn migrate(conn: &Connection) -> Result<()> { pub fn get_preferences(conn: &Connection) -> Result { let mut stmt = conn.prepare( - "SELECT backend, voice, lang, rate, gender, style, model FROM preferences WHERE id = 1", + "SELECT backend, voice, lang, rate, gender, style, model, pack FROM preferences WHERE id = 1", )?; let result = stmt.query_row([], |row| { Ok(Preferences { @@ -97,6 +105,7 @@ pub fn get_preferences(conn: &Connection) -> Result { gender: row.get(4)?, style: row.get(5)?, model: row.get(6)?, + pack: row.get(7)?, }) }); match result { @@ -108,7 +117,7 @@ pub fn get_preferences(conn: &Connection) -> Result { pub fn set_preference(conn: &Connection, key: &str, value: &str) -> Result<()> { let valid_keys = [ - "backend", "voice", "lang", "rate", "gender", "style", "model", + "backend", "voice", "lang", "rate", "gender", "style", "model", "pack", ]; if !valid_keys.contains(&key) { anyhow::bail!( @@ -155,8 +164,8 @@ pub fn set_preference(conn: &Connection, key: &str, value: &str) -> Result<()> { // Upsert: insert or update conn.execute( - "INSERT INTO preferences (id, backend, voice, lang, rate, gender, style, model) - VALUES (1, NULL, NULL, NULL, NULL, NULL, NULL, NULL) + "INSERT INTO preferences (id, backend, voice, lang, rate, gender, style, model, pack) + VALUES (1, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL) ON CONFLICT(id) DO NOTHING", [], )?; diff --git a/src/lib.rs b/src/lib.rs index aec2fbb..d39ea79 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,5 +8,6 @@ pub mod db; pub mod init; pub mod input; pub mod mcp; +pub mod pack; #[cfg(target_os = "macos")] pub mod stt; diff --git a/src/main.rs b/src/main.rs index d392e6b..627fde8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use clap::{Parser, Subcommand, ValueEnum}; use vox::backend::{self, SpeakOptions}; use vox::config::DEFAULT_BACKEND; -use vox::{clone, db, init, input, mcp}; +use vox::{clone, db, init, input, mcp, pack}; #[derive(Parser)] #[command(name = "vox", version, about = "Voice Command — read text aloud")] @@ -71,6 +71,11 @@ enum Commands { }, /// Launch MCP server (stdio transport for Claude Code / Claude Desktop) Serve, + /// Manage fun sound packs (peon-ping compatible) + Pack { + #[command(subcommand)] + action: PackAction, + }, /// Start a voice conversation with Claude (macOS only) #[cfg(target_os = "macos")] Chat { @@ -128,6 +133,36 @@ enum InitMode { All, } +#[derive(Subcommand)] +enum PackAction { + /// List available and installed sound packs + List, + /// Install a sound pack from peon-ping repository + Install { + /// Pack name (e.g. peon, peon_fr, sc_kerrigan) + name: String, + }, + /// Remove an installed sound pack + Remove { + /// Pack name + name: String, + }, + /// Set the active sound pack + Set { + /// Pack name + name: String, + }, + /// Play a random sound from the active pack (or a specific pack) + Play { + /// Sound category (greeting, acknowledge, complete, error, permission, annoyed) + #[arg(default_value = "greeting")] + category: String, + /// Pack name (uses active pack if omitted) + #[arg(short = 'p', long)] + pack: Option, + }, +} + #[derive(Subcommand)] enum ConfigAction { /// Show current preferences @@ -152,6 +187,7 @@ fn main() -> Result<()> { Some(Commands::Stats) => handle_stats(), Some(Commands::Init { mode }) => handle_init(mode), Some(Commands::Serve) => mcp::run_server(), + Some(Commands::Pack { action }) => handle_pack(action), #[cfg(target_os = "macos")] Some(Commands::Chat { voice, lang }) => handle_chat(voice, lang), None => handle_speak(cli), @@ -318,6 +354,7 @@ fn handle_config(action: ConfigAction) -> Result<()> { ); println!("style: {}", prefs.style.as_deref().unwrap_or("(default)")); println!("model: {}", prefs.model.as_deref().unwrap_or("(default)")); + println!("pack: {}", prefs.pack.as_deref().unwrap_or("(none)")); } ConfigAction::Set { key, value } => { db::set_preference(&conn, &key, &value)?; @@ -452,6 +489,94 @@ fn handle_init(mode: InitMode) -> Result<()> { Ok(()) } +fn handle_pack(action: PackAction) -> Result<()> { + match action { + PackAction::List => { + let installed = pack::list_installed()?; + let available = pack::list_available(); + + let conn = db::open()?; + let prefs = db::get_preferences(&conn)?; + let active = prefs.pack.as_deref().unwrap_or(""); + + if installed.is_empty() { + println!("No packs installed.\n"); + } else { + println!("Installed:"); + for p in &installed { + let marker = if p.name == active { " (active)" } else { "" }; + let cats: Vec<&str> = p.categories.keys().map(|k| k.as_str()).collect(); + println!( + " {} — {}{} [{}]", + p.name, + p.display_name, + marker, + cats.join(", ") + ); + } + println!(); + } + + let installed_names: Vec<&str> = installed.iter().map(|p| p.name.as_str()).collect(); + let not_installed: Vec<&&str> = available + .iter() + .filter(|n| !installed_names.contains(*n)) + .collect(); + + if !not_installed.is_empty() { + println!("Available for install:"); + for name in ¬_installed { + println!(" {name}"); + } + } + } + PackAction::Install { name } => { + println!("Installing pack '{name}'..."); + pack::install(&name)?; + println!("Pack '{name}' installed."); + } + PackAction::Remove { name } => { + if pack::remove(&name)? { + // Clear active pack if it was the removed one + let conn = db::open()?; + let prefs = db::get_preferences(&conn)?; + if prefs.pack.as_deref() == Some(&name) { + db::set_preference(&conn, "pack", "")?; + } + println!("Pack '{name}' removed."); + } else { + println!("Pack '{name}' not found."); + } + } + PackAction::Set { name } => { + // Verify pack is installed + let _ = pack::load_manifest(&name)?; + let conn = db::open()?; + db::set_preference(&conn, "pack", &name)?; + println!("Active pack set to '{name}'."); + } + PackAction::Play { + category, + pack: pack_name, + } => { + let name = match pack_name { + Some(n) => n, + None => { + let conn = db::open()?; + let prefs = db::get_preferences(&conn)?; + prefs.pack.unwrap_or_default() + } + }; + if name.is_empty() { + anyhow::bail!("No active pack. Set one with: vox pack set "); + } + let line = pack::play(&name, Some(&category))?; + println!("{line}"); + } + } + Ok(()) +} + fn handle_stats() -> Result<()> { let conn = db::open()?; let (count, total_chars) = db::get_usage_summary(&conn)?; diff --git a/src/mcp.rs b/src/mcp.rs index 45f81de..95a051f 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -7,6 +7,7 @@ use serde_json::{Value, json}; use crate::backend::{self, SpeakOptions}; use crate::clone; use crate::db; +use crate::pack; const SERVER_NAME: &str = "vox"; const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -282,6 +283,70 @@ fn tool_definitions() -> Value { "name": "vox_stats", "description": "Show vox usage statistics (total requests, characters spoken, recent history).", "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "vox_pack_list", + "description": "List installed and available fun sound packs (peon-ping compatible: Warcraft, StarCraft, Red Alert voices).", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "vox_pack_install", + "description": "Install a fun sound pack. Available: peon, peon_fr, peon_pl, peasant, peasant_fr, sc_kerrigan, sc_battlecruiser, ra2_soviet_engineer.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Pack name to install" + } + }, + "required": ["name"] + } + }, + { + "name": "vox_pack_set", + "description": "Set the active sound pack.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Pack name to activate" + } + }, + "required": ["name"] + } + }, + { + "name": "vox_pack_play", + "description": "Play a random sound from a pack category. Categories: greeting, acknowledge, complete, error, permission, resource_limit, annoyed.", + "inputSchema": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Sound category (default: greeting)" + }, + "pack": { + "type": "string", + "description": "Pack name (uses active pack if omitted)" + } + } + } + }, + { + "name": "vox_pack_remove", + "description": "Remove an installed sound pack.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Pack name to remove" + } + }, + "required": ["name"] + } } ]) } @@ -334,6 +399,11 @@ fn call_tool(name: &str, args: &Value) -> ToolResult { "vox_config_show" => tool_config_show(), "vox_config_set" => tool_config_set(args), "vox_stats" => tool_stats(), + "vox_pack_list" => tool_pack_list(), + "vox_pack_install" => tool_pack_install(args), + "vox_pack_set" => tool_pack_set(args), + "vox_pack_play" => tool_pack_play(args), + "vox_pack_remove" => tool_pack_remove(args), _ => tool_err(format!("unknown tool: {name}")), } } @@ -570,6 +640,7 @@ fn tool_config_show() -> ToolResult { ), format!("style: {}", prefs.style.as_deref().unwrap_or("(default)")), format!("model: {}", prefs.model.as_deref().unwrap_or("(default)")), + format!("pack: {}", prefs.pack.as_deref().unwrap_or("(none)")), ]; tool_ok(lines.join("\n")) } @@ -631,3 +702,137 @@ fn tool_stats() -> ToolResult { tool_ok(output) } + +// --------------------------------------------------------------------------- +// Sound pack tools +// --------------------------------------------------------------------------- + +fn tool_pack_list() -> ToolResult { + let installed = match pack::list_installed() { + Ok(p) => p, + Err(e) => return tool_err(format!("error: {e}")), + }; + + let conn = match db::open() { + Ok(c) => c, + Err(e) => return tool_err(format!("database error: {e}")), + }; + let active = db::get_preferences(&conn) + .ok() + .and_then(|p| p.pack) + .unwrap_or_default(); + + let mut output = String::new(); + + if installed.is_empty() { + output.push_str("No packs installed.\n"); + } else { + output.push_str("Installed:\n"); + for p in &installed { + let marker = if p.name == active { " (active)" } else { "" }; + output.push_str(&format!(" {} — {}{}\n", p.name, p.display_name, marker)); + } + } + + let installed_names: Vec<&str> = installed.iter().map(|p| p.name.as_str()).collect(); + let not_installed: Vec<&&str> = pack::list_available() + .iter() + .filter(|n| !installed_names.contains(*n)) + .collect(); + + if !not_installed.is_empty() { + output.push_str("\nAvailable for install:\n"); + for name in ¬_installed { + output.push_str(&format!(" {name}\n")); + } + } + + tool_ok(output) +} + +fn tool_pack_install(args: &Value) -> ToolResult { + let name = match args.get("name").and_then(|v| v.as_str()) { + Some(n) => n, + None => return tool_err("missing required parameter: name".into()), + }; + + match pack::install(name) { + Ok(()) => tool_ok(format!("Pack '{name}' installed.")), + Err(e) => tool_err(format!("install error: {e}")), + } +} + +fn tool_pack_set(args: &Value) -> ToolResult { + let name = match args.get("name").and_then(|v| v.as_str()) { + Some(n) => n, + None => return tool_err("missing required parameter: name".into()), + }; + + if let Err(e) = pack::load_manifest(name) { + return tool_err(format!("error: {e}")); + } + + let conn = match db::open() { + Ok(c) => c, + Err(e) => return tool_err(format!("database error: {e}")), + }; + + match db::set_preference(&conn, "pack", name) { + Ok(()) => tool_ok(format!("Active pack set to '{name}'.")), + Err(e) => tool_err(format!("error: {e}")), + } +} + +fn tool_pack_play(args: &Value) -> ToolResult { + let category = args + .get("category") + .and_then(|v| v.as_str()) + .unwrap_or("greeting"); + + let pack_name = match args.get("pack").and_then(|v| v.as_str()) { + Some(n) => n.to_string(), + None => { + let conn = match db::open() { + Ok(c) => c, + Err(e) => return tool_err(format!("database error: {e}")), + }; + db::get_preferences(&conn) + .ok() + .and_then(|p| p.pack) + .unwrap_or_default() + } + }; + + if pack_name.is_empty() { + return tool_err( + "No active pack. Set one with vox_pack_set or pass the 'pack' parameter.".into(), + ); + } + + match pack::play(&pack_name, Some(category)) { + Ok(line) => tool_ok(format!("[{pack_name}/{category}] {line}")), + Err(e) => tool_err(format!("play error: {e}")), + } +} + +fn tool_pack_remove(args: &Value) -> ToolResult { + let name = match args.get("name").and_then(|v| v.as_str()) { + Some(n) => n, + None => return tool_err("missing required parameter: name".into()), + }; + + match pack::remove(name) { + Ok(true) => { + // Clear active pack if it was the removed one + if let Ok(conn) = db::open() + && let Ok(prefs) = db::get_preferences(&conn) + && prefs.pack.as_deref() == Some(name) + { + let _ = db::set_preference(&conn, "pack", ""); + } + tool_ok(format!("Pack '{name}' removed.")) + } + Ok(false) => tool_err(format!("Pack '{name}' not found.")), + Err(e) => tool_err(format!("error: {e}")), + } +} diff --git a/src/pack.rs b/src/pack.rs new file mode 100644 index 0000000..f38cafb --- /dev/null +++ b/src/pack.rs @@ -0,0 +1,221 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::PathBuf; +use std::time::SystemTime; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::audio; +use crate::config; + +const PACKS_REPO: &str = "https://raw.githubusercontent.com/tonyyont/peon-ping/main/packs"; + +const AVAILABLE_PACKS: &[&str] = &[ + "peon", + "peon_fr", + "peon_pl", + "peasant", + "peasant_fr", + "sc_kerrigan", + "sc_battlecruiser", + "ra2_soviet_engineer", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackManifest { + pub name: String, + pub display_name: String, + pub categories: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Category { + pub sounds: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SoundEntry { + pub file: String, + pub line: String, +} + +/// List packs available for install from the remote repository. +pub fn list_available() -> &'static [&'static str] { + AVAILABLE_PACKS +} + +/// List installed packs by reading manifest.json from each pack directory. +pub fn list_installed() -> Result> { + let dir = config::packs_dir(); + if !dir.exists() { + return Ok(vec![]); + } + let mut packs = Vec::new(); + for entry in fs::read_dir(&dir)? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let manifest_path = entry.path().join("manifest.json"); + if manifest_path.exists() { + let content = fs::read_to_string(&manifest_path)?; + if let Ok(manifest) = serde_json::from_str::(&content) { + packs.push(manifest); + } + } + } + packs.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(packs) +} + +/// Install a pack by downloading manifest + sound files from peon-ping repo. +pub fn install(name: &str) -> Result<()> { + if !AVAILABLE_PACKS.contains(&name) { + anyhow::bail!( + "Unknown pack: {name}. Available: {}", + AVAILABLE_PACKS.join(", ") + ); + } + + let dest = config::packs_dir().join(name); + if dest.exists() { + anyhow::bail!("Pack '{name}' is already installed"); + } + + let manifest_url = format!("{PACKS_REPO}/{name}/manifest.json"); + let client = reqwest::blocking::Client::new(); + + let manifest_resp = client + .get(&manifest_url) + .send() + .context("Failed to download manifest")?; + if !manifest_resp.status().is_success() { + anyhow::bail!( + "Failed to download manifest: HTTP {}", + manifest_resp.status() + ); + } + let manifest_text = manifest_resp.text().context("Failed to read manifest")?; + + let manifest: PackManifest = + serde_json::from_str(&manifest_text).context("Failed to parse manifest")?; + + // Collect unique sound files + let mut files: HashSet = HashSet::new(); + for cat in manifest.categories.values() { + for sound in &cat.sounds { + files.insert(sound.file.clone()); + } + } + + // Create directories + let sounds_dir = dest.join("sounds"); + fs::create_dir_all(&sounds_dir)?; + + // Save manifest + fs::write(dest.join("manifest.json"), &manifest_text)?; + + // Download each sound file + for file in &files { + let url = format!("{PACKS_REPO}/{name}/sounds/{file}"); + let resp = client + .get(&url) + .send() + .with_context(|| format!("Failed to download {file}"))?; + if !resp.status().is_success() { + // Clean up on failure + let _ = fs::remove_dir_all(&dest); + anyhow::bail!("Failed to download {file}: HTTP {}", resp.status()); + } + let bytes = resp + .bytes() + .with_context(|| format!("Failed to read {file}"))?; + fs::write(sounds_dir.join(file), &bytes)?; + } + + Ok(()) +} + +/// Remove an installed pack. +pub fn remove(name: &str) -> Result { + let dest = config::packs_dir().join(name); + if !dest.exists() { + return Ok(false); + } + fs::remove_dir_all(&dest)?; + Ok(true) +} + +/// Load a pack's manifest. +pub fn load_manifest(name: &str) -> Result { + let manifest_path = config::packs_dir().join(name).join("manifest.json"); + if !manifest_path.exists() { + anyhow::bail!("Pack '{name}' is not installed. Use: vox pack install {name}"); + } + let content = fs::read_to_string(&manifest_path)?; + let manifest: PackManifest = serde_json::from_str(&content)?; + Ok(manifest) +} + +/// Play a random sound from a category in the given pack. +/// Returns the voice line text of the sound played. +pub fn play(name: &str, category: Option<&str>) -> Result { + let manifest = load_manifest(name)?; + + let cat_name = category.unwrap_or("greeting"); + let cat = manifest + .categories + .get(cat_name) + .with_context(|| format!("Category '{cat_name}' not found in pack '{name}'"))?; + + if cat.sounds.is_empty() { + anyhow::bail!("No sounds in category '{cat_name}'"); + } + + // Pseudo-random selection using system time nanoseconds + let idx = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() as usize + % cat.sounds.len(); + + let sound = &cat.sounds[idx]; + let sound_path = config::packs_dir() + .join(name) + .join("sounds") + .join(&sound.file); + + audio::play_audio_blocking(&sound_path)?; + + Ok(sound.line.clone()) +} + +/// Get the sound file path for a random sound (without playing it). +pub fn pick_sound(name: &str, category: Option<&str>) -> Result<(PathBuf, String)> { + let manifest = load_manifest(name)?; + + let cat_name = category.unwrap_or("greeting"); + let cat = manifest + .categories + .get(cat_name) + .with_context(|| format!("Category '{cat_name}' not found in pack '{name}'"))?; + + if cat.sounds.is_empty() { + anyhow::bail!("No sounds in category '{cat_name}'"); + } + + let idx = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() as usize + % cat.sounds.len(); + + let sound = &cat.sounds[idx]; + let sound_path = config::packs_dir() + .join(name) + .join("sounds") + .join(&sound.file); + + Ok((sound_path, sound.line.clone())) +}