#![allow(clippy::wildcard_imports)]
pub(crate) fn gemini_style_from_params(rate: f32, pitch: f32, volume: f32) -> String {
let mut parts: Vec<&str> = Vec::new();
if rate > 0.0 && (rate - 1.0).abs() > f32::EPSILON {
if rate < 1.0 {
parts.push("speaking slowly");
} else {
parts.push("speaking rapidly");
}
}
if pitch > 0.0 && (pitch - 1.0).abs() > f32::EPSILON {
if pitch < 1.0 {
parts.push("low pitch");
} else {
parts.push("high pitch");
}
}
if volume > 0.0 && (volume - 1.0).abs() > f32::EPSILON {
if volume < 1.0 {
parts.push("speaking softly");
} else {
parts.push("speaking loudly");
}
}
parts.join(", ")
}
#[must_use]
pub fn build_gemini_request(
text: &str,
voice: &str,
rate: f32,
pitch: f32,
volume: f32,
model: Option<&str>,
style_override: Option<&str>,
) -> serde_json::Value {
let style = style_override.map_or_else(
|| gemini_style_from_params(rate, pitch, volume),
str::to_string,
);
let mut content = serde_json::json!({ "type": "text", "text": text });
if !style.is_empty() {
content["annotations"] = serde_json::json!([
{ "type": "speech_metadata", "style": style }
]);
}
serde_json::json!({
"model": model.unwrap_or("gemini-3.8-flash-tts"),
"input": [{ "type": "user_input", "content": [content] }],
"response_format": { "type": "audio" },
"generation_config": { "speech_config": [{ "voice": voice }] },
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GeminiAudioBlock {
Present(Vec<u8>),
Absent,
Corrupt,
}
#[must_use]
pub fn parse_gemini_interaction_audio(json: &serde_json::Value) -> GeminiAudioBlock {
use base64::Engine;
let Some(steps) = json.get("steps").and_then(|v| v.as_array()) else {
return GeminiAudioBlock::Absent;
};
let mut last: Option<&str> = None;
for step in steps {
let Some(content) = step.get("content").and_then(|v| v.as_array()) else {
continue;
};
for block in content {
if block.get("type").and_then(|v| v.as_str()) == Some("audio") {
if let Some(data) = block.get("data").and_then(|v| v.as_str()) {
last = Some(data);
}
}
}
}
match last {
Some(data) => base64::engine::general_purpose::STANDARD
.decode(data)
.map_or(GeminiAudioBlock::Corrupt, GeminiAudioBlock::Present),
None => GeminiAudioBlock::Absent,
}
}
pub(crate) fn wav_sample_rate(wav: &[u8]) -> u32 {
if wav.len() > 28 && &wav[0..4] == b"RIFF" && &wav[8..12] == b"WAVE" && &wav[12..16] == b"fmt "
{
let rate = u32::from_le_bytes([wav[24], wav[25], wav[26], wav[27]]);
if (8_000..=192_000).contains(&rate) {
return rate;
}
}
24_000
}
#[allow(clippy::cast_precision_loss)]
pub(crate) fn fire_scaled_estimates(
cb: &mut crate::engine::OnBoundaryCallback<'_>,
text: &str,
pcm: &[u8],
sample_rate: u32,
) {
let plan = crate::boundaries::EstimatePlan::build(text);
let n = plan.len();
if n == 0 || pcm.is_empty() {
return;
}
let Some(last) = plan.event(n - 1) else {
return;
};
let est_ms = (last.end_s * 1000.0).max(1.0);
let actual_ms = (pcm.len() as f32 / 2.0) * 1000.0 / sample_rate as f32;
let scale = actual_ms / est_ms;
for i in 0..n {
if let Some(e) = plan.event(i) {
cb(
&e.word,
e.start_s * scale,
e.end_s * scale,
e.char_offset,
e.char_len,
true,
);
}
}
}