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/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/tts.rs | 147 |
1 files changed, 147 insertions, 0 deletions
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 | } | ||
