1use serde_json::Value;
11
12use crate::{agent, base_url, call, read_bytes, ApiError};
13
14pub const DEFAULT_BASE_URL: &str = "https://translate.google.com";
15const MAX_CHARS: usize = 100;
16const RPC: &str = "jQ1olc";
17const USER_AGENT: &str =
18 "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36";
19
20const TONE_MARKS: [char; 4] = ['?', '!', '?', '!'];
21const ABBREVIATIONS: [&str; 9] = ["dr", "jr", "mr", "mrs", "ms", "msgr", "prof", "sr", "st"];
22const ALL_PUNC: &str = "?!?!.,¡()[]¿…‥،;:—。,、:\n";
24
25fn pp_tone_marks(text: &str) -> String {
27 let mut out = String::with_capacity(text.len() + 8);
28 for c in text.chars() {
29 out.push(c);
30 if TONE_MARKS.contains(&c) {
31 out.push(' ');
32 }
33 }
34 out
35}
36
37fn pp_end_of_line(text: &str) -> String {
39 text.replace("-\n", "")
40}
41
42fn pp_abbreviations(text: &str) -> String {
46 let mut text = text.to_string();
47 for abbr in ABBREVIATIONS {
48 let chars: Vec<char> = text.chars().collect();
49 let a: Vec<char> = abbr.chars().collect();
50 let mut out = String::with_capacity(text.len());
51 for (i, &c) in chars.iter().enumerate() {
52 if c == '.' && i >= a.len() {
53 let before = &chars[i - a.len()..i];
54 if before
55 .iter()
56 .zip(&a)
57 .all(|(x, y)| x.to_lowercase().eq(y.to_lowercase()))
58 {
59 continue;
60 }
61 }
62 out.push(c);
63 }
64 text = out;
65 }
66 text
67}
68
69fn pp_word_sub(text: &str) -> String {
71 let lower = text.to_lowercase();
72 if lower.len() != text.len() {
73 let chars: Vec<char> = text.chars().collect();
75 let mut out = String::new();
76 let mut i = 0;
77 while i < chars.len() {
78 let window: String = chars[i..(i + 4).min(chars.len())].iter().collect();
79 if window.to_lowercase() == "esq." {
80 out.push_str("Esquire");
81 i += 4;
82 } else {
83 out.push(chars[i]);
84 i += 1;
85 }
86 }
87 return out;
88 }
89 let mut out = String::new();
90 let mut i = 0;
91 while i < text.len() {
92 if lower[i..].starts_with("esq.") {
93 out.push_str("Esquire");
94 i += 4;
95 } else {
96 let c = text[i..].chars().next().unwrap();
97 out.push(c);
98 i += c.len_utf8();
99 }
100 }
101 out
102}
103
104fn is_other_punc(c: char) -> bool {
105 ALL_PUNC.contains(c) && !TONE_MARKS.contains(&c) && c != '.' && c != ',' && c != ':'
106}
107
108fn tokenize(text: &str) -> Vec<String> {
111 let chars: Vec<char> = text.chars().collect();
112 let mut tokens = Vec::new();
113 let mut start = 0;
114 let mut i = 0;
115 while i < chars.len() {
116 let c = chars[i];
117 let tone = i > 0 && TONE_MARKS.contains(&chars[i - 1]) && c != '\n';
119 let period_comma = (c == '.' || c == ',')
121 && chars.get(i + 1) == Some(&' ')
122 && !(i >= 2 && chars[i - 2] == '.' && chars[i - 1].is_ascii_alphabetic());
123 let colon = c == ':' && !(i > 0 && chars[i - 1].is_ascii_digit());
125 let len = if tone {
126 1
127 } else if period_comma {
128 2
129 } else if colon || is_other_punc(c) {
130 1
131 } else {
132 0
133 };
134 if len > 0 {
135 tokens.push(chars[start..i].iter().collect());
136 i += len;
137 start = i;
138 } else {
139 i += 1;
140 }
141 }
142 tokens.push(chars[start..].iter().collect());
143 tokens
144}
145
146fn is_punc_or_space(t: &str) -> bool {
148 t.chars()
149 .all(|c| ALL_PUNC.contains(c) || " \t\n\r\x0b\x0c".contains(c))
150}
151
152fn py_strip(s: &str) -> String {
154 s.trim_matches(|c: char| c.is_whitespace() || ('\x1c'..='\x1f').contains(&c))
155 .to_string()
156}
157
158fn clean_tokens(tokens: Vec<String>) -> Vec<String> {
159 tokens
160 .into_iter()
161 .filter(|t| !is_punc_or_space(t))
162 .map(|t| py_strip(&t))
163 .collect()
164}
165
166fn minimize(s: &str, max: usize) -> Vec<String> {
168 let mut out = Vec::new();
169 let mut chars: Vec<char> = s.chars().collect();
170 loop {
171 if chars.first() == Some(&' ') {
172 chars.remove(0);
173 }
174 if chars.len() <= max {
175 out.push(chars.iter().collect());
176 return out;
177 }
178 let idx = chars[..max].iter().rposition(|&c| c == ' ').unwrap_or(max);
179 out.push(chars[..idx].iter().collect());
180 chars = chars[idx..].to_vec();
181 }
182}
183
184pub fn text_parts(text: &str) -> Vec<String> {
186 let mut t = py_strip(text);
187 t = pp_tone_marks(&t);
188 t = pp_end_of_line(&t);
189 t = pp_abbreviations(&t);
190 t = pp_word_sub(&t);
191 if t.chars().count() <= MAX_CHARS {
192 return clean_tokens(vec![t]);
193 }
194 let tokens = clean_tokens(tokenize(&t));
195 tokens
196 .iter()
197 .flat_map(|tok| minimize(tok, MAX_CHARS))
198 .filter(|s| !s.is_empty())
199 .collect()
200}
201
202fn quote(s: &str) -> String {
204 let mut out = String::new();
205 for b in s.bytes() {
206 match b {
207 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'.' | b'-' | b'~' | b'/' => {
208 out.push(b as char)
209 }
210 _ => out.push_str(&format!("%{b:02X}")),
211 }
212 }
213 out
214}
215
216fn compact_ascii(v: &Value) -> String {
218 let s = xil_core::pyjson::dumps(v, xil_core::pyjson::Style::COMPACT);
219 reserialize_tight(&s)
221}
222
223fn reserialize_tight(s: &str) -> String {
225 let mut out = String::with_capacity(s.len());
226 let mut in_str = false;
227 let mut escaped = false;
228 let mut chars = s.chars().peekable();
229 while let Some(c) = chars.next() {
230 if in_str {
231 out.push(c);
232 if escaped {
233 escaped = false;
234 } else if c == '\\' {
235 escaped = true;
236 } else if c == '"' {
237 in_str = false;
238 }
239 continue;
240 }
241 match c {
242 '"' => {
243 in_str = true;
244 out.push(c);
245 }
246 ',' | ':' => {
247 out.push(c);
248 if chars.peek() == Some(&' ') {
249 chars.next();
250 }
251 }
252 _ => out.push(c),
253 }
254 }
255 out
256}
257
258fn package_rpc(text: &str, lang: &str) -> String {
260 let parameter = serde_json::json!([text, lang, null, "null"]);
261 let escaped = compact_ascii(¶meter);
262 let rpc = serde_json::json!([[[RPC, escaped, null, "generic"]]]);
263 format!("f.req={}&", quote(&compact_ascii(&rpc)))
264}
265
266#[derive(Debug, thiserror::Error)]
267pub enum GttsError {
268 #[error("{0}")]
269 Api(#[from] ApiError),
270 #[error("200 (OK) from TTS API. Probable cause: Unknown")]
271 NoAudio,
272 #[error("No text to send to TTS API")]
273 NoText,
274}
275
276pub fn synthesize(text: &str, lang: &str) -> Result<Vec<u8>, GttsError> {
278 let parts = text_parts(text);
279 if parts.is_empty() {
280 return Err(GttsError::NoText);
281 }
282 let url = format!(
283 "{}/_/TranslateWebserverUi/data/batchexecute",
284 base_url("XIL_GTTS_BASE_URL", DEFAULT_BASE_URL)
285 );
286 let mut audio = Vec::new();
287 for part in parts {
288 let body = package_rpc(&part, lang);
289 let req = agent().post(&url).set("User-Agent", USER_AGENT).set(
290 "Content-Type",
291 "application/x-www-form-urlencoded;charset=utf-8",
292 );
293 let resp = call(req.send_string(&body))?;
294 let bytes = read_bytes(resp)?;
295 let text = String::from_utf8_lossy(&bytes);
296 for line in text.lines() {
297 if !line.contains(RPC) {
298 continue;
299 }
300 let needle = format!("{RPC}\",\"[\\\"");
301 let Some(pos) = line.find(&needle) else {
302 return Err(GttsError::NoAudio);
303 };
304 let rest = &line[pos + needle.len()..];
305 let Some(end) = rest.rfind("\\\"]") else {
306 return Err(GttsError::NoAudio);
307 };
308 audio.extend(decode_base64(&rest[..end]).ok_or(GttsError::NoAudio)?);
309 }
310 }
311 Ok(audio)
312}
313
314fn decode_base64(s: &str) -> Option<Vec<u8>> {
315 let mut out = Vec::with_capacity(s.len() * 3 / 4);
316 let mut buf = 0u32;
317 let mut bits = 0;
318 for b in s.bytes() {
319 let v = match b {
320 b'A'..=b'Z' => b - b'A',
321 b'a'..=b'z' => b - b'a' + 26,
322 b'0'..=b'9' => b - b'0' + 52,
323 b'+' => 62,
324 b'/' => 63,
325 b'=' => break,
326 _ => return None,
327 } as u32;
328 buf = (buf << 6) | v;
329 bits += 6;
330 if bits >= 8 {
331 bits -= 8;
332 out.push((buf >> bits) as u8);
333 buf &= (1 << bits) - 1;
334 }
335 }
336 Some(out)
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn short_text_is_one_part() {
345 assert_eq!(text_parts(" Hello there! "), vec!["Hello there!"]);
346 assert!(text_parts(" ... ").is_empty());
347 }
348
349 #[test]
350 fn preprocessors() {
351 assert_eq!(
352 pp_abbreviations("Dr. Who and Mrs. Smith, first."),
353 "Dr Who and Mrs Smith, first"
354 );
355 assert_eq!(pp_word_sub("John Doe, esq."), "John Doe, Esquire");
356 assert_eq!(pp_tone_marks("Hi!Yes?"), "Hi! Yes? ");
357 }
358
359 #[test]
360 fn rpc_body_is_quoted_compact_json() {
361 let body = package_rpc("hello world", "en");
362 assert_eq!(
363 body,
364 "f.req=%5B%5B%5B%22jQ1olc%22%2C%22%5B%5C%22hello%20world%5C%22%2C%5C%22en%5C%22%2Cnull%2C%5C%22null%5C%22%5D%22%2Cnull%2C%22generic%22%5D%5D%5D&"
365 );
366 }
367
368 #[test]
369 fn base64_round_trip() {
370 assert_eq!(decode_base64("aGVsbG8=").unwrap(), b"hello");
371 }
372}