Skip to main content

rust_tts_wrapper/cloud_engine/
google.rs

1// The split modules resolve shared names through the parent glob.
2#![allow(clippy::wildcard_imports)]
3
4use crate::types::WordBoundary;
5
6/// Build JSON body for Google TTS REST API.
7///
8/// When `input_ssml` is `Some`, it is sent directly as Google's `"ssml"` input
9/// (used when `tts_speak_ssml` passes W3C SSML with the `<voice>` wrapper
10/// already stripped). Otherwise the text/marks path builds Google SSML or plain
11/// text from `text`.
12#[must_use]
13pub fn build_google_request(
14    text: &str,
15    voice: &str,
16    add_marks: bool,
17    input_ssml: Option<&str>,
18) -> (serde_json::Value, Vec<String>) {
19    // Derive the language code from the voice name. Standard Google voice
20    // names start with "xx-YY" (e.g. "en-US-Wavenet-D"). Newer named
21    // voices (Gemini/Chirp3-HD: "Algieba", "Aoede", etc.) don't — Google
22    // routes those by name alone, so we omit languageCode for them.
23    let voice_obj = if voice.len() >= 5 && voice.as_bytes().get(2) == Some(&b'-') {
24        let lang = &voice[..5];
25        serde_json::json!({ "languageCode": lang, "name": voice })
26    } else {
27        serde_json::json!({ "name": voice })
28    };
29
30    let mut words_list = Vec::new();
31
32    let input = if let Some(ssml) = input_ssml {
33        serde_json::json!({ "ssml": ssml })
34    } else if add_marks {
35        let words: Vec<&str> = text.split_whitespace().filter(|w| !w.is_empty()).collect();
36        let mut ssml = String::from("<speak>");
37        for (i, w) in words.iter().enumerate() {
38            if i > 0 {
39                ssml.push(' ');
40            }
41            let _ = std::fmt::Write::write_fmt(&mut ssml, format_args!("<mark name=\"{i}\"/>{w}"));
42            words_list = words.iter().map(|w| (*w).to_string()).collect();
43        }
44        ssml.push_str("</speak>");
45        serde_json::json!({ "ssml": ssml })
46    } else {
47        serde_json::json!({ "text": text })
48    };
49
50    let mut body = serde_json::json!({
51        "input": input,
52        "voice": voice_obj,
53        // sampleRateHertz is pinned so the decoded PCM rate is knowable by
54        // callers that receive bare bytes via on_audio (Google otherwise
55        // returns each voice's natural rate: 22050/24000/32000).
56        "audioConfig": { "audioEncoding": "MP3", "sampleRateHertz": 24000 }
57    });
58
59    if add_marks {
60        body["enableTimePointing"] = serde_json::json!(["SSML_MARK"]);
61    }
62
63    (body, words_list)
64}
65
66/// Parse Google timepoints into word boundaries.
67pub(crate) fn parse_google_timepoints(
68    timepoints: &[serde_json::Value],
69    words: &[String],
70) -> Vec<WordBoundary> {
71    #[derive(Clone)]
72    struct RawTp {
73        index: usize,
74        time_ms: u64,
75    }
76
77    let mut raw: Vec<RawTp> = Vec::new();
78    for tp in timepoints {
79        let mark = tp.get("markName").and_then(|v| v.as_str()).unwrap_or("");
80        let idx: usize = mark.parse().unwrap_or(usize::MAX);
81        let secs = tp
82            .get("timeSeconds")
83            .and_then(serde_json::Value::as_f64)
84            .unwrap_or(0.0);
85        if idx < words.len() {
86            raw.push(RawTp {
87                index: idx,
88                time_ms: (secs * 1000.0) as u64,
89            });
90        }
91    }
92    raw.sort_by_key(|r| r.time_ms);
93
94    let mut boundaries = Vec::with_capacity(raw.len());
95    for (i, tp) in raw.iter().enumerate() {
96        let word = &words[tp.index];
97        let duration = if i + 1 < raw.len() {
98            raw[i + 1].time_ms.saturating_sub(tp.time_ms)
99        } else {
100            ((word.len() as u64) * 80).max(50)
101        };
102        boundaries.push(WordBoundary {
103            text: word.clone(),
104            offset: tp.time_ms,
105            duration,
106            estimated: false,
107        });
108    }
109    boundaries
110}