use super::*;
impl AudioCaptchaSolver {
pub async fn transcribe(&self, audio_bytes: bytes::Bytes) -> Result<String> {
let prepared = if audio_bytes.starts_with(b"RIFF") {
match crate::audio_dsp::preprocess_for_stt(&audio_bytes) {
Ok(pcm) => bytes::Bytes::from(crate::audio_dsp::encode_wav_pcm16(&pcm)),
Err(e) => {
tracing::debug!(
error = %e,
"audio_dsp pre-process failed; sending raw audio to STT"
);
audio_bytes
}
}
} else {
audio_bytes
};
match &self.stt_backend {
Some(b) if b.kind == SttKind::WhisperCli => {
Self::transcribe_via_whisper_cli(b, prepared).await
}
Some(b) if b.kind == SttKind::OpenAIApi => {
self.transcribe_via_openai(b, prepared).await
}
_ => self.transcribe_via_http(prepared).await,
}
}
async fn transcribe_via_whisper_cli(
backend: &SttBackend,
audio_bytes: bytes::Bytes,
) -> Result<String> {
let model = backend.model.as_deref().unwrap_or("tiny");
let bin = backend.endpoint.clone();
let ext = if audio_bytes.starts_with(b"RIFF") {
"wav"
} else if audio_bytes.starts_with(b"OggS") {
"ogg"
} else {
"mp3"
};
let mut tmp = tempfile::Builder::new()
.prefix("captchaforge-stt-")
.suffix(&format!(".{ext}"))
.tempfile()?;
use std::io::Write;
tmp.write_all(&audio_bytes)?;
tmp.flush()?;
let audio_path = tmp.path().to_path_buf();
let outdir = tempfile::tempdir()?;
let outdir_path = outdir.path().to_path_buf();
let model_owned = model.to_string();
let result = tokio::task::spawn_blocking(move || -> Result<String> {
let status = std::process::Command::new(&bin)
.arg(&audio_path)
.args(["--model", &model_owned])
.args(["--output_format", "txt"])
.arg("--output_dir")
.arg(&outdir_path)
.args(["--language", "en"])
.arg("--fp16=False")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(|e| anyhow::anyhow!("spawning whisper: {e}"))?;
if !status.success() {
anyhow::bail!("whisper exited {status}");
}
let stem = audio_path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("audio path has no stem"))?;
let txt_path = outdir_path.join(format!("{stem}.txt"));
let body = std::fs::read_to_string(&txt_path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", txt_path.display()))?;
Ok(body.trim().to_string())
})
.await
.map_err(|e| anyhow::anyhow!("whisper task join: {e}"))??;
Ok(result)
}
async fn transcribe_via_openai(
&self,
backend: &SttBackend,
audio_bytes: bytes::Bytes,
) -> Result<String> {
let key = std::env::var("OPENAI_API_KEY")
.map_err(|_| anyhow::anyhow!("OPENAI_API_KEY not set"))?;
let model = backend.model.as_deref().unwrap_or("whisper-1");
let part = reqwest::multipart::Part::bytes(audio_bytes.to_vec())
.file_name("audio.mp3")
.mime_str("audio/mpeg")?;
let form = reqwest::multipart::Form::new()
.text("model", model.to_string())
.text("response_format", "text")
.part("file", part);
let resp = self
.client
.post(&backend.endpoint)
.bearer_auth(key)
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("openai whisper returned {}", resp.status());
}
Ok(resp.text().await?.trim().to_string())
}
async fn transcribe_via_http(&self, audio_bytes: bytes::Bytes) -> Result<String> {
let resp = self
.client
.post(&self.stt_endpoint)
.header("Content-Type", "audio/mpeg")
.body(audio_bytes)
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("local STT returned {}", resp.status());
}
Ok(resp.text().await?.trim().to_string())
}
}