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 }