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/tts.rs | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 src/tts.rs (limited to 'src/tts.rs') 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 @@ +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Instant; + +use tokio::io::AsyncWriteExt; +use tracing::{debug, info, warn}; + +use crate::config; +use crate::config::TEMP_PATH; +use crate::error::{Error, Result}; + +/// Synthesize speech for `text`. +/// +/// Primary engine is local Piper TTS (via the `piper` CLI). If Piper is not +/// installed, fails, or `TTS_MODEL_PATH` is unset, we fall back to Google +/// Translate TTS over HTTP. +/// +/// Returns the encoded audio bytes plus the file extension to use when saving +/// (`"wav"` for Piper, `"mp3"` for Google). +pub async fn synthesize( + config: &config::Config, + http_client: &reqwest::Client, + text: &str, +) -> Result<(Vec, &'static str)> { + match config.piper_model_path() { + Some(model_path) => { + let started = Instant::now(); + match piper_synthesize(&model_path, text).await { + Ok(bytes) => { + info!( + model = %model_path.display(), + text_len = text.len(), + bytes = bytes.len(), + elapsed_ms = started.elapsed().as_millis() as u64, + "Piper TTS synthesized" + ); + return Ok((bytes, "wav")); + } + Err(e) => { + warn!( + model = %model_path.display(), + text_len = text.len(), + elapsed_ms = started.elapsed().as_millis() as u64, + "Piper TTS failed, falling back to Google TTS: {e}" + ); + } + } + } + None => { + debug!("TTS_MODEL_PATH is not set; using Google TTS fallback"); + } + } + + let started = Instant::now(); + let bytes = google_synthesize(http_client, text).await?; + info!( + text_len = text.len(), + bytes = bytes.len(), + elapsed_ms = started.elapsed().as_millis() as u64, + "Google TTS synthesized" + ); + Ok((bytes, "mp3")) +} + +/// Run `piper` as a subprocess, feeding `text` on stdin and collecting the +/// generated WAV from `--output_file`. +async fn piper_synthesize(model_path: &PathBuf, text: &str) -> Result> { + tokio::fs::create_dir_all(TEMP_PATH).await.ok(); + + let out_path = format!("{}/tts_piper_{}.wav", TEMP_PATH, uuid::Uuid::new_v4()); + let model = model_path.to_string_lossy().to_string(); + + debug!(model = %model, "spawning piper subprocess"); + let started = Instant::now(); + + let mut child = tokio::process::Command::new("piper") + .arg("--model") + .arg(&model) + .arg("--output_file") + .arg(&out_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| Error::Custom(crate::lang::fmt_tts_piper_launch(&e.to_string())))?; + + { + let mut stdin = child + .stdin + .take() + .ok_or_else(|| Error::Custom("Piper stdin unavailable".into()))?; + stdin.write_all(text.as_bytes()).await?; + stdin.write_all(b"\n").await?; + } + + let output = child.wait_with_output().await?; + let elapsed_ms = started.elapsed().as_millis() as u64; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + warn!( + model = %model, + elapsed_ms, + "piper exited with error: {}", + stderr.trim_end() + ); + let _ = tokio::fs::remove_file(&out_path).await; + return Err(Error::Custom(crate::lang::fmt_tts_piper_failed( + stderr.trim_end(), + ))); + } + + let bytes = tokio::fs::read(&out_path).await?; + debug!( + model = %model, + bytes = bytes.len(), + elapsed_ms, + "piper succeeded" + ); + let _ = tokio::fs::remove_file(&out_path).await; + Ok(bytes) +} + +/// Fetch Google Translate TTS (Polish) over HTTP. +async fn google_synthesize(client: &reqwest::Client, text: &str) -> Result> { + let tts_url = format!( + "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob", + urlencoding(text) + ); + debug!(url_len = tts_url.len(), "fetching Google TTS"); + let bytes = client.get(&tts_url).send().await?.bytes().await?; + Ok(bytes.to_vec()) +} + +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) + } + b' ' => out.push('+'), + _ => out.push_str(&format!("%{:02X}", byte)), + } + } + out +} -- cgit v1.2.3