use crate::audio::wake_word::{
self, ENROLLMENT_CONSISTENCY_MIN_FRACTION, ENROLLMENT_CONSISTENCY_MIN_SIMILARITY,
MIN_ENROLLMENT_UTTERANCES, WAKE_WORD_EMBEDDING_DIM, WINDOW_SAMPLES, WakeWordEnrollment,
calibrate_negatives, encode_window,
};
use crate::config::{CONFIG, CONFIG_KEY_WAKE_WORD_TEMPLATES};
use crate::turso;
use crate::util::UnwrapPoison;
use crate::util::hex_string;
use anyhow::{Context, Result, anyhow};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use sha2::{Digest, Sha256};
use std::collections::VecDeque;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock, RwLock};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
#[cfg(feature = "voice-tests")]
#[path = "voice_pipeline_e2e_test.rs"]
pub(crate) mod voice_pipeline_e2e_test;
#[cfg(feature = "voice-tests")]
pub fn run_wake_word_benchmark() {
voice_pipeline_e2e_test::run_wake_word_benchmark();
}
pub const SAMPLE_RATE: u32 = 16_000;
#[must_use]
fn samples_to_ms(len: usize, rate: u32) -> u64 {
(len as u64 * 1000) / u64::from(rate)
}
pub(crate) const FRAME_LENGTH: usize = 512;
pub(crate) const HOP_LENGTH: usize = 256;
const MAX_RECORD_SECS: usize = 600;
pub(crate) const SILENCE_DURATION: Duration = Duration::from_millis(1500);
pub(crate) const SILENCE_THRESHOLD_SAMPLES: usize =
(SILENCE_DURATION.as_millis() as usize * SAMPLE_RATE as usize) / 1000;
pub(crate) const ENROLLMENT_SILENCE_THRESHOLD_SAMPLES: usize = SEGMENT_TIMEOUT_HOPS * HOP_LENGTH;
const SILENCE_UI_GATE_SAMPLES: usize = 200 * SAMPLE_RATE as usize / 1000;
const MIC_CHANNEL_CAPACITY: usize = 32;
const ENROLLMENT_NO_SPEECH_DURATION: Duration = Duration::from_secs(5);
const ENROLLMENT_NO_SPEECH_TIMEOUT_FRAMES: usize =
(ENROLLMENT_NO_SPEECH_DURATION.as_millis() as usize * SAMPLE_RATE as usize)
/ (HOP_LENGTH * 1000);
pub(crate) const DEFAULT_WAKE_WORD_PHRASE: &str = "mahbot";
const SEGMENT_TIMEOUT_HOPS: usize = 19;
const NUM_ENROLLMENT_SAMPLES: usize = 10;
const MIN_NEGATIVE_AUDIO_LEN: usize = SAMPLE_RATE as usize / 2;
const MAX_NEGATIVE_AUDIO_CHUNKS: usize = 100;
const MAX_AUTO_MODEL_RETRY_CYCLES: u32 = 3;
const NEGATIVES_TARGET_SECONDS: usize = 15;
const MAX_OWNER_NEGATIVE_SAMPLES: usize = SAMPLE_RATE as usize * NEGATIVES_TARGET_SECONDS * 3 / 2;
const PHASE3_TIMEOUT_SECS: u64 = 120;
const ENROLLMENT_QUALITY_CLIPPING_THRESHOLD: f32 = 0.999;
pub(crate) const ENROLLMENT_QUALITY_DURATION_MIN_MS: u64 = 400;
pub(crate) const ENROLLMENT_QUALITY_DURATION_MAX_MS: u64 = 2000;
const ENROLLMENT_QUALITY_SELF_TEST_MIN_FRACTION: f32 = 0.8;
const ENROLLMENT_PROMPTS: &[(&str, usize)] = &[
("Say it normally", 3),
("Say it a bit further from the mic", 3),
("Say it at a slightly different angle", 2),
("Say it with your normal morning voice", 2),
];
pub(crate) const RAW_RING_MAX: usize = SAMPLE_RATE as usize / 5;
pub(crate) const WAKE_WORD_WINDOW_SAMPLES: usize = WINDOW_SAMPLES;
const SCORE_STRIDE_SAMPLES: usize = crate::audio::wake_word::SCORE_STRIDE_MEL_FRAMES * 160;
const AUDIO_BUFFER_MAX: usize = WINDOW_SAMPLES + 16 * 160 * 8;
const WAKE_WORD_COOLDOWN: Duration = Duration::from_secs(3);
const NO_MATCH_RESET_THRESHOLD: f32 = 0.35;
const ROLLING_WINDOW_N: usize = 3;
const MATCH_THRESHOLD_FACTOR: f32 = 0.55;
#[expect(clippy::cast_precision_loss)]
const fn match_threshold() -> f32 {
(ROLLING_WINDOW_N as f32) * MATCH_THRESHOLD_FACTOR
}
const ADAPTIVE_WINDOW_N: usize = 15;
const ADAPTIVE_K_DEFAULT: f32 = 2.5;
const ADAPTIVE_CEILING: f32 = 2.60;
const ADAPTIVE_SAFE_HARBOR: f32 = match_threshold();
const ADAPTIVE_BOOTSTRAP_FRAMES: usize = 5;
fn process_wake_word_score(
total_score: f32,
score_window: &mut Vec<f32>,
adaptive_threshold_override: Option<f32>,
preserve_window_on_reset: bool,
) -> (bool, f32) {
if total_score < NO_MATCH_RESET_THRESHOLD {
if !preserve_window_on_reset {
if !score_window.is_empty() {
debug!(
"Wake word match lost: total_score={total_score:.4} < NO_MATCH_RESET_THRESHOLD \
(window reset, had {} scores)",
score_window.len(),
);
}
score_window.clear();
}
(false, 0.0)
} else {
score_window.push(total_score);
while score_window.len() > ROLLING_WINDOW_N {
score_window.remove(0);
}
let rolling_sum: f32 = score_window.iter().sum();
let threshold = adaptive_threshold_override.unwrap_or_else(match_threshold);
debug!(
"Wake word score: total_score={total_score:.4} rolling_sum={rolling_sum:.4}/ \
threshold={threshold:.2} window={}",
score_window.len(),
);
if rolling_sum >= threshold {
info!(
"Wake word detected! rolling_sum={rolling_sum:.4} >= {threshold:.2} \
(window={} scores)",
score_window.len(),
);
(true, rolling_sum)
} else {
(false, rolling_sum)
}
}
}
pub(crate) fn score_single_embedding(
embedding: &[f32],
enrollment: Option<&WakeWordEnrollment>,
score_window: &mut Vec<f32>,
mut adaptive_state: Option<&mut AdaptiveThresholdState>,
adaptive_k: f32,
) -> (bool, f32, f32, f32) {
let total_score = enrollment.map_or(0.0, |enr| enr.soft_score(embedding));
let adaptive_override = adaptive_state.as_mut().and_then(|state| {
if total_score < NO_MATCH_RESET_THRESHOLD {
state.feed(total_score, adaptive_k)
} else {
state.peek(adaptive_k)
}
});
let effective_threshold = adaptive_override.unwrap_or_else(match_threshold);
let (detected, rolling_sum) =
process_wake_word_score(total_score, score_window, adaptive_override, false);
#[cfg(feature = "voice-debug")]
{
let passed_threshold = total_score >= NO_MATCH_RESET_THRESHOLD;
let below_note = if passed_threshold {
""
} else {
" (below NO_MATCH_RESET_THRESHOLD — window reset)"
};
info!("VOICE_DEBUG: total_score={total_score:.4}{below_note} rolling_sum={rolling_sum:.4}",);
}
if detected {
(true, rolling_sum, total_score, effective_threshold)
} else {
(false, rolling_sum, total_score, effective_threshold)
}
}
const VAD_THRESHOLD: f32 = 0.5;
pub(crate) const ENROLLMENT_VAD_CONSECUTIVE_REQUIRED: usize = 1;
static VAD_DETECTOR: OnceLock<std::sync::Mutex<earshot::Detector>> = OnceLock::new();
static MANUAL_RECORDING_ACTIVE: AtomicBool = AtomicBool::new(false);
#[must_use]
pub fn models_ready() -> bool {
crate::audio::local_transcriber::is_loaded()
}
#[must_use]
pub fn is_transcription_disabled() -> bool {
crate::config::CONFIG
.audio_transcription_use_local()
.as_deref()
== Some("false")
}
fn resolved_model_status(
transcriber_loaded: bool,
transcriber_failed: bool,
voice_enabled: bool,
) -> VoiceStatus {
if transcriber_failed {
VoiceStatus::ModelError
} else if transcriber_loaded {
if voice_enabled {
VoiceStatus::Listening
} else {
VoiceStatus::Disabled
}
} else {
VoiceStatus::LoadingModels
}
}
#[derive(Debug, Clone)]
pub enum VoiceStatus {
Disabled,
LoadingModels,
ModelError,
Listening,
Recording,
RecordingManual,
Transcribing,
MicPermissionDenied,
MicDisconnected,
Enrolling {
sample: usize,
total: usize,
duration_ms: u64,
quality: Option<UtteranceQuality>,
},
ListeningDuringEnrollment {
sample: usize,
total: usize,
},
WaitingForSilenceDuringEnrollment {
sample: usize,
total: usize,
},
EnrollingNegatives {
accumulated_secs: usize,
target_secs: usize,
wall_clock_elapsed: u64,
},
Enrolled,
Error(String),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum QualityLevel {
Good, Acceptable, Poor, }
impl QualityLevel {
fn from_score(score: f32) -> Self {
if score > 0.7 {
Self::Good
} else if score >= 0.4 {
Self::Acceptable
} else {
Self::Poor
}
}
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::Good => "✅ Good sample — clear and consistent",
Self::Acceptable => "⚠️ Acceptable — a bit quiet, try speaking closer to the mic",
Self::Poor => "❌ Poor sample — too much noise, please re-record",
}
}
}
#[derive(Debug, Clone)]
pub struct UtteranceQuality {
pub score: f32,
pub level: QualityLevel,
pub clipping_detected: bool,
pub duration_ms: u64,
pub snr_db: f32,
}
static VOICE_PIPELINE: OnceLock<RwLock<VoicePipelineState>> = OnceLock::new();
static LAST_ACTIVE_USER: OnceLock<RwLock<String>> = OnceLock::new();
pub fn set_active_user_name(name: &str) {
if let Some(state) = LAST_ACTIVE_USER.get() {
*state.write().unwrap_poison() = name.to_string();
}
}
fn active_user_name() -> String {
LAST_ACTIVE_USER
.get()
.map(|s| s.read().unwrap_poison().clone())
.unwrap_or_default()
}
struct VoicePipelineState {
enabled: bool,
status: VoiceStatus,
enrollment: Option<WakeWordEnrollment>,
enrollment_embeddings: Vec<Vec<f32>>,
negative_audio_chunks: Vec<Vec<f32>>,
owner_negative_chunks: Vec<Vec<f32>>,
utterances_collected: bool,
enrolled_utterance_count: usize,
model_phrase: Option<String>,
enrolling_phrase: Option<String>,
cmd_tx: Option<mpsc::UnboundedSender<VoiceCommand>>,
}
impl VoicePipelineState {
fn reset_enrollment(&mut self) {
self.enrollment_embeddings.clear();
self.negative_audio_chunks.clear();
self.owner_negative_chunks.clear();
self.enrolled_utterance_count = 0;
self.utterances_collected = false;
self.enrolling_phrase = None;
}
}
#[derive(Debug)]
pub enum VoiceCommand {
StartListening,
StopListening,
StartEnrollment(String),
CancelEnrollment,
RetryModelLoading,
StartRecording,
StopRecordingSend,
StopRecordingDiscard,
Shutdown,
}
fn voice_state() -> &'static RwLock<VoicePipelineState> {
VOICE_PIPELINE.get().expect("VoicePipeline not initialized")
}
pub fn init_global() -> Result<()> {
VAD_DETECTOR.get_or_init(|| std::sync::Mutex::new(earshot::Detector::default()));
LAST_ACTIVE_USER
.set(RwLock::new(String::new()))
.map_err(|_| anyhow!("LAST_ACTIVE_USER already initialized"))?;
VOICE_PIPELINE
.set(RwLock::new(VoicePipelineState {
enabled: false,
status: VoiceStatus::Disabled,
enrollment: None,
enrollment_embeddings: Vec::new(),
negative_audio_chunks: Vec::new(),
owner_negative_chunks: Vec::new(),
enrolled_utterance_count: 0,
utterances_collected: false,
model_phrase: None,
enrolling_phrase: None,
cmd_tx: None,
}))
.map_err(|_| anyhow!("VoicePipeline already initialized"))?;
Ok(())
}
#[must_use]
pub fn get_status() -> VoiceStatus {
voice_state().read().unwrap_poison().status.clone()
}
#[must_use]
pub fn is_manual_recording() -> bool {
MANUAL_RECORDING_ACTIVE.load(Ordering::Relaxed)
}
#[must_use]
pub fn manual_recording_blocked_reason() -> Option<&'static str> {
if is_transcription_disabled() {
return Some("Voice recording unavailable — local transcription is disabled");
}
match get_status() {
VoiceStatus::Recording | VoiceStatus::Transcribing => {
Some("A voice message is already being processed")
}
VoiceStatus::Enrolling { .. }
| VoiceStatus::ListeningDuringEnrollment { .. }
| VoiceStatus::WaitingForSilenceDuringEnrollment { .. }
| VoiceStatus::EnrollingNegatives { .. } => Some("Voice enrollment is in progress"),
VoiceStatus::MicPermissionDenied => {
Some("Microphone permission denied — enable mic access to record")
}
VoiceStatus::MicDisconnected => Some("Microphone disconnected"),
_ => None,
}
}
#[must_use]
pub fn is_enabled() -> bool {
voice_state().read().unwrap_poison().enabled
}
pub fn set_enabled(enabled: bool) {
let mut state = voice_state().write().unwrap_poison();
state.enabled = enabled;
if !enabled {
state.status = VoiceStatus::Disabled;
}
}
pub fn set_status(status: VoiceStatus) {
voice_state().write().unwrap_poison().status = status;
}
#[must_use]
pub(crate) fn get_enrollment() -> Option<WakeWordEnrollment> {
voice_state().read().unwrap_poison().enrollment.clone()
}
pub(crate) fn set_enrollment(enrollment: WakeWordEnrollment) {
let mut state = voice_state().write().unwrap_poison();
state.enrollment = Some(enrollment);
}
pub fn send_command(cmd: VoiceCommand) {
if let Some(tx) = &voice_state().read().unwrap_poison().cmd_tx {
let _ = tx.send(cmd);
} else {
warn!("Voice pipeline not initialized — dropping command {cmd:?}");
}
}
pub(crate) fn is_speech_with_detector(
samples: &[f32],
detector: &mut earshot::Detector,
threshold: f32,
) -> bool {
if samples.is_empty() {
return false;
}
let mut any_speech = false;
let clamp_frame = |frame: &[f32]| -> [f32; 256] {
let mut clamped = [0.0f32; 256];
for (i, &s) in frame.iter().enumerate() {
clamped[i] = s.clamp(-1.0, 1.0);
}
clamped
};
for chunk in samples.as_chunks::<256>().0 {
if detector.predict_f32(&clamp_frame(chunk)) >= threshold {
any_speech = true;
}
}
let remainder = samples.len() % 256;
if remainder > 0 {
let mut padded = [0.0f32; 256];
padded[..remainder].copy_from_slice(&samples[samples.len() - remainder..]);
for s in &mut padded[..remainder] {
*s = s.clamp(-1.0, 1.0);
}
if detector.predict_f32(&padded) >= threshold {
any_speech = true;
}
}
any_speech
}
fn is_speech_with_threshold(samples: &[f32], threshold: f32) -> bool {
let detector = VAD_DETECTOR.get_or_init(|| std::sync::Mutex::new(earshot::Detector::default()));
let mut detector = detector.lock().unwrap_poison();
is_speech_with_detector(samples, &mut detector, threshold)
}
#[doc(hidden)]
pub fn reset_vad() {
if let Some(detector) = VAD_DETECTOR.get()
&& let Ok(mut d) = detector.lock()
{
d.reset();
}
}
fn is_mic_permission_error(err: &anyhow::Error) -> bool {
let msg = format!("{err:#}");
msg.contains("NoConnection")
|| msg.contains("-10875")
|| msg.contains("permission")
|| msg.contains("denied")
|| msg.to_lowercase().contains("access denied")
}
#[cfg_attr(not(feature = "voice-tests"), allow(dead_code))]
pub(crate) fn pcm_cache_key(
text: &str,
style: &str,
seed: u64,
sample_rate: u32,
model_hash: &str,
) -> String {
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
hasher.update([0u8]);
hasher.update(style.as_bytes());
hasher.update([0u8]);
hasher.update(seed.to_le_bytes());
hasher.update([0u8]);
hasher.update(sample_rate.to_le_bytes());
hasher.update([0u8]);
hasher.update(model_hash.as_bytes());
hex_string(&hasher.finalize())
}
#[cfg_attr(not(feature = "voice-tests"), allow(dead_code))]
pub(crate) fn tts_model_version_hash() -> String {
let mut hasher = Sha256::new();
hasher.update(crate::audio::tts::DP_MODEL_SHA256.as_bytes());
hasher.update(crate::audio::tts::TEXT_ENC_MODEL_SHA256.as_bytes());
hasher.update(crate::audio::tts::VECTOR_EST_MODEL_SHA256.as_bytes());
hasher.update(crate::audio::tts::VOCODER_MODEL_SHA256.as_bytes());
hasher.update(crate::audio::tts::TTS_JSON_SHA256.as_bytes());
hasher.update(crate::audio::tts::UNICODE_INDEXER_SHA256.as_bytes());
hasher.update(crate::audio::tts::VOICE_STYLE_SHA256.as_bytes());
hex_string(&hasher.finalize())
}
#[cfg_attr(not(feature = "voice-tests"), allow(dead_code))]
pub(crate) fn write_pcm_cache(path: &Path, samples: &[f32]) {
let tmp_path = path.with_extension("tmp");
let mut data: Vec<u8> = Vec::with_capacity(samples.len() * 4);
for &s in samples {
data.extend_from_slice(&s.to_le_bytes());
}
if let Err(e) = std::fs::write(&tmp_path, &data) {
warn!(
"PCM cache: failed to write tmp file {}: {e}",
tmp_path.display()
);
return;
}
if let Err(e) = std::fs::rename(&tmp_path, path) {
warn!(
"PCM cache: failed to rename {} -> {}: {e}",
tmp_path.display(),
path.display(),
);
}
}
#[cfg_attr(not(feature = "voice-tests"), allow(dead_code))]
pub(crate) fn read_pcm_cache(path: &Path) -> Option<Vec<f32>> {
let data = std::fs::read(path).ok()?;
if data.is_empty() {
warn!("PCM cache: file {} is empty — deleting", path.display(),);
let _ = std::fs::remove_file(path);
return None;
}
if data.len() % 4 != 0 {
warn!(
"PCM cache: file {} has non-aligned size {} — deleting",
path.display(),
data.len(),
);
let _ = std::fs::remove_file(path);
return None;
}
let samples: Vec<f32> = data
.as_chunks::<4>()
.0
.iter()
.map(|b| f32::from_le_bytes(*b))
.collect();
Some(samples)
}
#[cfg_attr(not(feature = "voice-tests"), allow(dead_code))]
pub(crate) fn synthesize_with_pcm_cache(
text: &str,
style: &str,
seed: u64,
sample_rate: u32,
model_hash: &str,
cache_dir: &Path,
) -> Option<Vec<f32>> {
let key = pcm_cache_key(text, style, seed, sample_rate, model_hash);
let cache_path = cache_dir.join(&key);
if let Some(pcm) = read_pcm_cache(&cache_path) {
debug!("PCM cache HIT for key {key} ({text}, style={style}, seed={seed})");
return Some(pcm);
}
debug!("PCM cache MISS for key {key} ({text}, style={style}, seed={seed}) — synthesising");
let Ok(pcm) = crate::audio::tts::synthesize(text, style, seed, sample_rate) else {
return None;
};
write_pcm_cache(&cache_path, &pcm);
Some(pcm)
}
fn convert_and_send_audio_to_pipeline<T, F>(
tx: &mpsc::Sender<Vec<f32>>,
data: &[T],
channels: u16,
sample_rate: u32,
convert: F,
) where
F: Fn(&T) -> f32,
{
if channels == 1 {
let mono: Vec<f32> = data.iter().map(&convert).collect();
let resampled = if sample_rate == SAMPLE_RATE {
mono
} else {
crate::util::resample_audio(&mono, sample_rate, SAMPLE_RATE)
};
let _ = tx.try_send(resampled);
return;
}
let frames = data.len() / usize::from(channels);
let mut mono: Vec<f32> = Vec::with_capacity(frames);
for frame in data.chunks_exact(usize::from(channels)) {
let sum: f32 = frame.iter().map(&convert).sum();
mono.push(sum / f32::from(channels));
}
let resampled = if sample_rate == SAMPLE_RATE {
mono
} else {
crate::util::resample_audio(&mono, sample_rate, SAMPLE_RATE)
};
let _ = tx.try_send(resampled);
}
#[allow(clippy::needless_pass_by_value)]
fn mic_error(err: cpal::Error) {
error!("Microphone stream error: {err}");
}
fn build_int_stream<T, F>(
device: &cpal::Device,
config: &cpal::StreamConfig,
sample_tx: &Arc<mpsc::Sender<Vec<f32>>>,
channels: u16,
sample_rate: u32,
convert: F,
) -> Result<cpal::Stream, cpal::Error>
where
T: cpal::SizedSample,
F: Fn(&T) -> f32 + Send + 'static,
{
let tx = sample_tx.clone();
device.build_input_stream::<T, _, _>(
*config,
move |data, _| {
convert_and_send_audio_to_pipeline(&tx, data, channels, sample_rate, &convert);
},
mic_error,
None,
)
}
fn start_microphone() -> Result<(mpsc::Receiver<Vec<f32>>, cpal::Stream)> {
let (tx, rx) = mpsc::channel::<Vec<f32>>(MIC_CHANNEL_CAPACITY);
let host = cpal::default_host();
let device = host
.default_input_device()
.ok_or_else(|| anyhow!("No default input device found"))?;
let config = device
.default_input_config()
.context("Failed to get default input config")?;
debug!(
"Microphone: {} ({:?}, {} Hz, {} ch)",
device
.description()
.map_or_else(|_| "unknown".to_string(), |d| d.name().to_string()),
config.sample_format(),
config.sample_rate(),
config.channels()
);
let sample_rate = config.sample_rate();
let channels = config.channels();
let sample_tx = Arc::new(tx);
let stream_config: cpal::StreamConfig = config.into();
let stream = match config.sample_format() {
cpal::SampleFormat::F32 => build_int_stream::<f32, _>(
&device,
&stream_config,
&sample_tx,
channels,
sample_rate,
|&s| s,
),
cpal::SampleFormat::I16 => build_int_stream::<i16, _>(
&device,
&stream_config,
&sample_tx,
channels,
sample_rate,
|&s| f32::from(s) / f32::from(i16::MAX),
),
cpal::SampleFormat::U16 => build_int_stream::<u16, _>(
&device,
&stream_config,
&sample_tx,
channels,
sample_rate,
|&s| (f32::from(s) / f32::from(u16::MAX)) * 2.0 - 1.0,
),
_ => anyhow::bail!("Unsupported sample format: {:?}", config.sample_format()),
}
.context("Failed to build microphone input stream")?;
stream.play().context("Failed to start microphone stream")?;
debug!(
"Microphone listening started ({} Hz, {} channels)",
sample_rate, channels
);
Ok((rx, stream))
}
async fn transcribe_audio(samples: &[f32]) -> Result<String> {
let wav_bytes = crate::audio::tts::render_wav(samples, SAMPLE_RATE)?;
let tmp_dir = std::env::temp_dir().join("mahbot_voice");
let _ = tokio::fs::remove_dir_all(&tmp_dir).await;
tokio::fs::create_dir_all(&tmp_dir).await?;
let tmp_path = tmp_dir.join(format!("cmd_{}.wav", crate::generate_id()));
tokio::fs::write(&tmp_path, &wav_bytes).await?;
let result = crate::audio::local_transcriber::transcribe_file_async(
&tmp_path,
crate::audio::local_transcriber::INFERENCE_TIMEOUT,
)
.await;
if let Err(e) = tokio::fs::remove_file(&tmp_path).await {
warn!("Failed to remove temp transcription file: {e}");
}
if let Err(e) = tokio::fs::remove_dir_all(&tmp_dir).await {
warn!("Failed to remove temp transcription directory: {e}");
}
result
}
#[expect(clippy::cast_precision_loss)]
pub(crate) fn compute_utterance_quality(
samples: &[f32],
noise_rms: Option<f32>,
) -> UtteranceQuality {
let duration_ms = samples_to_ms(samples.len(), SAMPLE_RATE);
let clipping_detected = samples
.iter()
.any(|&s| s.abs() >= ENROLLMENT_QUALITY_CLIPPING_THRESHOLD);
let snr_db = if let Some(noise_rms) = noise_rms {
let speech_rms = crate::util::compute_rms(samples);
if noise_rms > 1e-10 && speech_rms > noise_rms {
20.0 * (speech_rms / noise_rms).log10()
} else {
0.0
}
} else {
estimate_snr_energy(samples)
};
let duration_score = if duration_ms < ENROLLMENT_QUALITY_DURATION_MIN_MS {
0.0
} else if duration_ms > ENROLLMENT_QUALITY_DURATION_MAX_MS {
0.3 } else {
0.6 + (0.4 * (duration_ms - ENROLLMENT_QUALITY_DURATION_MIN_MS) as f32
/ (ENROLLMENT_QUALITY_DURATION_MAX_MS - ENROLLMENT_QUALITY_DURATION_MIN_MS) as f32)
};
let clipping_score = if clipping_detected { 0.0 } else { 1.0 };
let snr_score = if snr_db.is_finite() {
(snr_db / 20.0).clamp(0.0, 1.0)
} else {
0.5
};
let score = duration_score * 0.50 + clipping_score * 0.25 + snr_score * 0.25;
UtteranceQuality {
score,
level: QualityLevel::from_score(score),
clipping_detected,
duration_ms,
snr_db,
}
}
#[expect(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn estimate_snr_energy(samples: &[f32]) -> f32 {
if samples.len() < FRAME_LENGTH * 3 {
return f32::NAN; }
let mut frame_rms: Vec<f32> = Vec::new();
for chunk in samples.chunks(FRAME_LENGTH) {
if chunk.len() < FRAME_LENGTH / 2 {
continue; }
frame_rms.push(crate::util::compute_rms(chunk));
}
if frame_rms.len() < 3 {
return f32::NAN;
}
frame_rms.sort_unstable_by(|a, b| a.partial_cmp(b).expect("RMS values must be finite"));
let n = frame_rms.len();
let noise_len = (n as f32 * 0.4).ceil() as usize;
let noise_rms: f32 = frame_rms[..noise_len.min(n)].iter().sum::<f32>() / noise_len as f32;
let speech_start = (n as f32 * 0.6).ceil() as usize;
let speech_len = n.saturating_sub(speech_start);
let speech_rms = if speech_len > 0 {
frame_rms[speech_start..].iter().sum::<f32>() / speech_len as f32
} else {
return f32::NAN;
};
if noise_rms <= 1e-10 || speech_rms <= noise_rms {
return 0.0; }
let snr = 20.0 * (speech_rms / noise_rms).log10();
snr.clamp(0.0, 40.0)
}
#[expect(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn run_enrollment_self_test(
utterance_embeddings: &[Vec<f32>],
enrollment: &WakeWordEnrollment,
) -> Result<(), String> {
if utterance_embeddings.is_empty() {
return Err("Self-test skipped: no enrollment samples".to_string());
}
let mut passed = 0usize;
for embedding in utterance_embeddings {
let mut score_window = Vec::new();
let mut detected_this = false;
for _ in 0..ROLLING_WINDOW_N {
let (detected, _, _, _) = score_single_embedding(
embedding,
Some(enrollment),
&mut score_window,
None, ADAPTIVE_K_DEFAULT,
);
if detected {
detected_this = true;
break;
}
}
if detected_this {
passed += 1;
}
}
let required = (utterance_embeddings.len() as f32 * ENROLLMENT_QUALITY_SELF_TEST_MIN_FRACTION)
.ceil() as usize;
if passed < required {
Err(format!(
"Self-test failed: only {passed}/{} utterances triggered detection (need ≥{required}). \
Try re-enrolling with clearer, more consistent speech.",
utterance_embeddings.len(),
))
} else {
Ok(())
}
}
#[must_use]
pub fn enrollment_prompt_for_sample(sample: usize) -> &'static str {
let mut cumulative = 0;
for &(prompt, count) in ENROLLMENT_PROMPTS {
cumulative += count;
if sample < cumulative {
return prompt;
}
}
"Say the wake word clearly"
}
pub(crate) struct VadSegmentationConfig {
frame_length: usize,
hop_length: usize,
consecutive_required: usize,
silence_threshold_samples: usize,
context_padding_samples: usize,
raw_ring_max: usize,
}
pub(crate) const DEFAULT_VAD_SEGMENTATION_CONFIG: VadSegmentationConfig = VadSegmentationConfig {
frame_length: FRAME_LENGTH,
hop_length: HOP_LENGTH,
consecutive_required: ENROLLMENT_VAD_CONSECUTIVE_REQUIRED,
silence_threshold_samples: ENROLLMENT_SILENCE_THRESHOLD_SAMPLES,
context_padding_samples: 0,
raw_ring_max: RAW_RING_MAX,
};
pub(crate) fn segment_utterances_by_vad(
raw_audio: &[f32],
vad_decisions: &[bool],
config: &VadSegmentationConfig,
) -> Vec<Vec<f32>> {
let VadSegmentationConfig {
frame_length,
hop_length,
consecutive_required,
silence_threshold_samples,
context_padding_samples,
raw_ring_max,
} = *config;
assert!(
!vad_decisions.is_empty(),
"segment_utterances_by_vad: vad_decisions must not be empty",
);
assert!(
raw_audio.len() >= frame_length,
"segment_utterances_by_vad: raw_audio too short \
({} < {frame_length})",
raw_audio.len(),
);
let mut utterances: Vec<Vec<f32>> = Vec::new();
let mut utterance_buf: Vec<f32> = Vec::new();
let mut utterance_had_speech = false;
let mut utterance_silence_samples: usize = 0;
let mut utterance_speech_end_len: usize = 0;
let mut vad_positives_in_a_row: usize = 0;
let mut raw_audio_ring: Vec<f32> = Vec::with_capacity(raw_ring_max);
let mut post_speech_tail: Vec<f32> = Vec::new();
for (frame_idx, &is_speech) in vad_decisions.iter().enumerate() {
let frame_start = frame_idx * hop_length;
let frame_end = (frame_start + frame_length).min(raw_audio.len());
if frame_end > frame_start {
raw_audio_ring.extend_from_slice(&raw_audio[frame_start..frame_end]);
if raw_audio_ring.len() > raw_ring_max {
let excess = raw_audio_ring.len() - raw_ring_max;
raw_audio_ring.drain(..excess);
}
}
if is_speech {
let hop_end = (frame_start + hop_length).min(raw_audio.len());
if hop_end > frame_start {
utterance_buf.extend_from_slice(&raw_audio[frame_start..hop_end]);
}
vad_positives_in_a_row += 1;
if vad_positives_in_a_row >= consecutive_required {
if !utterance_had_speech {
let start = raw_audio_ring.len().saturating_sub(context_padding_samples);
let padding: Vec<f32> = raw_audio_ring[start..].to_vec();
if !padding.is_empty() {
let mut padded = padding;
padded.extend_from_slice(&utterance_buf);
utterance_buf = padded;
}
}
utterance_had_speech = true;
utterance_speech_end_len = utterance_buf.len();
utterance_silence_samples = 0;
} else if utterance_had_speech {
utterance_speech_end_len = utterance_buf.len();
utterance_silence_samples = 0;
}
} else {
vad_positives_in_a_row = 0;
if utterance_had_speech {
if utterance_silence_samples == 0 {
let start = raw_audio_ring.len().saturating_sub(context_padding_samples);
post_speech_tail = raw_audio_ring[start..].to_vec();
}
utterance_silence_samples += hop_length;
if utterance_silence_samples >= silence_threshold_samples {
utterance_buf.truncate(utterance_speech_end_len);
if !post_speech_tail.is_empty() {
utterance_buf.extend_from_slice(&post_speech_tail);
}
if !utterance_buf.is_empty() {
utterances.push(std::mem::take(&mut utterance_buf));
}
utterance_speech_end_len = 0;
utterance_had_speech = false;
utterance_silence_samples = 0;
post_speech_tail.clear();
vad_positives_in_a_row = 0;
}
}
}
}
utterances
}
async fn broadcast_voice_transcript(transcript: &str, user_name: &str, workspace: &str) {
let msg = crate::ChannelMessage {
user_name: user_name.to_string(),
reply_target: String::new(),
content: transcript.to_string(),
channel: "voice".to_string(),
workspace: workspace.to_string(),
optimistic_id: None,
callback_query_id: None,
};
crate::channels::broadcast_and_persist_incoming_message(&msg, transcript, transcript).await;
}
async fn route_to_agent(text: String) {
let user_name = active_user_name();
if !user_name.is_empty() {
let pool = crate::users::role_pool(&user_name).await;
let Some(role) = crate::users::resolve_active_role_from_pool(&user_name, &pool).await
else {
info!("Voice command dropped (no active role) (user: {user_name}): {text}");
return;
};
let ws = crate::users::resolve_workspace_for_user_name(&user_name).await;
let (role, ws) = crate::users::effective_role_and_workspace(role, ws, &user_name, &pool);
info!(
"Voice command -> {role} (user: {user_name}, workspace: {})",
ws.name
);
broadcast_voice_transcript(&text, &user_name, &ws.name).await;
crate::message_router::route_user_message(
text,
ws.name,
user_name,
"voice".to_string(),
role,
None,
)
.await;
return;
}
let ws = crate::users::resolve_workspace_for_user_name("admin").await;
let admin_pool = crate::users::role_pool("admin").await;
if admin_pool.is_empty() {
info!("Voice command dropped (no active role) (user: admin): {text}");
return;
}
let role = if admin_pool.contains(&crate::Role::Manager) {
crate::Role::Manager
} else {
admin_pool[0]
};
let (role, ws) = crate::users::effective_role_and_workspace(role, ws, "admin", &admin_pool);
info!("Voice command -> {role} (workspace: {})", ws.name);
broadcast_voice_transcript(&text, "admin", &ws.name).await;
crate::message_router::route_user_message(
text,
ws.name,
"admin".to_string(),
"voice".to_string(),
role,
None,
)
.await;
}
#[derive(Debug, Clone)]
pub(crate) struct AdaptiveThresholdState {
scores: Vec<f32>,
sum: f32,
sum_sq: f32,
bootstrap_count: usize,
}
impl AdaptiveThresholdState {
pub(crate) fn new() -> Self {
Self {
scores: Vec::with_capacity(ADAPTIVE_WINDOW_N),
sum: 0.0,
sum_sq: 0.0,
bootstrap_count: 0,
}
}
pub(crate) fn feed(&mut self, score: f32, k: f32) -> Option<f32> {
if self.scores.len() >= ADAPTIVE_WINDOW_N {
let oldest = self.scores.remove(0);
self.sum -= oldest;
self.sum_sq -= oldest * oldest;
}
self.scores.push(score);
self.sum += score;
self.sum_sq += score * score;
if self.bootstrap_count < ADAPTIVE_BOOTSTRAP_FRAMES {
self.bootstrap_count += 1;
return None;
}
let threshold = self.compute_threshold(k);
Some(threshold)
}
#[expect(clippy::cast_precision_loss)]
fn compute_threshold(&self, k: f32) -> f32 {
let n = self.scores.len() as f32;
let mean = self.sum / n;
let variance = (self.sum_sq / n) - (mean * mean);
let std = variance.max(0.0).sqrt();
#[expect(clippy::cast_precision_loss)]
let adaptive = (mean + k * std) * ROLLING_WINDOW_N as f32;
adaptive.clamp(ADAPTIVE_SAFE_HARBOR, ADAPTIVE_CEILING)
}
pub(crate) fn peek(&self, k: f32) -> Option<f32> {
if self.bootstrap_count < ADAPTIVE_BOOTSTRAP_FRAMES {
return None;
}
if self.scores.is_empty() {
return None;
}
Some(self.compute_threshold(k))
}
#[cfg(test)]
pub(crate) fn is_bootstrapping(&self) -> bool {
self.bootstrap_count < ADAPTIVE_BOOTSTRAP_FRAMES
}
pub(crate) fn reset(&mut self) {
self.scores.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
self.bootstrap_count = 0;
}
#[cfg(any(test, feature = "voice-tests"))]
pub(crate) fn warmed() -> Self {
let mut state = Self::new();
for _ in 0..ADAPTIVE_BOOTSTRAP_FRAMES {
state.feed(0.033, ADAPTIVE_K_DEFAULT);
}
state
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.scores.len()
}
}
#[cfg(feature = "voice-tests")]
pub(crate) struct DetectionInstrumentation {
pub vad_speech_frames: usize,
pub peak_score: f32,
}
#[cfg(feature = "voice-tests")]
impl DetectionInstrumentation {
pub fn new() -> Self {
Self {
vad_speech_frames: 0,
peak_score: 0.0,
}
}
}
#[expect(clippy::struct_excessive_bools)]
pub(crate) struct PipelineCtx {
mic_rx: Option<mpsc::Receiver<Vec<f32>>>,
mic_stream: Option<cpal::Stream>,
is_listening: bool,
is_recording: bool,
manual_recording: bool,
resume_listening_after_recording: bool,
command_buffer: Vec<f32>,
silence_sample_count: usize,
enrollment_mode: bool,
frame_vad: Vec<bool>,
frame_raw_audio: Vec<f32>,
emitted_utterances: usize,
utterance_had_speech: bool,
utterance_silence_samples: usize,
enrollment_no_speech_frame_count: usize,
vad_positives_in_a_row: usize,
vad_threshold: f32,
enrollment_vad: Option<earshot::Detector>,
#[cfg(feature = "voice-tests")]
pub(crate) injected_vad: Option<earshot::Detector>,
audio_buffer: Vec<f32>,
speech_window: Vec<f32>,
vad_cursor: usize,
last_score_sample_count: usize,
enrollment_pending: VecDeque<Vec<f32>>,
auto_start_pending: bool,
last_model_retry: Option<Instant>,
pub(crate) last_wake_word_detection: Option<Instant>,
noise_rms_estimate: Option<f32>,
collecting_negatives: bool,
phase3_audio_buf: Vec<f32>,
phase3_silence_samples: usize,
negatives_speech_samples: usize,
phase3_processed: usize,
phase3_start_time: Option<Instant>,
score_window: Vec<f32>,
segment_silence_hops: usize,
adaptive_threshold: AdaptiveThresholdState,
adaptive_k: f32,
negative_audio_buf: Vec<f32>,
refractory_until: Option<Instant>,
last_error_message_time: Option<Instant>,
last_voice_notice_time: Option<Instant>,
auto_model_retries_left: u32,
#[cfg(feature = "voice-tests")]
instrumentation: DetectionInstrumentation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResetLevel {
Full,
Soft,
Cancel,
}
impl PipelineCtx {
pub(crate) fn new() -> Self {
Self {
mic_rx: None,
mic_stream: None,
is_listening: false,
is_recording: false,
manual_recording: false,
resume_listening_after_recording: false,
command_buffer: Vec::new(),
silence_sample_count: 0,
enrollment_mode: false,
frame_vad: Vec::new(),
frame_raw_audio: Vec::new(),
emitted_utterances: 0,
utterance_had_speech: false,
utterance_silence_samples: 0,
enrollment_no_speech_frame_count: 0,
vad_positives_in_a_row: 0,
audio_buffer: Vec::new(),
speech_window: Vec::new(),
vad_cursor: 0,
last_score_sample_count: 0,
enrollment_pending: VecDeque::new(),
auto_start_pending: CONFIG.voice_enabled().as_deref() == Some("true"),
last_model_retry: None,
last_wake_word_detection: None,
score_window: Vec::new(),
noise_rms_estimate: None,
collecting_negatives: false,
phase3_audio_buf: Vec::new(),
phase3_silence_samples: 0,
negatives_speech_samples: 0,
phase3_processed: 0,
phase3_start_time: None,
vad_threshold: VAD_THRESHOLD,
enrollment_vad: None,
#[cfg(feature = "voice-tests")]
injected_vad: None,
negative_audio_buf: Vec::new(),
refractory_until: None,
last_error_message_time: None,
last_voice_notice_time: None,
auto_model_retries_left: MAX_AUTO_MODEL_RETRY_CYCLES,
adaptive_threshold: AdaptiveThresholdState::new(),
adaptive_k: ADAPTIVE_K_DEFAULT,
segment_silence_hops: 0,
#[cfg(feature = "voice-tests")]
instrumentation: DetectionInstrumentation::new(),
}
}
fn set_manual_recording(&mut self, active: bool) {
self.manual_recording = active;
MANUAL_RECORDING_ACTIVE.store(active, Ordering::Relaxed);
}
fn full_reset_preserving_auto_start(&mut self) {
let auto_start = self.auto_start_pending;
self.reset_pipeline_state(ResetLevel::Full);
self.auto_start_pending = auto_start;
}
fn reset_pipeline_state(&mut self, level: ResetLevel) {
self.audio_buffer.clear();
self.speech_window.clear();
self.vad_cursor = 0;
self.command_buffer.clear();
self.silence_sample_count = 0;
self.score_window.clear();
self.negative_audio_buf.clear();
self.segment_silence_hops = 0;
self.last_score_sample_count = 0;
self.utterance_had_speech = false;
self.utterance_silence_samples = 0;
self.enrollment_no_speech_frame_count = 0;
self.vad_positives_in_a_row = 0;
self.enrollment_pending.clear();
self.noise_rms_estimate = None;
self.frame_vad.clear();
self.frame_raw_audio.clear();
self.emitted_utterances = 0;
self.collecting_negatives = false;
self.phase3_audio_buf.clear();
self.phase3_silence_samples = 0;
self.negatives_speech_samples = 0;
self.phase3_processed = 0;
self.phase3_start_time = None;
self.enrollment_vad = None;
match level {
ResetLevel::Full => {
self.vad_threshold = VAD_THRESHOLD;
self.last_wake_word_detection = None;
self.auto_start_pending = false;
self.is_recording = false;
self.set_manual_recording(false);
reset_vad();
self.adaptive_threshold.reset();
}
ResetLevel::Soft => {
}
ResetLevel::Cancel => {
self.vad_threshold = VAD_THRESHOLD;
self.last_wake_word_detection = None;
self.adaptive_threshold.reset();
voice_state().write().unwrap_poison().reset_enrollment();
}
}
}
fn reset_detection_segment(&mut self) {
self.score_window.clear();
self.adaptive_threshold.reset();
self.segment_silence_hops = 0;
self.audio_buffer.clear();
self.speech_window.clear();
self.vad_cursor = 0;
self.last_score_sample_count = 0;
}
fn handle_segment_boundary(&mut self, hop_count: usize) {
if hop_count >= SEGMENT_TIMEOUT_HOPS {
if !self.is_recording {
self.reset_detection_segment();
}
} else {
self.segment_silence_hops = hop_count;
}
}
fn check_refractory_period(&mut self) {
if let Some(refractory_until) = self.refractory_until
&& Instant::now() >= refractory_until
{
self.refractory_until = None;
if !self.is_recording && matches!(get_status(), VoiceStatus::Error(_)) {
set_status(VoiceStatus::Listening);
}
}
}
fn should_send_rate_limited(last: Option<Instant>) -> bool {
let now = Instant::now();
last.is_none_or(|t| now.duration_since(t).as_secs() >= 10)
}
fn should_send_error_message(&self) -> bool {
Self::should_send_rate_limited(self.last_error_message_time)
}
fn should_send_voice_notice(&self) -> bool {
Self::should_send_rate_limited(self.last_voice_notice_time)
}
async fn broadcast_voice_message(&mut self, msg: &str) {
let user_name = active_user_name();
if user_name.is_empty() {
return;
}
let role = crate::users::resolve_active_role(&user_name).await;
let ws = crate::users::resolve_workspace_for_user_name(&user_name).await;
let ws = match role {
Some(role) => crate::users::effective_workspace_for_role(role, ws, &user_name),
None => ws,
};
crate::channels::broadcast_and_persist_agent_response(
&user_name,
"voice",
msg,
Some("voice".to_string()),
&ws.name,
)
.await;
}
async fn broadcast_transcription_error(&mut self) {
if !self.should_send_error_message() {
return;
}
self.last_error_message_time = Some(Instant::now());
self.broadcast_voice_message("*Voice: transcription failed — try again*")
.await;
}
async fn broadcast_voice_notice(&mut self, msg: &str) {
if !self.should_send_voice_notice() {
return;
}
self.last_voice_notice_time = Some(Instant::now());
self.broadcast_voice_message(msg).await;
}
fn handle_start_listening(&mut self) {
if self.manual_recording {
self.resume_listening_after_recording = true;
info!("Voice pipeline: start_listening deferred until manual recording ends");
return;
}
if is_transcription_disabled() {
self.auto_start_pending = false;
warn!(
"Ignoring start_listening — local transcription disabled (wake word requires ASR)"
);
return;
}
if !is_enabled() {
self.auto_start_pending = false;
warn!("Ignoring start_listening — voice assistant is disabled");
return;
}
if !models_ready() {
if crate::audio::local_transcriber::is_failed() {
warn!("ASR model previously failed — triggering retry...");
self.try_retry_models();
}
self.auto_start_pending = true;
warn!("Voice models not ready yet");
return;
}
if !self.is_listening {
self.reset_pipeline_state(ResetLevel::Full);
drop(self.mic_stream.take());
match start_microphone() {
Ok((rx, stream)) => {
self.mic_rx = Some(rx);
self.mic_stream = Some(stream);
self.is_listening = true;
set_status(VoiceStatus::Listening);
info!("Voice pipeline: started listening");
}
Err(e) => {
warn!("Failed to start microphone: {e}");
set_status(if is_mic_permission_error(&e) {
VoiceStatus::MicPermissionDenied
} else {
VoiceStatus::MicDisconnected
});
}
}
}
}
fn handle_stop_listening(&mut self) -> bool {
let aborted_recording = self.manual_recording;
self.reset_pipeline_state(ResetLevel::Full);
self.is_listening = false;
self.enrollment_mode = false;
self.set_manual_recording(false);
self.resume_listening_after_recording = false;
drop(self.mic_stream.take());
self.mic_rx = None;
set_status(VoiceStatus::Disabled);
info!("Voice pipeline: stopped listening");
aborted_recording
}
fn handle_start_enrollment(&mut self, phrase: &str) -> bool {
if !self.is_listening {
warn!("Cannot start enrollment: microphone not running");
set_status(VoiceStatus::Error(
"Microphone not running — enable Voice first".to_string(),
));
return false;
}
let aborted_recording = self.manual_recording;
if aborted_recording {
warn!("Aborting manual recording — enrollment started");
self.set_manual_recording(false);
self.resume_listening_after_recording = false;
self.command_buffer.clear();
self.silence_sample_count = 0;
}
let existing_utterances = voice_state()
.read()
.unwrap_poison()
.enrolled_utterance_count;
if existing_utterances == 0 {
self.reset_pipeline_state(ResetLevel::Cancel);
} else {
info!(
"Resuming enrollment from utterance \
{existing_utterances}/{NUM_ENROLLMENT_SAMPLES}",
);
}
let normalized = normalize_phrase(phrase);
voice_state().write().unwrap_poison().enrolling_phrase = Some(normalized);
self.enrollment_mode = true;
self.enrollment_vad = Some(earshot::Detector::default());
set_status(VoiceStatus::Enrolling {
sample: existing_utterances,
total: NUM_ENROLLMENT_SAMPLES,
duration_ms: 0,
quality: None,
});
info!(
"Voice pipeline: enrollment started (resuming from utterance \
{existing_utterances}/{NUM_ENROLLMENT_SAMPLES})",
);
aborted_recording
}
fn handle_cancel_enrollment(&mut self) {
self.reset_pipeline_state(ResetLevel::Cancel);
self.enrollment_mode = false;
set_status(if self.is_listening {
VoiceStatus::Listening
} else {
VoiceStatus::Disabled
});
info!("Voice pipeline: enrollment cancelled");
}
fn transition_to_phase3(&mut self) {
if !self.enrollment_pending.is_empty() {
warn!(
"transition_to_phase3: draining {} stale enrollment pending utterances",
self.enrollment_pending.len(),
);
self.enrollment_pending.clear();
}
self.negative_audio_buf.clear();
self.utterance_silence_samples = 0;
self.utterance_had_speech = false;
self.vad_positives_in_a_row = 0;
self.collecting_negatives = true;
self.phase3_start_time = Some(Instant::now());
set_status(VoiceStatus::EnrollingNegatives {
accumulated_secs: 0,
target_secs: NEGATIVES_TARGET_SECONDS,
wall_clock_elapsed: 0,
});
info!(
"Voice pipeline: transitioning to Phase 3 owner-negative \
collection (target {NEGATIVES_TARGET_SECONDS}s of VAD-positive speech)",
);
}
fn handle_shutdown(&mut self) {
self.set_manual_recording(false);
self.resume_listening_after_recording = false;
drop(self.mic_stream.take());
}
fn handle_start_manual_recording(&mut self) -> Option<&'static str> {
if self.manual_recording {
warn!("Manual recording already in progress");
return None;
}
if self.is_recording {
warn!("Cannot start manual recording — wake-word recording in progress");
return None;
}
if self.enrollment_mode || self.collecting_negatives {
warn!("Cannot start manual recording during enrollment");
return None;
}
if is_transcription_disabled() {
warn!("Cannot start manual recording — local transcription disabled");
return Some("Voice recording unavailable — local transcription is disabled");
}
self.resume_listening_after_recording = self.is_listening;
if self.mic_rx.is_none() {
if !models_ready() {
warn!("Cannot start manual recording — models not ready");
return Some("Voice models are still loading — try again in a moment");
}
match start_microphone() {
Ok((rx, stream)) => {
self.mic_rx = Some(rx);
self.mic_stream = Some(stream);
}
Err(e) => {
warn!("Failed to start recording mic: {e}");
return Some(if is_mic_permission_error(&e) {
"Microphone permission denied — enable mic access to record"
} else {
"Could not start the microphone — check your input device"
});
}
}
}
self.set_manual_recording(true);
self.command_buffer.clear();
self.silence_sample_count = 0;
set_status(VoiceStatus::RecordingManual);
info!("Voice pipeline: manual recording started");
None
}
async fn handle_stop_manual_recording(&mut self, send: bool) {
if !self.manual_recording {
warn!("StopRecording called but no manual recording in progress");
return;
}
let cmd_buf = std::mem::take(&mut self.command_buffer);
self.silence_sample_count = 0;
if send && !cmd_buf.is_empty() {
self.finalize_manual_recording(cmd_buf).await;
} else {
self.end_manual_recording();
if send {
self.broadcast_voice_notice("*Voice: no speech detected — recording discarded*")
.await;
}
}
}
#[expect(clippy::cast_precision_loss)]
async fn handle_manual_recording_audio(&mut self, samples: &[f32]) {
self.command_buffer.extend_from_slice(samples);
let duration_secs = self.command_buffer.len() as f64 / f64::from(SAMPLE_RATE);
if duration_secs > MAX_RECORD_SECS as f64 {
debug!("Manual recording stopped: max duration ({duration_secs:.1}s)");
let cmd_buf = std::mem::take(&mut self.command_buffer);
self.silence_sample_count = 0;
self.finalize_manual_recording(cmd_buf).await;
}
}
async fn finalize_manual_recording(&mut self, cmd_buf: Vec<f32>) {
set_status(VoiceStatus::Transcribing);
match transcribe_audio(&cmd_buf).await {
Ok(transcribed) if !transcribed.trim().is_empty() => {
route_to_agent(transcribed).await;
}
Ok(_) => {
warn!(
"Empty transcription — dropping manual recording ({} samples)",
cmd_buf.len()
);
self.broadcast_voice_notice("*Voice: no speech detected — recording discarded*")
.await;
}
Err(e) => {
warn!("Manual recording transcription failed: {e}");
self.broadcast_transcription_error().await;
}
}
self.end_manual_recording();
}
fn end_manual_recording(&mut self) {
self.set_manual_recording(false);
if self.resume_listening_after_recording {
self.resume_listening_after_recording = false;
if self.is_listening {
self.reset_pipeline_state(ResetLevel::Soft);
set_status(VoiceStatus::Listening);
} else {
self.handle_start_listening();
if !self.is_listening {
drop(self.mic_stream.take());
self.mic_rx = None;
if !matches!(
get_status(),
VoiceStatus::MicPermissionDenied | VoiceStatus::MicDisconnected
) {
set_status(VoiceStatus::Disabled);
}
}
}
} else {
self.full_reset_preserving_auto_start();
self.is_listening = false;
drop(self.mic_stream.take());
self.mic_rx = None;
set_status(VoiceStatus::Disabled);
}
debug!("Voice pipeline: manual recording ended");
}
fn try_retry_models(&mut self) -> bool {
let cooldown = Duration::from_secs(30);
if self
.last_model_retry
.is_some_and(|t| t.elapsed() < cooldown)
{
return false;
}
if crate::audio::local_transcriber::retry_init() {
self.last_model_retry = Some(Instant::now());
set_status(VoiceStatus::LoadingModels);
true
} else {
false
}
}
fn try_retry_models_auto(&mut self) {
if self.auto_model_retries_left == 0 {
return;
}
if self.try_retry_models() {
self.auto_model_retries_left -= 1;
if self.auto_model_retries_left == 0 {
warn!(
"Voice: automatic ASR retry budget exhausted ({} cycles) — \
use the GUI retry button or restart the app",
MAX_AUTO_MODEL_RETRY_CYCLES,
);
}
}
}
fn check_auto_start(&mut self) {
if self.auto_start_pending && models_ready() && !self.is_listening {
self.auto_start_pending = false;
send_command(VoiceCommand::StartListening);
}
}
}
fn schedule_listening_transition(ctx: &mut PipelineCtx) {
ctx.reset_pipeline_state(ResetLevel::Cancel);
ctx.enrollment_mode = false;
tokio::spawn(async {
let shutdown_token = crate::shutdown::shutdown_token();
tokio::select! {
() = tokio::time::sleep(Duration::from_millis(1500)) => {
if matches!(get_status(), VoiceStatus::Enrolled) {
set_status(VoiceStatus::Listening);
}
}
() = shutdown_token.cancelled() => {
}
}
});
}
async fn extract_negative_embeddings(chunks: Vec<Vec<f32>>) -> Vec<Vec<f32>> {
if chunks.is_empty() {
return Vec::new();
}
let Some(model) = crate::audio::local_transcriber::shared_model_arc() else {
warn!("extract_negative_embeddings: ASR model not loaded");
return Vec::new();
};
tokio::task::spawn_blocking(move || {
let mut embeddings: Vec<Vec<f32>> = Vec::with_capacity(chunks.len());
for chunk in &chunks {
match encode_window(&model, chunk) {
Ok(emb) => embeddings.push(emb),
Err(e) => {
warn!(
"Failed to encode negative chunk ({} samples): {e} — skipping",
chunk.len(),
);
}
}
}
embeddings
})
.await
.unwrap_or_else(|e| {
warn!("Negative embedding task panicked: {e}");
Vec::new()
})
}
#[expect(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn enrollment_consistency_check(utterance_embeddings: &[Vec<f32>]) -> Result<Vec<f32>, String> {
if utterance_embeddings.len() < MIN_ENROLLMENT_UTTERANCES {
return Err(format!(
"Only {} enrollment utterances collected (need ≥{MIN_ENROLLMENT_UTTERANCES}). \
Speak clearly and close to the microphone.",
utterance_embeddings.len(),
));
}
let dim = WAKE_WORD_EMBEDDING_DIM;
let mut centroid = vec![0.0f32; dim];
for emb in utterance_embeddings {
if emb.len() != dim {
return Err(format!(
"Utterance embedding has dim {} (expected {dim})",
emb.len(),
));
}
for (c, v) in centroid.iter_mut().zip(emb) {
*c += v;
}
}
let inv = 1.0 / utterance_embeddings.len() as f32;
for c in &mut centroid {
*c *= inv;
}
let norm: f32 = centroid.iter().map(|v| v * v).sum::<f32>().sqrt();
if norm > 1e-8 {
let inv_norm = 1.0 / norm;
for c in &mut centroid {
*c *= inv_norm;
}
}
let required =
(utterance_embeddings.len() as f32 * ENROLLMENT_CONSISTENCY_MIN_FRACTION).ceil() as usize;
let passed = utterance_embeddings
.iter()
.filter(|emb| {
crate::vector::cosine_similarity(emb, ¢roid)
>= ENROLLMENT_CONSISTENCY_MIN_SIMILARITY
})
.count();
if passed < required {
return Err(format!(
"Only {passed}/{} enrollment utterances match the prototype \
(need ≥{required} at cosine ≥ {ENROLLMENT_CONSISTENCY_MIN_SIMILARITY}). \
Try re-enrolling with clearer, more consistent speech.",
utterance_embeddings.len(),
));
}
Ok(centroid)
}
#[expect(clippy::too_many_lines)]
async fn finalize_enrollment_pipeline() -> bool {
if !models_ready() {
warn!("finalize_enrollment_pipeline: models not ready");
return false;
}
let (utterance_embeddings, negative_audio_chunks, owner_negative_chunks) = {
let state = voice_state().read().unwrap_poison();
(
state.enrollment_embeddings.clone(),
state.negative_audio_chunks.clone(),
state.owner_negative_chunks.clone(),
)
};
let enrolled_phrase = voice_state()
.read()
.unwrap_poison()
.enrolling_phrase
.clone()
.unwrap_or_else(|| DEFAULT_WAKE_WORD_PHRASE.to_string());
let prototype = match enrollment_consistency_check(&utterance_embeddings) {
Ok(proto) => proto,
Err(msg) => {
warn!("Enrollment finalization failed: {msg}");
set_status(VoiceStatus::Error(format!("Enrollment failed: {msg}")));
return false;
}
};
let owner_fut = async {
if owner_negative_chunks.is_empty() {
Vec::new()
} else {
extract_negative_embeddings(owner_negative_chunks).await
}
};
let ambient_fut = async {
if negative_audio_chunks.is_empty() {
Vec::new()
} else {
extract_negative_embeddings(negative_audio_chunks).await
}
};
let (owner_embs, ambient_embs) = tokio::join!(owner_fut, ambient_fut);
info!(
"Enrollment: encoded {} owner-negative + {} ambient-negative embeddings",
owner_embs.len(),
ambient_embs.len(),
);
let mut negative_embeddings: Vec<Vec<f32>> = owner_embs;
negative_embeddings.extend(ambient_embs);
{
let mut state = voice_state().write().unwrap_poison();
state.negative_audio_chunks.clear();
state.owner_negative_chunks.clear();
}
let calibration = calibrate_negatives(&prototype, &negative_embeddings);
let created_at = get_enrollment()
.filter(|e| !e.created_at.is_empty())
.map_or_else(turso::now, |e| e.created_at.clone());
let trained_at = turso::now();
let Some(enrollment) = WakeWordEnrollment::build(
enrolled_phrase.clone(),
&utterance_embeddings,
calibration,
&negative_embeddings,
created_at,
trained_at,
) else {
warn!("Enrollment finalization failed: could not build enrollment record");
set_status(VoiceStatus::Error(
"Enrollment failed — please re-enroll".to_string(),
));
return false;
};
if crate::shutdown::shutdown_token().is_cancelled() {
warn!(
"finalize_enrollment_pipeline: cancelled during finalization, \
not persisting enrollment state"
);
return false;
}
if let Err(e) = run_enrollment_self_test(&utterance_embeddings, &enrollment) {
warn!("Enrollment self-test failed — model rejected: {e}. Re-enrollment required.");
set_status(VoiceStatus::Error(format!(
"Enrollment validation failed: {e}. Please try again with clearer speech."
)));
return false;
}
info!("Enrollment self-test: passed — deploying model");
set_enrollment(enrollment.clone());
voice_state().write().unwrap_poison().model_phrase = Some(enrolled_phrase.clone());
if !persist_enrollment(&enrollment).await {
warn!("Enrollment persisted to memory but failed to save to config DB");
return false;
}
{
let mut state = voice_state().write().unwrap_poison();
state.enrollment_embeddings.clear();
state.enrolled_utterance_count = 0;
state.utterances_collected = false;
}
true
}
#[expect(clippy::too_many_lines)]
pub async fn run_voice_pipeline() {
info!("Voice pipeline starting...");
let shutdown_token = crate::shutdown::shutdown_token();
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<VoiceCommand>();
{
let mut state = voice_state().write().unwrap_poison();
state.cmd_tx = Some(cmd_tx);
}
if let Some(json) = CONFIG.wake_word_templates() {
match serde_json::from_str::<WakeWordEnrollment>(&json) {
Ok(enr)
if enr.schema_version == wake_word::ENROLLMENT_SCHEMA_VERSION
&& enr.embedding_dim == WAKE_WORD_EMBEDDING_DIM
&& enr.prototype.len() == WAKE_WORD_EMBEDDING_DIM =>
{
let phrase = enr.phrase.clone();
set_enrollment(enr);
voice_state().write().unwrap_poison().model_phrase = Some(phrase.clone());
info!("Loaded wake word enrollment (v2, phrase={phrase})");
}
Ok(_) | Err(_) => {
warn!(
"Stored wake word enrollment is incompatible or legacy (v1). \
Re-enrollment required."
);
}
}
}
let transcription_disabled = is_transcription_disabled();
if transcription_disabled {
warn!(
"Voice assistant: local transcription disabled — wake word is disabled too \
(shared ASR model required)"
);
set_status(VoiceStatus::Disabled);
} else {
set_status(resolved_model_status(
crate::audio::local_transcriber::is_loaded(),
crate::audio::local_transcriber::is_failed(),
is_enabled(),
));
}
let mut ctx = PipelineCtx::new();
if ctx.auto_start_pending && !transcription_disabled {
set_enabled(true);
info!("Voice assistant enabled in config — will auto-start when models are ready");
}
ctx.check_auto_start();
loop {
tokio::select! {
() = shutdown_token.cancelled() => {
info!("Voice pipeline shutting down");
ctx.handle_shutdown();
break;
}
cmd = cmd_rx.recv() => {
match cmd {
Some(VoiceCommand::StartListening) => ctx.handle_start_listening(),
Some(VoiceCommand::StopListening) => {
if ctx.handle_stop_listening() {
ctx.broadcast_voice_notice(
"*Voice: recording discarded — voice assistant turned off*",
)
.await;
}
}
Some(VoiceCommand::StartEnrollment(phrase)) => {
if ctx.handle_start_enrollment(&phrase) {
ctx.broadcast_voice_notice(
"*Voice: recording discarded — enrollment started*",
)
.await;
}
}
Some(VoiceCommand::CancelEnrollment) => ctx.handle_cancel_enrollment(),
Some(VoiceCommand::RetryModelLoading) => {
if crate::audio::local_transcriber::retry_init() {
ctx.last_model_retry = Some(Instant::now());
set_status(VoiceStatus::LoadingModels);
} else {
warn!("RetryModelLoading: transcriber is not in Failed state");
}
}
Some(VoiceCommand::StartRecording) => {
if let Some(msg) = ctx.handle_start_manual_recording() {
ctx.broadcast_voice_notice(msg).await;
}
}
Some(VoiceCommand::StopRecordingSend) => {
ctx.handle_stop_manual_recording(true).await;
}
Some(VoiceCommand::StopRecordingDiscard) => {
ctx.handle_stop_manual_recording(false).await;
}
Some(VoiceCommand::Shutdown) | None => break,
}
}
audio_chunk = async {
if let Some(rx) = &mut ctx.mic_rx {
rx.recv().await
} else {
std::future::pending::<Option<Vec<f32>>>().await
}
} => {
let Some(samples) = audio_chunk else {
warn!("Microphone stream ended");
set_status(VoiceStatus::MicDisconnected);
if ctx.handle_stop_listening() {
ctx.broadcast_voice_notice(
"*Voice: recording discarded — microphone disconnected*",
)
.await;
}
continue;
};
if crate::audio::tts::is_playback_active() {
continue;
}
if ctx.collecting_negatives {
handle_negative_collection_audio(&samples, &mut ctx);
} else if ctx.enrollment_mode {
let (sample, total) = {
let state = voice_state().read().unwrap_poison();
(state.enrolled_utterance_count, NUM_ENROLLMENT_SAMPLES)
};
handle_enrollment_audio(&samples, &mut ctx, sample, total);
} else if ctx.manual_recording {
ctx.handle_manual_recording_audio(&samples).await;
} else if ctx.is_recording {
handle_recording_audio(samples, &mut ctx).await;
} else {
handle_wake_word_detection(&samples, &mut ctx);
}
}
() = tokio::time::sleep(Duration::from_secs(1)) => {
if crate::audio::local_transcriber::is_failed() {
if !matches!(get_status(), VoiceStatus::ModelError) {
set_status(VoiceStatus::ModelError);
}
} else if matches!(get_status(), VoiceStatus::LoadingModels) {
let resolved = resolved_model_status(
crate::audio::local_transcriber::is_loaded(),
crate::audio::local_transcriber::is_failed(),
is_enabled(),
);
if !matches!(resolved, VoiceStatus::LoadingModels) {
set_status(resolved);
}
}
}
}
if crate::audio::local_transcriber::is_failed() {
ctx.try_retry_models_auto();
}
let utterances_collected = voice_state().read().unwrap_poison().utterances_collected;
if utterances_collected && !ctx.collecting_negatives {
ctx.transition_to_phase3();
}
if ctx.collecting_negatives {
let target_samples = SAMPLE_RATE as usize * NEGATIVES_TARGET_SECONDS;
let target_met = ctx.negatives_speech_samples >= target_samples;
let timed_out = ctx
.phase3_start_time
.is_some_and(|t| t.elapsed() >= Duration::from_secs(PHASE3_TIMEOUT_SECS));
if target_met || timed_out {
if !ctx.phase3_audio_buf.is_empty() {
push_owner_negative_chunk(
std::mem::take(&mut ctx.phase3_audio_buf),
"residual",
);
}
ctx.phase3_processed = 0;
ctx.phase3_silence_samples = 0;
let collected_secs = {
#[expect(clippy::cast_precision_loss)]
{
(ctx.negatives_speech_samples as f64) / f64::from(SAMPLE_RATE)
}
};
info!(
"Phase 3 complete: {:.1}s VAD-positive speech collected \
(target {}s){}",
collected_secs,
NEGATIVES_TARGET_SECONDS,
if timed_out {
" (wall-clock timeout)"
} else {
""
},
);
let success = finalize_enrollment_pipeline().await;
if success {
set_status(VoiceStatus::Enrolled);
schedule_listening_transition(&mut ctx);
}
} else {
let accumulated_secs = ctx.negatives_speech_samples / SAMPLE_RATE as usize;
let wall_clock_elapsed = ctx.phase3_start_time.map_or(0, |t| t.elapsed().as_secs());
set_status(VoiceStatus::EnrollingNegatives {
accumulated_secs,
target_secs: NEGATIVES_TARGET_SECONDS,
wall_clock_elapsed,
});
}
}
if !utterances_collected && let Some(samples) = ctx.enrollment_pending.pop_front() {
let noise_rms = ctx.noise_rms_estimate.take();
handle_enrollment_sample(samples, noise_rms).await;
}
ctx.check_refractory_period();
ctx.check_auto_start();
}
info!("Voice pipeline exited");
}
#[must_use]
pub(crate) fn normalize_phrase(s: &str) -> String {
let normalized = s
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase();
if normalized.is_empty() {
DEFAULT_WAKE_WORD_PHRASE.to_string()
} else {
normalized
}
}
#[must_use]
pub fn get_enrolled_phrase() -> Option<String> {
voice_state().read().unwrap_poison().model_phrase.clone()
}
async fn persist_enrollment(enrollment: &WakeWordEnrollment) -> bool {
let Ok(json) = serde_json::to_string(enrollment) else {
warn!("Failed to serialize wake word enrollment for persistence");
return false;
};
let store = crate::config_db::store();
if let Err(e) = store.set_kv(CONFIG_KEY_WAKE_WORD_TEMPLATES, &json).await {
warn!("Failed to persist wake word enrollment: {e}");
return false;
}
if !CONFIG.set_string_field(CONFIG_KEY_WAKE_WORD_TEMPLATES, &json) {
warn!(
"Failed to update CONFIG with wake word enrollment (key not recognized by \
set_string_field — it may have drifted from the `stringify!` arms)"
);
return false;
}
true
}
#[expect(clippy::cast_precision_loss)]
async fn handle_recording_audio(samples: Vec<f32>, ctx: &mut PipelineCtx) {
ctx.command_buffer.extend_from_slice(&samples);
let speech = is_speech_with_threshold(&samples, ctx.vad_threshold);
if speech {
ctx.silence_sample_count = 0;
} else {
ctx.silence_sample_count += samples.len();
}
let duration_secs = ctx.command_buffer.len() as f64 / f64::from(SAMPLE_RATE);
let silence_timeout = ctx.silence_sample_count >= SILENCE_THRESHOLD_SAMPLES;
if silence_timeout || duration_secs > MAX_RECORD_SECS as f64 {
debug!(
"Recording stopped: {:.1}s, reason: {}",
duration_secs,
if silence_timeout {
"silence"
} else {
"max duration"
}
);
set_status(VoiceStatus::Transcribing);
let cmd_buf = std::mem::take(&mut ctx.command_buffer);
match transcribe_audio(&cmd_buf).await {
Ok(transcribed) => {
if transcribed.trim().is_empty() {
warn!(
"Empty transcription — dropping ({} samples, {:.1}s)",
cmd_buf.len(),
duration_secs,
);
} else {
route_to_agent(transcribed).await;
}
ctx.reset_pipeline_state(ResetLevel::Soft);
ctx.is_recording = false;
set_status(VoiceStatus::Listening);
}
Err(e) => {
warn!("Transcription failed: {e}");
set_status(VoiceStatus::Error("Transcription failed".to_string()));
ctx.broadcast_transcription_error().await;
ctx.refractory_until = Some(Instant::now() + Duration::from_secs(3));
ctx.reset_pipeline_state(ResetLevel::Soft);
ctx.is_recording = false;
}
}
}
}
fn check_enrollment_utterance_length(duration_ms: u64) -> Result<(), String> {
if duration_ms < ENROLLMENT_QUALITY_DURATION_MIN_MS {
Err(format!(
"Utterance too short ({duration_ms}ms) — speak longer"
))
} else {
Ok(())
}
}
#[expect(clippy::cast_precision_loss)]
async fn handle_enrollment_sample(samples: Vec<f32>, noise_rms: Option<f32>) {
if !models_ready() {
warn!("Models not ready for enrollment");
return;
}
let duration_ms = samples_to_ms(samples.len(), SAMPLE_RATE);
if let Err(msg) = check_enrollment_utterance_length(duration_ms) {
warn!("{msg}");
set_status(VoiceStatus::Error(msg));
return;
}
let quality = compute_utterance_quality(&samples, noise_rms);
let Some(model) = crate::audio::local_transcriber::shared_model_arc() else {
warn!("ASR model not loaded — skipping enrollment sample");
return;
};
let embedding = tokio::task::spawn_blocking(move || encode_window(&model, &samples))
.await
.unwrap_or_else(|e| Err(anyhow!("Enrollment encode task panicked: {e}")));
let embedding = match embedding {
Ok(emb) => emb,
Err(e) => {
warn!("Enrollment sample encoding failed: {e}");
return;
}
};
let utterance_count = {
let mut state = voice_state().write().unwrap_poison();
state.enrollment_embeddings.push(embedding);
state.enrolled_utterance_count += 1;
state.enrolled_utterance_count
};
info!(
"Enrolled utterance {utterance_count}/{NUM_ENROLLMENT_SAMPLES} \
({:.1}s, quality={:.2})",
duration_ms as f64 / 1000.0,
quality.score,
);
if utterance_count >= NUM_ENROLLMENT_SAMPLES {
voice_state().write().unwrap_poison().utterances_collected = true;
} else {
set_status(VoiceStatus::Enrolling {
sample: utterance_count,
total: NUM_ENROLLMENT_SAMPLES,
duration_ms,
quality: Some(quality),
});
}
}
#[expect(clippy::too_many_lines)]
pub(crate) fn handle_wake_word_detection(samples: &[f32], ctx: &mut PipelineCtx) {
if let Some(last) = ctx.last_wake_word_detection
&& last.elapsed() < WAKE_WORD_COOLDOWN
{
debug!(
"Wake word cooldown active ({}ms elapsed)",
last.elapsed().as_millis()
);
ctx.audio_buffer.extend_from_slice(samples);
if ctx.audio_buffer.len() > AUDIO_BUFFER_MAX {
let excess = ctx.audio_buffer.len() - AUDIO_BUFFER_MAX;
ctx.audio_buffer.drain(..excess);
}
return;
}
ctx.audio_buffer.extend_from_slice(samples);
if ctx.audio_buffer.len() > AUDIO_BUFFER_MAX {
let excess = ctx.audio_buffer.len() - AUDIO_BUFFER_MAX;
ctx.audio_buffer.drain(..excess);
ctx.vad_cursor = ctx.vad_cursor.saturating_sub(excess);
}
let mut speech_seen_this_call = false;
let mut hop_count = ctx.segment_silence_hops;
while ctx.vad_cursor + FRAME_LENGTH <= ctx.audio_buffer.len() {
let frame = &ctx.audio_buffer[ctx.vad_cursor..ctx.vad_cursor + FRAME_LENGTH];
#[cfg(feature = "voice-tests")]
let is_speech = if let Some(ref mut det) = ctx.injected_vad {
is_speech_with_detector(&frame[..HOP_LENGTH], det, VAD_THRESHOLD)
} else {
is_speech_with_threshold(&frame[..HOP_LENGTH], VAD_THRESHOLD)
};
#[cfg(not(feature = "voice-tests"))]
let is_speech = is_speech_with_threshold(&frame[..HOP_LENGTH], VAD_THRESHOLD);
if is_speech {
speech_seen_this_call = true;
hop_count = 0;
#[cfg(feature = "voice-tests")]
{
ctx.instrumentation.vad_speech_frames += 1;
}
ctx.speech_window.extend_from_slice(&frame[..HOP_LENGTH]);
if ctx.speech_window.len() > WAKE_WORD_WINDOW_SAMPLES {
let excess = ctx.speech_window.len() - WAKE_WORD_WINDOW_SAMPLES;
ctx.speech_window.drain(..excess);
}
} else {
hop_count += 1;
}
ctx.vad_cursor += HOP_LENGTH;
ctx.last_score_sample_count += HOP_LENGTH;
}
ctx.handle_segment_boundary(hop_count);
if !ctx.is_recording
&& ctx.last_score_sample_count >= SCORE_STRIDE_SAMPLES
&& (speech_seen_this_call || !ctx.score_window.is_empty())
{
ctx.last_score_sample_count = 0;
let Some(enrollment) = voice_state().read().unwrap_poison().enrollment.clone() else {
return;
};
let Some(model) = crate::audio::local_transcriber::shared_model_arc() else {
return;
};
let start = ctx
.speech_window
.len()
.saturating_sub(WAKE_WORD_WINDOW_SAMPLES);
let window = &ctx.speech_window[start..];
let embedding = crate::util::with_block_in_place(|| encode_window(&model, window));
match embedding {
Ok(embedding) => {
let (detected, _rolling_sum, _total_score, _effective_threshold) =
score_single_embedding(
&embedding,
Some(&enrollment),
&mut ctx.score_window,
Some(&mut ctx.adaptive_threshold),
ctx.adaptive_k,
);
#[cfg(feature = "voice-tests")]
#[expect(clippy::used_underscore_binding)]
{
if _rolling_sum > ctx.instrumentation.peak_score {
ctx.instrumentation.peak_score = _rolling_sum;
}
}
if detected {
ctx.is_recording = true;
ctx.last_wake_word_detection = Some(Instant::now());
set_status(VoiceStatus::Recording);
}
}
Err(e) => {
warn!("Wake word window encoding failed: {e}");
}
}
}
if ctx.is_recording {
let audio = std::mem::take(&mut ctx.audio_buffer);
ctx.reset_pipeline_state(ResetLevel::Soft);
ctx.command_buffer.extend_from_slice(&audio);
ctx.last_score_sample_count = 0;
}
}
fn handle_enrollment_audio(samples: &[f32], ctx: &mut PipelineCtx, sample: usize, total: usize) {
ctx.audio_buffer.extend_from_slice(samples);
ctx.frame_raw_audio.extend_from_slice(samples);
let len = ctx.audio_buffer.len();
let mut consumed = 0;
while consumed + FRAME_LENGTH <= len {
let frame = &ctx.audio_buffer[consumed..consumed + FRAME_LENGTH];
let is_speech = if let Some(ref mut det) = ctx.enrollment_vad {
is_speech_with_detector(&frame[..HOP_LENGTH], det, ctx.vad_threshold)
} else {
is_speech_with_threshold(&frame[..HOP_LENGTH], ctx.vad_threshold)
};
ctx.frame_vad.push(is_speech);
if is_speech {
ctx.vad_positives_in_a_row += 1;
ctx.enrollment_no_speech_frame_count = 0;
if ctx.vad_positives_in_a_row >= ENROLLMENT_VAD_CONSECUTIVE_REQUIRED {
let was_waiting_for_silence = ctx.utterance_silence_samples > 0;
let already_had_speech = ctx.utterance_had_speech;
if !already_had_speech {
if ctx.negative_audio_buf.len() >= MIN_NEGATIVE_AUDIO_LEN {
let mut state = voice_state().write().unwrap_poison();
if state.negative_audio_chunks.len() >= MAX_NEGATIVE_AUDIO_CHUNKS {
warn!(
"negative_audio_chunks at max ({}): discarding oldest chunk \
to cap memory growth",
MAX_NEGATIVE_AUDIO_CHUNKS,
);
state.negative_audio_chunks.remove(0);
}
state
.negative_audio_chunks
.push(std::mem::take(&mut ctx.negative_audio_buf));
} else {
ctx.negative_audio_buf.clear();
}
}
if !already_had_speech && ctx.noise_rms_estimate.is_none() {
let speech_boundary = ENROLLMENT_VAD_CONSECUTIVE_REQUIRED * HOP_LENGTH;
let pre_speech_end = ctx.audio_buffer.len().saturating_sub(speech_boundary);
if pre_speech_end > 0 {
let rms = crate::util::compute_rms(&ctx.audio_buffer[..pre_speech_end]);
ctx.noise_rms_estimate = Some(rms);
}
}
if !already_had_speech || was_waiting_for_silence {
set_status(VoiceStatus::ListeningDuringEnrollment { sample, total });
}
ctx.utterance_had_speech = true;
ctx.utterance_silence_samples = 0;
} else if ctx.utterance_had_speech {
ctx.utterance_silence_samples = 0;
}
} else {
ctx.vad_positives_in_a_row = 0;
if ctx.utterance_had_speech {
ctx.utterance_silence_samples += HOP_LENGTH;
let silence_ui_check = ctx.utterance_silence_samples;
if ctx.utterance_silence_samples >= ENROLLMENT_SILENCE_THRESHOLD_SAMPLES {
ctx.utterance_had_speech = false;
ctx.utterance_silence_samples = 0;
ctx.enrollment_no_speech_frame_count = 0;
ctx.vad_positives_in_a_row = 0;
}
if silence_ui_check < SILENCE_UI_GATE_SAMPLES {
set_status(VoiceStatus::WaitingForSilenceDuringEnrollment { sample, total });
}
} else if !ctx.utterance_had_speech {
ctx.negative_audio_buf
.extend_from_slice(&frame[..HOP_LENGTH]);
ctx.enrollment_no_speech_frame_count += 1;
if ctx.enrollment_no_speech_frame_count >= ENROLLMENT_NO_SPEECH_TIMEOUT_FRAMES {
set_status(VoiceStatus::Error(
"No speech detected — try speaking louder or move closer to microphone"
.to_string(),
));
}
}
}
consumed += HOP_LENGTH;
}
if consumed > 0 {
ctx.audio_buffer.drain(..consumed);
}
if !ctx.frame_vad.is_empty() {
let utterances = segment_utterances_by_vad(
&ctx.frame_raw_audio,
&ctx.frame_vad,
&DEFAULT_VAD_SEGMENTATION_CONFIG,
);
while utterances.len() > ctx.emitted_utterances {
let new_idx = ctx.emitted_utterances;
ctx.emitted_utterances += 1;
let utterance = utterances[new_idx].clone();
ctx.enrollment_pending.push_back(utterance);
ctx.utterance_had_speech = false;
ctx.utterance_silence_samples = 0;
ctx.vad_positives_in_a_row = 0;
ctx.enrollment_no_speech_frame_count = 0;
}
}
}
fn push_owner_negative_chunk(chunk: Vec<f32>, label: &str) {
let mut state = voice_state().write().unwrap_poison();
let total_samples: usize = state
.owner_negative_chunks
.iter()
.map(std::vec::Vec::len)
.sum();
if total_samples + chunk.len() <= MAX_OWNER_NEGATIVE_SAMPLES {
state.owner_negative_chunks.push(chunk);
} else {
warn!(
"owner_negative_chunks at capacity ({} samples): \
dropping {label} chunk of {} samples",
MAX_OWNER_NEGATIVE_SAMPLES,
chunk.len(),
);
}
}
struct Phase3Progress {
processed: usize,
silence_samples: usize,
negatives_speech_samples: usize,
completed_chunks: Vec<Vec<f32>>,
}
fn process_phase3_frames(
buf: &mut Vec<f32>,
processed: usize,
silence_samples: usize,
negatives_speech_samples: usize,
mut vad: impl FnMut(&[f32]) -> bool,
) -> Phase3Progress {
let len = buf.len();
let mut consumed = processed;
let mut segment_start = 0;
let mut silence_samples = silence_samples;
let mut negatives_speech_samples = negatives_speech_samples;
let mut completed_chunks: Vec<Vec<f32>> = Vec::new();
while consumed + FRAME_LENGTH <= len {
let is_speech = vad(&buf[consumed..consumed + HOP_LENGTH]);
if is_speech {
negatives_speech_samples += HOP_LENGTH;
silence_samples = 0;
} else {
silence_samples += HOP_LENGTH;
if silence_samples >= ENROLLMENT_SILENCE_THRESHOLD_SAMPLES {
let chunk_end = consumed.saturating_sub(silence_samples);
if chunk_end > segment_start {
completed_chunks.push(buf[segment_start..chunk_end].to_vec());
}
segment_start = consumed;
silence_samples = 0;
}
}
consumed += HOP_LENGTH;
}
if segment_start > 0 {
buf.drain(..segment_start);
consumed -= segment_start;
}
Phase3Progress {
processed: consumed,
silence_samples,
negatives_speech_samples,
completed_chunks,
}
}
fn handle_negative_collection_audio(samples: &[f32], ctx: &mut PipelineCtx) {
ctx.phase3_audio_buf.extend_from_slice(samples);
let progress = process_phase3_frames(
&mut ctx.phase3_audio_buf,
ctx.phase3_processed,
ctx.phase3_silence_samples,
ctx.negatives_speech_samples,
|hop| {
if let Some(ref mut det) = ctx.enrollment_vad {
is_speech_with_detector(hop, det, ctx.vad_threshold)
} else {
is_speech_with_threshold(hop, ctx.vad_threshold)
}
},
);
ctx.phase3_processed = progress.processed;
ctx.phase3_silence_samples = progress.silence_samples;
ctx.negatives_speech_samples = progress.negatives_speech_samples;
for chunk in progress.completed_chunks {
push_owner_negative_chunk(chunk, "speech");
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
const TEST_VAD_CONFIG: VadSegmentationConfig = VadSegmentationConfig {
frame_length: FRAME_LENGTH,
hop_length: HOP_LENGTH,
consecutive_required: ENROLLMENT_VAD_CONSECUTIVE_REQUIRED,
silence_threshold_samples: HOP_LENGTH * 10, context_padding_samples: 0,
raw_ring_max: RAW_RING_MAX,
};
fn audio_for_frames(n_frames: usize) -> Vec<f32> {
let last_end = (n_frames.saturating_sub(1)) * HOP_LENGTH + FRAME_LENGTH;
vec![0.0f32; last_end]
}
#[test]
fn segment_no_speech_returns_empty() {
let n_frames = 10;
let audio = audio_for_frames(n_frames);
let vad = vec![false; n_frames];
let utterances = segment_utterances_by_vad(&audio, &vad, &TEST_VAD_CONFIG);
assert!(utterances.is_empty(), "no speech → no utterances");
}
#[test]
fn segment_single_utterance_detected() {
let audio = audio_for_frames(14);
let mut vad = vec![true; 4];
vad.extend(vec![false; 10]);
let utterances = segment_utterances_by_vad(&audio, &vad, &TEST_VAD_CONFIG);
assert_eq!(
utterances.len(),
1,
"sustained speech + silence → 1 utterance"
);
assert!(
!utterances[0].is_empty(),
"utterance should contain audio samples"
);
}
#[test]
fn segment_multiple_utterances_separated_by_silence() {
let n_frames = 4 + 10 + 3 + 10;
let audio = audio_for_frames(n_frames);
let mut vad = vec![true; 4];
vad.extend(vec![false; 10]);
vad.extend(vec![true; 3]);
vad.extend(vec![false; 10]);
let utterances = segment_utterances_by_vad(&audio, &vad, &TEST_VAD_CONFIG);
assert_eq!(utterances.len(), 2, "two speech segments → two utterances");
for (i, utt) in utterances.iter().enumerate() {
assert!(
!utt.is_empty(),
"utterance {i} should contain audio samples"
);
}
}
#[test]
fn segment_utterance_at_end_without_silence_not_emitted() {
let n_frames = 12;
let audio = audio_for_frames(n_frames);
let mut vad = vec![false; 8];
vad.extend(vec![true; 4]);
let utterances = segment_utterances_by_vad(&audio, &vad, &TEST_VAD_CONFIG);
assert!(
utterances.is_empty(),
"speech at end without trailing silence → no utterance",
);
}
#[test]
fn vad_segmentation_config_defaults() {
assert_eq!(DEFAULT_VAD_SEGMENTATION_CONFIG.frame_length, FRAME_LENGTH);
assert_eq!(DEFAULT_VAD_SEGMENTATION_CONFIG.hop_length, HOP_LENGTH);
assert_eq!(
DEFAULT_VAD_SEGMENTATION_CONFIG.consecutive_required,
ENROLLMENT_VAD_CONSECUTIVE_REQUIRED
);
assert_eq!(
DEFAULT_VAD_SEGMENTATION_CONFIG.silence_threshold_samples,
ENROLLMENT_SILENCE_THRESHOLD_SAMPLES
);
assert_eq!(DEFAULT_VAD_SEGMENTATION_CONFIG.context_padding_samples, 0);
assert_eq!(DEFAULT_VAD_SEGMENTATION_CONFIG.raw_ring_max, RAW_RING_MAX);
}
#[test]
#[serial_test::serial(voice)]
fn refractory_period_transition_table() {
let _ = init_global();
#[expect(clippy::type_complexity)] let cases: [(
&str,
bool,
bool,
VoiceStatus,
fn(&VoiceStatus) -> bool,
bool,
); 4] = [
(
"elapsed_error_to_listening",
true,
false,
VoiceStatus::Error("test error".to_string()),
|s| matches!(s, VoiceStatus::Listening),
true,
),
(
"elapsed_disabled_stays",
true,
false,
VoiceStatus::Disabled,
|s| matches!(s, VoiceStatus::Disabled),
true,
),
(
"elapsed_recording_stays_error",
true,
true,
VoiceStatus::Error("test error".to_string()),
|s| matches!(s, VoiceStatus::Error(_)),
true,
),
(
"future_timer_preserved",
false,
false,
VoiceStatus::Error("test error".to_string()),
|s| matches!(s, VoiceStatus::Error(_)),
false,
),
];
for (name, timer_elapsed, is_recording, initial, expect, timer_cleared) in cases {
let mut ctx = PipelineCtx::new();
ctx.is_recording = is_recording;
ctx.refractory_until = Some(if timer_elapsed {
Instant::now()
.checked_sub(Duration::from_secs(1))
.expect("1s in the past should not underflow")
} else {
Instant::now()
.checked_add(Duration::from_mins(1))
.expect("60s in the future should not overflow")
});
set_status(initial);
ctx.check_refractory_period();
assert!(
expect(&get_status()),
"case {name}: unexpected status after refractory check",
);
assert_eq!(
ctx.refractory_until.is_none(),
timer_cleared,
"case {name}: refractory timer state",
);
}
}
#[test]
#[serial_test::serial(voice)]
fn manual_recording_rejects_while_wake_word_recording() {
let _ = init_global();
let mut ctx = PipelineCtx::new();
ctx.is_recording = true; set_status(VoiceStatus::Recording);
ctx.handle_start_manual_recording();
assert!(!ctx.manual_recording);
assert!(!ctx.resume_listening_after_recording);
assert!(matches!(get_status(), VoiceStatus::Recording));
}
#[test]
#[serial_test::serial(voice)]
fn manual_recording_rejects_during_enrollment() {
let _ = init_global();
let mut ctx = PipelineCtx::new();
ctx.enrollment_mode = true;
set_status(VoiceStatus::Listening);
ctx.handle_start_manual_recording();
assert!(!ctx.manual_recording);
assert!(matches!(get_status(), VoiceStatus::Listening));
}
#[test]
#[serial_test::serial(voice)]
fn manual_recording_end_resumes_wake_word_listening() {
let _ = init_global();
let mut ctx = PipelineCtx::new();
ctx.is_listening = true; ctx.manual_recording = true;
ctx.resume_listening_after_recording = true;
set_status(VoiceStatus::RecordingManual);
ctx.end_manual_recording();
assert!(!ctx.manual_recording);
assert!(!ctx.resume_listening_after_recording);
assert!(ctx.is_listening);
assert!(matches!(get_status(), VoiceStatus::Listening));
}
#[test]
#[serial_test::serial(voice)]
fn manual_recording_end_tears_down_recording_only_mic() {
let _ = init_global();
let mut ctx = PipelineCtx::new();
ctx.manual_recording = true; set_status(VoiceStatus::RecordingManual);
ctx.end_manual_recording();
assert!(!ctx.manual_recording);
assert!(!ctx.is_listening);
assert!(ctx.mic_rx.is_none());
assert!(matches!(get_status(), VoiceStatus::Disabled));
}
#[tokio::test]
#[serial_test::serial(voice)]
async fn manual_recording_discard_clears_buffer_and_ends() {
let _ = init_global();
let mut ctx = PipelineCtx::new();
ctx.manual_recording = true;
ctx.command_buffer.extend_from_slice(&[0.1, 0.2, 0.3]);
set_status(VoiceStatus::RecordingManual);
ctx.handle_stop_manual_recording(false).await;
assert!(!ctx.manual_recording);
assert!(ctx.command_buffer.is_empty());
assert!(matches!(get_status(), VoiceStatus::Disabled));
}
#[test]
#[serial_test::serial(voice)]
fn manual_recording_teardown_preserves_auto_start_pending() {
let _ = init_global();
let mut ctx = PipelineCtx::new();
ctx.auto_start_pending = true;
ctx.manual_recording = true; set_status(VoiceStatus::RecordingManual);
ctx.end_manual_recording();
assert!(!ctx.manual_recording);
assert!(ctx.auto_start_pending);
assert!(!ctx.is_listening);
assert!(matches!(get_status(), VoiceStatus::Disabled));
}
#[test]
#[serial_test::serial(voice)]
fn manual_recording_aborted_when_enrollment_starts() {
let _ = init_global();
let mut ctx = PipelineCtx::new();
ctx.is_listening = true; ctx.manual_recording = true;
ctx.command_buffer.extend_from_slice(&[0.1, 0.2, 0.3]);
ctx.resume_listening_after_recording = true;
set_status(VoiceStatus::RecordingManual);
assert!(ctx.handle_start_enrollment("mahbot"));
assert!(!ctx.manual_recording);
assert!(!ctx.resume_listening_after_recording);
assert!(ctx.command_buffer.is_empty());
assert!(ctx.enrollment_mode);
}
#[test]
fn resolved_model_status_failure_is_terminal() {
assert!(matches!(
resolved_model_status(false, true, true),
VoiceStatus::ModelError
));
assert!(matches!(
resolved_model_status(false, true, false),
VoiceStatus::ModelError
));
}
#[test]
fn resolved_model_status_loaded_resolves_by_enabled() {
assert!(matches!(
resolved_model_status(true, false, true),
VoiceStatus::Listening
));
assert!(matches!(
resolved_model_status(true, false, false),
VoiceStatus::Disabled
));
}
#[test]
fn resolved_model_status_still_loading_is_transient() {
assert!(matches!(
resolved_model_status(false, false, true),
VoiceStatus::LoadingModels
));
assert!(matches!(
resolved_model_status(false, false, false),
VoiceStatus::LoadingModels
));
}
#[test]
fn rate_limit_error_message_table() {
let cases = [
("no_prior_error", None, true),
("recent_error", Some(0), false),
("old_error_15s", Some(15), true),
("exact_threshold_10s", Some(10), true),
("just_below_9s", Some(9), false),
];
for (name, elapsed_secs, expected) in cases {
let mut ctx = PipelineCtx::new();
ctx.last_error_message_time = elapsed_secs.map(|secs| {
Instant::now()
.checked_sub(Duration::from_secs(secs))
.expect("elapsed seconds in the past should not underflow")
});
assert_eq!(
ctx.should_send_error_message(),
expected,
"case {name}: 10s error-message rate limit",
);
}
}
#[test]
fn voice_notice_limiter_is_independent_of_error_limiter() {
let mut ctx = PipelineCtx::new();
ctx.last_error_message_time = Some(Instant::now());
assert!(ctx.should_send_voice_notice());
ctx.last_voice_notice_time = Some(Instant::now());
assert!(!ctx.should_send_voice_notice());
}
#[test]
fn adaptive_after_bootstrap_returns_some() {
let mut state = AdaptiveThresholdState::new();
for i in 0..ADAPTIVE_BOOTSTRAP_FRAMES {
assert!(
state.feed(0.5, ADAPTIVE_K_DEFAULT).is_none(),
"frame {i} should return None during bootstrap",
);
}
let result = state.feed(0.5, ADAPTIVE_K_DEFAULT);
assert!(result.is_some(), "should return Some after bootstrap");
}
#[test]
fn adaptive_safe_harbor_enforced() {
let mut state = AdaptiveThresholdState::new();
for _ in 0..ADAPTIVE_BOOTSTRAP_FRAMES {
state.feed(0.1, ADAPTIVE_K_DEFAULT);
}
let result = state.feed(0.1, ADAPTIVE_K_DEFAULT);
let threshold = result.expect("should return Some after bootstrap");
assert!(
(threshold - ADAPTIVE_SAFE_HARBOR).abs() < 0.01,
"with constant low score, threshold {threshold} should equal safe harbor {ADAPTIVE_SAFE_HARBOR}",
);
}
#[test]
fn adaptive_ceiling_enforced() {
let mut state = AdaptiveThresholdState::new();
for _ in 0..ADAPTIVE_BOOTSTRAP_FRAMES {
state.feed(0.99, ADAPTIVE_K_DEFAULT);
}
for i in ADAPTIVE_BOOTSTRAP_FRAMES..ADAPTIVE_WINDOW_N {
let score = if i % 2 == 0 { 1.0 } else { 0.0 };
state.feed(score, ADAPTIVE_K_DEFAULT);
}
let result = state.feed(1.0, ADAPTIVE_K_DEFAULT);
let threshold = result.expect("should return Some after bootstrap");
assert!(
(threshold - ADAPTIVE_CEILING).abs() < 0.01,
"with high-variance scores, threshold {threshold} should equal ceiling {ADAPTIVE_CEILING}",
);
}
#[test]
fn adaptive_reset_clears_state() {
let mut state = AdaptiveThresholdState::new();
for _ in 0..ADAPTIVE_BOOTSTRAP_FRAMES {
state.feed(0.5, ADAPTIVE_K_DEFAULT);
}
assert!(state.feed(0.5, ADAPTIVE_K_DEFAULT).is_some());
assert_eq!(state.len(), ADAPTIVE_BOOTSTRAP_FRAMES + 1);
state.reset();
assert_eq!(state.len(), 0, "window should be empty after reset");
assert!(
state.feed(0.5, ADAPTIVE_K_DEFAULT).is_none(),
"after reset, first feed should return None (re-enters bootstrap)",
);
}
#[test]
fn adaptive_warmed_clamps_to_safe_harbor() {
let mut state = AdaptiveThresholdState::warmed();
let threshold = state
.feed(0.033, ADAPTIVE_K_DEFAULT)
.expect("warmed() should exit bootstrap");
assert!(
(threshold - ADAPTIVE_SAFE_HARBOR).abs() < 0.01,
"warmed() threshold {threshold} should equal safe harbor {ADAPTIVE_SAFE_HARBOR}",
);
assert_eq!(state.len(), ADAPTIVE_BOOTSTRAP_FRAMES + 1);
}
#[test]
fn adaptive_window_eviction_correctness() {
let mut state = AdaptiveThresholdState::new();
for _ in 0..ADAPTIVE_BOOTSTRAP_FRAMES {
state.feed(0.5, ADAPTIVE_K_DEFAULT);
}
for _ in ADAPTIVE_BOOTSTRAP_FRAMES..ADAPTIVE_WINDOW_N {
state.feed(0.5, ADAPTIVE_K_DEFAULT);
}
#[expect(clippy::cast_precision_loss)] let window_n = ADAPTIVE_WINDOW_N as f32;
#[expect(clippy::cast_precision_loss)] let rolling_n = ROLLING_WINDOW_N as f32;
let expected_mean = (window_n - 1.0) * 0.5 / window_n + 1.0 / window_n;
let result = state.feed(1.0, 0.0); let threshold = result.expect("should return Some");
let expected_raw = expected_mean * rolling_n;
let clamped = expected_raw.clamp(ADAPTIVE_SAFE_HARBOR, ADAPTIVE_CEILING);
assert!(
(threshold - clamped).abs() < 0.001,
"threshold {threshold} should match expected clamped value {clamped} (raw={expected_raw})",
);
}
#[test]
fn adaptive_peek_bootstrap_boundary_and_safe_harbor() {
let mut state = AdaptiveThresholdState::new();
for i in 0..ADAPTIVE_BOOTSTRAP_FRAMES - 1 {
assert!(
state.feed(0.5, ADAPTIVE_K_DEFAULT).is_none(),
"feed frame {i} should be None during bootstrap",
);
assert!(
state.peek(ADAPTIVE_K_DEFAULT).is_none(),
"peek frame {i} should be None during bootstrap",
);
}
assert!(
state.feed(0.5, ADAPTIVE_K_DEFAULT).is_none(),
"last feed during bootstrap should return None",
);
assert!(
state.peek(ADAPTIVE_K_DEFAULT).is_some(),
"peek should return Some after bootstrap is complete",
);
let mut state = AdaptiveThresholdState::new();
for _ in 0..ADAPTIVE_BOOTSTRAP_FRAMES {
state.feed(0.1, ADAPTIVE_K_DEFAULT);
}
let threshold = state
.peek(ADAPTIVE_K_DEFAULT)
.expect("peek should return Some after bootstrap");
assert!(
(threshold - ADAPTIVE_SAFE_HARBOR).abs() < 0.01,
"peek threshold {threshold} should equal safe harbor {ADAPTIVE_SAFE_HARBOR} with constant low input",
);
}
#[test]
fn adaptive_peek_does_not_mutate_state() {
let mut state = AdaptiveThresholdState::new();
for _ in 0..ADAPTIVE_BOOTSTRAP_FRAMES {
state.feed(0.5, ADAPTIVE_K_DEFAULT);
}
state.feed(0.5, ADAPTIVE_K_DEFAULT);
let before_scores = state.scores.clone();
let before_sum = state.sum;
let before_sum_sq = state.sum_sq;
let before_bootstrap = state.bootstrap_count;
for _ in 0..3 {
let _ = state.peek(ADAPTIVE_K_DEFAULT);
}
assert_eq!(state.scores, before_scores, "peek must not modify scores");
assert!(
(state.sum - before_sum).abs() < f32::EPSILON,
"peek must not modify sum",
);
assert!(
(state.sum_sq - before_sum_sq).abs() < f32::EPSILON,
"peek must not modify sum_sq",
);
assert_eq!(
state.bootstrap_count, before_bootstrap,
"peek must not modify bootstrap_count",
);
}
#[test]
fn adaptive_peek_empty_after_reset() {
let mut state = AdaptiveThresholdState::new();
for _ in 0..ADAPTIVE_BOOTSTRAP_FRAMES {
state.feed(0.5, ADAPTIVE_K_DEFAULT);
}
assert!(state.peek(ADAPTIVE_K_DEFAULT).is_some());
state.reset();
assert!(state.peek(ADAPTIVE_K_DEFAULT).is_none());
}
#[test]
fn adaptive_peek_threshold_in_valid_range() {
let mut state = AdaptiveThresholdState::new();
for _ in 0..ADAPTIVE_BOOTSTRAP_FRAMES {
state.feed(0.5, ADAPTIVE_K_DEFAULT);
}
let threshold = state
.peek(ADAPTIVE_K_DEFAULT)
.expect("Some after bootstrap");
assert!(
(ADAPTIVE_SAFE_HARBOR..=ADAPTIVE_CEILING).contains(&threshold),
"peek threshold {threshold} must be within [{ADAPTIVE_SAFE_HARBOR}, {ADAPTIVE_CEILING}]",
);
}
#[test]
fn adaptive_peek_agrees_with_feed_on_same_state() {
let mut state = AdaptiveThresholdState::new();
for _ in 0..ADAPTIVE_BOOTSTRAP_FRAMES {
state.feed(0.5, ADAPTIVE_K_DEFAULT);
}
state.feed(0.7, ADAPTIVE_K_DEFAULT);
let feed_threshold = state.feed(0.3, ADAPTIVE_K_DEFAULT);
let peek_threshold = state.peek(ADAPTIVE_K_DEFAULT);
assert_eq!(feed_threshold, peek_threshold);
}
#[test]
fn score_window_resets_below_no_match_threshold() {
let mut window = vec![0.9, 0.8, 0.7];
let (detected, rolling) =
process_wake_word_score(NO_MATCH_RESET_THRESHOLD - 0.01, &mut window, None, false);
assert!(!detected);
#[expect(clippy::float_cmp)] {
assert_eq!(rolling, 0.0);
}
assert!(
window.is_empty(),
"below-reset score must clear the rolling window"
);
}
#[test]
fn score_window_accumulates_and_fires() {
let mut window = Vec::new();
for score in [0.7, 0.7] {
let (detected, _) = process_wake_word_score(score, &mut window, None, false);
assert!(!detected, "2 frames of 0.7 must not fire (sum 1.4 < 1.65)");
}
let (detected, rolling) = process_wake_word_score(0.7, &mut window, None, false);
assert!(detected, "3 frames of 0.7 must fire (sum 2.1 ≥ 1.65)");
assert!(
(rolling - 2.1).abs() < 1e-6,
"rolling sum should be 2.1, got {rolling}"
);
}
#[test]
fn score_window_preserve_on_reset_keeps_window() {
let mut window = vec![0.9, 0.8];
let (detected, _) = process_wake_word_score(0.1, &mut window, None, true);
assert!(!detected);
assert_eq!(
window.len(),
2,
"preserve_window_on_reset must not clear the window"
);
}
#[test]
fn score_window_adaptive_override_used() {
let mut window = Vec::new();
for _ in 0..3 {
let (detected, _) = process_wake_word_score(0.7, &mut window, Some(3.0), false);
assert!(!detected, "adaptive override must raise the detection bar");
}
}
fn norm_embedding(v: &[f32]) -> Vec<f32> {
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!(norm > 0.0, "test embedding must be non-zero");
v.iter().map(|x| x / norm).collect()
}
fn basis_embedding(d: usize) -> Vec<f32> {
let mut v = vec![0.0; WAKE_WORD_EMBEDDING_DIM];
if d < WAKE_WORD_EMBEDDING_DIM {
v[d] = 1.0;
}
v
}
fn synthetic_enrollment(n_utterances: usize) -> WakeWordEnrollment {
let emb = basis_embedding(0);
WakeWordEnrollment::build(
"mahbot".to_string(),
&vec![emb; n_utterances],
crate::audio::wake_word::Calibration::default(),
&[],
String::new(),
String::new(),
)
.expect("synthetic enrollment")
}
#[test]
fn score_single_no_enrollment_returns_zero_score() {
let mut window = vec![0.9, 0.8];
let emb = norm_embedding(&vec![1.0; WAKE_WORD_EMBEDDING_DIM]);
let (detected, rolling, total, _) =
score_single_embedding(&emb, None, &mut window, None, ADAPTIVE_K_DEFAULT);
assert!(!detected);
#[expect(clippy::float_cmp)] {
assert_eq!(total, 0.0);
}
#[expect(clippy::float_cmp)] {
assert_eq!(rolling, 0.0);
}
assert!(
window.is_empty(),
"zero score must reset the rolling window"
);
}
#[test]
fn score_single_detects_high_cosine_match() {
let enrollment = synthetic_enrollment(MIN_ENROLLMENT_UTTERANCES);
let mut window = Vec::new();
let mut adaptive = AdaptiveThresholdState::new();
let emb = basis_embedding(0);
let mut detected = false;
for _ in 0..ROLLING_WINDOW_N {
let (d, _, total, _) = score_single_embedding(
&emb,
Some(&enrollment),
&mut window,
Some(&mut adaptive),
ADAPTIVE_K_DEFAULT,
);
assert!(
total >= NO_MATCH_RESET_THRESHOLD,
"a prototype match must score above the reset threshold"
);
detected = d;
}
assert!(
detected,
"3 consecutive high-cosine frames must fire detection"
);
}
#[test]
fn score_single_adaptive_feeds_background_only() {
let enrollment = synthetic_enrollment(MIN_ENROLLMENT_UTTERANCES);
let mut window = Vec::new();
let mut adaptive = AdaptiveThresholdState::new();
let bg = basis_embedding(1);
let (detected, _, total, _) = score_single_embedding(
&bg,
Some(&enrollment),
&mut window,
Some(&mut adaptive),
ADAPTIVE_K_DEFAULT,
);
assert!(!detected);
assert!(total < NO_MATCH_RESET_THRESHOLD);
assert!(window.is_empty(), "below-reset score clears the window");
assert_eq!(
adaptive.len(),
1,
"background score must feed the adaptive window"
);
}
#[test]
fn score_single_low_score_resets_window() {
let enrollment = synthetic_enrollment(MIN_ENROLLMENT_UTTERANCES);
let mut window = vec![0.9, 0.8];
let bg = basis_embedding(1);
let (detected, rolling, _, _) = score_single_embedding(
&bg,
Some(&enrollment),
&mut window,
None,
ADAPTIVE_K_DEFAULT,
);
assert!(!detected);
#[expect(clippy::float_cmp)] {
assert_eq!(rolling, 0.0);
}
assert!(window.is_empty(), "low score must reset the rolling window");
}
#[test]
fn consistency_gate_fails_when_too_few_utterances() {
let embs: Vec<Vec<f32>> = vec![basis_embedding(0); 4]; let err = enrollment_consistency_check(&embs).unwrap_err();
assert!(
err.contains("4 enrollment utterances"),
"error should mention the utterance count: {err}"
);
}
#[test]
fn consistency_gate_fails_when_too_few_pass_threshold() {
let mut embs: Vec<Vec<f32>> = vec![basis_embedding(0); 6];
embs.extend(vec![basis_embedding(1); 4]);
let err = enrollment_consistency_check(&embs).unwrap_err();
assert!(
err.contains("6/10"),
"error should report 6/10 passed: {err}"
);
}
#[test]
fn consistency_gate_succeeds_with_high_quality_utterances() {
let mut embs: Vec<Vec<f32>> = vec![basis_embedding(0); 8];
embs.extend(vec![basis_embedding(1); 2]);
assert!(
enrollment_consistency_check(&embs).is_ok(),
"8/10 consistent utterances must pass the gate"
);
}
#[test]
fn consistency_gate_rejects_wrong_dimension() {
let mut embs: Vec<Vec<f32>> = vec![basis_embedding(0); MIN_ENROLLMENT_UTTERANCES];
embs[0] = vec![0.5; 64]; let err = enrollment_consistency_check(&embs).unwrap_err();
assert!(
err.contains("expected 1024"),
"error should mention the dimension mismatch: {err}"
);
}
#[test]
fn test_pcm_cache_key_determinism() {
let h = |text, style, seed| pcm_cache_key(text, style, seed, 16000, "test_hash");
let a = h("hey mahbot", "default", 42);
let b = h("hey mahbot", "default", 42);
assert_eq!(a, b, "same inputs must produce same cache key");
}
#[test]
fn test_pcm_cache_key_sensitivity_to_text() {
let a = pcm_cache_key("hey mahbot", "default", 42, 16000, "hash");
let b = pcm_cache_key("hey jarvis", "default", 42, 16000, "hash");
assert_ne!(a, b, "different text must produce different cache keys");
}
#[test]
fn test_pcm_cache_key_sensitivity_to_seed() {
let a = pcm_cache_key("hey mahbot", "default", 41, 16000, "hash");
let b = pcm_cache_key("hey mahbot", "default", 42, 16000, "hash");
assert_ne!(a, b, "different seed must produce different cache keys");
}
#[test]
fn test_pcm_cache_key_sensitivity_to_model_hash() {
let a = pcm_cache_key("hey mahbot", "default", 42, 16000, "hash_a");
let b = pcm_cache_key("hey mahbot", "default", 42, 16000, "hash_b");
assert_ne!(
a, b,
"different model hash must produce different cache keys"
);
}
#[test]
fn test_tts_model_version_hash_is_non_empty() {
let hash = tts_model_version_hash();
assert_eq!(hash.len(), 64, "SHA-256 hex is 64 chars");
assert!(
hash.chars().all(|c| c.is_ascii_hexdigit()),
"hash must be hex: {hash}"
);
}
#[test]
#[serial_test::serial(voice)]
fn test_enrolled_utterance_count_tracks_utterances() {
let _ = init_global();
let mut state = voice_state().write().unwrap_poison();
state.reset_enrollment(); assert_eq!(state.enrollment_embeddings.len(), 0);
assert_eq!(state.enrolled_utterance_count, 0);
state.enrollment_embeddings.push(basis_embedding(0));
state.enrolled_utterance_count = 1;
assert_eq!(state.enrolled_utterance_count, 1);
assert_eq!(state.enrollment_embeddings.len(), 1);
state.reset_enrollment();
assert_eq!(state.enrolled_utterance_count, 0);
assert!(state.enrollment_embeddings.is_empty());
}
#[test]
fn samples_to_ms_conversion() {
assert_eq!(samples_to_ms(0, SAMPLE_RATE), 0);
assert_eq!(samples_to_ms(16_000, SAMPLE_RATE), 1000);
assert_eq!(samples_to_ms(400, SAMPLE_RATE), 25);
assert_eq!(samples_to_ms(1, SAMPLE_RATE), 0);
}
#[test]
fn compute_utterance_quality_short_utterance_scores_zero_duration() {
let samples = vec![0.1; 1600]; let q = compute_utterance_quality(&samples, None);
assert_eq!(q.duration_ms, 100);
assert!((q.score - 0.25).abs() < 1e-6, "score {}", q.score);
assert_eq!(q.level, QualityLevel::Poor);
}
#[test]
fn normalize_phrase_trims_lowercases_collapses() {
assert_eq!(normalize_phrase(" HeY MahBot "), "hey mahbot");
assert_eq!(normalize_phrase("OK Computer"), "ok computer");
assert_eq!(normalize_phrase(" hello WORLD "), "hello world");
assert_eq!(normalize_phrase("singleword"), "singleword");
assert_eq!(normalize_phrase(" already fine "), "already fine");
}
#[test]
fn normalize_phrase_empty_returns_default() {
assert_eq!(normalize_phrase(""), DEFAULT_WAKE_WORD_PHRASE);
assert_eq!(normalize_phrase(" "), DEFAULT_WAKE_WORD_PHRASE);
assert_eq!(normalize_phrase("\t\n"), DEFAULT_WAKE_WORD_PHRASE);
}
#[test]
#[serial_test::serial(voice)]
fn get_enrolled_phrase_returns_none_initially() {
let _ = init_global();
voice_state().write().unwrap_poison().model_phrase = None;
assert!(get_enrolled_phrase().is_none());
}
#[test]
#[serial_test::serial(voice)]
fn get_enrolled_phrase_returns_phrase_after_set() {
let _ = init_global();
{
let mut state = voice_state().write().unwrap_poison();
state.model_phrase = Some("hey mahbot".to_string());
}
assert_eq!(get_enrolled_phrase(), Some("hey mahbot".to_string()));
assert_eq!(get_enrolled_phrase(), Some("hey mahbot".to_string()));
}
#[test]
#[serial_test::serial(voice)]
fn model_phrase_survives_enrollment_cancel() {
let _ = init_global();
{
let mut state = voice_state().write().unwrap_poison();
state.model_phrase = Some("hey computer".to_string());
state.enrolling_phrase = Some("new phrase".to_string());
}
{
let mut state = voice_state().write().unwrap_poison();
state.reset_enrollment();
}
let state = voice_state().read().unwrap_poison();
assert_eq!(
state.model_phrase,
Some("hey computer".to_string()),
"model_phrase must survive enrollment cancel"
);
assert!(
state.enrolling_phrase.is_none(),
"enrolling_phrase must be cleared on enrollment cancel"
);
assert_eq!(get_enrolled_phrase(), Some("hey computer".to_string()),);
}
fn ctx_with_populated_buffers() -> PipelineCtx {
let mut ctx = PipelineCtx::new();
ctx.audio_buffer = vec![0.5; 100];
ctx.command_buffer = vec![0.5; 100];
ctx.silence_sample_count = 1000;
ctx.score_window = vec![0.5; 5];
ctx.last_score_sample_count = 512;
ctx.negative_audio_buf = vec![0.5; 50];
ctx.frame_vad = vec![true; 3];
ctx.frame_raw_audio = vec![0.5; 200];
ctx.emitted_utterances = 2;
ctx.utterance_had_speech = true;
ctx.utterance_silence_samples = 500;
ctx.enrollment_no_speech_frame_count = 3;
ctx.vad_positives_in_a_row = 5;
ctx.enrollment_pending.push_back(vec![0.5; 50]);
ctx.noise_rms_estimate = Some(0.1);
ctx.collecting_negatives = true;
ctx.phase3_audio_buf = vec![0.5; 100];
ctx.phase3_silence_samples = 500;
ctx.negatives_speech_samples = 1000;
ctx.phase3_processed = 1234;
ctx.phase3_start_time = Some(Instant::now().checked_sub(Duration::from_secs(10)).unwrap());
ctx.vad_threshold = 0.75;
ctx.last_wake_word_detection =
Some(Instant::now().checked_sub(Duration::from_secs(5)).unwrap());
ctx.auto_start_pending = true;
ctx.is_recording = true;
ctx
}
fn assert_buffers_cleared(ctx: &PipelineCtx) {
assert!(ctx.audio_buffer.is_empty());
assert!(ctx.command_buffer.is_empty());
assert_eq!(ctx.silence_sample_count, 0);
assert!(ctx.score_window.is_empty());
assert_eq!(ctx.last_score_sample_count, 0);
assert!(ctx.negative_audio_buf.is_empty());
assert!(ctx.frame_vad.is_empty());
assert!(ctx.frame_raw_audio.is_empty());
assert_eq!(ctx.emitted_utterances, 0);
assert!(!ctx.utterance_had_speech);
assert_eq!(ctx.utterance_silence_samples, 0);
assert_eq!(ctx.enrollment_no_speech_frame_count, 0);
assert_eq!(ctx.vad_positives_in_a_row, 0);
assert!(ctx.enrollment_pending.is_empty());
assert!(ctx.noise_rms_estimate.is_none());
assert_eq!(
ctx.segment_silence_hops, 0,
"segment_silence_hops must be cleared by all reset levels"
);
assert!(
!ctx.collecting_negatives,
"collecting_negatives must be false after reset"
);
assert!(
ctx.phase3_audio_buf.is_empty(),
"phase3_audio_buf must be cleared by all reset levels"
);
assert_eq!(
ctx.phase3_silence_samples, 0,
"phase3_silence_samples must be cleared by all reset levels"
);
assert_eq!(
ctx.negatives_speech_samples, 0,
"negatives_speech_samples must be cleared by all reset levels"
);
assert_eq!(
ctx.phase3_processed, 0,
"phase3_processed must be cleared by all reset levels"
);
assert!(
ctx.phase3_start_time.is_none(),
"phase3_start_time must be None after reset"
);
}
#[test]
#[serial_test::serial(voice)]
fn reset_full_clears_all_buffers_and_state() {
let _ = init_global();
let mut ctx = ctx_with_populated_buffers();
{
let mut state = voice_state().write().unwrap_poison();
state.enrollment_embeddings.push(vec![0.5; 1024]);
state.negative_audio_chunks.push(vec![0.5; 100]);
}
ctx.reset_pipeline_state(ResetLevel::Full);
assert_buffers_cleared(&ctx);
#[expect(clippy::float_cmp)] {
assert_eq!(ctx.vad_threshold, VAD_THRESHOLD);
}
assert!(ctx.last_wake_word_detection.is_none());
assert!(!ctx.auto_start_pending);
assert!(!ctx.is_recording);
let state = voice_state().read().unwrap_poison();
assert_eq!(state.enrollment_embeddings.len(), 1);
assert_eq!(state.negative_audio_chunks.len(), 1);
}
#[test]
#[serial_test::serial(voice)]
fn reset_full_preserves_handler_managed_flags() {
let _ = init_global();
let mut ctx = ctx_with_populated_buffers();
ctx.is_listening = true;
ctx.enrollment_mode = true;
ctx.reset_pipeline_state(ResetLevel::Full);
assert!(ctx.is_listening);
assert!(ctx.enrollment_mode);
}
#[cfg(feature = "voice-tests")]
#[test]
#[serial_test::serial(voice)]
fn injected_vad_preserved_across_all_reset_levels() {
let _ = init_global();
for level in [ResetLevel::Full, ResetLevel::Soft, ResetLevel::Cancel] {
let mut ctx = ctx_with_populated_buffers();
ctx.injected_vad = Some(earshot::Detector::default());
ctx.reset_pipeline_state(level);
assert!(
ctx.injected_vad.is_some(),
"injected_vad must be preserved across {level:?} — the voice-tests \
parallel feed depends on the per-context detector surviving resets",
);
}
}
#[test]
#[serial_test::serial(voice)]
fn reset_soft_preserves_vad_threshold_cooldown_and_flags() {
let _ = init_global();
let mut ctx = ctx_with_populated_buffers();
let saved_embeddings = vec![vec![0.5; 1024]];
let saved_chunks = vec![vec![0.5; 100]];
{
let mut state = voice_state().write().unwrap_poison();
state.enrollment_embeddings = saved_embeddings.clone();
state.negative_audio_chunks = saved_chunks.clone();
}
let saved_threshold = ctx.vad_threshold; let saved_cooldown = ctx.last_wake_word_detection;
let saved_auto_start = ctx.auto_start_pending;
let saved_recording = ctx.is_recording;
ctx.reset_pipeline_state(ResetLevel::Soft);
assert_buffers_cleared(&ctx);
#[expect(clippy::float_cmp)] {
assert_eq!(ctx.vad_threshold, saved_threshold);
}
assert_eq!(ctx.last_wake_word_detection, saved_cooldown);
assert_eq!(ctx.auto_start_pending, saved_auto_start);
assert_eq!(ctx.is_recording, saved_recording);
let state = voice_state().read().unwrap_poison();
assert_eq!(state.enrollment_embeddings, saved_embeddings);
assert_eq!(state.negative_audio_chunks, saved_chunks);
}
#[test]
#[serial_test::serial(voice)]
fn reset_cancel_clears_enrollment_and_vad_threshold() {
let _ = init_global();
let mut ctx = ctx_with_populated_buffers();
{
let mut state = voice_state().write().unwrap_poison();
state.enrollment_embeddings.push(vec![0.5; 1024]);
state.negative_audio_chunks.push(vec![0.5; 100]);
}
let saved_auto_start = ctx.auto_start_pending;
let saved_recording = ctx.is_recording;
ctx.reset_pipeline_state(ResetLevel::Cancel);
assert_buffers_cleared(&ctx);
#[expect(clippy::float_cmp)] {
assert_eq!(ctx.vad_threshold, VAD_THRESHOLD);
}
assert!(ctx.last_wake_word_detection.is_none());
assert_eq!(ctx.auto_start_pending, saved_auto_start);
assert_eq!(ctx.is_recording, saved_recording);
let state = voice_state().read().unwrap_poison();
assert!(state.enrollment_embeddings.is_empty());
assert!(state.negative_audio_chunks.is_empty());
}
#[test]
#[serial_test::serial(voice)]
fn reset_levels_preserve_session_ux_state() {
let _ = init_global();
for level in [ResetLevel::Soft, ResetLevel::Full, ResetLevel::Cancel] {
let mut ctx = PipelineCtx::new();
ctx.refractory_until = Some(Instant::now());
ctx.last_error_message_time = Some(Instant::now());
ctx.last_voice_notice_time = Some(Instant::now());
ctx.reset_pipeline_state(level);
assert!(
ctx.refractory_until.is_some(),
"refractory_until lost at {level:?}"
);
assert!(
ctx.last_error_message_time.is_some(),
"last_error_message_time lost at {level:?}"
);
assert!(
ctx.last_voice_notice_time.is_some(),
"last_voice_notice_time lost at {level:?}"
);
}
}
fn empty_phase3_progress() -> Phase3Progress {
Phase3Progress {
processed: 0,
silence_samples: 0,
negatives_speech_samples: 0,
completed_chunks: Vec::new(),
}
}
#[test]
fn phase3_speech_counter_is_1_to_1_and_chunking_invariant() {
const CHUNK_SAMPLES: usize = SAMPLE_RATE as usize / 2; const CHUNKS: usize = 60;
let total_samples = CHUNK_SAMPLES * CHUNKS;
let mut buf: Vec<f32> = Vec::new();
let mut prog = empty_phase3_progress();
let mut seen = 0usize;
for _ in 0..CHUNKS {
buf.extend(std::iter::repeat_n(0.1f32, CHUNK_SAMPLES));
prog = process_phase3_frames(
&mut buf,
prog.processed,
prog.silence_samples,
prog.negatives_speech_samples,
|_| true, );
seen += CHUNK_SAMPLES;
assert!(
prog.negatives_speech_samples <= seen,
"counter {} exceeded real audio {seen}",
prog.negatives_speech_samples,
);
assert_eq!(
prog.negatives_speech_samples, prog.processed,
"counter must track processed hops 1:1 across calls \
(no re-count, no under-count)",
);
}
let expected =
(total_samples.saturating_sub(FRAME_LENGTH)) / HOP_LENGTH * HOP_LENGTH + HOP_LENGTH;
assert_eq!(prog.negatives_speech_samples, expected);
assert!(prog.negatives_speech_samples <= total_samples);
let mut one_shot = vec![0.1f32; total_samples];
let one_shot_prog = process_phase3_frames(&mut one_shot, 0, 0, 0, |_| true);
assert_eq!(
one_shot_prog.negatives_speech_samples, prog.negatives_speech_samples,
"counter must not depend on mic-chunk boundaries",
);
assert!(prog.completed_chunks.is_empty());
assert_eq!(buf.len(), total_samples);
}
#[test]
fn phase3_chunks_are_full_speech_segments_across_mic_chunks() {
let decision = |hop: &[f32]| hop[0] > 0.0;
let mut audio: Vec<f32> = Vec::new();
for _ in 0..30 {
audio.extend(std::iter::repeat_n(1.0f32, HOP_LENGTH));
}
for _ in 0..30 {
audio.extend(std::iter::repeat_n(-1.0f32, HOP_LENGTH));
}
for _ in 0..10 {
audio.extend(std::iter::repeat_n(1.0f32, HOP_LENGTH));
}
let split = 40 * HOP_LENGTH;
let mut buf = audio[..split].to_vec();
let mut prog = process_phase3_frames(&mut buf, 0, 0, 0, decision);
assert!(prog.completed_chunks.is_empty());
assert_eq!(prog.negatives_speech_samples, 30 * HOP_LENGTH);
assert_eq!(prog.silence_samples, 9 * HOP_LENGTH);
buf.extend_from_slice(&audio[split..]);
prog = process_phase3_frames(
&mut buf,
prog.processed,
prog.silence_samples,
prog.negatives_speech_samples,
decision,
);
assert_eq!(prog.completed_chunks.len(), 1);
assert_eq!(prog.completed_chunks[0].len(), 29 * HOP_LENGTH);
assert_eq!(prog.negatives_speech_samples, 39 * HOP_LENGTH);
assert!(buf.len() >= 10 * HOP_LENGTH);
for _ in 0..4 {
buf.extend(std::iter::repeat_n(-1.0f32, HOP_LENGTH));
}
prog = process_phase3_frames(
&mut buf,
prog.processed,
prog.silence_samples,
prog.negatives_speech_samples,
decision,
);
assert_eq!(prog.negatives_speech_samples, 40 * HOP_LENGTH);
assert!(
prog.completed_chunks.is_empty(),
"continued silence must not push spurious chunks",
);
}
#[test]
fn phase3_negative_collection_audio_counts_1_to_1_through_pipeline() {
const CHUNK_SAMPLES: usize = SAMPLE_RATE as usize / 2;
let mut ctx = PipelineCtx::new();
ctx.enrollment_vad = Some(earshot::Detector::default());
ctx.vad_threshold = -1.0;
let chunk = vec![0.1f32; CHUNK_SAMPLES];
for _ in 0..10 {
handle_negative_collection_audio(&chunk, &mut ctx);
}
let total = 10 * CHUNK_SAMPLES;
let expected = (total.saturating_sub(FRAME_LENGTH)) / HOP_LENGTH * HOP_LENGTH + HOP_LENGTH;
assert_eq!(ctx.negatives_speech_samples, expected);
assert!(ctx.negatives_speech_samples <= total);
let mut ctx2 = PipelineCtx::new();
ctx2.enrollment_vad = Some(earshot::Detector::default());
ctx2.vad_threshold = -1.0;
let big = vec![0.1f32; total];
handle_negative_collection_audio(&big, &mut ctx2);
assert_eq!(ctx2.negatives_speech_samples, ctx.negatives_speech_samples);
assert_eq!(ctx.phase3_audio_buf.len(), total);
#[expect(clippy::float_cmp)] {
assert!(ctx.phase3_audio_buf.iter().all(|&s| s == 0.1));
}
}
#[test]
fn handle_segment_boundary_resets_at_threshold() {
let mut ctx = PipelineCtx::new();
ctx.score_window = vec![0.5; 5];
ctx.segment_silence_hops = 10;
{
let mut at = AdaptiveThresholdState::new();
for _ in 0..=ADAPTIVE_BOOTSTRAP_FRAMES {
at.feed(0.5, ADAPTIVE_K_DEFAULT);
}
assert!(
!at.is_bootstrapping(),
"adaptive threshold must exit bootstrap after {} feeds",
ADAPTIVE_BOOTSTRAP_FRAMES + 1
);
ctx.adaptive_threshold = at;
}
ctx.handle_segment_boundary(SEGMENT_TIMEOUT_HOPS);
assert!(ctx.score_window.is_empty(), "score_window must be cleared");
assert_eq!(
ctx.segment_silence_hops, 0,
"segment_silence_hops must be reset"
);
assert!(
ctx.adaptive_threshold.is_bootstrapping(),
"adaptive_threshold must be reset (re-enter bootstrap)"
);
}
#[test]
fn handle_segment_boundary_persists_below_threshold() {
let mut ctx = PipelineCtx::new();
ctx.score_window = vec![0.5; 5];
ctx.segment_silence_hops = 10;
let below_threshold = SEGMENT_TIMEOUT_HOPS - 1;
ctx.handle_segment_boundary(below_threshold);
assert_eq!(
ctx.segment_silence_hops, below_threshold,
"counter must be persisted below threshold"
);
assert!(
!ctx.score_window.is_empty(),
"score_window must survive below threshold"
);
}
#[test]
fn handle_segment_boundary_does_not_reset_when_recording() {
let mut ctx = PipelineCtx::new();
ctx.is_recording = true;
ctx.score_window = vec![0.5; 5];
ctx.segment_silence_hops = 0;
ctx.handle_segment_boundary(SEGMENT_TIMEOUT_HOPS);
assert!(
!ctx.score_window.is_empty(),
"recording mode must skip the boundary reset"
);
assert_eq!(
ctx.segment_silence_hops, 0,
"recording mode must not write back the counter"
);
}
#[test]
fn handle_segment_boundary_counting_across_calls() {
let mut ctx = PipelineCtx::new();
ctx.segment_silence_hops = 0;
ctx.handle_segment_boundary(10);
assert_eq!(ctx.segment_silence_hops, 10);
ctx.segment_silence_hops = 0;
ctx.handle_segment_boundary(5);
assert_eq!(ctx.segment_silence_hops, 5);
ctx.handle_segment_boundary(SEGMENT_TIMEOUT_HOPS);
assert_eq!(ctx.segment_silence_hops, 0);
assert!(ctx.score_window.is_empty());
}
fn seed_test_pcm(path: &Path) -> u64 {
let samples: Vec<f32> = vec![0.0; 4096]; write_pcm_cache(path, &samples);
std::fs::metadata(path).map_or(0, |m| m.len())
}
#[test]
fn pcm_cache_read_normal_returns_some() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("a".repeat(64));
seed_test_pcm(&path);
assert!(path.exists());
let result = read_pcm_cache(&path);
assert!(result.is_some(), "normal read should return cached PCM");
}
}