aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.env.example8
-rw-r--r--.gitignore1
-rw-r--r--AGENTS.md3
-rw-r--r--Cargo.lock2
-rw-r--r--Cargo.toml2
-rw-r--r--README.md8
-rw-r--r--src/commands/ai.rs14
-rw-r--r--src/commands/tts.rs59
-rw-r--r--src/config.rs35
-rw-r--r--src/events/mod.rs79
-rw-r--r--src/lang.rs7
-rw-r--r--src/main.rs38
-rw-r--r--src/tts.rs147
13 files changed, 308 insertions, 95 deletions
diff --git a/.env.example b/.env.example
index fe76c05..01e24b3 100644
--- a/.env.example
+++ b/.env.example
@@ -3,13 +3,17 @@ TOKEN=
3 3
4# Optional - Bot Configuration 4# Optional - Bot Configuration
5STATUS_CHANNEL_ID= 5STATUS_CHANNEL_ID=
6ASSISTANT_KEYWORD=jarvis # Keyword to trigger AI assistant
7 6
8# Optional - Copyparty File Server (for music /search commands) 7# Optional - Copyparty File Server (for music /search commands)
9COPYPARTY_URL=http://localhost:8080 8COPYPARTY_URL=http://localhost:8080
10COPYPARTY_URL_UPLOAD=http://localhost:8080/uploads 9COPYPARTY_URL_UPLOAD=http://localhost:8080/uploads
11COPYPARTY_PASSWORD= 10COPYPARTY_PASSWORD=
12 11
13# Optional - AI commands 12# Optional - AI functionality
13ASSISTANT_KEYWORD=jarvis # Keyword to trigger AI assistant
14OPENROUTER_API_KEY= 14OPENROUTER_API_KEY=
15OPENROUTER_MODEL= 15OPENROUTER_MODEL=
16
17# Optional - Local TTS
18TTS_MODEL_PATH=/path/to/models # or a full path to pl_PL-bass-high.onnx
19TTS_VOICE=pl_PL-bass-high
diff --git a/.gitignore b/.gitignore
index 31136fd..77b5758 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
2data/ 2data/
3.zed/ 3.zed/
4target 4target
5piper/
diff --git a/AGENTS.md b/AGENTS.md
index f5a78ee..a074a43 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,7 +15,7 @@ src/
15│ ├── music.rs — /play (🟡 stub), /stop, /join, /loop_toggle 15│ ├── music.rs — /play (🟡 stub), /stop, /join, /loop_toggle
16│ ├── qr.rs — /qr encode + decode 16│ ├── qr.rs — /qr encode + decode
17│ ├── trigger.rs — /trigger group (6 subcommands) 17│ ├── trigger.rs — /trigger group (6 subcommands)
18│ ├── tts.rs — /tts (Google TTS → file/vc), /log_volume 18│ ├── tts.rs — /tts (local Piper TTS → file/vc), /log_volume
19│ ├── utilities.rs — compress🟡, convert🟡, upload, search, download, say 19│ ├── utilities.rs — compress🟡, convert🟡, upload, search, download, say
20│ └── web.rs — /web (DuckDuckGo scrape) 20│ └── web.rs — /web (DuckDuckGo scrape)
21├── copyparty/mod.rs — CopypartyClient (search, upload) 21├── copyparty/mod.rs — CopypartyClient (search, upload)
@@ -23,6 +23,7 @@ src/
23├── events/mod.rs — handle_ready, handle_voice_state_update, handle_message 23├── events/mod.rs — handle_ready, handle_voice_state_update, handle_message
24├── openrouter/mod.rs — OpenRouterClient (chat, chat_with_history) 24├── openrouter/mod.rs — OpenRouterClient (chat, chat_with_history)
25├── tasks/mod.rs — cron_scheduler_loop (30s polling, stub exec bodies) 25├── tasks/mod.rs — cron_scheduler_loop (30s polling, stub exec bodies)
26├── tts.rs — synthesize(): Piper subprocess → Google TTS fallback
26└── voice/mod.rs — VoiceManager (connect, disconnect, get_call), mp3_to_input 27└── voice/mod.rs — VoiceManager (connect, disconnect, get_call), mp3_to_input
27``` 28```
28 29
diff --git a/Cargo.lock b/Cargo.lock
index 8bc2ab8..10dbabe 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4391,7 +4391,7 @@ dependencies = [
4391 4391
4392[[package]] 4392[[package]]
4393name = "senator-sperma" 4393name = "senator-sperma"
4394version = "0.2.5" 4394version = "0.3.0"
4395dependencies = [ 4395dependencies = [
4396 "chrono", 4396 "chrono",
4397 "cron", 4397 "cron",
diff --git a/Cargo.toml b/Cargo.toml
index 07cb32a..d311d17 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "senator-sperma" 2name = "senator-sperma"
3version = "0.2.5" 3version = "0.3.0"
4edition = "2024" 4edition = "2024"
5 5
6[profile.dev] 6[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`:
23- `ffmpeg` — audio decoding/encoding for playback, conversion, and compression 23- `ffmpeg` — audio decoding/encoding for playback, conversion, and compression
24- `qalc` (Qalculate) — used by `/math` to evaluate expressions 24- `qalc` (Qalculate) — used by `/math` to evaluate expressions
25- `yt-dlp` (optional) — used by `/download` 25- `yt-dlp` (optional) — used by `/download`
26- `piper` (optional) — local TTS for `/tts` and `/senator ... tts_vc`; falls back to Google Translate TTS if unavailable
26 27
27Unlike the Python version, QR encode/decode is handled by the pure-Rust 28Unlike the Python version, QR encode/decode is handled by the pure-Rust
28`qrcode`/`rxing` crates — no `zbar` system library required. 29`qrcode`/`rxing` crates — no `zbar` system library required.
@@ -44,6 +45,11 @@ On launch, missing variables are reported via tracing (`error!` for required,
44`warn!` for optional with defaults). The database and schema migrations are 45`warn!` for optional with defaults). The database and schema migrations are
45created automatically on first run. 46created automatically on first run.
46 47
48For local TTS, set `TTS_MODEL_PATH` to a Piper `.onnx` model file (or a
49directory of models, in which case `TTS_VOICE` selects the model, defaulting to
50`pl_PL-bass-high`). When `TTS_MODEL_PATH` is unset or Piper is unavailable, the
51bot falls back to Google Translate TTS.
52
47### 5. Run 53### 5. Run
48 54
49```bash 55```bash
@@ -65,7 +71,7 @@ servers.
65- `/join` — join the caller's voice channel 71- `/join` — join the caller's voice channel
66 72
67### TTS 73### TTS
68- `/tts` — Google Translate TTS (Polish), sent as a file or played in voice 74- `/tts` — local Piper TTS (Polish, Google Translate fallback), sent as a file or played in voice
69 75
70### Search & Web 76### Search & Web
71- `/web` — DuckDuckGo HTML scrape (soon to use 4get) 77- `/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(
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;
3use crate::Data; 3use crate::Data;
4use crate::error::{Error, Result}; 4use crate::error::{Error, Result};
5use crate::lang; 5use crate::lang;
6use tracing::warn;
6 7
7type Context<'a> = poise::Context<'a, Data, Error>; 8type 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)]
19pub async fn tts( 20pub 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
90pub 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
104pub fn commands() -> Vec<poise::Command<Data, Error>> { 85pub 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
23struct EnvVar { 29struct 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
30async fn play_tts_in_channel( 30async 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 {
114pub const L_TTS_SAVED: &str = "TTS zapisany"; 114pub const L_TTS_SAVED: &str = "TTS zapisany";
115pub const L_TTS_PLAYING: &str = "Grom TTS na kanale głosowym"; 115pub const L_TTS_PLAYING: &str = "Grom TTS na kanale głosowym";
116 116
117pub fn fmt_tts_piper_launch(e: &str) -> String {
118 format!("Piper sie nie uruchomił w czas: {e}")
119}
120pub 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
119pub const L_TRIGGER_UPDATED: &str = "Trigger zaktualizowany"; 126pub 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;
15mod lang; 15mod lang;
16mod openrouter; 16mod openrouter;
17mod tasks; 17mod tasks;
18mod tts;
18mod voice; 19mod voice;
19 20
20use database::DbPool; 21use database::DbPool;
@@ -26,9 +27,7 @@ const AC_SEARCH_CACHE_TTL: Duration = Duration::from_secs(10);
26type AcCache = tokio::sync::Mutex<HashMap<(serenity::UserId, String), copyparty::SearchHit>>; 27type 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).
29type AcSearchCache = Arc< 30type AcSearchCache = Arc<tokio::sync::Mutex<HashMap<String, (Vec<copyparty::SearchHit>, Instant)>>>;
30 tokio::sync::Mutex<HashMap<String, (Vec<copyparty::SearchHit>, Instant)>>,
31>;
32 31
33pub struct Data { 32pub 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 @@
1use std::path::PathBuf;
2use std::process::Stdio;
3use std::time::Instant;
4
5use tokio::io::AsyncWriteExt;
6use tracing::{debug, info, warn};
7
8use crate::config;
9use crate::config::TEMP_PATH;
10use 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).
20pub 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`.
67async 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.
125async 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
135pub 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}