#![allow(
clippy::wildcard_imports,
clippy::cast_precision_loss,
clippy::cast_sign_loss
)]
use super::*; use crate::audio::tts;
use earshot::Detector;
use rand::{RngExt, SeedableRng};
use std::borrow::Cow;
use std::time::Instant;
const WARMUP_PREPEND_SAMPLES: usize = 20480;
const WARMUP_TTS_PHRASE: &str = "testing one two three";
static WARMUP_TTS_CACHE: std::sync::OnceLock<Vec<f32>> = std::sync::OnceLock::new();
const BENCH_WAKE_PHRASE: &str = "hey mahbot";
const NUM_ENROLLMENT_VARIANTS: usize = 10;
const OWNER_NEGATIVE_PHRASES: &[&str] = &[
"please stop",
"i am going out",
"where are my keys",
"the meeting starts soon",
"let me check the weather",
"remind me to call the dentist",
"what should we have for dinner",
"the wifi keeps disconnecting",
"i need to buy new shoes",
"turn down the music please",
];
struct VoiceAllocation {
enrolled: String,
negative_pool: Vec<String>,
}
fn allocate_voices(available_styles: &[String]) -> VoiceAllocation {
let enrolled = available_styles
.first()
.cloned()
.unwrap_or_else(|| DEFAULT_TTS_STYLE.to_string());
let take = |range: std::ops::Range<usize>| {
available_styles
.get(range)
.map(<[String]>::to_vec)
.unwrap_or_default()
};
VoiceAllocation {
enrolled: enrolled.clone(),
negative_pool: {
let pool = take(0..6);
if pool.is_empty() {
available_styles.to_vec()
} else {
pool
}
},
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum GuidedPromptGroup {
Normal,
Distance,
Angle,
Morning,
}
impl GuidedPromptGroup {
const fn for_clip_index(i: usize) -> Self {
match i {
0..=2 => Self::Normal,
3..=5 => Self::Distance,
6..=7 => Self::Angle,
_ => Self::Morning,
}
}
const fn noise_snr_db(self) -> f32 {
match self {
Self::Normal => 20.0,
Self::Distance => 14.0,
Self::Angle | Self::Morning => 17.0,
}
}
}
const CONFUSABLE_PHRASES: &[&str] = &[
"hey madbot",
"hey map bot",
"day mahbot",
"hey nab it",
"hey man",
"hey mabot",
"hey mahbott",
"hey mat",
"hey max",
"pay mabot",
"hay map pot",
"huh mahbot",
"eh mad bot",
"hey maybott",
"they mad bot",
"haymaker",
"hey maybe not",
"play mah jong",
"hey matter of fact",
"a day with mahbot",
"madbot",
"mat bot",
"bad bot",
"mad lot",
"mad pot",
"med bot",
"my bot",
"may bot",
];
const CONFUSABLE_SEEDS_PER_PHRASE: usize = 5;
const CONFUSABLE_SEED_BASE: u64 = 1000;
const UNRELATED_PHRASES: &[&str] = &[
"the weather today is sunny",
"what time is it",
"one two three four five",
"hello world",
"good morning everyone",
"turn on the lights",
"play some music",
"set a timer",
"i need to buy groceries today",
"can you remind me of my appointment",
"please send a message to john",
"what is the capital of france",
"tell me a joke about programming",
"how do I get to the airport",
"the quick brown fox jumps over the lazy dog",
"according to all known laws of aviation there is no way a bee should be able to fly",
"the principle of superposition states that a quantum system exists in all its possible states simultaneously",
"bonjour comment allez vous aujourd hui",
"buenos días cómo estás",
"guten morgen wie geht es dir",
];
const UNRELATED_SEEDS_PER_PHRASE: usize = 3;
const UNRELATED_SEED_BASE: u64 = 2000;
const SILENCE_DURATIONS: &[(&str, usize)] = &[
("silence_0_5s", 8_000),
("silence_1_0s", 16_000),
("silence_2_0s", 32_000),
];
const NOISE_LEN: usize = 16_000;
type NoiseGenerator = fn() -> Vec<f32>;
const NOISE_PROFILES: &[(&str, NoiseGenerator)] = &[
("white uniform noise", generate_white_uniform_noise),
("white gaussian noise", generate_white_gaussian_noise),
("pink noise", generate_pink_noise),
("brown noise", generate_brown_noise),
("mixed speech+noise", generate_mixed_speech_noise),
("blue noise", generate_blue_noise),
("violet noise", generate_violet_noise),
("low-frequency rumble", generate_low_freq_rumble),
("modulated noise", generate_modulated_noise),
("high-frequency hiss", generate_high_freq_hiss),
];
const NOISE_PROFILES_DETECTION_ONLY: &[(&str, NoiseGenerator)] = &[
("impulse burst noise", generate_impulse_burst_noise),
("oscillating tone noise", generate_oscillating_tone_noise),
("crackle static noise", generate_crackle_static_noise),
("pulsed broadband noise", generate_pulsed_broadband_noise),
];
fn all_noise_profiles() -> impl Iterator<Item = &'static (&'static str, NoiseGenerator)> {
NOISE_PROFILES.iter().chain(NOISE_PROFILES_DETECTION_ONLY)
}
const TARGET_SAMPLE_RATE: u32 = 16_000;
const DEFAULT_TTS_STYLE: &str = "M1.json";
const TEST_CACHE_DIR: &str = "test_cache/voice_e2e";
fn cache_dir() -> std::path::PathBuf {
let root = crate::config::CONFIG
.try_storage_root()
.expect("CONFIG storage root must be set");
root.join(TEST_CACHE_DIR)
}
fn synthesize_wake_word_variant_cached(
text: &str,
style: &str,
seed: u64,
sample_rate: u32,
model_hash: &str,
cache_dir: &std::path::Path,
) -> Option<Vec<f32>> {
super::synthesize_with_pcm_cache(text, style, seed, sample_rate, model_hash, cache_dir)
}
fn one_pole_lowpass(pcm: &[f32], cutoff_hz: f32, sample_rate: u32) -> Vec<f32> {
if cutoff_hz <= 0.0 || cutoff_hz >= sample_rate as f32 * 0.5 {
return pcm.to_vec();
}
let alpha = 1.0 - (-2.0 * core::f32::consts::PI * cutoff_hz / sample_rate as f32).exp();
let mut y = 0.0f32;
pcm.iter()
.map(|&x| {
y += alpha * (x - y);
y
})
.collect()
}
fn high_shelf_cut(pcm: &[f32], gain_db: f32, fc_hz: f32, sample_rate: u32) -> Vec<f32> {
if gain_db.abs() < 1e-6 || fc_hz <= 0.0 {
return pcm.to_vec();
}
let a = 10.0_f32.powf(gain_db / 40.0);
let w0 = 2.0 * core::f32::consts::PI * fc_hz / sample_rate as f32;
let alpha = w0.sin() / 2.0 * core::f32::consts::SQRT_2;
let cos_w0 = w0.cos();
let two_sqrt_a_alpha = 2.0 * a.sqrt() * alpha;
let b0 = a * ((a + 1.0) + (a - 1.0) * cos_w0 + two_sqrt_a_alpha);
let b1 = -2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0);
let b2 = a * ((a + 1.0) + (a - 1.0) * cos_w0 - two_sqrt_a_alpha);
let a0 = (a + 1.0) - (a - 1.0) * cos_w0 + two_sqrt_a_alpha;
let a1 = 2.0 * ((a - 1.0) - (a + 1.0) * cos_w0);
let a2 = (a + 1.0) - (a - 1.0) * cos_w0 - two_sqrt_a_alpha;
let inv_a0 = 1.0 / a0;
let (b0, b1, b2, a1, a2) = (
b0 * inv_a0,
b1 * inv_a0,
b2 * inv_a0,
a1 * inv_a0,
a2 * inv_a0,
);
let mut x1 = 0.0f32;
let mut x2 = 0.0f32;
let mut y1 = 0.0f32;
let mut y2 = 0.0f32;
pcm.iter()
.map(|&x| {
let y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
x2 = x1;
x1 = x;
y2 = y1;
y1 = y;
y
})
.collect()
}
fn condition_enrollment_clip(pcm: &[f32], clip_index: usize) -> Vec<f32> {
let group = GuidedPromptGroup::for_clip_index(clip_index);
let conditioned = match group {
GuidedPromptGroup::Normal => pcm.to_vec(),
GuidedPromptGroup::Distance => {
let attenuated = crate::util::apply_gain(pcm, -6.0);
one_pole_lowpass(&attenuated, 3200.0, TARGET_SAMPLE_RATE)
}
GuidedPromptGroup::Angle => {
let tilted = high_shelf_cut(pcm, -4.0, 3000.0, TARGET_SAMPLE_RATE);
crate::util::apply_gain(&tilted, -3.0)
}
GuidedPromptGroup::Morning => {
let slower = crate::util::speed_perturbation(pcm, TARGET_SAMPLE_RATE, 0.92);
let reduced = crate::util::apply_gain(&slower, -3.0);
one_pole_lowpass(&reduced, 2200.0, TARGET_SAMPLE_RATE)
}
};
crate::util::add_noise(&conditioned, group.noise_snr_db(), 4000 + clip_index as u64)
}
fn generate_enrollment_variants_cached(
enrolled_style: &str,
model_hash: &str,
cache_dir: &std::path::Path,
) -> Vec<(Vec<f32>, String)> {
let mut variants = Vec::with_capacity(NUM_ENROLLMENT_VARIANTS);
for i in 0..NUM_ENROLLMENT_VARIANTS {
let seed = 100 + i as u64;
if let Some(pcm) = synthesize_wake_word_variant_cached(
BENCH_WAKE_PHRASE,
enrolled_style,
seed,
TARGET_SAMPLE_RATE,
model_hash,
cache_dir,
) {
variants.push((
condition_enrollment_clip(&pcm, i),
format!("{enrolled_style}_enroll{i}"),
));
}
}
variants
}
const HELD_OUT_WAKE_ONLY_CLIPS: usize = 40;
fn generate_held_out_recall_clips_cached(
enrolled_style: &str,
model_hash: &str,
cache_dir: &std::path::Path,
) -> Vec<(Vec<f32>, String)> {
let mut clips = Vec::new();
for i in 0..HELD_OUT_WAKE_ONLY_CLIPS {
let seed = 3000 + i as u64;
if let Some(pcm) = synthesize_wake_word_variant_cached(
BENCH_WAKE_PHRASE,
enrolled_style,
seed,
TARGET_SAMPLE_RATE,
model_hash,
cache_dir,
) {
clips.push((pcm, format!("{enrolled_style}_heldout_wake_s{seed}")));
}
}
clips
}
#[derive(Clone, Copy)]
struct SeedConfig {
base_seed: u64,
num_variants: usize,
seed_variant: usize,
}
fn generate_phrase_variants_cached(
phrases: &[&str],
available_styles: &[String],
seed: SeedConfig,
prefix: &str,
model_hash: &str,
cache_dir: &std::path::Path,
) -> Vec<(Vec<f32>, String)> {
let mut variants = Vec::new();
let num_styles = available_styles.len().max(1);
for (i, &phrase) in phrases.iter().enumerate() {
let style_idx = (i * seed.num_variants + seed.seed_variant) % num_styles;
let style = if available_styles.is_empty() {
DEFAULT_TTS_STYLE
} else {
&available_styles[style_idx]
};
let seed_val =
seed.base_seed + i as u64 * seed.num_variants as u64 + seed.seed_variant as u64;
if let Some(pcm) = synthesize_wake_word_variant_cached(
phrase,
style,
seed_val,
TARGET_SAMPLE_RATE,
model_hash,
cache_dir,
) {
variants.push((pcm, format!("{prefix}_{phrase}_s{i}")));
}
}
variants
}
fn generate_white_uniform_noise() -> Vec<f32> {
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
(0..NOISE_LEN)
.map(|_| rng.random::<f32>() * 2.0 - 1.0)
.collect()
}
fn generate_white_gaussian_noise() -> Vec<f32> {
let mut rng = rand::rngs::StdRng::seed_from_u64(43);
let mut samples = Vec::with_capacity(NOISE_LEN);
let mut i = 0;
while i < NOISE_LEN {
let (z1, z2) = crate::util::sample_gaussian_pair_clamped(&mut rng);
samples.push((z1 * 0.333).clamp(-1.0, 1.0));
if i + 1 < NOISE_LEN {
samples.push((z2 * 0.333).clamp(-1.0, 1.0));
}
i += 2;
}
samples
}
fn generate_pink_noise() -> Vec<f32> {
crate::util::generate_pink_noise(NOISE_LEN, rand::rngs::StdRng::seed_from_u64(44))
}
fn generate_brown_noise() -> Vec<f32> {
let mut rng = rand::rngs::StdRng::seed_from_u64(45);
let mut samples = Vec::with_capacity(NOISE_LEN);
let mut prev = 0.0;
for _ in 0..NOISE_LEN {
let white: f32 = rng.random::<f32>() * 2.0 - 1.0;
prev = (prev + white * 0.125) * 0.98;
samples.push(prev.clamp(-1.0, 1.0));
}
samples
}
fn generate_mixed_speech_noise() -> Vec<f32> {
let mut rng = rand::rngs::StdRng::seed_from_u64(46);
let mut samples = Vec::with_capacity(NOISE_LEN);
for i in 0..NOISE_LEN {
let t = i as f32 / TARGET_SAMPLE_RATE as f32;
let tone = (2.0 * core::f32::consts::PI * 200.0 * t).sin() * 0.15;
let noise: f32 = rng.random::<f32>() * 2.0 - 1.0;
samples.push((tone + noise * 0.85).clamp(-1.0, 1.0));
}
samples
}
fn generate_blue_noise() -> Vec<f32> {
let mut rng = rand::rngs::StdRng::seed_from_u64(47);
let mut prev: f32 = 0.0;
(0..NOISE_LEN)
.map(|_| {
let white = rng.random::<f32>() * 2.0 - 1.0;
let blue = (white - prev) * 0.5;
prev = white;
blue.clamp(-1.0, 1.0)
})
.collect()
}
fn generate_violet_noise() -> Vec<f32> {
let mut rng = rand::rngs::StdRng::seed_from_u64(48);
let mut prev1 = 0.0f32;
let mut prev2 = 0.0f32;
(0..NOISE_LEN)
.map(|_| {
let white = rng.random::<f32>() * 2.0 - 1.0;
let violet = (white - 2.0 * prev1 + prev2) * 0.25;
prev2 = prev1;
prev1 = white;
violet.clamp(-1.0, 1.0)
})
.collect()
}
fn generate_low_freq_rumble() -> Vec<f32> {
let mut rng = rand::rngs::StdRng::seed_from_u64(49);
let mut low: f32 = 0.0;
let alpha = 0.95; (0..NOISE_LEN)
.map(|_| {
let white = rng.random::<f32>() * 2.0 - 1.0;
low = low * alpha + white * (1.0 - alpha);
low.clamp(-1.0, 1.0)
})
.collect()
}
fn generate_modulated_noise() -> Vec<f32> {
let mut rng = rand::rngs::StdRng::seed_from_u64(50);
let mut samples = Vec::with_capacity(NOISE_LEN);
for i in 0..NOISE_LEN {
let t = i as f32 / TARGET_SAMPLE_RATE as f32;
let modulator = (2.0 * core::f32::consts::PI * 4.0 * t).sin() * 0.5 + 0.5; let noise: f32 = rng.random::<f32>() * 2.0 - 1.0;
samples.push((noise * modulator).clamp(-1.0, 1.0));
}
samples
}
fn generate_high_freq_hiss() -> Vec<f32> {
let mut rng = rand::rngs::StdRng::seed_from_u64(51);
let mut prev: f32 = 0.0;
(0..NOISE_LEN)
.map(|_| {
let white = rng.random::<f32>() * 2.0 - 1.0;
let hiss = 0.9 * (white - prev) + 0.8 * prev;
prev = white;
hiss.clamp(-1.0, 1.0)
})
.collect()
}
fn generate_impulse_burst_noise() -> Vec<f32> {
let mut rng = rand::rngs::StdRng::seed_from_u64(52);
(0..NOISE_LEN)
.map(|_| {
if rng.random::<f32>() < 0.02 {
(rng.random::<f32>() * 2.0 - 1.0).clamp(-1.0, 1.0)
} else {
0.0
}
})
.collect()
}
fn generate_oscillating_tone_noise() -> Vec<f32> {
let mut rng = rand::rngs::StdRng::seed_from_u64(53);
(0..NOISE_LEN)
.map(|i| {
let t = i as f32 / TARGET_SAMPLE_RATE as f32;
let freq = 200.0 + 300.0 * (2.0 * core::f32::consts::PI * 0.5 * t).sin();
let tone = (2.0 * core::f32::consts::PI * freq * t).sin() * 0.3;
let noise: f32 = rng.random::<f32>() * 2.0 - 1.0;
(tone + noise * 0.5).clamp(-1.0, 1.0)
})
.collect()
}
fn generate_crackle_static_noise() -> Vec<f32> {
let mut rng = rand::rngs::StdRng::seed_from_u64(54);
let mut prev: f32 = 0.0;
(0..NOISE_LEN)
.map(|_| {
let white = rng.random::<f32>() * 2.0 - 1.0;
let crackle = if rng.random::<f32>() < 0.005 {
white * 3.0
} else {
white * 0.1
};
let hi = 0.9 * (crackle - prev) + 0.8 * prev;
prev = crackle;
hi.clamp(-1.0, 1.0)
})
.collect()
}
fn generate_pulsed_broadband_noise() -> Vec<f32> {
let mut rng = rand::rngs::StdRng::seed_from_u64(55);
(0..NOISE_LEN)
.map(|i| {
let t = i as f32 / TARGET_SAMPLE_RATE as f32;
let gate = if (t * 2.0).sin() > 0.0 { 1.0 } else { 0.0 };
let noise: f32 = rng.random::<f32>() * 2.0 - 1.0;
(noise * gate).clamp(-1.0, 1.0)
})
.collect()
}
fn generate_warmup_noise() -> Cow<'static, [f32]> {
if let Some(cached) = WARMUP_TTS_CACHE.get() {
return Cow::Borrowed(cached);
}
if let Some(pcm) = try_warmup_tts() {
let cached = WARMUP_TTS_CACHE.get_or_init(|| pcm);
return Cow::Borrowed(cached);
}
warn!(
"TTS warm-up synthesis failed — falling back to pink noise + tone. \
This may not trigger Earshot VAD, producing 0 warm-up embeddings. \
Ensure TTS models are cached (~/.mahbot/models/tts/)."
);
Cow::Owned(generate_warmup_noise_fallback())
}
fn try_warmup_tts() -> Option<Vec<f32>> {
let pcm = match crate::audio::tts::synthesize(
WARMUP_TTS_PHRASE,
DEFAULT_TTS_STYLE,
947, TARGET_SAMPLE_RATE,
) {
Ok(p) => p,
Err(e) => {
warn!("TTS warm-up synthesis failed: {e}");
return None;
}
};
if pcm.len() > WARMUP_PREPEND_SAMPLES {
warn!(
"Warm-up TTS output ({} samples = {:.2}s) exceeds \
WARMUP_PREPEND_SAMPLES ({}) — truncating to {}",
pcm.len(),
pcm.len() as f64 / f64::from(TARGET_SAMPLE_RATE),
WARMUP_PREPEND_SAMPLES,
WARMUP_PREPEND_SAMPLES,
);
Some(pcm[..WARMUP_PREPEND_SAMPLES].to_vec())
} else {
info!(
"Warm-up audio: TTS phrase '{}' ({:.2}s = {} samples) — VAD will trigger",
WARMUP_TTS_PHRASE,
pcm.len() as f64 / f64::from(TARGET_SAMPLE_RATE),
pcm.len(),
);
Some(pcm)
}
}
fn generate_warmup_noise_fallback() -> Vec<f32> {
let mut rng = rand::rngs::StdRng::seed_from_u64(922);
let pink = crate::util::generate_pink_noise(WARMUP_PREPEND_SAMPLES, &mut rng);
let pink_gain = 0.20;
let mut samples = Vec::with_capacity(WARMUP_PREPEND_SAMPLES);
for (i, &p) in pink.iter().enumerate() {
let t = i as f32 / TARGET_SAMPLE_RATE as f32;
let tone = (2.0 * core::f32::consts::PI * 200.0 * t).sin() * 0.10;
samples.push(p * pink_gain + tone);
}
samples
}
fn process_frame(samples: &[f32], ctx: &mut super::PipelineCtx) {
super::handle_wake_word_detection(samples, ctx);
}
fn feed_audio(samples: &[f32], ctx: &mut super::PipelineCtx) {
for chunk in samples.chunks(super::FRAME_LENGTH) {
process_frame(chunk, ctx);
}
for _ in 0..3 {
process_frame(&vec![0.0; super::FRAME_LENGTH], ctx);
}
}
fn consume_warmup(ctx: &mut super::PipelineCtx) {
let before_detection = ctx.last_wake_word_detection;
let noise = generate_warmup_noise();
feed_audio(&noise, ctx);
if ctx.last_wake_word_detection != before_detection {
warn!(
"Warm-up triggered a false detection — restoring detection \
state to prevent cooldown corruption.",
);
ctx.last_wake_word_detection = before_detection;
ctx.is_recording = false;
}
ctx.score_window.clear();
ctx.segment_silence_hops = 0;
ctx.last_score_sample_count = 0;
ctx.audio_buffer.clear();
ctx.speech_window.clear();
ctx.vad_cursor = 0;
ctx.instrumentation = super::DetectionInstrumentation::new();
}
fn ensure_voice_models_loaded() -> Result<(), String> {
if super::models_ready() {
return Ok(());
}
let loaded = if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.block_on(crate::audio::local_transcriber::try_init_from_cache())
} else {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("cannot build transcriber-init runtime: {e}"))?;
rt.block_on(crate::audio::local_transcriber::try_init_from_cache())
};
if loaded {
info!("Shared Qwen3-ASR model loaded for wake-word benchmark");
return Ok(());
}
if !crate::audio::local_transcriber::is_failed() {
for _ in 0..75 {
if crate::audio::local_transcriber::is_loaded() {
info!("Shared Qwen3-ASR model loaded for wake-word benchmark (concurrent init)");
return Ok(());
}
if crate::audio::local_transcriber::is_failed() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
Err(
"Failed to load the shared Qwen3-ASR model (missing/corrupt cache or \
download failure). Run the application first to download models."
.to_string(),
)
}
fn vad_gate_speech(pcm: &[f32], detector: &mut Detector) -> Vec<f32> {
let mut speech: Vec<f32> = Vec::new();
for chunk in pcm.chunks(256) {
if super::is_speech_with_detector(chunk, detector, super::VAD_THRESHOLD) {
speech.extend_from_slice(chunk);
}
}
speech
}
fn generate_owner_negative_sequences(
enrolled_style: &str,
model_hash: &str,
cache_dir: &std::path::Path,
) -> Vec<Vec<f32>> {
let Some(model) = crate::audio::local_transcriber::shared_model_arc() else {
warn!("Owner negatives: shared Qwen3-ASR model not loaded");
return Vec::new();
};
let mut embeddings = Vec::new();
for (i, &phrase) in OWNER_NEGATIVE_PHRASES.iter().enumerate() {
for seed in 0..3 {
let seed_val = 9000 + i as u64 * 3 + seed as u64;
if let Some(pcm) = super::synthesize_with_pcm_cache(
phrase,
enrolled_style,
seed_val,
TARGET_SAMPLE_RATE,
model_hash,
cache_dir,
) {
let mut detector = Detector::default();
let speech_audio = vad_gate_speech(&pcm, &mut detector);
if speech_audio.is_empty() {
warn!(
"Owner-negative '{phrase}' seed {seed}: no VAD-positive \
speech — skipping"
);
continue;
}
match crate::audio::wake_word::encode_window(&model, &speech_audio) {
Ok(emb) => embeddings.push(emb),
Err(e) => warn!("Owner-negative '{phrase}' seed {seed}: encode failed: {e}"),
}
let noisy = crate::util::add_noise_color(
&speech_audio,
10.0,
crate::util::NoiseColor::Brown,
seed_val,
);
match crate::audio::wake_word::encode_window(&model, &noisy) {
Ok(emb) => embeddings.push(emb),
Err(e) => {
warn!("Owner-negative '{phrase}' seed {seed}: brown-10 encode failed: {e}");
}
}
}
}
}
embeddings
}
fn generate_ambient_noise_sequences() -> Vec<Vec<f32>> {
let Some(model) = crate::audio::local_transcriber::shared_model_arc() else {
warn!("Ambient negatives: shared Qwen3-ASR model not loaded");
return Vec::new();
};
let mut embeddings = Vec::new();
for (label, noise_fn) in NOISE_PROFILES {
let raw = noise_fn();
match crate::audio::wake_word::encode_window(&model, &raw) {
Ok(emb) => embeddings.push(emb),
Err(e) => warn!("Ambient '{label}' level-0: encode failed: {e}"),
}
let attenuated = crate::util::apply_gain(&raw, -6.0);
match crate::audio::wake_word::encode_window(&model, &attenuated) {
Ok(emb) => embeddings.push(emb),
Err(e) => warn!("Ambient '{label}' level-1: encode failed: {e}"),
}
}
embeddings
}
fn generate_restricted_phrase_negatives(
phrase_type: &'static str,
phrases: &'static [&'static str],
seeds_per_phrase: usize,
seed_base: u64,
styles: &[String],
model_hash: &str,
cache_dir: &std::path::Path,
) -> Vec<Vec<f32>> {
let Some(model) = crate::audio::local_transcriber::shared_model_arc() else {
warn!("{phrase_type} negatives: shared Qwen3-ASR model not loaded");
return Vec::new();
};
if styles.is_empty() {
return Vec::new();
}
let num_styles = styles.len();
let mut embeddings: Vec<Vec<f32>> = Vec::new();
for (i, &phrase) in phrases.iter().enumerate() {
for seed_idx in 0..seeds_per_phrase {
let style_idx = (i * seeds_per_phrase + seed_idx) % num_styles;
let style = &styles[style_idx];
let seed = seed_base + i as u64 * seeds_per_phrase as u64 + seed_idx as u64;
let Some(pcm) = super::synthesize_with_pcm_cache(
phrase,
style,
seed,
TARGET_SAMPLE_RATE,
model_hash,
cache_dir,
) else {
continue;
};
let mut detector = Detector::default();
let speech_audio = vad_gate_speech(&pcm, &mut detector);
if speech_audio.is_empty() {
warn!(
"{phrase_type} phrase '{phrase}' (seed {seed}) produced no \
VAD-positive speech — skipping (matches streaming: no \
speech ⇒ no embeddings)"
);
continue;
}
match crate::audio::wake_word::encode_window(&model, &speech_audio) {
Ok(emb) => embeddings.push(emb),
Err(e) => {
warn!("{phrase_type} phrase '{phrase}' (seed {seed}): encode failed: {e}");
}
}
}
}
embeddings
}
fn compute_vad_segments(audio: &[f32]) -> (Vec<bool>, Vec<Vec<f32>>) {
let n_frames = audio.len().saturating_sub(super::FRAME_LENGTH) / super::HOP_LENGTH + 1;
let mut detector = Detector::default();
let mut vad_decisions: Vec<bool> = Vec::with_capacity(n_frames);
for i in 0..n_frames {
let start = i * super::HOP_LENGTH;
let end = (start + super::FRAME_LENGTH).min(audio.len());
let frame = &audio[start..end];
vad_decisions.push(super::is_speech_with_detector(
&frame[..super::HOP_LENGTH],
&mut detector,
super::VAD_THRESHOLD,
));
}
let utterances = super::segment_utterances_by_vad(
audio,
&vad_decisions,
&super::DEFAULT_VAD_SEGMENTATION_CONFIG,
);
(vad_decisions, utterances)
}
fn vad_segment_and_enroll(enrollment_variants: &[(Vec<f32>, String)]) -> Vec<Vec<f32>> {
const SILENCE_GAP_SAMPLES: usize = 2 * 16_000;
let Some(model) = crate::audio::local_transcriber::shared_model_arc() else {
panic!("FATAL: shared Qwen3-ASR model not loaded — cannot run VAD-gated enrollment");
};
let silence: Vec<f32> = vec![0.0f32; SILENCE_GAP_SAMPLES];
let mut combined: Vec<f32> = Vec::new();
for (pcm, _label) in enrollment_variants {
if !combined.is_empty() {
combined.extend_from_slice(&silence);
}
combined.extend_from_slice(pcm);
}
combined.extend_from_slice(&silence);
let (_vad_decisions, utterances) = compute_vad_segments(&combined);
info!(
"VAD concatenation: {} total samples ({:.1}s) from {} originals with 2.0s gaps",
combined.len(),
combined.len() as f64 / f64::from(super::SAMPLE_RATE),
enrollment_variants.len(),
);
info!(
"VAD segmentation: {} utterances from {} concatenated originals",
utterances.len(),
enrollment_variants.len(),
);
let mut utterance_embeddings: Vec<Vec<f32>> = Vec::new();
for (i, utterance) in utterances.iter().enumerate() {
match crate::audio::wake_word::encode_window(&model, utterance) {
Ok(emb) => utterance_embeddings.push(emb),
Err(e) => warn!("Enrollment utterance {i}: encode failed: {e}"),
}
}
info!(
"VAD-gated enrollment: {} utterance embeddings from {} VAD utterances across {} clips \
(encoder pipeline — one embedding per utterance, no augmentation)",
utterance_embeddings.len(),
utterances.len(),
enrollment_variants.len(),
);
utterance_embeddings
}
struct DetectionResult {
detected: bool,
adaptive_state_pre_flush: super::AdaptiveThresholdState,
}
fn run_streaming_detection(samples: &[f32], ctx: &mut super::PipelineCtx) -> DetectionResult {
let before = ctx.last_wake_word_detection;
for chunk in samples.chunks(super::FRAME_LENGTH) {
process_frame(chunk, ctx);
if ctx.last_wake_word_detection != before {
return DetectionResult {
detected: true,
adaptive_state_pre_flush: ctx.adaptive_threshold.clone(),
};
}
}
let adaptive_state_pre_flush = ctx.adaptive_threshold.clone();
let mut silence_chunks = 0usize;
while silence_chunks < super::SEGMENT_TIMEOUT_HOPS + 4 {
if ctx.last_wake_word_detection != before {
return DetectionResult {
detected: true,
adaptive_state_pre_flush,
};
}
let window_was_nonempty = !ctx.score_window.is_empty();
process_frame(&vec![0.0; super::FRAME_LENGTH], ctx);
silence_chunks += 1;
if window_was_nonempty && ctx.score_window.is_empty() {
break;
}
}
DetectionResult {
detected: ctx.last_wake_word_detection != before,
adaptive_state_pre_flush,
}
}
#[derive(Debug, Default)]
struct DetectionMetrics {
false_accepts: Vec<String>,
}
fn test_detection_samples(
variants: &[(Vec<f32>, String)],
metrics: &mut DetectionMetrics,
on_detection: impl Fn(&mut DetectionMetrics, &str),
mut adaptive_state: Option<&mut super::AdaptiveThresholdState>,
cold_start: bool,
) {
for (i, (samples, label)) in variants.iter().enumerate() {
info!(
" Variant {}/{}: {label} — processing ({})",
i + 1,
variants.len(),
if cold_start { "cold start" } else { "warm" }
);
let mut ctx = super::PipelineCtx::new();
if !cold_start && let Some(ref mut state) = adaptive_state {
ctx.adaptive_threshold = state.clone();
}
if !cold_start {
consume_warmup(&mut ctx);
}
let result = run_streaming_detection(samples, &mut ctx);
if !cold_start && let Some(ref mut state) = adaptive_state {
let boundary_fired = !result.detected && ctx.score_window.is_empty();
if boundary_fired {
**state = result.adaptive_state_pre_flush.clone();
} else {
**state = ctx.adaptive_threshold.clone();
}
}
let peak = ctx.instrumentation.peak_score;
if result.detected {
on_detection(metrics, label);
}
info!(
" Variant {}/{}: {label} — {} (peak_score={:.4})",
i + 1,
variants.len(),
if result.detected {
"DETECTED"
} else {
"passed"
},
peak,
);
}
}
struct NegativeCorpus {
confusable: Vec<(Vec<f32>, String)>,
unrelated: Vec<(Vec<f32>, String)>,
silence: Vec<(Vec<f32>, String)>,
noise: Vec<(Vec<f32>, String)>,
}
fn build_negative_corpus(
available_styles: &[String],
model_version_hash: &str,
cache_dir_path: &std::path::Path,
) -> NegativeCorpus {
let conf_seed = |band: u64, prefix: &str| {
generate_phrase_variants_cached(
CONFUSABLE_PHRASES,
available_styles,
SeedConfig {
base_seed: band,
num_variants: 1, seed_variant: 0,
},
prefix,
model_version_hash,
cache_dir_path,
)
};
let mut confusable = conf_seed(800, "confusable");
confusable.extend(conf_seed(810, "confusable2"));
let unrel_seed = |band: u64, prefix: &str| {
generate_phrase_variants_cached(
UNRELATED_PHRASES,
available_styles,
SeedConfig {
base_seed: band,
num_variants: 1,
seed_variant: 0,
},
prefix,
model_version_hash,
cache_dir_path,
)
};
let mut unrelated = unrel_seed(900, "unrelated");
unrelated.extend(unrel_seed(910, "unrelated2"));
let silence: Vec<(Vec<f32>, String)> = SILENCE_DURATIONS
.iter()
.map(|(label, len)| (vec![0.0f32; *len], label.to_string()))
.collect();
let noise: Vec<(Vec<f32>, String)> = all_noise_profiles()
.map(|(label, generator)| (generator(), (*label).to_string()))
.collect();
NegativeCorpus {
confusable,
unrelated,
silence,
noise,
}
}
fn run_enrolled_cold_variant(pcm: &[f32]) -> bool {
let mut ctx = super::PipelineCtx::new();
let result = run_streaming_detection(pcm, &mut ctx);
result.detected
}
fn faph_sha256_file(path: &std::path::Path) -> std::io::Result<String> {
use sha2::{Digest, Sha256};
let data = std::fs::read(path)?;
Ok(crate::util::hex_string(&Sha256::digest(&data)))
}
fn faph_download_file(
url: &str,
dest: &std::path::Path,
expected_sha256: &str,
) -> Result<(), String> {
use sha2::{Digest, Sha256};
let fut = async {
crate::util::http::install_ring_provider();
let client = reqwest::Client::new();
let resp = client
.get(url)
.send()
.await
.map_err(|e| format!("HTTP request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("HTTP status {}", resp.status()));
}
let bytes = resp
.bytes()
.await
.map_err(|e| format!("body read failed: {e}"))?;
Ok::<Vec<u8>, String>(bytes.to_vec())
};
let bytes = if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.block_on(fut)
} else {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("cannot build download runtime: {e}"))?;
rt.block_on(fut)
}?;
let sha = crate::util::hex_string(&Sha256::digest(&bytes));
if sha != expected_sha256 {
return Err(format!(
"SHA-256 mismatch: expected {expected_sha256}, got {sha}"
));
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
}
std::fs::write(dest, &bytes).map_err(|e| format!("write: {e}"))
}
fn faph_feed_file_continuous(
samples: &[f32],
ctx: &mut super::PipelineCtx,
audio_pos: &mut f64,
) -> Vec<f64> {
let mut raw_events: Vec<f64> = Vec::new();
for chunk in samples.chunks(super::FRAME_LENGTH) {
process_frame(chunk, ctx);
if ctx.last_wake_word_detection.is_some() {
raw_events.push(*audio_pos);
ctx.reset_pipeline_state(super::ResetLevel::Soft);
ctx.is_recording = false;
ctx.last_wake_word_detection = None;
}
*audio_pos += chunk.len() as f64 / f64::from(super::SAMPLE_RATE);
}
raw_events
}
fn faph_merge_events(events: &[f64], cooldown_secs: f64) -> usize {
let mut merged = 0usize;
let mut last_kept: Option<f64> = None;
for &pos in events {
match last_kept {
Some(prev) if pos - prev < cooldown_secs => {}
_ => {
merged += 1;
last_kept = Some(pos);
}
}
}
merged
}
fn faph_clear_instrumentation(ctx: &mut super::PipelineCtx) {
ctx.instrumentation.vad_speech_frames = 0;
}
const BENCH_REAL_AUDIO_SUBSET: &[&str] = &[
"speech/librivox/speech-librivox-0027.wav",
"speech/librivox/speech-librivox-0051.wav",
"speech/librivox/speech-librivox-0093.wav",
"speech/librivox/speech-librivox-0075.wav",
"speech/librivox/speech-librivox-0103.wav",
"speech/librivox/speech-librivox-0088.wav",
"speech/us-gov/speech-us-gov-0252.wav",
"speech/us-gov/speech-us-gov-0147.wav",
"speech/us-gov/speech-us-gov-0082.wav",
"speech/us-gov/speech-us-gov-0159.wav",
"speech/us-gov/speech-us-gov-0067.wav",
"speech/us-gov/speech-us-gov-0250.wav",
"speech/us-gov/speech-us-gov-0210.wav",
"speech/us-gov/speech-us-gov-0229.wav",
"speech/us-gov/speech-us-gov-0201.wav",
"speech/us-gov/speech-us-gov-0130.wav",
"speech/us-gov/speech-us-gov-0005.wav",
"speech/us-gov/speech-us-gov-0109.wav",
"noise/free-sound/noise-free-sound-0640.wav",
"noise/free-sound/noise-free-sound-0184.wav",
"noise/free-sound/noise-free-sound-0761.wav",
"noise/free-sound/noise-free-sound-0012.wav",
"noise/free-sound/noise-free-sound-0605.wav",
"noise/free-sound/noise-free-sound-0298.wav",
"noise/free-sound/noise-free-sound-0251.wav",
"noise/free-sound/noise-free-sound-0733.wav",
"noise/free-sound/noise-free-sound-0035.wav",
"noise/free-sound/noise-free-sound-0709.wav",
"noise/sound-bible/noise-sound-bible-0075.wav",
"noise/sound-bible/noise-sound-bible-0044.wav",
"noise/sound-bible/noise-sound-bible-0067.wav",
"noise/sound-bible/noise-sound-bible-0014.wav",
"noise/sound-bible/noise-sound-bible-0032.wav",
];
const BENCH_WORKERS: usize = 8;
const BENCH_ASSIGNMENT: &str = "round_robin";
const BENCH_FILE_GAP_SAMPLES: usize = 32_000;
#[derive(Default)]
struct WorkerTotals {
files_fed: u64,
audio_secs: f64,
speech_audio_secs: f64,
noise_audio_secs: f64,
vad_active_secs: f64,
raw_events: Vec<f64>,
merged_events: usize,
}
impl WorkerTotals {
fn merge(&mut self, other: WorkerTotals) {
self.files_fed += other.files_fed;
self.audio_secs += other.audio_secs;
self.speech_audio_secs += other.speech_audio_secs;
self.noise_audio_secs += other.noise_audio_secs;
self.vad_active_secs += other.vad_active_secs;
self.raw_events.extend(other.raw_events);
self.merged_events += other.merged_events;
}
}
fn worker_feed(files: &[(String, String, u64)], cache_root: &std::path::Path) -> WorkerTotals {
const GAP_SAMPLES: usize = BENCH_FILE_GAP_SAMPLES;
let gap: Vec<f32> = vec![0.0; GAP_SAMPLES];
let mut ctx = super::PipelineCtx::new();
ctx.injected_vad = Some(earshot::Detector::default());
let mut audio_pos = 0.0f64;
let mut totals = WorkerTotals::default();
for (path, _sha256, _size) in files {
let p = cache_root.join(path);
let samples = match crate::audio::local_transcriber::decode_audio_to_mono_f32(&p) {
Ok(s) => s,
Err(e) => {
warn!("wake-word bench: decode failed for {path}: {e}");
continue;
}
};
let audio_secs = samples.len() as f64 / f64::from(super::SAMPLE_RATE);
totals.audio_secs += audio_secs;
if path.starts_with("speech/") {
totals.speech_audio_secs += audio_secs;
} else {
totals.noise_audio_secs += audio_secs;
}
totals.raw_events.extend(faph_feed_file_continuous(
&samples,
&mut ctx,
&mut audio_pos,
));
totals.files_fed += 1;
totals
.raw_events
.extend(faph_feed_file_continuous(&gap, &mut ctx, &mut audio_pos));
totals.vad_active_secs += ctx.instrumentation.vad_speech_frames as f64
* super::HOP_LENGTH as f64
/ f64::from(super::SAMPLE_RATE);
faph_clear_instrumentation(&mut ctx);
}
totals.merged_events =
faph_merge_events(&totals.raw_events, super::WAKE_WORD_COOLDOWN.as_secs_f64());
totals
}
fn skip_json(reason_key: &str, detail: &str) -> serde_json::Value {
warn!("Wake-word bench real-audio phase skipped: {reason_key} — {detail}");
eprintln!(" Wake-word bench real-audio phase skipped: {reason_key} — {detail}");
serde_json::json!({
"status": "skipped",
"metric": "FA rate per hour on real audio — NOT MEASURED (degraded skip)",
"skip_reason": reason_key,
"skip_detail": detail,
})
}
#[expect(clippy::too_many_lines)]
fn run_real_audio_phase() -> serde_json::Value {
let phase_start = Instant::now();
let manifest: serde_json::Value =
match serde_json::from_str(include_str!("faph_corpus_manifest.json")) {
Ok(m) => m,
Err(e) => {
return skip_json(
"manifest_parse_failed",
&format!("embedded manifest failed to parse: {e}"),
);
}
};
let manifest_files: Vec<(String, String, u64)> = match manifest["files"].as_array() {
Some(arr) => arr
.iter()
.filter_map(|f| {
Some((
f[0].as_str()?.to_string(),
f[1].as_str()?.to_string(),
f[2].as_u64()?,
))
})
.collect(),
None => Vec::new(),
};
let repo = manifest["repo"].as_str().unwrap_or("alexwengg/musan_mini");
let revision = manifest["revision"].as_str().unwrap_or("");
if manifest_files.is_empty() {
return skip_json("manifest_empty", "manifest file list is empty");
}
let mut subset: Vec<(String, String, u64)> = Vec::with_capacity(BENCH_REAL_AUDIO_SUBSET.len());
for &path in BENCH_REAL_AUDIO_SUBSET {
match manifest_files.iter().find(|(p, _, _)| p == path) {
Some((p, sha, size)) => subset.push((p.clone(), sha.clone(), *size)),
None => {
return skip_json(
"subset_path_not_in_manifest",
&format!("pinned subset path missing from manifest: {path}"),
);
}
}
}
let cache_root = match crate::config::default_config_dir() {
Ok(d) => d.join("faph_corpus"),
Err(e) => {
return skip_json(
"cache_root_unavailable",
&format!("cannot resolve ~/.mahbot for corpus cache: {e}"),
);
}
};
let mut download_errors: Vec<String> = Vec::new();
for (path, sha256, size) in &subset {
let dest = cache_root.join(path);
if dest.exists() && std::fs::metadata(&dest).is_ok_and(|m| m.len() == *size) {
match faph_sha256_file(&dest) {
Ok(h) if h == *sha256 => continue,
Ok(_) => {
download_errors.push(format!("{path}: hash mismatch on cached file"));
continue;
}
Err(e) => {
download_errors.push(format!("{path}: read error {e}"));
continue;
}
}
}
let url = format!("https://huggingface.co/datasets/{repo}/resolve/{revision}/{path}");
match faph_download_file(&url, &dest, sha256) {
Ok(()) => {
info!("wake-word bench corpus: downloaded {path} ({size} bytes)");
}
Err(e) => {
download_errors.push(format!("{path}: {e}"));
}
}
}
if !download_errors.is_empty() {
let reason = format!(
"corpus subset incomplete — {} file(s) failed download/verify (first: {})",
download_errors.len(),
download_errors[0],
);
return skip_json("corpus_download_failed", &reason);
}
let worker_files: Vec<Vec<(String, String, u64)>> = (0..BENCH_WORKERS)
.map(|w| {
subset
.iter()
.skip(w)
.step_by(BENCH_WORKERS)
.cloned()
.collect()
})
.collect();
let mut handles = Vec::with_capacity(BENCH_WORKERS);
for wf in worker_files {
let cr = cache_root.clone();
handles.push(std::thread::spawn(move || worker_feed(&wf, &cr)));
}
let mut totals = WorkerTotals::default();
let mut worker_panic: Option<String> = None;
for handle in handles {
match handle.join() {
Ok(wt) => totals.merge(wt),
Err(e) => {
if worker_panic.is_none() {
worker_panic = Some(format!("a real-audio worker thread panicked: {e:?}"));
}
}
}
}
if let Some(reason) = worker_panic {
return skip_json("worker_panicked", &reason);
}
let wall_secs = phase_start.elapsed().as_secs_f64();
let audio_hours = totals.audio_secs / 3600.0;
let vad_active_hours = totals.vad_active_secs / 3600.0;
let merged_events = totals.merged_events;
let raw_events = totals.raw_events.len();
let fa_per_hour_raw = if audio_hours > 0.0 {
merged_events as f64 / audio_hours
} else {
f64::NAN
};
let fa_per_hour_vad = if vad_active_hours > 0.0 {
merged_events as f64 / vad_active_hours
} else {
f64::NAN
};
info!(
"Wake-word bench real audio: {files_fed} files fed, {audio_hours:.2} h audio \
({vad_active_hours:.2} h VAD-active), {merged_events} cooldown-merged FA events \
(raw {raw_events}), {fa_per_hour_vad:.4} FA/h VAD-active, {wall_secs:.1}s wall \
({workers} parallel workers, {BENCH_ASSIGNMENT})",
files_fed = totals.files_fed,
workers = BENCH_WORKERS,
);
eprintln!(
" Wake-word bench real audio: {files_fed} files fed, {audio_hours:.2} h audio \
({vad_active_hours:.2} h VAD-active), {merged_events} cooldown-merged FA events \
(raw {raw_events}), {fa_per_hour_vad:.4} FA/h VAD-active, {wall_secs:.1}s wall \
({workers} parallel workers, {BENCH_ASSIGNMENT})",
files_fed = totals.files_fed,
workers = BENCH_WORKERS,
);
serde_json::json!({
"status": "ran",
"metric": "SPONTANEOUS-CONFUSABLE FA rate on real audio — the pinned subset \
contains ~zero wake-word utterances, so every detection is a \
spontaneous false accept",
"subset": {
"files_total": BENCH_REAL_AUDIO_SUBSET.len(),
"speech_files": BENCH_REAL_AUDIO_SUBSET
.iter()
.filter(|p| p.starts_with("speech/"))
.count(),
"noise_files": BENCH_REAL_AUDIO_SUBSET
.iter()
.filter(|p| p.starts_with("noise/"))
.count(),
"selection_heuristic": "longest librivox speech files up to a target + ALL \
us-gov speech files + a fixed noise selection (longest \
free-sound + sound-bible clips) — pinned as a bench \
constant",
},
"feed": {
"workers": BENCH_WORKERS,
"assignment": BENCH_ASSIGNMENT,
"files_fed": totals.files_fed,
"audio_hours_fed": audio_hours,
"speech_hours_fed": totals.speech_audio_secs / 3600.0,
"noise_hours_fed": totals.noise_audio_secs / 3600.0,
"vad_active_hours_fed": vad_active_hours,
"wall_secs": wall_secs,
},
"fa": {
"raw_events": raw_events,
"cooldown_merged_events": merged_events,
"fa_per_hour_raw_audio": fa_per_hour_raw,
"fa_per_hour_vad_active": fa_per_hour_vad,
"basis_note": "Denominators are PER-WORKER sums: raw audio hours and \
VAD-active hours, each accumulated per worker over its own \
continuous-listening stream, then summed across workers. \
Cooldown merge applies PER WORKER (events on different \
workers are independent streams and are never merged across \
workers); the number is a per-worker parallel-stream figure \
over the fixed pinned subset.",
},
})
}
#[expect(clippy::cast_precision_loss, clippy::too_many_lines)]
pub(crate) fn run_wake_word_benchmark() {
struct HeartbeatGuard(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for HeartbeatGuard {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
let _ = tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::builder()
.parse("info")
.expect("info env filter"),
)
.try_init();
let overall_start = Instant::now();
info!("═══ Wake-Word Benchmark (three metrics) ═══");
let heartbeat_stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let _heartbeat_guard = HeartbeatGuard(heartbeat_stop.clone());
let heartbeat_handle = {
let stop = heartbeat_stop.clone();
let start = overall_start;
std::thread::spawn(move || {
let mut counter: u64 = 0;
loop {
if counter.is_multiple_of(60) {
eprintln!(
"[heartbeat] wake_word benchmark still running — elapsed: {}m{:02}s",
start.elapsed().as_secs() / 60,
start.elapsed().as_secs() % 60,
);
}
std::thread::sleep(Duration::from_secs(1));
counter += 1;
if stop.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
}
})
};
if crate::config::CONFIG.try_storage_root().is_none() {
let mahbot_dir = crate::config::default_config_dir()
.expect("Cannot resolve home directory for ~/.mahbot");
crate::config::CONFIG.set_storage_root(mahbot_dir.clone());
info!("CONFIG storage root set to: {}", mahbot_dir.display());
}
let cache_dir_path = cache_dir();
if let Err(e) = std::fs::create_dir_all(&cache_dir_path) {
eprintln!(
"WARNING: Cannot create cache directory {}: {e}",
cache_dir_path.display()
);
}
let model_version_hash = super::tts_model_version_hash();
info!("TTS model version hash: {}", &model_version_hash[..16]);
crate::audio::tts::init_global()
.unwrap_or_else(|e| warn!("tts::init_global() already called: {e}"));
super::init_global().unwrap_or_else(|e| warn!("voice::init_global() already called: {e}"));
if let Err(msg) = crate::audio::tts::ensure_ready() {
panic!("{msg}\nRun the application first to download TTS models (~400 MB).");
}
if let Err(msg) = ensure_voice_models_loaded() {
panic!("{msg}\nRun the application first to download voice models.");
}
let available_styles = tts::list_voice_styles();
info!(
"TTS ready with {} voice styles: {:?}",
available_styles.len(),
available_styles
);
let voice_allocation = allocate_voices(&available_styles);
info!(
"Voice allocation: enrolled={} negative_pool={:?}",
voice_allocation.enrolled, voice_allocation.negative_pool,
);
let enrollment_variants = generate_enrollment_variants_cached(
&voice_allocation.enrolled,
&model_version_hash,
&cache_dir_path,
);
if enrollment_variants.is_empty() {
eprintln!(
"FATAL: Need at least one enrollment variant. TTS synthesis may have failed for all styles."
);
return;
}
let train_clips = enrollment_variants;
let utterance_embeddings = vad_segment_and_enroll(&train_clips);
if utterance_embeddings.is_empty() {
eprintln!(
"FATAL: VAD-gated enrollment produced no utterances from {} training clips",
train_clips.len(),
);
return;
}
info!(
"VAD-gated enrollment: {} utterance embeddings from {} clips",
utterance_embeddings.len(),
train_clips.len(),
);
let confusable_neg_embeddings = generate_restricted_phrase_negatives(
"confusable",
CONFUSABLE_PHRASES,
CONFUSABLE_SEEDS_PER_PHRASE,
CONFUSABLE_SEED_BASE,
&voice_allocation.negative_pool,
&model_version_hash,
&cache_dir_path,
);
let unrelated_neg_embeddings = generate_restricted_phrase_negatives(
"unrelated",
UNRELATED_PHRASES,
UNRELATED_SEEDS_PER_PHRASE,
UNRELATED_SEED_BASE,
&voice_allocation.negative_pool,
&model_version_hash,
&cache_dir_path,
);
let ambient_neg_embeddings = generate_ambient_noise_sequences();
let owner_neg_embeddings = generate_owner_negative_sequences(
&voice_allocation.enrolled,
&model_version_hash,
&cache_dir_path,
);
let mut negative_embeddings: Vec<Vec<f32>> = Vec::new();
negative_embeddings.extend(ambient_neg_embeddings);
negative_embeddings.extend(owner_neg_embeddings);
negative_embeddings.extend(confusable_neg_embeddings);
negative_embeddings.extend(unrelated_neg_embeddings);
assert!(
!negative_embeddings.is_empty(),
"Wake-word bench negative pool must be non-empty — an all-skip run would \
silently calibrate a weaker enrollment"
);
let enrollment: Option<crate::audio::wake_word::WakeWordEnrollment> =
match super::enrollment_consistency_check(&utterance_embeddings) {
Ok(proto) => {
let calibration =
crate::audio::wake_word::calibrate_negatives(&proto, &negative_embeddings);
let created_at = crate::turso::now();
let trained_at = crate::turso::now();
let phrase = super::normalize_phrase(BENCH_WAKE_PHRASE);
crate::audio::wake_word::WakeWordEnrollment::build(
phrase,
&utterance_embeddings,
calibration,
&negative_embeddings,
created_at,
trained_at,
)
}
Err(err) => {
warn!("enrollment_consistency_check FAILED: {err} — no enrollment to evaluate");
None
}
};
let Some(enrollment) = enrollment else {
eprintln!(
"FATAL: no enrollment (consistency gate failed) — cannot run the wake-word bench"
);
return;
};
info!(
"Enrollment built: phrase='{}', {} utterances, calibration neg_mean={:.4} (p99={:.4}, n={})",
enrollment.phrase,
enrollment.utterance_count,
enrollment.calibration.neg_mean,
enrollment.calibration.neg_p99,
enrollment.calibration.n_negatives,
);
super::set_enrollment(enrollment);
let held_out_recall_clips = generate_held_out_recall_clips_cached(
&voice_allocation.enrolled,
&model_version_hash,
&cache_dir_path,
);
info!(
"Recognition basis: {} held-out wake-only clips (enrolled voice, seeds 3000+)",
held_out_recall_clips.len(),
);
let mut recognized = 0usize;
for (pcm, _label) in &held_out_recall_clips {
if run_enrolled_cold_variant(pcm) {
recognized += 1;
}
}
let recognition_total = held_out_recall_clips.len();
let recognition_rate = if recognition_total > 0 {
recognized as f64 / recognition_total as f64
} else {
f64::NAN
};
info!(
"Recognition: {recognized}/{recognition_total} ({:.1}%)",
recognition_rate * 100.0,
);
eprintln!(" Recognition: {recognized}/{recognition_total} wake-word utterances recognized");
let negative_corpus =
build_negative_corpus(&available_styles, &model_version_hash, &cache_dir_path);
let non_phrase_total = negative_corpus.confusable.len()
+ negative_corpus.unrelated.len()
+ negative_corpus.silence.len()
+ negative_corpus.noise.len();
if non_phrase_total != 113 {
warn!(
"Wake-word bench non-phrase set size is {non_phrase_total}, not the pinned 113 \
(likely TTS synthesis misses on a cold cache) — reporting the actual count",
);
}
let mut fa_metrics = DetectionMetrics::default();
let mut shared_adaptive = super::AdaptiveThresholdState::warmed();
test_detection_samples(
&negative_corpus.confusable,
&mut fa_metrics,
|m, l| m.false_accepts.push(l.to_string()),
Some(&mut shared_adaptive),
false, );
test_detection_samples(
&negative_corpus.unrelated,
&mut fa_metrics,
|m, l| m.false_accepts.push(l.to_string()),
Some(&mut shared_adaptive),
false, );
test_detection_samples(
&negative_corpus.silence,
&mut fa_metrics,
|m, l| m.false_accepts.push(l.to_string()),
Some(&mut shared_adaptive),
false, );
test_detection_samples(
&negative_corpus.noise,
&mut fa_metrics,
|m, l| m.false_accepts.push(l.to_string()),
Some(&mut shared_adaptive),
false, );
let false_reactions = fa_metrics.false_accepts.len();
let non_phrase_rate = if non_phrase_total > 0 {
false_reactions as f64 / non_phrase_total as f64
} else {
f64::NAN
};
info!(
"False reactions: {false_reactions}/{non_phrase_total} ({:.1}%)",
non_phrase_rate * 100.0
);
eprintln!(" False reactions: {false_reactions}/{non_phrase_total} on the non-phrase set");
let real_audio = run_real_audio_phase();
let real_audio_audio_hours = real_audio["feed"]["audio_hours_fed"].as_f64();
let real_audio_speech_hours = real_audio["feed"]["speech_hours_fed"].as_f64();
let real_audio_noise_hours = real_audio["feed"]["noise_hours_fed"].as_f64();
let wall_clock_secs = overall_start.elapsed().as_secs_f64();
let report = serde_json::json!({
"benchmark": "wake_word",
"wake_phrase": BENCH_WAKE_PHRASE,
"recognition": {
"detected": recognized,
"of": recognition_total,
"rate": recognition_rate,
"basis": "existing 40-clip held-out wake-only basis (enrolled voice, \
seeds 3000+, fixed bench phrase)",
},
"false_reactions": {
"non_phrase_set": {
"false_reactions": false_reactions,
"of": non_phrase_total,
"rate": non_phrase_rate,
"basis": "the 113 non-phrase set (56 confusable + 40 unrelated + 3 \
silence + 14 noise profiles)",
},
"real_audio": real_audio,
},
"coverage": {
"phrase_utterances": recognition_total,
"non_phrase_set": non_phrase_total,
"real_audio_audio_hours": real_audio_audio_hours,
"real_audio_speech_hours": real_audio_speech_hours,
"real_audio_noise_hours": real_audio_noise_hours,
},
"workers": BENCH_WORKERS,
"wall_clock_secs": wall_clock_secs,
"fa_per_hour_basis_note": "FA-per-hour denominators are reported as BOTH raw \
audio hours and VAD-active hours (per-worker sums); \
cooldown merge applies per worker.",
"tts_caveat": "Recognition and the synthetic false-reaction set are measured on \
synthesized (TTS) speech, not real human speech; real audio is \
used for the false-reaction rate only.",
});
heartbeat_stop.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = heartbeat_handle.join();
println!("--- BENCHMARK_JSON_BEGIN ---");
let json_text = serde_json::to_string_pretty(&report).expect("JSON serialization");
println!("{json_text}");
println!("--- BENCHMARK_JSON_END ---");
if let Ok(report_dir) = crate::config::default_config_dir() {
let report_path = report_dir.join("wake_word_report.json");
match std::fs::write(&report_path, &json_text) {
Ok(()) => info!(
"Wake-word benchmark report written to {}",
report_path.display()
),
Err(e) => warn!(
"Could not write wake-word benchmark report to {}: {e}",
report_path.display()
),
}
}
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
eprintln!(
"\n\
═══════════════════════════════════════════════════════════\n\
Wake-Word Benchmark Report\n\
═══════════════════════════════════════════════════════════\n\
Date/Time: {timestamp}\n\
Wake phrase: {wake}\n\
1. Recognition: {recognized}/{recognition_total} of 40 phrase utterances\n\
2. False reactions: {false_reactions}/{non_phrase_total} on the 113 non-phrase set\n\
\x20 Real-audio FA/h: see real_audio section ({workers} parallel workers, {assignment})\n\
3. Coverage: {recognition_total} utterances + {non_phrase_total} non-phrases + real audio ({audio_hours:.2} h speech+noise)\n\
Wall time: {wall:.1}s ({wall_min:.1} min)\n\
FA/h basis: raw audio hours AND VAD-active hours (per-worker sums)\n\
TTS caveat: recognition + synthetic false reactions are measured on\n\
\x20 TTS-synthesized speech, not real human speech (real audio\n\
\x20 feeds the false-reaction rate only)",
wake = BENCH_WAKE_PHRASE,
workers = BENCH_WORKERS,
assignment = BENCH_ASSIGNMENT,
audio_hours = real_audio_audio_hours.unwrap_or(f64::NAN),
wall = wall_clock_secs,
wall_min = wall_clock_secs / 60.0,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn guided_prompt_dsp_deterministic_and_attenuates_hf() {
let sample_rate = TARGET_SAMPLE_RATE;
let rms = |x: &[f32]| crate::util::compute_rms(x);
let hf: Vec<f32> = (0..sample_rate as usize / 4)
.map(|i| (2.0 * core::f32::consts::PI * 5000.0 * i as f32 / sample_rate as f32).sin())
.collect();
let a = one_pole_lowpass(&hf, 2200.0, sample_rate);
let b = one_pole_lowpass(&hf, 2200.0, sample_rate);
assert_eq!(a, b, "lowpass must be deterministic");
assert!(
rms(&a) < rms(&hf) * 0.5,
"5 kHz through a 2.2 kHz lowpass must be attenuated"
);
assert_eq!(
one_pole_lowpass(&hf, sample_rate as f32 * 0.5, sample_rate),
hf,
"cutoff at Nyquist must pass through unchanged"
);
let tilt: Vec<f32> = (0..sample_rate as usize / 4)
.map(|i| (2.0 * core::f32::consts::PI * 4000.0 * i as f32 / sample_rate as f32).sin())
.collect();
let a = high_shelf_cut(&tilt, -4.0, 3000.0, sample_rate);
let b = high_shelf_cut(&tilt, -4.0, 3000.0, sample_rate);
assert_eq!(a, b, "high-shelf cut must be deterministic");
assert!(
rms(&a) < rms(&tilt) * 0.7,
"4 kHz above a -4 dB @ 3 kHz shelf must be attenuated"
);
assert_eq!(high_shelf_cut(&tilt, 0.0, 3000.0, sample_rate), tilt);
let mixed: Vec<f32> = (0..sample_rate as usize / 2)
.map(|i| {
0.5 * (2.0 * core::f32::consts::PI * 220.0 * i as f32 / sample_rate as f32).sin()
+ 0.3
* (2.0 * core::f32::consts::PI * 4000.0 * i as f32 / sample_rate as f32)
.sin()
})
.collect();
for clip_idx in 0..10 {
let a = condition_enrollment_clip(&mixed, clip_idx);
let b = condition_enrollment_clip(&mixed, clip_idx);
assert_eq!(a, b, "clip {clip_idx} conditioning must be deterministic");
assert_ne!(
a, mixed,
"clip {clip_idx} must be conditioned (noise floor)"
);
}
let normal_len = condition_enrollment_clip(&mixed, 0).len();
let morning_len = condition_enrollment_clip(&mixed, 8).len();
assert!(
morning_len > normal_len,
"morning clip (0.92x slower resample) must be longer than normal"
);
}
#[test]
fn allocate_voices_standard_set() {
let styles: Vec<String> = (1..=5)
.map(|i| format!("F{i}.json"))
.chain((1..=5).map(|i| format!("M{i}.json")))
.collect();
let a = allocate_voices(&styles);
assert_eq!(a.enrolled, "F1.json");
assert_eq!(a.negative_pool, styles[..6]);
}
}