use anyhow::{Context, Result};
use reqwest::Client;
use serde::Deserialize;
use std::time::Duration;
use super::openai_tts::build_endpoint_url;
const LIVENESS_TIMEOUT: Duration = Duration::from_secs(2);
const OGG_MAGIC: &[u8; 4] = b"OggS";
pub async fn probe_liveness(base_url: &str) -> Result<()> {
let url = build_endpoint_url(base_url, "")?;
let client = Client::builder()
.timeout(LIVENESS_TIMEOUT)
.build()
.context("Failed to build liveness probe client")?;
client
.get(&url)
.send()
.await
.with_context(|| format!("Voicebox liveness probe failed at {url}"))?;
Ok(())
}
#[cfg(feature = "local-stt")]
fn convert_ogg_to_wav(bytes: &[u8]) -> Result<Vec<u8>> {
let (samples, sample_rate) = super::local_whisper::decode_audio(bytes)
.context("Failed to decode OGG audio for voicebox WAV conversion")?;
let spec = hound::WavSpec {
channels: 1,
sample_rate,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut cursor = std::io::Cursor::new(Vec::new());
{
let mut writer =
hound::WavWriter::new(&mut cursor, spec).context("Failed to create WAV writer")?;
for &sample in &samples {
let clamped = sample.clamp(-1.0, 1.0);
writer
.write_sample((clamped * i16::MAX as f32) as i16)
.context("Failed to write WAV sample")?;
}
writer.finalize().context("Failed to finalize WAV")?;
}
Ok(cursor.into_inner())
}
pub async fn transcribe(audio_bytes: Vec<u8>, base_url: &str) -> Result<String> {
if let Err(e) = probe_liveness(base_url).await {
anyhow::bail!("Voicebox STT unreachable (liveness probe failed): {e}");
}
let transcribe_url = build_endpoint_url(base_url, "transcribe")?;
let client = Client::new();
#[cfg(feature = "local-stt")]
let (payload, file_name, mime_type): (Vec<u8>, &str, &str) =
if audio_bytes.len() >= 4 && &audio_bytes[..4] == OGG_MAGIC {
match convert_ogg_to_wav(&audio_bytes) {
Ok(wav) => {
tracing::debug!(
"Voicebox STT: converted {} OGG bytes → {} WAV bytes",
audio_bytes.len(),
wav.len(),
);
(wav, "voice.wav", "audio/wav")
}
Err(e) => {
tracing::warn!("Voicebox STT: OGG→WAV conversion failed, sending raw: {e}");
(audio_bytes, "voice.ogg", "audio/ogg")
}
}
} else {
(audio_bytes, "voice.ogg", "audio/ogg")
};
#[cfg(not(feature = "local-stt"))]
let (payload, file_name, mime_type): (Vec<u8>, &str, &str) =
(audio_bytes, "voice.ogg", "audio/ogg");
let file_part = reqwest::multipart::Part::bytes(payload)
.file_name(file_name)
.mime_str(mime_type)?;
let form = reqwest::multipart::Form::new().part("file", file_part);
let response = client
.post(&transcribe_url)
.multipart(form)
.send()
.await
.context("Failed to send audio to Voicebox STT")?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
anyhow::bail!(
"Voicebox STT error ({}): {}",
status,
translate_voicebox_error(&error_text),
);
}
let result: TranscribeResponse = response
.json()
.await
.context("Failed to parse Voicebox STT response")?;
tracing::info!(
"Voicebox STT: transcribed {} chars (url={})",
result.text.len(),
transcribe_url,
);
Ok(result.text)
}
#[derive(Deserialize)]
struct TranscribeResponse {
text: String,
}
pub(crate) fn translate_voicebox_error(raw: &str) -> String {
if raw.contains("Cannot load imports from non-existent stub") && raw.contains("__init__.pyi") {
let pkg = extract_package_from_stub_error(raw).unwrap_or_else(|| "<unknown>".to_string());
return format!(
"Voicebox is missing the `{pkg}` runtime stubs (likely a PyInstaller \
bundle issue — lazy_loader needs the `__init__.pyi` files at runtime \
but the build stripped them). Rebuild voicebox with \
`--collect-data {pkg}` and a `hooks/hook-lazy_loader.py` that sets \
`hiddenimports = ['lazy_loader']`. Original detail: {raw}"
);
}
raw.to_string()
}
fn extract_package_from_stub_error(raw: &str) -> Option<String> {
let after_marker = raw.split("__init__.pyi").next()?;
let path_part = after_marker.rsplit(['\'', '"']).next()?;
let mei_split = path_part.split("_MEI").nth(1)?;
let mut segments = mei_split.split('/').filter(|s| !s.is_empty());
let _suffix = segments.next();
segments.next().map(|s| s.to_string())
}