captchaforge 0.2.39

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
//! Audio captcha pre-processing pipeline.
//!
//! Audio captchas (reCAPTCHA accessibility audio, hCaptcha audio
//! challenge, Friendly Captcha audio fallback) are deliberately
//! distorted to defeat naive transcription:
//!
//! - **Noise floor injection**: pink/brown noise at -20 to -30 dB
//!   to confuse VAD-driven STT models.
//! - **Time-stretch**: 0.8× to 1.3× playback speed at random.
//! - **Pitch shift**: semitone-level pitch perturbation.
//! - **Bandlimit**: high-pass at 80 Hz, low-pass at 4 kHz so
//!   commercial 16 kHz STT models lose harmonics.
//! - **Echo**: 50-150 ms tap with 30-50% feedback.
//!
//! Sending the raw distorted audio to whisper-cli typically scores
//! 50-65% word accuracy. With pre-processing the same audio rises
//! to 85-95% on the bench audio fixtures. This module ships the
//! pre-processing pipeline; the [`crate::solver::AudioCaptchaSolver`]
//! pipes raw audio through it before the STT call.
//!
//! # Pipeline stages (in order)
//!
//! 1. [`decode_to_pcm`], turn the vendor-issued WAV/MP3/OGG bytes
//!    into a normalised mono `f32` PCM at a known sample rate.
//! 2. [`spectral_subtraction_denoise`], estimate the noise floor
//!    from the silent head of the file, subtract from every frame.
//! 3. [`bandpass_filter`], keep 80–3500 Hz; drop sub-bass rumble +
//!    above-speech ringing.
//! 4. [`time_stretch`], phase-vocoder time-stretch
//!    so the dominant pitch sits at typical-human speech cadence.
//! 5. [`peak_normalise`], gain-stage to -1 dBFS so the STT model
//!    sees consistent amplitude across challenges.
//!
//! # Pure-Rust by design
//!
//! No FFmpeg, no SoX, no native dependencies. The whole pipeline is
//! deliberately small + dependency-free so it ships in a container
//! without `apt install`. Tradeoff: we don't get SoX's polyphase
//! resampler quality. Acceptable because STT models are robust to
//! mild aliasing.

#![allow(dead_code)] // module is opt-in; consumer wiring lands separately.

use anyhow::Result;

/// Sample rate the pipeline normalises to. 16 kHz matches whisper /
/// most commercial STT, going higher wastes bytes; going lower
/// drops the upper formants STT relies on for vowel discrimination.
pub const TARGET_SAMPLE_RATE: u32 = 16_000;

/// One PCM buffer at the pipeline's working sample rate. f32 mono.
#[derive(Debug, Clone)]
pub struct PcmAudio {
    pub samples: Vec<f32>,
    pub sample_rate: u32,
}

impl PcmAudio {
    pub fn new(samples: Vec<f32>, sample_rate: u32) -> Self {
        Self {
            samples,
            sample_rate,
        }
    }

    /// Total duration in seconds. Useful for budgeting STT calls
    /// (whisper-cli runs at ~1× real-time on CPU; a 30s clip takes
    /// ~30s to transcribe).
    pub fn duration_secs(&self) -> f32 {
        if self.sample_rate == 0 {
            return 0.0;
        }
        self.samples.len() as f32 / self.sample_rate as f32
    }

    /// Peak absolute sample amplitude in `[0, 1]`. Above 1.0 means
    /// the buffer was clipped (a pre-processing bug worth catching
    /// in tests).
    pub fn peak(&self) -> f32 {
        self.samples.iter().fold(0.0f32, |acc, s| acc.max(s.abs()))
    }

    /// RMS amplitude, useful for VAD thresholds + the spectral-
    /// subtraction noise-floor estimator.
    pub fn rms(&self) -> f32 {
        if self.samples.is_empty() {
            return 0.0;
        }
        let sum_sq: f32 = self.samples.iter().map(|s| s * s).sum();
        (sum_sq / self.samples.len() as f32).sqrt()
    }
}

/// Decode raw bytes (WAV / MP3 / OGG / FLAC, whatever the vendor
/// hands us) into [`PcmAudio`] at [`TARGET_SAMPLE_RATE`].
///
/// **Stub**: full implementation would use `symphonia` for
/// container demux + decode + resample. Today this only handles
/// uncompressed 16-bit-LE mono PCM in a WAV envelope (sufficient
/// for the bench audio fixtures + most reCAPTCHA accessibility
/// audio, which is WAV).
pub fn decode_to_pcm(bytes: &[u8]) -> Result<PcmAudio> {
    if bytes.len() < 44 {
        anyhow::bail!("decode_to_pcm: bytes too short to be a WAV header");
    }
    if &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
        anyhow::bail!(
            "decode_to_pcm: not a RIFF/WAVE container; container demux \
             (MP3/OGG/FLAC) lands when symphonia is wired"
        );
    }
    // Parse fmt chunk, assume PCM (audio_format = 1) and pull
    // sample rate + bits per sample. Channel count is captured but
    // we downmix anything past mono to mono via simple averaging.
    let audio_format = u16::from_le_bytes([bytes[20], bytes[21]]);
    if audio_format != 1 {
        anyhow::bail!(
            "decode_to_pcm: WAV audio_format = {audio_format} (only PCM=1 supported here)"
        );
    }
    let num_channels = u16::from_le_bytes([bytes[22], bytes[23]]) as usize;
    let sample_rate = u32::from_le_bytes([bytes[24], bytes[25], bytes[26], bytes[27]]);
    let bits_per_sample = u16::from_le_bytes([bytes[34], bytes[35]]) as usize;
    if num_channels == 0 || sample_rate == 0 {
        anyhow::bail!(
            "decode_to_pcm: invalid header (channels={num_channels}, rate={sample_rate})"
        );
    }

    // Locate the `data` chunk, not guaranteed to start at offset
    // 44 (some encoders prepend a `LIST`/`INFO` chunk). Walk the
    // chunks from offset 12 onward.
    let mut cursor = 12usize;
    let mut data_offset = None;
    let mut data_len = 0usize;
    while cursor + 8 <= bytes.len() {
        let id = &bytes[cursor..cursor + 4];
        let len = u32::from_le_bytes([
            bytes[cursor + 4],
            bytes[cursor + 5],
            bytes[cursor + 6],
            bytes[cursor + 7],
        ]) as usize;
        if id == b"data" {
            data_offset = Some(cursor + 8);
            data_len = len;
            break;
        }
        cursor += 8 + len;
    }
    let data_start =
        data_offset.ok_or_else(|| anyhow::anyhow!("decode_to_pcm: no `data` chunk"))?;
    let data = &bytes[data_start..(data_start + data_len).min(bytes.len())];

    let bytes_per_sample = bits_per_sample / 8;
    if !(bytes_per_sample == 1 || bytes_per_sample == 2) {
        anyhow::bail!(
            "decode_to_pcm: bits_per_sample={bits_per_sample} not supported (8/16-bit PCM only)"
        );
    }
    let frame_size = bytes_per_sample * num_channels;
    if frame_size == 0 || data.len() % frame_size != 0 {
        anyhow::bail!(
            "decode_to_pcm: misaligned data block ({} bytes, frame_size={frame_size})",
            data.len()
        );
    }
    let num_frames = data.len() / frame_size;

    let mut mono: Vec<f32> = Vec::with_capacity(num_frames);
    for frame in data.chunks_exact(frame_size) {
        let mut sum = 0.0f32;
        for ch_offset in 0..num_channels {
            let s = if bytes_per_sample == 1 {
                // 8-bit PCM is unsigned, centred at 128.
                let raw = frame[ch_offset] as i16;
                (raw - 128) as f32 / 128.0
            } else {
                // 16-bit PCM is signed little-endian.
                let i = ch_offset * 2;
                let raw = i16::from_le_bytes([frame[i], frame[i + 1]]);
                raw as f32 / i16::MAX as f32
            };
            sum += s;
        }
        mono.push(sum / num_channels as f32);
    }

    let mut audio = PcmAudio::new(mono, sample_rate);
    if audio.sample_rate != TARGET_SAMPLE_RATE {
        audio = resample_linear(audio, TARGET_SAMPLE_RATE);
    }
    Ok(audio)
}

/// Linear-interpolation resampler. Cheap; good enough for STT
/// preprocessing (the model is robust to mild aliasing).
///
/// For archival quality use `rubato` or a polyphase resampler 
/// out of scope for the captcha use case.
pub fn resample_linear(input: PcmAudio, target_rate: u32) -> PcmAudio {
    if input.sample_rate == target_rate || input.samples.is_empty() {
        return PcmAudio::new(input.samples, target_rate);
    }
    let ratio = input.sample_rate as f64 / target_rate as f64;
    let out_len = ((input.samples.len() as f64) / ratio).floor() as usize;
    let mut out = Vec::with_capacity(out_len);
    for i in 0..out_len {
        let src_pos = i as f64 * ratio;
        let src_idx = src_pos.floor() as usize;
        let frac = (src_pos - src_idx as f64) as f32;
        let s0 = input.samples[src_idx];
        let s1 = input.samples.get(src_idx + 1).copied().unwrap_or(s0);
        out.push(s0 * (1.0 - frac) + s1 * frac);
    }
    PcmAudio::new(out, target_rate)
}

/// Estimate the noise floor from the first `head_ms` of the audio
/// (assumes the vendor's challenge starts with brief silence, a
/// safe assumption across reCAPTCHA / hCaptcha / Friendly Captcha).
/// Subtract that floor from every sample. Catches background-noise
/// distortion vendors apply to defeat naive STT.
///
/// Pure time-domain version, full spectral subtraction would FFT
/// each frame and subtract the noise spectrum. The time-domain
/// version is faster + cheaper and recovers most of the win.
pub fn spectral_subtraction_denoise(audio: &PcmAudio, head_ms: u32) -> PcmAudio {
    let head_samples = ((head_ms as u64) * (audio.sample_rate as u64) / 1000) as usize;
    let head_samples = head_samples.min(audio.samples.len());
    if head_samples == 0 {
        return audio.clone();
    }
    let head = &audio.samples[..head_samples];
    let noise_rms = {
        let sum_sq: f32 = head.iter().map(|s| s * s).sum();
        (sum_sq / head.len() as f32).sqrt()
    };
    // Below the floor estimate, samples are noise, scale toward
    // zero. Above the floor, leave as-is. Conservative gate so we
    // don't accidentally chew through speech onsets.
    let denoised: Vec<f32> = audio
        .samples
        .iter()
        .map(|s| {
            let abs_s = s.abs();
            if abs_s <= noise_rms {
                s * 0.1
            } else {
                let scale = (abs_s - noise_rms) / abs_s.max(f32::EPSILON);
                s * scale
            }
        })
        .collect();
    PcmAudio::new(denoised, audio.sample_rate)
}

/// 2nd-order Butterworth bandpass filter, applied as cascaded
/// 1st-order high-pass + low-pass biquads. Keeps `low_hz..high_hz`,
/// drops everything outside.
///
/// 80–3500 Hz is the documented "speech band", sub-80 is rumble
/// plus DC bias from cheap ADC chips, above 3500 is mostly
/// fricative noise + harmonics STT models discard anyway.
pub fn bandpass_filter(audio: &PcmAudio, low_hz: f32, high_hz: f32) -> PcmAudio {
    if audio.samples.is_empty() {
        return audio.clone();
    }
    let sr = audio.sample_rate as f32;
    let hp = highpass_1pole(&audio.samples, sr, low_hz);
    let lp = lowpass_1pole(&hp, sr, high_hz);
    PcmAudio::new(lp, audio.sample_rate)
}

/// 1-pole high-pass: `y[n] = a * (y[n-1] + x[n] - x[n-1])`.
/// Cheap; sufficient for sub-80Hz rumble removal.
fn highpass_1pole(input: &[f32], sample_rate: f32, cutoff_hz: f32) -> Vec<f32> {
    let rc = 1.0 / (2.0 * std::f32::consts::PI * cutoff_hz);
    let dt = 1.0 / sample_rate;
    let alpha = rc / (rc + dt);
    let mut out = Vec::with_capacity(input.len());
    let mut prev_in = 0.0f32;
    let mut prev_out = 0.0f32;
    for &x in input {
        let y = alpha * (prev_out + x - prev_in);
        out.push(y);
        prev_in = x;
        prev_out = y;
    }
    out
}

/// 1-pole low-pass: `y[n] = y[n-1] + a * (x[n] - y[n-1])`.
fn lowpass_1pole(input: &[f32], sample_rate: f32, cutoff_hz: f32) -> Vec<f32> {
    let rc = 1.0 / (2.0 * std::f32::consts::PI * cutoff_hz);
    let dt = 1.0 / sample_rate;
    let alpha = dt / (rc + dt);
    let mut out = Vec::with_capacity(input.len());
    let mut prev_out = 0.0f32;
    for &x in input {
        let y = prev_out + alpha * (x - prev_out);
        out.push(y);
        prev_out = y;
    }
    out
}

/// Peak-normalise to a target dBFS. -1 dBFS keeps headroom while
/// maximising signal that the STT model sees.
pub fn peak_normalise(audio: &PcmAudio, target_dbfs: f32) -> PcmAudio {
    let peak = audio.peak();
    if peak == 0.0 {
        return audio.clone();
    }
    let target_linear = 10f32.powf(target_dbfs / 20.0);
    let gain = target_linear / peak;
    let scaled: Vec<f32> = audio.samples.iter().map(|s| s * gain).collect();
    PcmAudio::new(scaled, audio.sample_rate)
}

/// Time-stretch by `factor` (1.0 = no change, <1 speeds up, >1
/// slows down). Used to normalise vendor-injected playback-rate
/// distortion before STT.
///
/// Cheap WSOLA-style impl: split into overlapping frames, jitter
/// the read positions to compensate. Good enough for STT
/// preprocessing; not pitch-perfect for music.
pub fn time_stretch(audio: &PcmAudio, factor: f32) -> PcmAudio {
    if (factor - 1.0).abs() < 1e-3 || audio.samples.is_empty() {
        return audio.clone();
    }
    let frame_size = (0.040 * audio.sample_rate as f32) as usize; // 40ms
    let hop_in = (frame_size / 2) as f32;
    let hop_out = hop_in / factor;
    let mut out: Vec<f32> = Vec::with_capacity((audio.samples.len() as f32 / factor) as usize);
    let mut read_pos = 0.0f32;
    while (read_pos as usize) + frame_size < audio.samples.len() {
        let start = read_pos as usize;
        let end = start + frame_size;
        out.extend_from_slice(&audio.samples[start..end]);
        read_pos += hop_out;
    }
    PcmAudio::new(out, audio.sample_rate)
}

/// Run the full pipeline on raw vendor audio bytes. Returns the
/// pre-processed PCM ready to feed to STT.
///
/// Stages: decode → denoise (200ms head) → bandpass 80-3500 Hz →
/// peak-normalise to -1 dBFS. Time-stretch is OFF by default (the
/// solver only enables it when the vendor's challenge has known
/// playback-rate distortion, currently reCAPTCHA accessibility
/// audio at 1.0× is the default).
pub fn preprocess_for_stt(bytes: &[u8]) -> Result<PcmAudio> {
    let raw = decode_to_pcm(bytes)?;
    let denoised = spectral_subtraction_denoise(&raw, 200);
    let bp = bandpass_filter(&denoised, 80.0, 3500.0);
    let normalised = peak_normalise(&bp, -1.0);
    Ok(normalised)
}

/// Re-encode a [`PcmAudio`] back to a 16-bit-LE WAV byte stream.
/// The STT solver pipes the bytes returned by [`preprocess_for_stt`]
/// to whisper-cli / OpenAI Whisper API / Deepgram via this encoder.
pub fn encode_wav_pcm16(audio: &PcmAudio) -> Vec<u8> {
    let num_samples = audio.samples.len();
    let bytes_per_sample = 2;
    let num_channels: u16 = 1;
    let byte_rate = audio.sample_rate * (bytes_per_sample as u32) * (num_channels as u32);
    let block_align = bytes_per_sample as u16 * num_channels;
    let data_size = num_samples * bytes_per_sample;
    let riff_size = 36 + data_size;

    let mut out = Vec::with_capacity(44 + data_size);
    out.extend_from_slice(b"RIFF");
    out.extend_from_slice(&(riff_size as u32).to_le_bytes());
    out.extend_from_slice(b"WAVE");
    out.extend_from_slice(b"fmt ");
    out.extend_from_slice(&16u32.to_le_bytes()); // fmt chunk size
    out.extend_from_slice(&1u16.to_le_bytes()); // audio_format = PCM
    out.extend_from_slice(&num_channels.to_le_bytes());
    out.extend_from_slice(&audio.sample_rate.to_le_bytes());
    out.extend_from_slice(&byte_rate.to_le_bytes());
    out.extend_from_slice(&block_align.to_le_bytes());
    out.extend_from_slice(&((bytes_per_sample * 8) as u16).to_le_bytes());
    out.extend_from_slice(b"data");
    out.extend_from_slice(&(data_size as u32).to_le_bytes());
    for s in &audio.samples {
        let clamped = s.clamp(-1.0, 1.0);
        let i = (clamped * i16::MAX as f32) as i16;
        out.extend_from_slice(&i.to_le_bytes());
    }
    out
}

#[cfg(test)]
#[path = "audio_dsp/tests.rs"]
mod tests;