diff options
| author | salmonik <salmonik@tuta.io> | 2026-08-18 23:18:37 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-08-18 23:18:37 +0200 |
| commit | b252c39517b9fce181d1a0fe4761404c7d981397 (patch) | |
| tree | 564cf01aed601f1974e0de8801d4452215ec05f5 /src | |
| parent | fir auto completion and send file in the response body upon grajcuj file (diff) | |
| download | senator-sperma-b252c39517b9fce181d1a0fe4761404c7d981397.tar.gz senator-sperma-b252c39517b9fce181d1a0fe4761404c7d981397.zip | |
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)
Diffstat (limited to 'src')
| -rw-r--r-- | src/commands/ai.rs | 14 | ||||
| -rw-r--r-- | src/commands/tts.rs | 59 | ||||
| -rw-r--r-- | src/config.rs | 35 | ||||
| -rw-r--r-- | src/events/mod.rs | 79 | ||||
| -rw-r--r-- | src/lang.rs | 7 | ||||
| -rw-r--r-- | src/main.rs | 38 | ||||
| -rw-r--r-- | src/tts.rs | 147 |
7 files changed, 290 insertions, 89 deletions
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( | |||
| 30 | }) | 30 | }) |
| 31 | .ok_or_else(lang::err_not_in_voice)?; | 31 | .ok_or_else(lang::err_not_in_voice)?; |
| 32 | 32 | ||
| 33 | let tts_url = format!( | 33 | let (audio_data, _) = |
| 34 | "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob", | 34 | crate::tts::synthesize(&ctx.data().config, &ctx.data().http_client, &response).await?; |
| 35 | crate::commands::tts::urlencoding(&response) | ||
| 36 | ); | ||
| 37 | let audio_data = ctx | ||
| 38 | .data() | ||
| 39 | .http_client | ||
| 40 | .get(&tts_url) | ||
| 41 | .send() | ||
| 42 | .await? | ||
| 43 | .bytes() | ||
| 44 | .await?; | ||
| 45 | 35 | ||
| 46 | let call = ctx | 36 | let call = ctx |
| 47 | .data() | 37 | .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; | |||
| 3 | use crate::Data; | 3 | use crate::Data; |
| 4 | use crate::error::{Error, Result}; | 4 | use crate::error::{Error, Result}; |
| 5 | use crate::lang; | 5 | use crate::lang; |
| 6 | use tracing::warn; | ||
| 6 | 7 | ||
| 7 | type Context<'a> = poise::Context<'a, Data, Error>; | 8 | type Context<'a> = poise::Context<'a, Data, Error>; |
| 8 | 9 | ||
| @@ -14,7 +15,7 @@ pub enum OutputMode { | |||
| 14 | Vc, | 15 | Vc, |
| 15 | } | 16 | } |
| 16 | 17 | ||
| 17 | /// Generate Google TTS (Polish) and output to file or voice channel | 18 | /// Generate TTS (Piper locally, Google as fallback) and output to file or voice channel |
| 18 | #[poise::command(slash_command)] | 19 | #[poise::command(slash_command)] |
| 19 | pub async fn tts( | 20 | pub async fn tts( |
| 20 | ctx: Context<'_>, | 21 | ctx: Context<'_>, |
| @@ -22,23 +23,10 @@ pub async fn tts( | |||
| 22 | #[description = "Output destination (file or vc)"] output: Option<OutputMode>, | 23 | #[description = "Output destination (file or vc)"] output: Option<OutputMode>, |
| 23 | ) -> Result<()> { | 24 | ) -> Result<()> { |
| 24 | ctx.defer().await?; | 25 | ctx.defer().await?; |
| 25 | let lang = "pl"; | ||
| 26 | let tts_url = format!( | ||
| 27 | "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl={}&client=tw-ob", | ||
| 28 | urlencoding(&text), | ||
| 29 | lang | ||
| 30 | ); | ||
| 31 | 26 | ||
| 32 | let output = output.unwrap_or(OutputMode::Vc); | 27 | let output = output.unwrap_or(OutputMode::Vc); |
| 33 | 28 | let (audio_data, ext) = | |
| 34 | let audio_data = ctx | 29 | crate::tts::synthesize(&ctx.data().config, &ctx.data().http_client, &text).await?; |
| 35 | .data() | ||
| 36 | .http_client | ||
| 37 | .get(&tts_url) | ||
| 38 | .send() | ||
| 39 | .await? | ||
| 40 | .bytes() | ||
| 41 | .await?; | ||
| 42 | 30 | ||
| 43 | match output { | 31 | match output { |
| 44 | OutputMode::File => { | 32 | OutputMode::File => { |
| @@ -46,17 +34,22 @@ pub async fn tts( | |||
| 46 | .await | 34 | .await |
| 47 | .ok(); | 35 | .ok(); |
| 48 | let path = format!( | 36 | let path = format!( |
| 49 | "{}/tts_{}.mp3", | 37 | "{}/tts_{}.{}", |
| 50 | crate::config::TEMP_PATH, | 38 | crate::config::TEMP_PATH, |
| 51 | uuid::Uuid::new_v4() | 39 | uuid::Uuid::new_v4(), |
| 40 | ext | ||
| 52 | ); | 41 | ); |
| 53 | tokio::fs::write(&path, &audio_data).await?; | 42 | tokio::fs::write(&path, &audio_data).await?; |
| 54 | ctx.send( | 43 | if let Err(e) = ctx |
| 55 | poise::CreateReply::default() | 44 | .send( |
| 56 | .content(lang::L_TTS_SAVED) | 45 | poise::CreateReply::default() |
| 57 | .attachment(serenity::CreateAttachment::path(&path).await.unwrap()), | 46 | .content(lang::L_TTS_SAVED) |
| 58 | ) | 47 | .attachment(serenity::CreateAttachment::path(&path).await.unwrap()), |
| 59 | .await?; | 48 | ) |
| 49 | .await | ||
| 50 | { | ||
| 51 | warn!("Failed to send /tts file reply: {e}"); | ||
| 52 | } | ||
| 60 | } | 53 | } |
| 61 | OutputMode::Vc => { | 54 | OutputMode::Vc => { |
| 62 | let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; | 55 | let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; |
| @@ -81,24 +74,12 @@ pub async fn tts( | |||
| 81 | if call_lock.current_channel().is_some() { | 74 | if call_lock.current_channel().is_some() { |
| 82 | call_lock.play_input(input); | 75 | call_lock.play_input(input); |
| 83 | } | 76 | } |
| 84 | ctx.say(lang::L_TTS_PLAYING).await?; | 77 | if let Err(e) = ctx.say(lang::L_TTS_PLAYING).await { |
| 85 | } | 78 | warn!("Failed to send /tts voice reply: {e}"); |
| 86 | } | ||
| 87 | Ok(()) | ||
| 88 | } | ||
| 89 | |||
| 90 | pub fn urlencoding(s: &str) -> String { | ||
| 91 | let mut out = String::with_capacity(s.len()); | ||
| 92 | for &byte in s.as_bytes() { | ||
| 93 | match byte { | ||
| 94 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'~' => { | ||
| 95 | out.push(byte as char) | ||
| 96 | } | 79 | } |
| 97 | b' ' => out.push('+'), | ||
| 98 | _ => out.push_str(&format!("%{:02X}", byte)), | ||
| 99 | } | 80 | } |
| 100 | } | 81 | } |
| 101 | out | 82 | Ok(()) |
| 102 | } | 83 | } |
| 103 | 84 | ||
| 104 | pub fn commands() -> Vec<poise::Command<Data, Error>> { | 85 | pub fn commands() -> Vec<poise::Command<Data, Error>> { |
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 { | |||
| 18 | pub llm_model: Option<String>, | 18 | pub llm_model: Option<String>, |
| 19 | pub llm_api_url: String, | 19 | pub llm_api_url: String, |
| 20 | pub database_path: String, | 20 | pub database_path: String, |
| 21 | /// Optional path to the Piper TTS `.onnx` model (or a directory of models). | ||
| 22 | /// When unset, TTS falls back to Google Translate TTS. | ||
| 23 | pub tts_model_path: Option<String>, | ||
| 24 | /// Piper voice name, used to resolve the model file when `tts_model_path` | ||
| 25 | /// points at a directory. Ignored when it points at a `.onnx` file directly. | ||
| 26 | pub tts_voice: String, | ||
| 21 | } | 27 | } |
| 22 | 28 | ||
| 23 | struct EnvVar { | 29 | struct EnvVar { |
| @@ -101,6 +107,18 @@ const ENV_VARS: &[EnvVar] = &[ | |||
| 101 | description: "SQLite database path", | 107 | description: "SQLite database path", |
| 102 | }, | 108 | }, |
| 103 | EnvVar { | 109 | EnvVar { |
| 110 | name: "TTS_MODEL_PATH", | ||
| 111 | required: false, | ||
| 112 | default: None, | ||
| 113 | description: "Path to a Piper TTS .onnx model (or a models directory). Unset => Google TTS fallback", | ||
| 114 | }, | ||
| 115 | EnvVar { | ||
| 116 | name: "TTS_VOICE", | ||
| 117 | required: false, | ||
| 118 | default: Some("pl_PL-bass-high"), | ||
| 119 | description: "Piper TTS voice name (used when TTS_MODEL_PATH points at a directory)", | ||
| 120 | }, | ||
| 121 | EnvVar { | ||
| 104 | name: "RUST_LOG", | 122 | name: "RUST_LOG", |
| 105 | required: false, | 123 | required: false, |
| 106 | default: Some("info"), | 124 | default: Some("info"), |
| @@ -180,6 +198,23 @@ impl Config { | |||
| 180 | llm_api_url: env::var("LLM_API_URL") | 198 | llm_api_url: env::var("LLM_API_URL") |
| 181 | .unwrap_or_else(|_| "https://openrouter.ai/api/v1/chat/completions".into()), | 199 | .unwrap_or_else(|_| "https://openrouter.ai/api/v1/chat/completions".into()), |
| 182 | database_path: env::var("SENATOR_DB_PATH").unwrap_or_else(|_| "data/senator.db".into()), | 200 | database_path: env::var("SENATOR_DB_PATH").unwrap_or_else(|_| "data/senator.db".into()), |
| 201 | tts_model_path: env::var("TTS_MODEL_PATH").ok(), | ||
| 202 | tts_voice: env::var("TTS_VOICE").unwrap_or_else(|_| "pl_PL-bass-high".into()), | ||
| 203 | } | ||
| 204 | } | ||
| 205 | |||
| 206 | /// Resolve the Piper model path to pass to the `piper` CLI. | ||
| 207 | /// | ||
| 208 | /// - `TTS_MODEL_PATH` set to a `.onnx` file => used verbatim. | ||
| 209 | /// - `TTS_MODEL_PATH` set to a directory => `{dir}/{TTS_VOICE}.onnx`. | ||
| 210 | /// - `TTS_MODEL_PATH` unset => `None` (caller falls back to Google TTS). | ||
| 211 | pub fn piper_model_path(&self) -> Option<std::path::PathBuf> { | ||
| 212 | let configured = self.tts_model_path.as_ref()?; | ||
| 213 | let path = std::path::PathBuf::from(configured); | ||
| 214 | if path.is_dir() { | ||
| 215 | Some(path.join(format!("{}.onnx", self.tts_voice))) | ||
| 216 | } else { | ||
| 217 | Some(path) | ||
| 183 | } | 218 | } |
| 184 | } | 219 | } |
| 185 | 220 | ||
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 { | |||
| 28 | } | 28 | } |
| 29 | 29 | ||
| 30 | async fn play_tts_in_channel( | 30 | async fn play_tts_in_channel( |
| 31 | _ctx: &serenity::Context, | 31 | config: crate::config::Config, |
| 32 | data: &Data, | 32 | http_client: reqwest::Client, |
| 33 | voice_manager: crate::voice::VoiceManager, | ||
| 33 | guild_id: serenity::GuildId, | 34 | guild_id: serenity::GuildId, |
| 34 | channel_id: serenity::ChannelId, | 35 | channel_id: serenity::ChannelId, |
| 35 | text: &str, | 36 | text: String, |
| 36 | ) { | 37 | ) { |
| 37 | let tts_url = format!( | 38 | let audio_data = match crate::tts::synthesize(&config, &http_client, &text).await { |
| 38 | "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob", | 39 | Ok((bytes, _)) => bytes, |
| 39 | crate::commands::tts::urlencoding(text), | ||
| 40 | ); | ||
| 41 | |||
| 42 | let audio_data = match data.http_client.get(&tts_url).send().await { | ||
| 43 | Ok(resp) => match resp.bytes().await { | ||
| 44 | Ok(b) => b, | ||
| 45 | Err(e) => { | ||
| 46 | warn!("Failed to read TTS audio bytes: {e}"); | ||
| 47 | return; | ||
| 48 | } | ||
| 49 | }, | ||
| 50 | Err(e) => { | 40 | Err(e) => { |
| 51 | warn!("Failed to fetch TTS audio: {e}"); | 41 | warn!("Failed to synthesize TTS: {e}"); |
| 52 | return; | 42 | return; |
| 53 | } | 43 | } |
| 54 | }; | 44 | }; |
| 55 | 45 | ||
| 56 | let call = match data.voice_manager.connect(guild_id, channel_id).await { | 46 | let call = match voice_manager.connect(guild_id, channel_id).await { |
| 57 | Ok(c) => c, | 47 | Ok(c) => c, |
| 58 | Err(e) => { | 48 | Err(e) => { |
| 59 | warn!("Failed to connect to voice for TTS: {e}"); | 49 | warn!("Failed to connect to voice for TTS: {e}"); |
| @@ -93,9 +83,10 @@ pub async fn handle_voice_state_update( | |||
| 93 | .and_then(|gid| ctx.cache.guild(gid)) | 83 | .and_then(|gid| ctx.cache.guild(gid)) |
| 94 | .and_then(|g| g.members.get(&user_id).cloned()); | 84 | .and_then(|g| g.members.get(&user_id).cloned()); |
| 95 | if let Some(ref m) = member | 85 | if let Some(ref m) = member |
| 96 | && m.user.bot { | 86 | && m.user.bot |
| 97 | return Ok(()); | 87 | { |
| 98 | } | 88 | return Ok(()); |
| 89 | } | ||
| 99 | 90 | ||
| 100 | let member_name = member | 91 | let member_name = member |
| 101 | .as_ref() | 92 | .as_ref() |
| @@ -130,7 +121,9 @@ pub async fn handle_voice_state_update( | |||
| 130 | let mut tts_channel: Option<serenity::ChannelId> = None; | 121 | let mut tts_channel: Option<serenity::ChannelId> = None; |
| 131 | 122 | ||
| 132 | // ── JOIN ── | 123 | // ── JOIN ── |
| 133 | if old_channel.is_none() && let Some(cid) = new_channel { | 124 | if old_channel.is_none() |
| 125 | && let Some(cid) = new_channel | ||
| 126 | { | ||
| 134 | let is_afk = new.self_mute || new.self_deaf; | 127 | let is_afk = new.self_mute || new.self_deaf; |
| 135 | 128 | ||
| 136 | va.join_time = now; | 129 | va.join_time = now; |
| @@ -274,9 +267,17 @@ pub async fn handle_voice_state_update( | |||
| 274 | lang::fmt_ev_undeafened(&display_name) | 267 | lang::fmt_ev_undeafened(&display_name) |
| 275 | }; | 268 | }; |
| 276 | if let Some(gid) = guild_id | 269 | if let Some(gid) = guild_id |
| 277 | && let Some(cid) = tts_channel.or(new_channel) { | 270 | && let Some(cid) = tts_channel.or(new_channel) |
| 278 | play_tts_in_channel(ctx, data, gid, cid, &phrase).await; | 271 | { |
| 279 | } | 272 | tokio::spawn(play_tts_in_channel( |
| 273 | data.config.clone(), | ||
| 274 | data.http_client.clone(), | ||
| 275 | data.voice_manager.clone(), | ||
| 276 | gid, | ||
| 277 | cid, | ||
| 278 | phrase, | ||
| 279 | )); | ||
| 280 | } | ||
| 280 | return Ok(()); | 281 | return Ok(()); |
| 281 | } | 282 | } |
| 282 | 283 | ||
| @@ -287,17 +288,33 @@ pub async fn handle_voice_state_update( | |||
| 287 | lang::fmt_ev_unmuted(&display_name) | 288 | lang::fmt_ev_unmuted(&display_name) |
| 288 | }; | 289 | }; |
| 289 | if let Some(gid) = guild_id | 290 | if let Some(gid) = guild_id |
| 290 | && let Some(cid) = tts_channel.or(new_channel) { | 291 | && let Some(cid) = tts_channel.or(new_channel) |
| 291 | play_tts_in_channel(ctx, data, gid, cid, &phrase).await; | 292 | { |
| 292 | } | 293 | tokio::spawn(play_tts_in_channel( |
| 294 | data.config.clone(), | ||
| 295 | data.http_client.clone(), | ||
| 296 | data.voice_manager.clone(), | ||
| 297 | gid, | ||
| 298 | cid, | ||
| 299 | phrase, | ||
| 300 | )); | ||
| 301 | } | ||
| 293 | return Ok(()); | 302 | return Ok(()); |
| 294 | } | 303 | } |
| 295 | 304 | ||
| 296 | // ── Play join/leave TTS if no mute/deafen change ── | 305 | // ── Play join/leave TTS if no mute/deafen change ── |
| 297 | if let (Some(text), Some(cid)) = (tts_text, tts_channel) | 306 | if let (Some(text), Some(cid)) = (tts_text, tts_channel) |
| 298 | && let Some(gid) = guild_id { | 307 | && let Some(gid) = guild_id |
| 299 | play_tts_in_channel(ctx, data, gid, cid, &text).await; | 308 | { |
| 300 | } | 309 | tokio::spawn(play_tts_in_channel( |
| 310 | data.config.clone(), | ||
| 311 | data.http_client.clone(), | ||
| 312 | data.voice_manager.clone(), | ||
| 313 | gid, | ||
| 314 | cid, | ||
| 315 | text, | ||
| 316 | )); | ||
| 317 | } | ||
| 301 | 318 | ||
| 302 | Ok(()) | 319 | Ok(()) |
| 303 | } | 320 | } |
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 { | |||
| 114 | pub const L_TTS_SAVED: &str = "TTS zapisany"; | 114 | pub const L_TTS_SAVED: &str = "TTS zapisany"; |
| 115 | pub const L_TTS_PLAYING: &str = "Grom TTS na kanale głosowym"; | 115 | pub const L_TTS_PLAYING: &str = "Grom TTS na kanale głosowym"; |
| 116 | 116 | ||
| 117 | pub fn fmt_tts_piper_launch(e: &str) -> String { | ||
| 118 | format!("Piper sie nie uruchomił w czas: {e}") | ||
| 119 | } | ||
| 120 | pub fn fmt_tts_piper_failed(e: &str) -> String { | ||
| 121 | format!("Piper TTS sie nie powiodło: {e}") | ||
| 122 | } | ||
| 123 | |||
| 117 | // ── Trigger command ─────────────────────────────────────────────────── | 124 | // ── Trigger command ─────────────────────────────────────────────────── |
| 118 | 125 | ||
| 119 | pub const L_TRIGGER_UPDATED: &str = "Trigger zaktualizowany"; | 126 | 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; | |||
| 15 | mod lang; | 15 | mod lang; |
| 16 | mod openrouter; | 16 | mod openrouter; |
| 17 | mod tasks; | 17 | mod tasks; |
| 18 | mod tts; | ||
| 18 | mod voice; | 19 | mod voice; |
| 19 | 20 | ||
| 20 | use database::DbPool; | 21 | use database::DbPool; |
| @@ -26,9 +27,7 @@ const AC_SEARCH_CACHE_TTL: Duration = Duration::from_secs(10); | |||
| 26 | type AcCache = tokio::sync::Mutex<HashMap<(serenity::UserId, String), copyparty::SearchHit>>; | 27 | type AcCache = tokio::sync::Mutex<HashMap<(serenity::UserId, String), copyparty::SearchHit>>; |
| 27 | 28 | ||
| 28 | /// Shared autocomplete search cache: query → (hits, cached_at). | 29 | /// Shared autocomplete search cache: query → (hits, cached_at). |
| 29 | type AcSearchCache = Arc< | 30 | type AcSearchCache = Arc<tokio::sync::Mutex<HashMap<String, (Vec<copyparty::SearchHit>, Instant)>>>; |
| 30 | tokio::sync::Mutex<HashMap<String, (Vec<copyparty::SearchHit>, Instant)>>, | ||
| 31 | >; | ||
| 32 | 31 | ||
| 33 | pub struct Data { | 32 | pub struct Data { |
| 34 | pub db_pool: DbPool, | 33 | pub db_pool: DbPool, |
| @@ -84,10 +83,7 @@ impl Data { | |||
| 84 | 83 | ||
| 85 | /// Look up search cache for an autocomplete query. Returns `None` if | 84 | /// Look up search cache for an autocomplete query. Returns `None` if |
| 86 | /// uncached or stale. | 85 | /// uncached or stale. |
| 87 | pub async fn ac_search_cache_lookup( | 86 | pub async fn ac_search_cache_lookup(&self, query: &str) -> Option<Vec<copyparty::SearchHit>> { |
| 88 | &self, | ||
| 89 | query: &str, | ||
| 90 | ) -> Option<Vec<copyparty::SearchHit>> { | ||
| 91 | let cache = self.ac_search_cache.lock().await; | 87 | let cache = self.ac_search_cache.lock().await; |
| 92 | let (hits, cached_at) = cache.get(query)?; | 88 | let (hits, cached_at) = cache.get(query)?; |
| 93 | if cached_at.elapsed() > AC_SEARCH_CACHE_TTL { | 89 | if cached_at.elapsed() > AC_SEARCH_CACHE_TTL { |
| @@ -150,6 +146,34 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | |||
| 150 | Ok(()) | 146 | Ok(()) |
| 151 | }) | 147 | }) |
| 152 | }, | 148 | }, |
| 149 | on_error: |error| { | ||
| 150 | Box::pin(async move { | ||
| 151 | match error { | ||
| 152 | poise::FrameworkError::Command { ctx, error, .. } => { | ||
| 153 | tracing::error!(command = %ctx.command().name, "Command failed: {error}"); | ||
| 154 | if let Err(e) = ctx | ||
| 155 | .send( | ||
| 156 | poise::CreateReply::default() | ||
| 157 | .content(format!("Błąd: {error}")) | ||
| 158 | .ephemeral(true), | ||
| 159 | ) | ||
| 160 | .await | ||
| 161 | { | ||
| 162 | tracing::debug!( | ||
| 163 | "Could not send error reply (interaction likely expired): {e}" | ||
| 164 | ); | ||
| 165 | } | ||
| 166 | } | ||
| 167 | other => { | ||
| 168 | if let Err(e) = poise::builtins::on_error(other).await { | ||
| 169 | tracing::warn!( | ||
| 170 | "Error while sending error message (interaction likely expired): {e}" | ||
| 171 | ); | ||
| 172 | } | ||
| 173 | } | ||
| 174 | } | ||
| 175 | }) | ||
| 176 | }, | ||
| 153 | ..Default::default() | 177 | ..Default::default() |
| 154 | }) | 178 | }) |
| 155 | .setup(move |ctx, _ready, framework| { | 179 | .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 @@ | |||
| 1 | use std::path::PathBuf; | ||
| 2 | use std::process::Stdio; | ||
| 3 | use std::time::Instant; | ||
| 4 | |||
| 5 | use tokio::io::AsyncWriteExt; | ||
| 6 | use tracing::{debug, info, warn}; | ||
| 7 | |||
| 8 | use crate::config; | ||
| 9 | use crate::config::TEMP_PATH; | ||
| 10 | use crate::error::{Error, Result}; | ||
| 11 | |||
| 12 | /// Synthesize speech for `text`. | ||
| 13 | /// | ||
| 14 | /// Primary engine is local Piper TTS (via the `piper` CLI). If Piper is not | ||
| 15 | /// installed, fails, or `TTS_MODEL_PATH` is unset, we fall back to Google | ||
| 16 | /// Translate TTS over HTTP. | ||
| 17 | /// | ||
| 18 | /// Returns the encoded audio bytes plus the file extension to use when saving | ||
| 19 | /// (`"wav"` for Piper, `"mp3"` for Google). | ||
| 20 | pub async fn synthesize( | ||
| 21 | config: &config::Config, | ||
| 22 | http_client: &reqwest::Client, | ||
| 23 | text: &str, | ||
| 24 | ) -> Result<(Vec<u8>, &'static str)> { | ||
| 25 | match config.piper_model_path() { | ||
| 26 | Some(model_path) => { | ||
| 27 | let started = Instant::now(); | ||
| 28 | match piper_synthesize(&model_path, text).await { | ||
| 29 | Ok(bytes) => { | ||
| 30 | info!( | ||
| 31 | model = %model_path.display(), | ||
| 32 | text_len = text.len(), | ||
| 33 | bytes = bytes.len(), | ||
| 34 | elapsed_ms = started.elapsed().as_millis() as u64, | ||
| 35 | "Piper TTS synthesized" | ||
| 36 | ); | ||
| 37 | return Ok((bytes, "wav")); | ||
| 38 | } | ||
| 39 | Err(e) => { | ||
| 40 | warn!( | ||
| 41 | model = %model_path.display(), | ||
| 42 | text_len = text.len(), | ||
| 43 | elapsed_ms = started.elapsed().as_millis() as u64, | ||
| 44 | "Piper TTS failed, falling back to Google TTS: {e}" | ||
| 45 | ); | ||
| 46 | } | ||
| 47 | } | ||
| 48 | } | ||
| 49 | None => { | ||
| 50 | debug!("TTS_MODEL_PATH is not set; using Google TTS fallback"); | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | let started = Instant::now(); | ||
| 55 | let bytes = google_synthesize(http_client, text).await?; | ||
| 56 | info!( | ||
| 57 | text_len = text.len(), | ||
| 58 | bytes = bytes.len(), | ||
| 59 | elapsed_ms = started.elapsed().as_millis() as u64, | ||
| 60 | "Google TTS synthesized" | ||
| 61 | ); | ||
| 62 | Ok((bytes, "mp3")) | ||
| 63 | } | ||
| 64 | |||
| 65 | /// Run `piper` as a subprocess, feeding `text` on stdin and collecting the | ||
| 66 | /// generated WAV from `--output_file`. | ||
| 67 | async fn piper_synthesize(model_path: &PathBuf, text: &str) -> Result<Vec<u8>> { | ||
| 68 | tokio::fs::create_dir_all(TEMP_PATH).await.ok(); | ||
| 69 | |||
| 70 | let out_path = format!("{}/tts_piper_{}.wav", TEMP_PATH, uuid::Uuid::new_v4()); | ||
| 71 | let model = model_path.to_string_lossy().to_string(); | ||
| 72 | |||
| 73 | debug!(model = %model, "spawning piper subprocess"); | ||
| 74 | let started = Instant::now(); | ||
| 75 | |||
| 76 | let mut child = tokio::process::Command::new("piper") | ||
| 77 | .arg("--model") | ||
| 78 | .arg(&model) | ||
| 79 | .arg("--output_file") | ||
| 80 | .arg(&out_path) | ||
| 81 | .stdin(Stdio::piped()) | ||
| 82 | .stdout(Stdio::piped()) | ||
| 83 | .stderr(Stdio::piped()) | ||
| 84 | .spawn() | ||
| 85 | .map_err(|e| Error::Custom(crate::lang::fmt_tts_piper_launch(&e.to_string())))?; | ||
| 86 | |||
| 87 | { | ||
| 88 | let mut stdin = child | ||
| 89 | .stdin | ||
| 90 | .take() | ||
| 91 | .ok_or_else(|| Error::Custom("Piper stdin unavailable".into()))?; | ||
| 92 | stdin.write_all(text.as_bytes()).await?; | ||
| 93 | stdin.write_all(b"\n").await?; | ||
| 94 | } | ||
| 95 | |||
| 96 | let output = child.wait_with_output().await?; | ||
| 97 | let elapsed_ms = started.elapsed().as_millis() as u64; | ||
| 98 | |||
| 99 | if !output.status.success() { | ||
| 100 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 101 | warn!( | ||
| 102 | model = %model, | ||
| 103 | elapsed_ms, | ||
| 104 | "piper exited with error: {}", | ||
| 105 | stderr.trim_end() | ||
| 106 | ); | ||
| 107 | let _ = tokio::fs::remove_file(&out_path).await; | ||
| 108 | return Err(Error::Custom(crate::lang::fmt_tts_piper_failed( | ||
| 109 | stderr.trim_end(), | ||
| 110 | ))); | ||
| 111 | } | ||
| 112 | |||
| 113 | let bytes = tokio::fs::read(&out_path).await?; | ||
| 114 | debug!( | ||
| 115 | model = %model, | ||
| 116 | bytes = bytes.len(), | ||
| 117 | elapsed_ms, | ||
| 118 | "piper succeeded" | ||
| 119 | ); | ||
| 120 | let _ = tokio::fs::remove_file(&out_path).await; | ||
| 121 | Ok(bytes) | ||
| 122 | } | ||
| 123 | |||
| 124 | /// Fetch Google Translate TTS (Polish) over HTTP. | ||
| 125 | async fn google_synthesize(client: &reqwest::Client, text: &str) -> Result<Vec<u8>> { | ||
| 126 | let tts_url = format!( | ||
| 127 | "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob", | ||
| 128 | urlencoding(text) | ||
| 129 | ); | ||
| 130 | debug!(url_len = tts_url.len(), "fetching Google TTS"); | ||
| 131 | let bytes = client.get(&tts_url).send().await?.bytes().await?; | ||
| 132 | Ok(bytes.to_vec()) | ||
| 133 | } | ||
| 134 | |||
| 135 | pub fn urlencoding(s: &str) -> String { | ||
| 136 | let mut out = String::with_capacity(s.len()); | ||
| 137 | for &byte in s.as_bytes() { | ||
| 138 | match byte { | ||
| 139 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'~' => { | ||
| 140 | out.push(byte as char) | ||
| 141 | } | ||
| 142 | b' ' => out.push('+'), | ||
| 143 | _ => out.push_str(&format!("%{:02X}", byte)), | ||
| 144 | } | ||
| 145 | } | ||
| 146 | out | ||
| 147 | } | ||
