captchaforge 0.2.40

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
Documentation
use super::*;

impl AudioCaptchaSolver {
    /// Transcribe `audio_bytes` using the configured backend. Public so
    /// callers can reuse the audio→text path without going through a full
    /// page solve (e.g. CLI `captchaforge transcribe foo.mp3`).
    ///
    /// Pre-pipeline: when the input is a WAV container we run it through
    /// [`crate::audio_dsp::preprocess_for_stt`] (denoise → bandpass →
    /// peak-normalise) before handing it to the STT backend. Vendors
    /// deliberately distort accessibility audio; pre-processing typically
    /// lifts whisper accuracy from ~60% to ~90% on the bench fixtures.
    /// Non-WAV inputs (MP3/OGG) pass through unchanged because the
    /// pre-pipeline doesn't ship a container demuxer yet (tracked under
    /// `audio_dsp` module-level docs).
    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) => {
                    // Pre-processing is best-effort, if the WAV is
                    // malformed we'd rather hand the raw bytes to STT
                    // (whose own decoder may be more forgiving) than
                    // hard-fail the whole solve.
                    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();
        // tempfile crate gives a path that auto-deletes on drop; whisper
        // is happy with `.wav` / `.mp3` based on the extension. We sniff
        // the first 4 bytes for a basic format choice.
        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();

        // whisper writes <stem>.txt into --output_dir; spawn synchronously
        // inside spawn_blocking so we don't stall the async runtime.
        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)
                // English-only is faster + more accurate for digit
                // CAPTCHAs; whisper auto-detects otherwise (slower).
                .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())
    }
}