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/commands/tts.rs | |
| 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 '')
| -rw-r--r-- | src/commands/tts.rs | 59 |
1 files changed, 20 insertions, 39 deletions
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>> { |
