Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
17 changes: 13 additions & 4 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub struct Preferences {
pub gender: Option<String>,
pub style: Option<String>,
pub model: Option<String>,
pub pack: Option<String>,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -79,14 +80,21 @@ 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(())
}

// --- Preferences ---

pub fn get_preferences(conn: &Connection) -> Result<Preferences> {
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 {
Expand All @@ -97,6 +105,7 @@ pub fn get_preferences(conn: &Connection) -> Result<Preferences> {
gender: row.get(4)?,
style: row.get(5)?,
model: row.get(6)?,
pack: row.get(7)?,
})
});
match result {
Expand All @@ -108,7 +117,7 @@ pub fn get_preferences(conn: &Connection) -> Result<Preferences> {

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!(
Expand Down Expand Up @@ -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",
[],
)?;
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
127 changes: 126 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String>,
},
}

#[derive(Subcommand)]
enum ConfigAction {
/// Show current preferences
Expand All @@ -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),
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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 &not_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 <name>");
}
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)?;
Expand Down
Loading