aboutsummaryrefslogtreecommitdiffstats
path: root/src/tts.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/tts.rs')
-rw-r--r--src/tts.rs147
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 @@
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}