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) --- src/commands/tts.rs | 59 ++++++++++++++++++----------------------------------- 1 file changed, 20 insertions(+), 39 deletions(-) (limited to 'src/commands/tts.rs') 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> { -- cgit v1.2.3