rust_tts_wrapper/cloud_engine/
gemini.rs1#![allow(clippy::wildcard_imports)]
3
4pub(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#[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#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum GeminiAudioBlock {
79 Present(Vec<u8>),
81 Absent,
83 Corrupt,
85}
86
87#[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
122pub(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#[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 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}