use std::path::{Path, PathBuf};
use std::process::Command;
pub fn download_telegram_voice(
http: &reqwest::blocking::Client,
base_url: &str,
file_id: &str,
) -> Result<PathBuf, String> {
let resp: serde_json::Value = http
.get(format!("{base_url}/getFile"))
.query(&[("file_id", file_id)])
.send()
.map_err(|e| format!("voice: getFile request failed: {e}"))?
.json()
.map_err(|e| format!("voice: getFile parse failed: {e}"))?;
let file_path = resp
.pointer("/result/file_path")
.and_then(serde_json::Value::as_str)
.ok_or("voice: getFile response missing file_path")?;
let download_url = format!("{}/{}", base_url.replace("/bot", "/file/bot"), file_path);
let bytes = http
.get(&download_url)
.send()
.map_err(|e| format!("voice: download failed: {e}"))?
.bytes()
.map_err(|e| format!("voice: reading file bytes failed: {e}"))?;
let tmp_dir = std::env::temp_dir().join("claudette-voice");
let _ = std::fs::create_dir_all(&tmp_dir);
let ogg_path = tmp_dir.join(format!("{file_id}.ogg"));
std::fs::write(&ogg_path, &bytes)
.map_err(|e| format!("voice: writing ogg file failed: {e}"))?;
Ok(ogg_path)
}
fn ffmpeg_bin() -> String {
std::env::var("CLAUDETTE_FFMPEG_BIN").unwrap_or_else(|_| "ffmpeg".to_string())
}
pub fn ogg_to_wav(ogg_path: &Path) -> Result<PathBuf, String> {
let wav_path = ogg_path.with_extension("wav");
let bin = ffmpeg_bin();
let output = Command::new(&bin)
.args([
"-y", "-i",
&ogg_path.to_string_lossy(),
"-ar",
"16000", "-ac",
"1", "-c:a",
"pcm_s16le",
&wav_path.to_string_lossy(),
])
.output()
.map_err(|e| {
format!(
"voice: ffmpeg not found or failed to start: {e}. \
Install ffmpeg and ensure it's on PATH."
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"voice: ffmpeg conversion failed (exit {}): {}",
output.status,
stderr.chars().take(300).collect::<String>()
));
}
Ok(wav_path)
}
fn whisper_bin() -> String {
std::env::var("CLAUDETTE_WHISPER_BIN").unwrap_or_else(|_| "whisper-cli".to_string())
}
fn whisper_model() -> PathBuf {
if let Ok(path) = std::env::var("CLAUDETTE_WHISPER_MODEL") {
return PathBuf::from(path);
}
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(home)
.join(".claudette")
.join("models")
.join("ggml-medium.bin")
}
pub fn transcribe_wav(wav_path: &Path, lang: &str) -> Result<String, String> {
let bin = whisper_bin();
let model = whisper_model();
if !model.exists() {
return Err(format!(
"voice: whisper model not found at {}. Download ggml-medium.bin from \
https://huggingface.co/ggerganov/whisper.cpp/tree/main and place it there, \
or set CLAUDETTE_WHISPER_MODEL to the correct path.",
model.display()
));
}
let mut args: Vec<String> = vec![
"-m".into(),
model.to_string_lossy().into_owned(),
"-f".into(),
wav_path.to_string_lossy().into_owned(),
"--no-timestamps".into(),
"--output-txt".into(),
];
if lang == "en" {
args.push("--language".into());
args.push("auto".into());
args.push("--translate".into());
} else {
args.push("--language".into());
args.push(lang.to_string());
}
let output = Command::new(&bin).args(&args).output().map_err(|e| {
format!(
"voice: whisper binary '{bin}' not found or failed to start: {e}. \
Install whisper.cpp and set CLAUDETTE_WHISPER_BIN if not on PATH."
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"voice: whisper transcription failed (exit {}): {}",
output.status,
stderr.chars().take(300).collect::<String>()
));
}
let txt_path = wav_path.with_extension("wav.txt");
let text = if txt_path.exists() {
std::fs::read_to_string(&txt_path)
.map_err(|e| format!("voice: reading transcript file failed: {e}"))?
} else {
String::from_utf8_lossy(&output.stdout).to_string()
};
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
return Err("voice: whisper produced empty transcript".to_string());
}
Ok(trimmed)
}
fn unload_ollama_model() {
let host =
std::env::var("OLLAMA_HOST").unwrap_or_else(|_| "http://localhost:11434".to_string());
let model = std::env::var("CLAUDETTE_MODEL").unwrap_or_else(|_| "qwen3:8b".to_string());
let _ = reqwest::blocking::Client::new()
.post(format!("{host}/api/chat"))
.json(&serde_json::json!({
"model": model,
"keep_alive": 0,
}))
.send();
}
pub fn transcribe_telegram_voice(
http: &reqwest::blocking::Client,
base_url: &str,
file_id: &str,
lang: &str,
) -> Result<String, String> {
let ogg_path = download_telegram_voice(http, base_url, file_id)?;
let wav_path = ogg_to_wav(&ogg_path)?;
unload_ollama_model();
let text = transcribe_wav(&wav_path, lang);
let _ = std::fs::remove_file(&ogg_path);
let _ = std::fs::remove_file(&wav_path);
let txt_path = wav_path.with_extension("wav.txt");
let _ = std::fs::remove_file(&txt_path);
text
}
pub fn check_voice_deps() -> Result<(), String> {
let mut missing = Vec::new();
let ffmpeg = ffmpeg_bin();
match Command::new(&ffmpeg).arg("-version").output() {
Ok(out) if out.status.success() => {}
_ => missing.push("ffmpeg (set CLAUDETTE_FFMPEG_BIN or install on PATH)"),
}
let model = whisper_model();
if !model.exists() {
missing.push("whisper model (download ggml-medium.bin)");
}
let bin = whisper_bin();
match Command::new(&bin).arg("--help").output() {
Ok(_) => {}
_ => missing.push("whisper.cpp binary (set CLAUDETTE_WHISPER_BIN)"),
}
if missing.is_empty() {
Ok(())
} else {
Err(format!("voice deps missing: {}", missing.join(", ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn whisper_model_path_is_under_claudette() {
let prev = std::env::var("CLAUDETTE_WHISPER_MODEL").ok();
std::env::remove_var("CLAUDETTE_WHISPER_MODEL");
let path = whisper_model();
assert!(
path.to_string_lossy().contains(".claudette"),
"default model path should be under .claudette: {}",
path.display()
);
assert!(path.ends_with("ggml-medium.bin"));
if let Some(p) = prev {
std::env::set_var("CLAUDETTE_WHISPER_MODEL", p);
}
}
#[test]
fn whisper_model_path_honors_env_var() {
std::env::set_var("CLAUDETTE_WHISPER_MODEL", "/custom/path/model.bin");
let path = whisper_model();
std::env::remove_var("CLAUDETTE_WHISPER_MODEL");
assert_eq!(path, PathBuf::from("/custom/path/model.bin"));
}
#[test]
fn whisper_bin_defaults_to_whisper_cli() {
let prev = std::env::var("CLAUDETTE_WHISPER_BIN").ok();
std::env::remove_var("CLAUDETTE_WHISPER_BIN");
let bin = whisper_bin();
assert_eq!(bin, "whisper-cli");
if let Some(p) = prev {
std::env::set_var("CLAUDETTE_WHISPER_BIN", p);
}
}
#[test]
fn whisper_bin_honors_env_var() {
std::env::set_var("CLAUDETTE_WHISPER_BIN", "/opt/whisper-cpp/main");
let bin = whisper_bin();
std::env::remove_var("CLAUDETTE_WHISPER_BIN");
assert_eq!(bin, "/opt/whisper-cpp/main");
}
#[test]
fn ogg_to_wav_fails_gracefully_without_ffmpeg() {
let result = ogg_to_wav(Path::new("/tmp/nonexistent.ogg"));
assert!(result.is_err());
}
#[test]
fn transcribe_wav_fails_without_model() {
let prev = std::env::var("CLAUDETTE_WHISPER_MODEL").ok();
std::env::set_var("CLAUDETTE_WHISPER_MODEL", "/nonexistent/path/model.bin");
let result = transcribe_wav(Path::new("/tmp/test.wav"), "en");
assert!(result.is_err());
assert!(result.unwrap_err().contains("model not found"));
if let Some(p) = prev {
std::env::set_var("CLAUDETTE_WHISPER_MODEL", p);
} else {
std::env::remove_var("CLAUDETTE_WHISPER_MODEL");
}
}
}