veilus-human-input 0.1.0

Human-like mouse paths, wheel scrolling and typing rhythm for browser automation: pure functions, parameters supplied by the caller.
Documentation
//! Typing rhythm and US keyboard layout.
//!
//! The pause between two keys is log-normal (a long right tail), longer after
//! a space or punctuation, with an occasional hesitation. Each key is held for
//! a random time between keydown and keyup.

use std::ops::Range;
use std::time::Duration;

use rand::Rng;

use crate::sample::log_normal;

/// Every tunable number of the typing model.
#[derive(Debug, Clone, PartialEq)]
pub struct TypingParams {
    /// Pause between two keys: log-normal median and sigma, clamped.
    pub gap_median_ms: f64,
    pub gap_sigma: f64,
    pub gap_min_ms: f64,
    pub gap_max_ms: f64,
    /// Multiplier for the pause after whitespace or punctuation.
    pub boundary_factor: f64,
    /// Chance of an extra hesitation, and its length.
    pub hesitate_chance: f64,
    pub hesitate_ms: Range<u64>,
    /// Key hold time between keydown and keyup.
    pub hold_ms: Range<u64>,
    /// Cap on the total duration of one `timings` call; longer text is
    /// scaled down uniformly to fit. `None` keeps the human pace however long.
    pub total_budget: Option<Duration>,
}

impl Default for TypingParams {
    fn default() -> Self {
        Self {
            gap_median_ms: 180.0,
            gap_sigma: 0.5,
            gap_min_ms: 50.0,
            gap_max_ms: 800.0,
            boundary_factor: 1.3,
            hesitate_chance: 0.08,
            hesitate_ms: 300..1000,
            hold_ms: 70..130,
            total_budget: None,
        }
    }
}

/// Timing of one character: hold, then pause before the next key (zero for
/// the last character).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KeyTiming {
    pub hold: Duration,
    pub gap: Duration,
}

/// Timings for every character of `text` (chars, not bytes).
pub fn timings(text: &str, p: &TypingParams, rng: &mut impl Rng) -> Vec<KeyTiming> {
    let chars: Vec<char> = text.chars().collect();
    let out: Vec<KeyTiming> = chars
        .iter()
        .enumerate()
        .map(|(i, &ch)| KeyTiming {
            hold: Duration::from_millis(rng.gen_range(p.hold_ms.clone())),
            gap: if i + 1 == chars.len() {
                Duration::ZERO
            } else {
                gap_after(ch, p, rng)
            },
        })
        .collect();

    let total: Duration = out.iter().map(|k| k.hold + k.gap).sum();
    match p.total_budget {
        Some(budget) if total > budget => {
            let scale = budget.as_secs_f64() / total.as_secs_f64();
            out.into_iter()
                .map(|k| KeyTiming {
                    hold: k.hold.mul_f64(scale),
                    gap: k.gap.mul_f64(scale),
                })
                .collect()
        }
        _ => out,
    }
}

fn gap_after(ch: char, p: &TypingParams, rng: &mut impl Rng) -> Duration {
    let mut ms = log_normal(p.gap_median_ms, p.gap_sigma, rng);
    if ch.is_whitespace() || ch.is_ascii_punctuation() {
        ms *= p.boundary_factor;
    }
    let mut ms = ms.clamp(p.gap_min_ms, p.gap_max_ms);
    if rng.gen_bool(p.hesitate_chance) {
        ms += rng.gen_range(p.hesitate_ms.clone()) as f64;
    }
    Duration::from_millis(ms.round() as u64)
}

/// Physical key of a character on a US keyboard: `KeyboardEvent.code`, the
/// Windows virtual key code (`keyCode`), and whether Shift is held.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UsKey {
    pub code: &'static str,
    pub key_code: u32,
    pub shift: bool,
}

/// `None` for characters not on a US keyboard (accented letters, emoji);
/// typing those needs an input method, which this crate does not model.
pub fn us_key(ch: char) -> Option<UsKey> {
    const LETTER_CODES: [&str; 26] = [
        "KeyA", "KeyB", "KeyC", "KeyD", "KeyE", "KeyF", "KeyG", "KeyH", "KeyI", "KeyJ", "KeyK",
        "KeyL", "KeyM", "KeyN", "KeyO", "KeyP", "KeyQ", "KeyR", "KeyS", "KeyT", "KeyU", "KeyV",
        "KeyW", "KeyX", "KeyY", "KeyZ",
    ];
    const DIGIT_CODES: [&str; 10] = [
        "Digit0", "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8",
        "Digit9",
    ];
    // (unshifted, shifted, code, Windows keyCode)
    const PUNCT: [(char, char, &str, u32); 11] = [
        ('-', '_', "Minus", 189),
        ('=', '+', "Equal", 187),
        ('[', '{', "BracketLeft", 219),
        (']', '}', "BracketRight", 221),
        ('\\', '|', "Backslash", 220),
        (';', ':', "Semicolon", 186),
        ('\'', '"', "Quote", 222),
        (',', '<', "Comma", 188),
        ('.', '>', "Period", 190),
        ('/', '?', "Slash", 191),
        ('`', '~', "Backquote", 192),
    ];
    const SHIFTED_DIGITS: &str = ")!@#$%^&*(";
    let key = |code, key_code, shift| {
        Some(UsKey {
            code,
            key_code,
            shift,
        })
    };
    match ch {
        'a'..='z' => key(
            LETTER_CODES[ch as usize - 'a' as usize],
            ch as u32 - 32,
            false,
        ),
        'A'..='Z' => key(LETTER_CODES[ch as usize - 'A' as usize], ch as u32, true),
        '0'..='9' => key(DIGIT_CODES[ch as usize - '0' as usize], ch as u32, false),
        ' ' => key("Space", 32, false),
        _ => {
            if let Some(i) = SHIFTED_DIGITS.find(ch) {
                return key(DIGIT_CODES[i], 48 + i as u32, true);
            }
            PUNCT.iter().find_map(|&(plain, shifted, code, key_code)| {
                if ch == plain {
                    key(code, key_code, false)
                } else if ch == shifted {
                    key(code, key_code, true)
                } else {
                    None
                }
            })
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rand::rngs::StdRng;
    use rand::SeedableRng;

    fn ms(d: Duration) -> f64 {
        d.as_secs_f64() * 1000.0
    }

    #[test]
    fn one_timing_per_char_and_no_pause_after_last() {
        let p = TypingParams::default();
        let t = timings("naïve café", &p, &mut StdRng::seed_from_u64(1));
        assert_eq!(t.len(), 10, "counts chars, not bytes");
        assert_eq!(t.last().unwrap().gap, Duration::ZERO);
        assert!(t
            .iter()
            .all(|k| p.hold_ms.contains(&(k.hold.as_millis() as u64))));
    }

    #[test]
    fn gaps_stay_in_range_and_are_uneven() {
        let p = TypingParams::default();
        let gaps: Vec<f64> = timings(&"a".repeat(200), &p, &mut StdRng::seed_from_u64(7))
            .iter()
            .take(199)
            .map(|k| ms(k.gap))
            .collect();
        let max = p.gap_max_ms + p.hesitate_ms.end as f64;
        assert!(gaps.iter().all(|&g| (p.gap_min_ms..=max).contains(&g)));
        let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
        let sd = (gaps.iter().map(|g| (g - mean).powi(2)).sum::<f64>() / gaps.len() as f64).sqrt();
        assert!(sd > 50.0, "standard deviation {sd} ms — too regular");
    }

    #[test]
    fn pause_after_space_is_longer() {
        let p = TypingParams::default();
        let mut rng = StdRng::seed_from_u64(3);
        // Median, not mean: hesitations make the mean too noisy.
        let median = |c: char, rng: &mut StdRng| {
            let mut v: Vec<f64> = (0..401).map(|_| ms(gap_after(c, &p, rng))).collect();
            v.sort_by(f64::total_cmp);
            v[200]
        };
        let (word, space) = (median('a', &mut rng), median(' ', &mut rng));
        assert!(space > word * 1.15, "after space {space} vs in word {word}");
    }

    #[test]
    fn budget_scales_long_text_and_leaves_short_text() {
        let p = TypingParams {
            total_budget: Some(Duration::from_secs(5)),
            ..TypingParams::default()
        };
        let long = timings(&"x".repeat(2_000), &p, &mut StdRng::seed_from_u64(5));
        let total: Duration = long.iter().map(|k| k.hold + k.gap).sum();
        assert!(total <= Duration::from_millis(5_005), "{total:?}");
        // Control row: short text is not scaled.
        let short = timings("hello", &p, &mut StdRng::seed_from_u64(5));
        assert!(short
            .iter()
            .all(|k| k.hold >= Duration::from_millis(p.hold_ms.start)));
        // And no budget means no scaling at all.
        let free = timings(
            &"x".repeat(2_000),
            &TypingParams::default(),
            &mut StdRng::seed_from_u64(5),
        );
        assert!(free.iter().map(|k| k.hold + k.gap).sum::<Duration>() > Duration::from_secs(60));
    }

    #[test]
    fn us_key_maps_letters_digits_and_shifted_symbols() {
        assert_eq!(
            us_key('h'),
            Some(UsKey {
                code: "KeyH",
                key_code: 72,
                shift: false
            })
        );
        assert_eq!(
            us_key('H'),
            Some(UsKey {
                code: "KeyH",
                key_code: 72,
                shift: true
            })
        );
        assert_eq!(
            us_key('@'),
            Some(UsKey {
                code: "Digit2",
                key_code: 50,
                shift: true
            })
        );
        assert_eq!(
            us_key('?'),
            Some(UsKey {
                code: "Slash",
                key_code: 191,
                shift: true
            })
        );
        assert_eq!(us_key(' ').map(|k| k.code), Some("Space"));
        assert_eq!(us_key('é'), None);
    }
}