captchaforge 0.2.36

[DO NOT USE — UNDER ACTIVE DEVELOPMENT, NOT PRODUCTION-READY] Captcha solver scaffolding for chromiumoxide-driven browsers. The architecture is in place (vendor solvers, retry-loop iframe walking, VLM provider abstraction, real-WAF bench harness) but the live-vendor success rate is still 0% — Cloudflare Turnstile / hCaptcha / reCAPTCHA detect us at a TLS / CDP fingerprint layer that no flag-based stealth has cleared. Watch the repo; do not depend on this for any real workload.
Documentation
//! Property tests for `audio_dsp`. Covers the production audio
//! preprocessing chain that feeds Whisper for reCAPTCHA / hCaptcha
//! audio captchas. Invariants here ensure no NaN/Inf can leak into
//! the STT input (which produces silent garbage transcripts) and
//! that decode_to_pcm rejects malformed input cleanly.

use captchaforge::audio_dsp::{
    bandpass_filter, decode_to_pcm, encode_wav_pcm16, peak_normalise, resample_linear,
    spectral_subtraction_denoise, time_stretch, PcmAudio,
};
use proptest::prelude::*;

fn arb_audio() -> impl Strategy<Value = PcmAudio> {
    (
        proptest::collection::vec(-1.0f32..=1.0f32, 64..1024),
        proptest::sample::select(vec![8000u32, 16000, 22050, 44100, 48000]),
    )
        .prop_map(|(samples, sample_rate)| PcmAudio::new(samples, sample_rate))
}

/// Audio long enough for the WSOLA-style time_stretch (40 ms frame
/// size means we need at least `0.04 * sample_rate * 2` samples
/// before the loop can emit anything).
fn arb_long_audio() -> impl Strategy<Value = PcmAudio> {
    (
        proptest::collection::vec(-1.0f32..=1.0f32, 2048..8192),
        proptest::sample::select(vec![8000u32, 16000]),
    )
        .prop_map(|(samples, sample_rate)| PcmAudio::new(samples, sample_rate))
}

fn no_nan_or_inf(samples: &[f32]) -> bool {
    samples.iter().all(|s| s.is_finite())
}

proptest! {
    /// bandpass_filter never injects NaN or Inf into the output —
    /// even with extreme cutoff values. A NaN in the STT input
    /// produces silent garbage transcripts that look like solver
    /// failures.
    #[test]
    fn bandpass_filter_output_is_finite(
        audio in arb_audio(),
        low_hz in 0.0f32..2000.0,
        high_hz in 2000.0f32..8000.0,
    ) {
        let out = bandpass_filter(&audio, low_hz, high_hz);
        prop_assert!(no_nan_or_inf(&out.samples),
            "bandpass produced non-finite samples");
        // Output length and rate preserved.
        prop_assert_eq!(out.samples.len(), audio.samples.len());
        prop_assert_eq!(out.sample_rate, audio.sample_rate);
    }

    /// peak_normalise never produces NaN/Inf and bounds the output
    /// peak to the requested target dBFS (within rounding).
    #[test]
    fn peak_normalise_bounds_output(
        audio in arb_audio(),
        target_dbfs in -60.0f32..=-1.0,
    ) {
        // Skip pure-silence audio — normalising silence is a no-op
        // by definition.
        prop_assume!(audio.peak() > 1e-6);

        let out = peak_normalise(&audio, target_dbfs);
        prop_assert!(no_nan_or_inf(&out.samples));
        let new_peak = out.peak();
        prop_assert!(new_peak.is_finite());
        // Peak should be at most ~10^(target_dbfs/20) plus epsilon.
        let target_linear = 10f32.powf(target_dbfs / 20.0);
        prop_assert!(new_peak <= target_linear + 1e-3,
            "peak {} exceeds target {}", new_peak, target_linear);
    }

    /// time_stretch with factor=1.0 returns audio of identical length
    /// (no-op invariant).
    #[test]
    fn time_stretch_factor_one_is_no_op_length(audio in arb_audio()) {
        let out = time_stretch(&audio, 1.0);
        prop_assert_eq!(out.samples.len(), audio.samples.len());
        prop_assert!(no_nan_or_inf(&out.samples));
    }

    /// time_stretch is monotonic in factor: a larger factor produces
    /// at least as much output as a smaller factor. Compares two
    /// outputs against each other rather than against the input,
    /// because the WSOLA-ish implementation emits `frame_size`
    /// samples per step (no overlap-add), so output length isn't
    /// directly comparable to input length — but factor → factor
    /// monotonicity does hold and is the property solver code
    /// implicitly relies on.
    #[test]
    fn time_stretch_is_monotonic_in_factor(
        audio in arb_long_audio(),
        factor_low in 0.5f32..0.9,
        factor_high in 1.5f32..2.5,
    ) {
        let out_low = time_stretch(&audio, factor_low);
        let out_high = time_stretch(&audio, factor_high);
        prop_assert!(no_nan_or_inf(&out_low.samples));
        prop_assert!(no_nan_or_inf(&out_high.samples));
        prop_assert!(out_high.samples.len() >= out_low.samples.len(),
            "factor {} produced {} samples; factor {} produced {}",
            factor_high, out_high.samples.len(),
            factor_low, out_low.samples.len());
    }

    /// spectral_subtraction_denoise on already-clean signal never
    /// produces NaN/Inf and preserves the sample rate.
    #[test]
    fn denoise_preserves_finiteness_and_rate(
        audio in arb_audio(),
        head_ms in 1u32..50,
    ) {
        let out = spectral_subtraction_denoise(&audio, head_ms);
        prop_assert!(no_nan_or_inf(&out.samples));
        prop_assert_eq!(out.sample_rate, audio.sample_rate);
    }

    /// encode_wav_pcm16 round-trips through decode_to_pcm without
    /// data loss in shape (length + sample rate). Sample values
    /// will quantise to 16-bit so we don't test bit-exact equality.
    #[test]
    fn encode_then_decode_preserves_shape(audio in arb_audio()) {
        let wav = encode_wav_pcm16(&audio);
        prop_assert!(wav.len() >= 44, "encoded WAV must include header");
        let decoded = decode_to_pcm(&wav);
        prop_assert!(decoded.is_ok(),
            "round-trip decode failed: {:?}", decoded.err());
        let decoded = decoded.unwrap();
        prop_assert!(no_nan_or_inf(&decoded.samples));
        // After encode_wav_pcm16, decode_to_pcm may resample to its
        // internal target rate. So we don't check sample_rate
        // equality, only that *some* finite audio came back.
    }

    /// decode_to_pcm rejects malformed bytes with an Err — never
    /// panics, never returns garbage. This is the captchaforge-side
    /// boundary against hostile audio responses from vendor APIs.
    #[test]
    fn decode_to_pcm_rejects_garbage_without_panic(bytes in proptest::collection::vec(any::<u8>(), 0..200)) {
        let _ = decode_to_pcm(&bytes); // must not panic
    }

    /// resample_linear with same rate is a length-preserving no-op.
    #[test]
    fn resample_same_rate_preserves_length(audio in arb_audio()) {
        let rate = audio.sample_rate;
        let len = audio.samples.len();
        let out = resample_linear(audio, rate);
        prop_assert_eq!(out.samples.len(), len);
        prop_assert!(no_nan_or_inf(&out.samples));
    }
}

#[test]
fn empty_audio_decode_returns_err() {
    assert!(decode_to_pcm(&[]).is_err());
}

#[test]
fn under_44_byte_input_returns_err() {
    let short = vec![0u8; 16];
    assert!(decode_to_pcm(&short).is_err());
}

#[test]
fn non_riff_input_returns_err() {
    let mut not_riff = vec![0u8; 64];
    not_riff[0..4].copy_from_slice(b"NOPE");
    assert!(decode_to_pcm(&not_riff).is_err());
}