Skip to main content

rust_tts_wrapper/cloud_engine/
gemini.rs

1// The split modules resolve shared names through the parent glob.
2#![allow(clippy::wildcard_imports)]
3
4/// Map the wrapper's rate/pitch/volume multipliers (1.0 = normal, 0.0 =
5/// unset) onto the Gemini prompting guide's style vocabulary. Sustained
6/// delivery is a turn-level `speech_metadata.style` concern on Gemini 3.8
7/// TTS — there is no numeric prosody — so numeric parameters can only be
8/// expressed approximately as style words.
9pub(crate) fn gemini_style_from_params(rate: f32, pitch: f32, volume: f32) -> String {
10    let mut parts: Vec<&str> = Vec::new();
11    if rate > 0.0 && (rate - 1.0).abs() > f32::EPSILON {
12        if rate < 1.0 {
13            parts.push("speaking slowly");
14        } else {
15            parts.push("speaking rapidly");
16        }
17    }
18    if pitch > 0.0 && (pitch - 1.0).abs() > f32::EPSILON {
19        if pitch < 1.0 {
20            parts.push("low pitch");
21        } else {
22            parts.push("high pitch");
23        }
24    }
25    if volume > 0.0 && (volume - 1.0).abs() > f32::EPSILON {
26        if volume < 1.0 {
27            parts.push("speaking softly");
28        } else {
29            parts.push("speaking loudly");
30        }
31    }
32    parts.join(", ")
33}
34
35/// Build the Interactions API request body for Gemini TTS.
36///
37/// The transcript is sent verbatim — the model performs the text as
38/// written (the SpeechMarkdown `gemini` dialect has already rendered
39/// angle-bracket vocal bursts, pause tags and CAPS emphasis into it).
40/// Turn-level delivery rides in `speech_metadata.style`: an explicit
41/// credential `style` wins outright; otherwise the rate/pitch/volume
42/// multipliers map onto the documented style vocabulary. The voice may
43/// be a prebuilt name ("Kore"), an Extended Voice Library ID, a voice
44/// design ID (`voice_...`) or a stateless replication key
45/// (`voicekey_...`) — all pass through in `speech_config`.
46#[must_use]
47pub fn build_gemini_request(
48    text: &str,
49    voice: &str,
50    rate: f32,
51    pitch: f32,
52    volume: f32,
53    model: Option<&str>,
54    style_override: Option<&str>,
55) -> serde_json::Value {
56    let style = style_override.map_or_else(
57        || gemini_style_from_params(rate, pitch, volume),
58        str::to_string,
59    );
60
61    let mut content = serde_json::json!({ "type": "text", "text": text });
62    if !style.is_empty() {
63        content["annotations"] = serde_json::json!([
64            { "type": "speech_metadata", "style": style }
65        ]);
66    }
67
68    serde_json::json!({
69        "model": model.unwrap_or("gemini-3.8-flash-tts"),
70        "input": [{ "type": "user_input", "content": [content] }],
71        "response_format": { "type": "audio" },
72        "generation_config": { "speech_config": [{ "voice": voice }] },
73    })
74}
75
76/// Outcome of scanning an Interactions API response for audio.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum GeminiAudioBlock {
79    /// The last audio block, decoded from base64.
80    Present(Vec<u8>),
81    /// No audio block in the response (text-only reply, refusal).
82    Absent,
83    /// An audio block existed but its base64 payload was corrupt.
84    Corrupt,
85}
86
87/// Extract the last audio block (decoded from base64) from an Interactions
88/// API response.
89///
90/// REST shape: `steps[*].content[*]` blocks with `type == "audio"` carry
91/// `data` (base64) and `mime_type` (`audio/wav`). The last audio block
92/// matches the SDK's `output_audio` convenience property. Base64
93/// corruption is reported distinctly from absence so the caller can
94/// produce an accurate diagnostic.
95#[must_use]
96pub fn parse_gemini_interaction_audio(json: &serde_json::Value) -> GeminiAudioBlock {
97    use base64::Engine;
98    let Some(steps) = json.get("steps").and_then(|v| v.as_array()) else {
99        return GeminiAudioBlock::Absent;
100    };
101    let mut last: Option<&str> = None;
102    for step in steps {
103        let Some(content) = step.get("content").and_then(|v| v.as_array()) else {
104            continue;
105        };
106        for block in content {
107            if block.get("type").and_then(|v| v.as_str()) == Some("audio") {
108                if let Some(data) = block.get("data").and_then(|v| v.as_str()) {
109                    last = Some(data);
110                }
111            }
112        }
113    }
114    match last {
115        Some(data) => base64::engine::general_purpose::STANDARD
116            .decode(data)
117            .map_or(GeminiAudioBlock::Corrupt, GeminiAudioBlock::Present),
118        None => GeminiAudioBlock::Absent,
119    }
120}
121
122/// Parse the sample rate from a RIFF/WAVE `fmt ` header (bytes 24–28,
123/// little-endian u32). Returns 24_000 (the documented Gemini output rate)
124/// for anything non-conforming — including a magic-valid header whose
125/// rate field is zero or implausible, which would otherwise divide the
126/// boundary scaler by zero.
127pub(crate) fn wav_sample_rate(wav: &[u8]) -> u32 {
128    if wav.len() > 28 && &wav[0..4] == b"RIFF" && &wav[8..12] == b"WAVE" && &wav[12..16] == b"fmt "
129    {
130        let rate = u32::from_le_bytes([wav[24], wav[25], wav[26], wav[27]]);
131        if (8_000..=192_000).contains(&rate) {
132            return rate;
133        }
134    }
135    24_000
136}
137
138/// Fire estimated word boundaries scaled to the actual delivered audio
139/// duration. The estimator assumes 150 wpm; for providers that return the
140/// complete buffer without timestamps (Gemini), scaling the estimates to
141/// the real duration keeps events roughly aligned with playback.
142#[allow(clippy::cast_precision_loss)]
143pub(crate) fn fire_scaled_estimates(
144    cb: &mut crate::engine::OnBoundaryCallback<'_>,
145    text: &str,
146    pcm: &[u8],
147    sample_rate: u32,
148) {
149    let plan = crate::boundaries::EstimatePlan::build(text);
150    let n = plan.len();
151    if n == 0 || pcm.is_empty() {
152        return;
153    }
154    let Some(last) = plan.event(n - 1) else {
155        return;
156    };
157    let est_ms = (last.end_s * 1000.0).max(1.0);
158    let actual_ms = (pcm.len() as f32 / 2.0) * 1000.0 / sample_rate as f32;
159    let scale = actual_ms / est_ms;
160    for i in 0..n {
161        if let Some(e) = plan.event(i) {
162            // These are proportional estimates (scaled to the real audio
163            // duration) — flagged as such per the callback contract.
164            cb(
165                &e.word,
166                e.start_s * scale,
167                e.end_s * scale,
168                e.char_offset,
169                e.char_len,
170                true,
171            );
172        }
173    }
174}