From b252c39517b9fce181d1a0fe4761404c7d981397 Mon Sep 17 00:00:00 2001 From: salmonik Date: Tue, 18 Aug 2026 23:18:37 +0200 Subject: feat: local TTS via piper subprocess with Google fallback - New src/tts.rs: centralize TTS in synthesize(config, http_client, text) Piper subprocess primary, Google Translate fallback on error/missing - TTS_MODEL_PATH/TTS_VOICE env config (defaults to pl_PL-bass-high) - Fix Unknown interaction errors: spawn play_tts_in_channel in background so voice-state TTS no longer blocks the shard event loop (3s Discord limit) - Best-effort confirmation replies in /tts to prevent error cascade - Custom on_error handler: graceful interaction-expired error handling - Comprehensive tracing logging (engine, model, duration, stderr) --- .env.example | 8 ++- .gitignore | 1 + AGENTS.md | 3 +- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 8 ++- src/commands/ai.rs | 14 +---- src/commands/tts.rs | 59 +++++++-------------- src/config.rs | 35 +++++++++++++ src/events/mod.rs | 79 +++++++++++++++++----------- src/lang.rs | 7 +++ src/main.rs | 38 +++++++++++--- src/tts.rs | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 13 files changed, 308 insertions(+), 95 deletions(-) create mode 100644 src/tts.rs diff --git a/.env.example b/.env.example index fe76c05..01e24b3 100644 --- a/.env.example +++ b/.env.example @@ -3,13 +3,17 @@ TOKEN= # Optional - Bot Configuration STATUS_CHANNEL_ID= -ASSISTANT_KEYWORD=jarvis # Keyword to trigger AI assistant # Optional - Copyparty File Server (for music /search commands) COPYPARTY_URL=http://localhost:8080 COPYPARTY_URL_UPLOAD=http://localhost:8080/uploads COPYPARTY_PASSWORD= -# Optional - AI commands +# Optional - AI functionality +ASSISTANT_KEYWORD=jarvis # Keyword to trigger AI assistant OPENROUTER_API_KEY= OPENROUTER_MODEL= + +# Optional - Local TTS +TTS_MODEL_PATH=/path/to/models # or a full path to pl_PL-bass-high.onnx +TTS_VOICE=pl_PL-bass-high diff --git a/.gitignore b/.gitignore index 31136fd..77b5758 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ data/ .zed/ target +piper/ diff --git a/AGENTS.md b/AGENTS.md index f5a78ee..a074a43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ src/ │ ├── music.rs — /play (🟡 stub), /stop, /join, /loop_toggle │ ├── qr.rs — /qr encode + decode │ ├── trigger.rs — /trigger group (6 subcommands) -│ ├── tts.rs — /tts (Google TTS → file/vc), /log_volume +│ ├── tts.rs — /tts (local Piper TTS → file/vc), /log_volume │ ├── utilities.rs — compress🟡, convert🟡, upload, search, download, say │ └── web.rs — /web (DuckDuckGo scrape) ├── copyparty/mod.rs — CopypartyClient (search, upload) @@ -23,6 +23,7 @@ src/ ├── events/mod.rs — handle_ready, handle_voice_state_update, handle_message ├── openrouter/mod.rs — OpenRouterClient (chat, chat_with_history) ├── tasks/mod.rs — cron_scheduler_loop (30s polling, stub exec bodies) +├── tts.rs — synthesize(): Piper subprocess → Google TTS fallback └── voice/mod.rs — VoiceManager (connect, disconnect, get_call), mp3_to_input ``` diff --git a/Cargo.lock b/Cargo.lock index 8bc2ab8..10dbabe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4391,7 +4391,7 @@ dependencies = [ [[package]] name = "senator-sperma" -version = "0.2.5" +version = "0.3.0" dependencies = [ "chrono", "cron", diff --git a/Cargo.toml b/Cargo.toml index 07cb32a..d311d17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "senator-sperma" -version = "0.2.5" +version = "0.3.0" edition = "2024" [profile.dev] diff --git a/README.md b/README.md index 9b1fea9..55ca02d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ The following binaries must be available on `PATH`: - `ffmpeg` — audio decoding/encoding for playback, conversion, and compression - `qalc` (Qalculate) — used by `/math` to evaluate expressions - `yt-dlp` (optional) — used by `/download` +- `piper` (optional) — local TTS for `/tts` and `/senator ... tts_vc`; falls back to Google Translate TTS if unavailable Unlike the Python version, QR encode/decode is handled by the pure-Rust `qrcode`/`rxing` crates — no `zbar` system library required. @@ -44,6 +45,11 @@ On launch, missing variables are reported via tracing (`error!` for required, `warn!` for optional with defaults). The database and schema migrations are created automatically on first run. +For local TTS, set `TTS_MODEL_PATH` to a Piper `.onnx` model file (or a +directory of models, in which case `TTS_VOICE` selects the model, defaulting to +`pl_PL-bass-high`). When `TTS_MODEL_PATH` is unset or Piper is unavailable, the +bot falls back to Google Translate TTS. + ### 5. Run ```bash @@ -65,7 +71,7 @@ servers. - `/join` — join the caller's voice channel ### TTS -- `/tts` — Google Translate TTS (Polish), sent as a file or played in voice +- `/tts` — local Piper TTS (Polish, Google Translate fallback), sent as a file or played in voice ### Search & Web - `/web` — DuckDuckGo HTML scrape (soon to use 4get) diff --git a/src/commands/ai.rs b/src/commands/ai.rs index a7a9a7e..61fb471 100644 --- a/src/commands/ai.rs +++ b/src/commands/ai.rs @@ -30,18 +30,8 @@ pub async fn senator( }) .ok_or_else(lang::err_not_in_voice)?; - let tts_url = format!( - "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob", - crate::commands::tts::urlencoding(&response) - ); - let audio_data = ctx - .data() - .http_client - .get(&tts_url) - .send() - .await? - .bytes() - .await?; + let (audio_data, _) = + crate::tts::synthesize(&ctx.data().config, &ctx.data().http_client, &response).await?; let call = ctx .data() diff --git a/src/commands/tts.rs b/src/commands/tts.rs index 4a99539..8d20179 100644 --- a/src/commands/tts.rs +++ b/src/commands/tts.rs @@ -3,6 +3,7 @@ use poise::serenity_prelude as serenity; use crate::Data; use crate::error::{Error, Result}; use crate::lang; +use tracing::warn; type Context<'a> = poise::Context<'a, Data, Error>; @@ -14,7 +15,7 @@ pub enum OutputMode { Vc, } -/// Generate Google TTS (Polish) and output to file or voice channel +/// Generate TTS (Piper locally, Google as fallback) and output to file or voice channel #[poise::command(slash_command)] pub async fn tts( ctx: Context<'_>, @@ -22,23 +23,10 @@ pub async fn tts( #[description = "Output destination (file or vc)"] output: Option, ) -> Result<()> { ctx.defer().await?; - let lang = "pl"; - let tts_url = format!( - "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl={}&client=tw-ob", - urlencoding(&text), - lang - ); let output = output.unwrap_or(OutputMode::Vc); - - let audio_data = ctx - .data() - .http_client - .get(&tts_url) - .send() - .await? - .bytes() - .await?; + let (audio_data, ext) = + crate::tts::synthesize(&ctx.data().config, &ctx.data().http_client, &text).await?; match output { OutputMode::File => { @@ -46,17 +34,22 @@ pub async fn tts( .await .ok(); let path = format!( - "{}/tts_{}.mp3", + "{}/tts_{}.{}", crate::config::TEMP_PATH, - uuid::Uuid::new_v4() + uuid::Uuid::new_v4(), + ext ); tokio::fs::write(&path, &audio_data).await?; - ctx.send( - poise::CreateReply::default() - .content(lang::L_TTS_SAVED) - .attachment(serenity::CreateAttachment::path(&path).await.unwrap()), - ) - .await?; + if let Err(e) = ctx + .send( + poise::CreateReply::default() + .content(lang::L_TTS_SAVED) + .attachment(serenity::CreateAttachment::path(&path).await.unwrap()), + ) + .await + { + warn!("Failed to send /tts file reply: {e}"); + } } OutputMode::Vc => { let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; @@ -81,24 +74,12 @@ pub async fn tts( if call_lock.current_channel().is_some() { call_lock.play_input(input); } - ctx.say(lang::L_TTS_PLAYING).await?; - } - } - Ok(()) -} - -pub fn urlencoding(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for &byte in s.as_bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'~' => { - out.push(byte as char) + if let Err(e) = ctx.say(lang::L_TTS_PLAYING).await { + warn!("Failed to send /tts voice reply: {e}"); } - b' ' => out.push('+'), - _ => out.push_str(&format!("%{:02X}", byte)), } } - out + Ok(()) } pub fn commands() -> Vec> { diff --git a/src/config.rs b/src/config.rs index 989a060..3b92dd8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,12 @@ pub struct Config { pub llm_model: Option, pub llm_api_url: String, pub database_path: String, + /// Optional path to the Piper TTS `.onnx` model (or a directory of models). + /// When unset, TTS falls back to Google Translate TTS. + pub tts_model_path: Option, + /// Piper voice name, used to resolve the model file when `tts_model_path` + /// points at a directory. Ignored when it points at a `.onnx` file directly. + pub tts_voice: String, } struct EnvVar { @@ -100,6 +106,18 @@ const ENV_VARS: &[EnvVar] = &[ default: Some("data/senator.db"), description: "SQLite database path", }, + EnvVar { + name: "TTS_MODEL_PATH", + required: false, + default: None, + description: "Path to a Piper TTS .onnx model (or a models directory). Unset => Google TTS fallback", + }, + EnvVar { + name: "TTS_VOICE", + required: false, + default: Some("pl_PL-bass-high"), + description: "Piper TTS voice name (used when TTS_MODEL_PATH points at a directory)", + }, EnvVar { name: "RUST_LOG", required: false, @@ -180,6 +198,23 @@ impl Config { llm_api_url: env::var("LLM_API_URL") .unwrap_or_else(|_| "https://openrouter.ai/api/v1/chat/completions".into()), database_path: env::var("SENATOR_DB_PATH").unwrap_or_else(|_| "data/senator.db".into()), + tts_model_path: env::var("TTS_MODEL_PATH").ok(), + tts_voice: env::var("TTS_VOICE").unwrap_or_else(|_| "pl_PL-bass-high".into()), + } + } + + /// Resolve the Piper model path to pass to the `piper` CLI. + /// + /// - `TTS_MODEL_PATH` set to a `.onnx` file => used verbatim. + /// - `TTS_MODEL_PATH` set to a directory => `{dir}/{TTS_VOICE}.onnx`. + /// - `TTS_MODEL_PATH` unset => `None` (caller falls back to Google TTS). + pub fn piper_model_path(&self) -> Option { + let configured = self.tts_model_path.as_ref()?; + let path = std::path::PathBuf::from(configured); + if path.is_dir() { + Some(path.join(format!("{}.onnx", self.tts_voice))) + } else { + Some(path) } } diff --git a/src/events/mod.rs b/src/events/mod.rs index 82b6f19..22fe976 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -28,32 +28,22 @@ fn format_duration(seconds: f64) -> String { } async fn play_tts_in_channel( - _ctx: &serenity::Context, - data: &Data, + config: crate::config::Config, + http_client: reqwest::Client, + voice_manager: crate::voice::VoiceManager, guild_id: serenity::GuildId, channel_id: serenity::ChannelId, - text: &str, + text: String, ) { - let tts_url = format!( - "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob", - crate::commands::tts::urlencoding(text), - ); - - let audio_data = match data.http_client.get(&tts_url).send().await { - Ok(resp) => match resp.bytes().await { - Ok(b) => b, - Err(e) => { - warn!("Failed to read TTS audio bytes: {e}"); - return; - } - }, + let audio_data = match crate::tts::synthesize(&config, &http_client, &text).await { + Ok((bytes, _)) => bytes, Err(e) => { - warn!("Failed to fetch TTS audio: {e}"); + warn!("Failed to synthesize TTS: {e}"); return; } }; - let call = match data.voice_manager.connect(guild_id, channel_id).await { + let call = match voice_manager.connect(guild_id, channel_id).await { Ok(c) => c, Err(e) => { warn!("Failed to connect to voice for TTS: {e}"); @@ -93,9 +83,10 @@ pub async fn handle_voice_state_update( .and_then(|gid| ctx.cache.guild(gid)) .and_then(|g| g.members.get(&user_id).cloned()); if let Some(ref m) = member - && m.user.bot { - return Ok(()); - } + && m.user.bot + { + return Ok(()); + } let member_name = member .as_ref() @@ -130,7 +121,9 @@ pub async fn handle_voice_state_update( let mut tts_channel: Option = None; // ── JOIN ── - if old_channel.is_none() && let Some(cid) = new_channel { + if old_channel.is_none() + && let Some(cid) = new_channel + { let is_afk = new.self_mute || new.self_deaf; va.join_time = now; @@ -274,9 +267,17 @@ pub async fn handle_voice_state_update( lang::fmt_ev_undeafened(&display_name) }; if let Some(gid) = guild_id - && let Some(cid) = tts_channel.or(new_channel) { - play_tts_in_channel(ctx, data, gid, cid, &phrase).await; - } + && let Some(cid) = tts_channel.or(new_channel) + { + tokio::spawn(play_tts_in_channel( + data.config.clone(), + data.http_client.clone(), + data.voice_manager.clone(), + gid, + cid, + phrase, + )); + } return Ok(()); } @@ -287,17 +288,33 @@ pub async fn handle_voice_state_update( lang::fmt_ev_unmuted(&display_name) }; if let Some(gid) = guild_id - && let Some(cid) = tts_channel.or(new_channel) { - play_tts_in_channel(ctx, data, gid, cid, &phrase).await; - } + && let Some(cid) = tts_channel.or(new_channel) + { + tokio::spawn(play_tts_in_channel( + data.config.clone(), + data.http_client.clone(), + data.voice_manager.clone(), + gid, + cid, + phrase, + )); + } return Ok(()); } // ── Play join/leave TTS if no mute/deafen change ── if let (Some(text), Some(cid)) = (tts_text, tts_channel) - && let Some(gid) = guild_id { - play_tts_in_channel(ctx, data, gid, cid, &text).await; - } + && let Some(gid) = guild_id + { + tokio::spawn(play_tts_in_channel( + data.config.clone(), + data.http_client.clone(), + data.voice_manager.clone(), + gid, + cid, + text, + )); + } Ok(()) } diff --git a/src/lang.rs b/src/lang.rs index 05e82fd..aad0413 100644 --- a/src/lang.rs +++ b/src/lang.rs @@ -114,6 +114,13 @@ pub fn fmt_voice_join_failed(e: &dyn std::fmt::Display) -> String { pub const L_TTS_SAVED: &str = "TTS zapisany"; pub const L_TTS_PLAYING: &str = "Grom TTS na kanale głosowym"; +pub fn fmt_tts_piper_launch(e: &str) -> String { + format!("Piper sie nie uruchomił w czas: {e}") +} +pub fn fmt_tts_piper_failed(e: &str) -> String { + format!("Piper TTS sie nie powiodło: {e}") +} + // ── Trigger command ─────────────────────────────────────────────────── pub const L_TRIGGER_UPDATED: &str = "Trigger zaktualizowany"; diff --git a/src/main.rs b/src/main.rs index d06176d..31c9e94 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod events; mod lang; mod openrouter; mod tasks; +mod tts; mod voice; use database::DbPool; @@ -26,9 +27,7 @@ const AC_SEARCH_CACHE_TTL: Duration = Duration::from_secs(10); type AcCache = tokio::sync::Mutex>; /// Shared autocomplete search cache: query → (hits, cached_at). -type AcSearchCache = Arc< - tokio::sync::Mutex, Instant)>>, ->; +type AcSearchCache = Arc, Instant)>>>; pub struct Data { pub db_pool: DbPool, @@ -84,10 +83,7 @@ impl Data { /// Look up search cache for an autocomplete query. Returns `None` if /// uncached or stale. - pub async fn ac_search_cache_lookup( - &self, - query: &str, - ) -> Option> { + pub async fn ac_search_cache_lookup(&self, query: &str) -> Option> { let cache = self.ac_search_cache.lock().await; let (hits, cached_at) = cache.get(query)?; if cached_at.elapsed() > AC_SEARCH_CACHE_TTL { @@ -150,6 +146,34 @@ async fn main() -> Result<(), Box> { Ok(()) }) }, + on_error: |error| { + Box::pin(async move { + match error { + poise::FrameworkError::Command { ctx, error, .. } => { + tracing::error!(command = %ctx.command().name, "Command failed: {error}"); + if let Err(e) = ctx + .send( + poise::CreateReply::default() + .content(format!("Błąd: {error}")) + .ephemeral(true), + ) + .await + { + tracing::debug!( + "Could not send error reply (interaction likely expired): {e}" + ); + } + } + other => { + if let Err(e) = poise::builtins::on_error(other).await { + tracing::warn!( + "Error while sending error message (interaction likely expired): {e}" + ); + } + } + } + }) + }, ..Default::default() }) .setup(move |ctx, _ready, framework| { diff --git a/src/tts.rs b/src/tts.rs new file mode 100644 index 0000000..ded76ad --- /dev/null +++ b/src/tts.rs @@ -0,0 +1,147 @@ +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Instant; + +use tokio::io::AsyncWriteExt; +use tracing::{debug, info, warn}; + +use crate::config; +use crate::config::TEMP_PATH; +use crate::error::{Error, Result}; + +/// Synthesize speech for `text`. +/// +/// Primary engine is local Piper TTS (via the `piper` CLI). If Piper is not +/// installed, fails, or `TTS_MODEL_PATH` is unset, we fall back to Google +/// Translate TTS over HTTP. +/// +/// Returns the encoded audio bytes plus the file extension to use when saving +/// (`"wav"` for Piper, `"mp3"` for Google). +pub async fn synthesize( + config: &config::Config, + http_client: &reqwest::Client, + text: &str, +) -> Result<(Vec, &'static str)> { + match config.piper_model_path() { + Some(model_path) => { + let started = Instant::now(); + match piper_synthesize(&model_path, text).await { + Ok(bytes) => { + info!( + model = %model_path.display(), + text_len = text.len(), + bytes = bytes.len(), + elapsed_ms = started.elapsed().as_millis() as u64, + "Piper TTS synthesized" + ); + return Ok((bytes, "wav")); + } + Err(e) => { + warn!( + model = %model_path.display(), + text_len = text.len(), + elapsed_ms = started.elapsed().as_millis() as u64, + "Piper TTS failed, falling back to Google TTS: {e}" + ); + } + } + } + None => { + debug!("TTS_MODEL_PATH is not set; using Google TTS fallback"); + } + } + + let started = Instant::now(); + let bytes = google_synthesize(http_client, text).await?; + info!( + text_len = text.len(), + bytes = bytes.len(), + elapsed_ms = started.elapsed().as_millis() as u64, + "Google TTS synthesized" + ); + Ok((bytes, "mp3")) +} + +/// Run `piper` as a subprocess, feeding `text` on stdin and collecting the +/// generated WAV from `--output_file`. +async fn piper_synthesize(model_path: &PathBuf, text: &str) -> Result> { + tokio::fs::create_dir_all(TEMP_PATH).await.ok(); + + let out_path = format!("{}/tts_piper_{}.wav", TEMP_PATH, uuid::Uuid::new_v4()); + let model = model_path.to_string_lossy().to_string(); + + debug!(model = %model, "spawning piper subprocess"); + let started = Instant::now(); + + let mut child = tokio::process::Command::new("piper") + .arg("--model") + .arg(&model) + .arg("--output_file") + .arg(&out_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| Error::Custom(crate::lang::fmt_tts_piper_launch(&e.to_string())))?; + + { + let mut stdin = child + .stdin + .take() + .ok_or_else(|| Error::Custom("Piper stdin unavailable".into()))?; + stdin.write_all(text.as_bytes()).await?; + stdin.write_all(b"\n").await?; + } + + let output = child.wait_with_output().await?; + let elapsed_ms = started.elapsed().as_millis() as u64; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + warn!( + model = %model, + elapsed_ms, + "piper exited with error: {}", + stderr.trim_end() + ); + let _ = tokio::fs::remove_file(&out_path).await; + return Err(Error::Custom(crate::lang::fmt_tts_piper_failed( + stderr.trim_end(), + ))); + } + + let bytes = tokio::fs::read(&out_path).await?; + debug!( + model = %model, + bytes = bytes.len(), + elapsed_ms, + "piper succeeded" + ); + let _ = tokio::fs::remove_file(&out_path).await; + Ok(bytes) +} + +/// Fetch Google Translate TTS (Polish) over HTTP. +async fn google_synthesize(client: &reqwest::Client, text: &str) -> Result> { + let tts_url = format!( + "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob", + urlencoding(text) + ); + debug!(url_len = tts_url.len(), "fetching Google TTS"); + let bytes = client.get(&tts_url).send().await?.bytes().await?; + Ok(bytes.to_vec()) +} + +pub fn urlencoding(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for &byte in s.as_bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'~' => { + out.push(byte as char) + } + b' ' => out.push('+'), + _ => out.push_str(&format!("%{:02X}", byte)), + } + } + out +} -- cgit v1.2.3