use std::f32::consts::PI;
use std::sync::Arc;
use super::VadContext;
use crate::error::{Error, Result};
use crate::time::{AudioDuration, AudioInstant};
use realfft::{ComplexToReal, RealFftPlanner, RealToComplex};
use tracing::{info, warn};
#[derive(Debug, Clone)]
#[allow(missing_copy_implementations)]
pub struct NoiseReductionConfig {
pub sample_rate_hz: u32,
pub window_ms: f32,
pub hop_ms: f32,
pub oversubtraction_factor: f32,
pub spectral_floor: f32,
pub noise_smoothing: f32,
pub enable: bool,
}
impl Default for NoiseReductionConfig {
fn default() -> Self {
Self {
sample_rate_hz: 16_000,
window_ms: 25.0,
hop_ms: 10.0,
oversubtraction_factor: 2.0,
spectral_floor: 0.02,
noise_smoothing: 0.98,
enable: true,
}
}
}
impl NoiseReductionConfig {
#[allow(clippy::trivially_copy_pass_by_ref)]
pub fn validate(&self) -> Result<()> {
if !(8000..=48_000).contains(&self.sample_rate_hz) {
return Err(Error::Configuration(format!(
"Invalid sample rate: {} Hz (range: 8000-48000)",
self.sample_rate_hz
)));
}
if !(10.0..=50.0).contains(&self.window_ms) {
return Err(Error::Configuration(format!(
"Invalid window size: {:.1} ms (range: 10-50)",
self.window_ms
)));
}
if self.hop_ms >= self.window_ms {
return Err(Error::Configuration(format!(
"Hop {:.1} ms must be < window {:.1} ms",
self.hop_ms, self.window_ms
)));
}
if !(1.0..=3.0).contains(&self.oversubtraction_factor) {
return Err(Error::Configuration(format!(
"Invalid oversubtraction factor: {:.2} (range: 1.0-3.0)",
self.oversubtraction_factor
)));
}
if !(0.001..=0.1).contains(&self.spectral_floor) {
return Err(Error::Configuration(format!(
"Invalid spectral floor: {:.3} (range: 0.001-0.1)",
self.spectral_floor
)));
}
if !(0.9..1.0).contains(&self.noise_smoothing) {
return Err(Error::Configuration(format!(
"Invalid noise smoothing: {:.3} (range: 0.9-0.999)",
self.noise_smoothing
)));
}
Ok(())
}
pub fn frame_length(&self) -> usize {
((self.window_ms / 1000.0) * self.sample_rate_hz as f32).round() as usize
}
pub fn hop_length(&self) -> usize {
((self.hop_ms / 1000.0) * self.sample_rate_hz as f32).round() as usize
}
pub fn fft_size(&self) -> usize {
self.frame_length().next_power_of_two()
}
}
#[allow(missing_copy_implementations)]
pub struct NoiseReducer {
config: NoiseReductionConfig,
fft_forward: Arc<dyn RealToComplex<f32>>,
fft_inverse: Arc<dyn ComplexToReal<f32>>,
window: Vec<f32>,
noise_profile: Vec<f32>,
noise_initialized: bool,
overlap_buffer: Vec<f32>,
}
impl std::fmt::Debug for NoiseReducer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NoiseReducer")
.field("config", &self.config)
.field("window_length", &self.window.len())
.field("noise_profile_bins", &self.noise_profile.len())
.field("noise_initialized", &self.noise_initialized)
.finish_non_exhaustive()
}
}
impl NoiseReducer {
pub fn new(config: NoiseReductionConfig) -> Result<Self> {
config.validate()?;
let fft_size = config.fft_size();
let frame_length = config.frame_length();
let mut planner = RealFftPlanner::<f32>::new();
let fft_forward = planner.plan_fft_forward(fft_size);
let fft_inverse = planner.plan_fft_inverse(fft_size);
let window = generate_hann_window(frame_length);
let num_bins = fft_size / 2 + 1;
let noise_profile = vec![1e-6; num_bins];
let overlap_buffer = vec![0.0; frame_length];
Ok(Self {
config,
fft_forward,
fft_inverse,
window,
noise_profile,
noise_initialized: false,
overlap_buffer,
})
}
#[allow(clippy::unnecessary_wraps)]
pub fn reduce(&mut self, samples: &[f32], vad_context: Option<VadContext>) -> Result<Vec<f32>> {
let processing_start = AudioInstant::now();
if samples.is_empty() {
return Ok(Vec::new());
}
if !self.config.enable {
return Ok(samples.to_vec());
}
let (mut output, frame_count) = self.process_stft_frames(samples, vad_context)?;
self.normalize_overlap_add(&mut output);
let elapsed = elapsed_duration(processing_start);
let latency_ms = elapsed.as_secs_f64() * 1000.0;
self.record_performance_metrics(samples, &output, latency_ms, frame_count);
Ok(output)
}
fn process_stft_frames(
&mut self,
samples: &[f32],
vad_context: Option<VadContext>,
) -> Result<(Vec<f32>, usize)> {
let frame_length = self.config.frame_length();
let hop_length = self.config.hop_length();
let mut output = vec![0.0; samples.len()];
let mut frame_idx = 0;
let mut pos = 0;
while pos < samples.len() {
let remaining = samples.len() - pos;
let frame = Self::extract_frame(samples, pos, frame_length, remaining)?;
let processed =
self.process_single_frame(&frame, vad_context, remaining >= frame_length)?;
Self::accumulate_frame_output(&processed, &mut output, pos);
frame_idx += 1;
if remaining < hop_length {
break;
}
pos += hop_length;
}
Ok((output, frame_idx))
}
fn extract_frame(
samples: &[f32],
pos: usize,
frame_length: usize,
remaining: usize,
) -> Result<Vec<f32>> {
let mut frame_buf = vec![0.0; frame_length];
if remaining >= frame_length {
let src = samples
.get(pos..pos + frame_length)
.ok_or_else(|| Error::Processing("frame window out of bounds".into()))?;
frame_buf.copy_from_slice(src);
} else {
let src = samples
.get(pos..)
.ok_or_else(|| Error::Processing("frame tail out of bounds".into()))?;
if let Some(dst) = frame_buf.get_mut(..remaining) {
dst.copy_from_slice(src);
}
}
Ok(frame_buf)
}
fn process_single_frame(
&mut self,
frame: &[f32],
vad_context: Option<VadContext>,
is_full_frame: bool,
) -> Result<Vec<f32>> {
let fft_size = self.config.fft_size();
let windowed: Vec<f32> = frame
.iter()
.zip(&self.window)
.map(|(&s, &w)| s * w)
.collect();
let complex_spectrum = self.forward_fft_complex(&windowed)?;
let magnitudes: Vec<f32> = complex_spectrum.iter().map(|c| c.norm()).collect();
let is_silence = vad_context.is_some_and(|ctx| ctx.is_silence);
if is_silence && is_full_frame {
self.update_noise_profile(&magnitudes);
}
let cleaned_magnitudes = self.spectral_subtract(&magnitudes);
let cleaned_complex =
Self::reconstruct_complex_spectrum(&complex_spectrum, &cleaned_magnitudes);
let time_signal = self.inverse_fft_complex(&cleaned_complex, fft_size)?;
let windowed_output: Vec<f32> = time_signal
.iter()
.take(frame.len())
.zip(&self.window)
.map(|(&s, &w)| s * w)
.collect();
Ok(windowed_output)
}
fn reconstruct_complex_spectrum(
original_spectrum: &[realfft::num_complex::Complex<f32>],
cleaned_magnitudes: &[f32],
) -> Vec<realfft::num_complex::Complex<f32>> {
original_spectrum
.iter()
.zip(cleaned_magnitudes)
.enumerate()
.map(|(i, (original, &new_mag))| {
if i == 0 || i == original_spectrum.len() - 1 {
realfft::num_complex::Complex::new(new_mag, 0.0)
} else {
let phase = original.arg();
realfft::num_complex::Complex::from_polar(new_mag, phase)
}
})
.collect()
}
fn accumulate_frame_output(frame: &[f32], output: &mut [f32], pos: usize) {
for (i, &sample) in frame.iter().enumerate() {
let out_idx = pos + i;
if let Some(dst) = output.get_mut(out_idx) {
*dst += sample;
}
}
}
fn normalize_overlap_add(&self, output: &mut [f32]) {
let hop_length = self.config.hop_length();
let window_sum = self.calculate_window_overlap_sum(hop_length);
if window_sum > 1e-6 {
for sample in output {
*sample /= window_sum;
}
}
}
fn record_performance_metrics(
&self,
input: &[f32],
output: &[f32],
latency_ms: f64,
frame_count: usize,
) {
if input.len() < 8000 {
return;
}
if latency_ms > 15.0 {
warn!(
target: "audio.preprocess.noise_reduction",
latency_ms,
samples = input.len(),
frames = frame_count,
oversubtraction = self.config.oversubtraction_factor,
spectral_floor = self.config.spectral_floor,
"noise reduction latency exceeded target"
);
}
let avg_noise_floor = self.noise_floor().max(1e-12);
let noise_floor_db = 20.0 * avg_noise_floor.log10();
let signal_power_out =
output.iter().map(|sample| sample * sample).sum::<f32>() / output.len() as f32;
let residual_power: f32 = input
.iter()
.zip(output)
.map(|(&noisy, &clean)| {
let residual = noisy - clean;
residual * residual
})
.sum::<f32>()
/ output.len() as f32;
let snr_improvement_db = if residual_power > 1e-12 && signal_power_out > 0.0 {
10.0 * (signal_power_out / residual_power).log10()
} else {
0.0
};
info!(
target: "audio.preprocess.noise_reduction",
noise_floor_db,
snr_improvement_db,
latency_ms,
frames = frame_count,
samples = input.len(),
oversubtraction = self.config.oversubtraction_factor,
spectral_floor = self.config.spectral_floor,
"noise reduction metrics"
);
}
pub fn reset(&mut self) {
self.noise_profile.fill(1e-6);
self.noise_initialized = false;
self.overlap_buffer.fill(0.0);
}
#[must_use]
pub fn noise_floor(&self) -> f32 {
if self.noise_profile.is_empty() {
return 0.0;
}
self.noise_profile.iter().sum::<f32>() / self.noise_profile.len() as f32
}
#[must_use]
pub fn config(&self) -> &NoiseReductionConfig {
&self.config
}
fn forward_fft_complex(
&self,
windowed: &[f32],
) -> Result<Vec<realfft::num_complex::Complex<f32>>> {
let mut input = self.fft_forward.make_input_vec();
for (i, &sample) in windowed.iter().enumerate() {
if let Some(dst) = input.get_mut(i) {
*dst = sample;
}
}
let mut spectrum = self.fft_forward.make_output_vec();
self.fft_forward
.process(&mut input, &mut spectrum)
.map_err(|e| Error::Processing(format!("FFT failed: {e}")))?;
Ok(spectrum)
}
fn inverse_fft_complex(
&self,
complex_spectrum: &[realfft::num_complex::Complex<f32>],
fft_size: usize,
) -> Result<Vec<f32>> {
let mut spectrum = self.fft_inverse.make_input_vec();
for (i, &c) in complex_spectrum.iter().enumerate() {
if let Some(bin) = spectrum.get_mut(i) {
*bin = c;
}
}
let mut output = self.fft_inverse.make_output_vec();
self.fft_inverse
.process(&mut spectrum, &mut output)
.map_err(|e| Error::Processing(format!("IFFT failed: {e}")))?;
for sample in &mut output {
*sample /= fft_size as f32;
}
Ok(output)
}
fn update_noise_profile(&mut self, spectrum: &[f32]) {
let alpha = self.config.noise_smoothing;
if self.noise_initialized {
for (noise, ¤t) in self.noise_profile.iter_mut().zip(spectrum.iter()) {
*noise = alpha.mul_add(*noise, (1.0 - alpha) * current);
}
} else {
self.noise_profile.copy_from_slice(spectrum);
self.noise_initialized = true;
}
}
fn spectral_subtract(&self, spectrum: &[f32]) -> Vec<f32> {
let alpha = self.config.oversubtraction_factor;
let beta = self.config.spectral_floor;
spectrum
.iter()
.zip(&self.noise_profile)
.map(|(&signal, &noise)| {
let subtracted = alpha.mul_add(-noise, signal);
let floor = beta * noise;
subtracted.max(floor)
})
.collect()
}
fn calculate_window_overlap_sum(&self, hop_length: usize) -> f32 {
let frame_length = self.window.len();
let mut sum: f32 = 0.0;
for i in 0..frame_length {
let mut overlap: f32 = 0.0;
let mut offset = 0;
while offset <= i {
if let Some(&w) = self.window.get(i - offset) {
overlap = w.mul_add(w, overlap); }
offset += hop_length;
}
sum = sum.max(overlap);
}
sum
}
}
fn generate_hann_window(length: usize) -> Vec<f32> {
if length == 0 {
return Vec::new();
}
if length == 1 {
return vec![1.0];
}
let denom = (length - 1) as f32;
(0..length)
.map(|n| {
let angle = 2.0 * PI * n as f32 / denom;
0.5f32.mul_add(-angle.cos(), 0.5)
})
.collect()
}
fn elapsed_duration(start: AudioInstant) -> AudioDuration {
AudioInstant::now().duration_since(start)
}
#[cfg(test)]
mod tests {
use super::*;
type TestResult<T> = std::result::Result<T, String>;
#[test]
#[allow(clippy::unnecessary_wraps)]
fn test_configuration_validation() -> TestResult<()> {
let valid = NoiseReductionConfig::default();
assert!(valid.validate().is_ok());
let invalid_sr = NoiseReductionConfig {
sample_rate_hz: 5000,
..Default::default()
};
assert!(invalid_sr.validate().is_err());
let invalid_window = NoiseReductionConfig {
window_ms: 100.0,
..Default::default()
};
assert!(invalid_window.validate().is_err());
let invalid_hop = NoiseReductionConfig {
hop_ms: 30.0,
window_ms: 25.0,
..Default::default()
};
assert!(invalid_hop.validate().is_err());
let invalid_alpha = NoiseReductionConfig {
oversubtraction_factor: 5.0,
..Default::default()
};
assert!(invalid_alpha.validate().is_err());
let invalid_beta = NoiseReductionConfig {
spectral_floor: 0.5,
..Default::default()
};
assert!(invalid_beta.validate().is_err());
let invalid_smoothing = NoiseReductionConfig {
noise_smoothing: 1.0,
..Default::default()
};
assert!(invalid_smoothing.validate().is_err());
Ok(())
}
#[test]
fn test_hann_window_properties() {
let window_0 = generate_hann_window(0);
assert!(window_0.is_empty());
let window_1 = generate_hann_window(1);
assert_eq!(window_1.len(), 1);
assert!((window_1[0] - 1.0).abs() < 1e-6);
let window = generate_hann_window(100);
assert_eq!(window.len(), 100);
assert!(window[0].abs() < 1e-6);
assert!(window[99].abs() < 1e-6);
assert!((window[50] - 1.0).abs() < 0.1);
}
#[test]
fn test_noise_reducer_creation() -> TestResult<()> {
let config = NoiseReductionConfig::default();
let reducer = NoiseReducer::new(config).map_err(|e| e.to_string())?;
assert_eq!(reducer.config().sample_rate_hz, 16000);
assert!(reducer.noise_floor() > 0.0);
Ok(())
}
#[test]
fn test_empty_input() -> TestResult<()> {
let config = NoiseReductionConfig::default();
let mut reducer = NoiseReducer::new(config).map_err(|e| e.to_string())?;
let output = reducer.reduce(&[], None).map_err(|e| e.to_string())?;
assert!(output.is_empty());
Ok(())
}
#[test]
fn test_bypass_mode() -> TestResult<()> {
let config = NoiseReductionConfig {
enable: false,
..Default::default()
};
let mut reducer = NoiseReducer::new(config).map_err(|e| e.to_string())?;
let input = vec![0.1, 0.2, 0.3, 0.4];
let output = reducer.reduce(&input, None).map_err(|e| e.to_string())?;
assert_eq!(output, input);
Ok(())
}
#[test]
fn test_noise_profile_update() -> TestResult<()> {
let config = NoiseReductionConfig::default();
let mut reducer = NoiseReducer::new(config).map_err(|e| e.to_string())?;
let silence = vec![0.01; 8000]; let vad_silence = VadContext { is_silence: true };
let initial_noise = reducer.noise_floor();
for _ in 0..5 {
let _ = reducer
.reduce(&silence, Some(vad_silence))
.map_err(|e| e.to_string())?;
}
let converged_noise = reducer.noise_floor();
assert!(
converged_noise > initial_noise,
"Noise floor should adapt: initial={:.6}, converged={:.6}",
initial_noise,
converged_noise
);
Ok(())
}
#[test]
fn test_vad_informed_noise_update() -> TestResult<()> {
let config = NoiseReductionConfig::default();
let mut reducer = NoiseReducer::new(config).map_err(|e| e.to_string())?;
let silence = vec![0.01; 8000];
let vad_silence = VadContext { is_silence: true };
for _ in 0..5 {
let _ = reducer
.reduce(&silence, Some(vad_silence))
.map_err(|e| e.to_string())?;
}
let noise_after_silence = reducer.noise_floor();
let speech = vec![0.5; 8000];
let vad_speech = VadContext { is_silence: false };
let _ = reducer
.reduce(&speech, Some(vad_speech))
.map_err(|e| e.to_string())?;
let noise_after_speech = reducer.noise_floor();
let diff = (noise_after_speech - noise_after_silence).abs();
assert!(
diff < noise_after_silence * 0.01,
"Noise profile changed during speech: {:.6} -> {:.6}",
noise_after_silence,
noise_after_speech
);
Ok(())
}
#[test]
fn test_reset_clears_state() -> TestResult<()> {
let config = NoiseReductionConfig::default();
let mut reducer = NoiseReducer::new(config).map_err(|e| e.to_string())?;
let samples = vec![0.1; 8000];
let vad = VadContext { is_silence: true };
let _ = reducer
.reduce(&samples, Some(vad))
.map_err(|e| e.to_string())?;
let noise_before = reducer.noise_floor();
assert!(noise_before > 1e-5, "Noise profile should be updated");
reducer.reset();
let noise_after = reducer.noise_floor();
assert!(
noise_after < 1e-5,
"Noise profile should be reset to initial value"
);
Ok(())
}
fn generate_sine_wave(
frequency: f32,
sample_rate: u32,
duration_secs: f32,
amplitude: f32,
) -> Vec<f32> {
use std::f32::consts::PI;
let samples = (sample_rate as f32 * duration_secs).round() as usize;
(0..samples)
.map(|i| {
let t = i as f32 / sample_rate as f32;
(2.0 * PI * frequency * t).sin() * amplitude
})
.collect()
}
fn add_white_noise(signal: &[f32], noise_amplitude: f32) -> Vec<f32> {
use rand::Rng;
let mut rng = rand::rng();
signal
.iter()
.map(|&s| {
let noise: f32 = rng.random_range(-noise_amplitude..noise_amplitude);
s + noise
})
.collect()
}
fn add_low_freq_hum(
signal: &[f32],
sample_rate: u32,
frequency: f32,
amplitude: f32,
) -> Vec<f32> {
signal
.iter()
.enumerate()
.map(|(i, &sample)| {
let t = i as f32 / sample_rate as f32;
let hum = (2.0 * PI * frequency * t).sin() * amplitude;
sample + hum
})
.collect()
}
fn add_cafe_noise(signal: &[f32], _sample_rate: u32, amplitude: f32) -> Vec<f32> {
use rand::Rng;
let mut rng = rand::rng();
signal
.iter()
.map(|&sample| {
let noise: f32 = rng.random_range(-1.0..1.0);
amplitude.mul_add(noise, sample)
})
.collect()
}
fn calculate_snr(clean: &[f32], noisy: &[f32]) -> f32 {
if clean.len() != noisy.len() {
return 0.0;
}
let signal_power: f32 = clean.iter().map(|&x| x * x).sum();
let noise: Vec<f32> = clean
.iter()
.zip(noisy.iter())
.map(|(&c, &n)| n - c)
.collect();
let noise_power: f32 = noise.iter().map(|&x| x * x).sum();
if noise_power < 1e-10 {
return 100.0; }
10.0 * (signal_power / noise_power).log10()
}
#[test]
fn test_snr_improvement_white_noise() -> TestResult<()> {
let config = NoiseReductionConfig::default();
let mut reducer = NoiseReducer::new(config).map_err(|e| e.to_string())?;
let clean_speech = generate_sine_wave(440.0, 16000, 1.0, 0.5);
let noisy_speech = add_white_noise(&clean_speech, 0.3);
let snr_before = calculate_snr(&clean_speech, &noisy_speech);
let pure_noise = add_white_noise(&vec![0.0; 8000], 0.3);
let vad_silence = VadContext { is_silence: true };
for _ in 0..10 {
let _ = reducer
.reduce(&pure_noise, Some(vad_silence))
.map_err(|e| e.to_string())?;
}
let vad_speech = VadContext { is_silence: false };
let denoised = reducer
.reduce(&noisy_speech, Some(vad_speech))
.map_err(|e| e.to_string())?;
let snr_after = calculate_snr(&clean_speech, &denoised);
let improvement = snr_after - snr_before;
assert!(
improvement >= 6.0,
"SNR improvement {:.1} dB < 6 dB target",
improvement
);
Ok(())
}
#[test]
fn test_snr_improvement_low_freq_hum() -> TestResult<()> {
let config = NoiseReductionConfig::default();
let mut reducer = NoiseReducer::new(config).map_err(|e| e.to_string())?;
let clean = generate_sine_wave(440.0, 16000, 1.0, 0.4);
let noisy = add_low_freq_hum(&clean, 16000, 60.0, 0.3);
let snr_before = calculate_snr(&clean, &noisy);
let hum_only = add_low_freq_hum(&vec![0.0; 8000], 16000, 60.0, 0.3);
let vad = VadContext { is_silence: true };
for _ in 0..6 {
let _ = reducer
.reduce(&hum_only, Some(vad))
.map_err(|e| e.to_string())?;
}
let vad_speech = VadContext { is_silence: false };
let denoised = reducer
.reduce(&noisy, Some(vad_speech))
.map_err(|e| e.to_string())?;
let snr_after = calculate_snr(&clean, &denoised);
let improvement = snr_after - snr_before;
assert!(
improvement >= 6.0,
"Hum SNR improvement {:.1} dB < 6 dB target",
improvement
);
Ok(())
}
#[test]
fn test_snr_improvement_cafe_ambient() -> TestResult<()> {
let config = NoiseReductionConfig::default();
let mut reducer = NoiseReducer::new(config).map_err(|e| e.to_string())?;
let clean = generate_sine_wave(220.0, 16000, 1.0, 0.4);
let noisy = add_cafe_noise(&clean, 16000, 0.25);
let snr_before = calculate_snr(&clean, &noisy);
let cafe_only = add_cafe_noise(&vec![0.0; 8000], 16000, 0.25);
let vad = VadContext { is_silence: true };
for _ in 0..10 {
let _ = reducer
.reduce(&cafe_only, Some(vad))
.map_err(|e| e.to_string())?;
}
let vad_speech = VadContext { is_silence: false };
let denoised = reducer
.reduce(&noisy, Some(vad_speech))
.map_err(|e| e.to_string())?;
let snr_after = calculate_snr(&clean, &denoised);
let improvement = snr_after - snr_before;
assert!(
improvement >= 6.0,
"Café ambient SNR improvement {:.1} dB < 6 dB target",
improvement
);
Ok(())
}
#[test]
fn test_trailing_partial_frame_preserved() -> TestResult<()> {
let config = NoiseReductionConfig::default();
let mut reducer = NoiseReducer::new(config).map_err(|e| e.to_string())?;
let silence = vec![0.0; 8000];
let vad_silence = VadContext { is_silence: true };
let _ = reducer
.reduce(&silence, Some(vad_silence))
.map_err(|e| e.to_string())?;
let speech_len = 8080;
let speech: Vec<f32> = (0..speech_len)
.map(|i| {
let phase = (i as f32 / speech_len as f32) * 20.0;
phase.sin()
})
.collect();
let vad_speech = VadContext { is_silence: false };
let output = reducer
.reduce(&speech, Some(vad_speech))
.map_err(|e| e.to_string())?;
assert_eq!(
output.len(),
speech_len,
"Output length should match input length"
);
let tail = &output[speech_len - 80..];
let tail_energy: f32 = tail.iter().map(|sample| sample.abs()).sum();
assert!(
tail_energy > 1e-3,
"Trailing samples should retain energy, got tail_energy={tail_energy}"
);
Ok(())
}
#[test]
fn test_missing_vad_context_does_not_update_noise_profile() -> TestResult<()> {
let config = NoiseReductionConfig::default();
let mut reducer = NoiseReducer::new(config).map_err(|e| e.to_string())?;
let ambient_noise = vec![0.05f32; 8000];
let vad_silence = VadContext { is_silence: true };
reducer
.reduce(&ambient_noise, Some(vad_silence))
.map_err(|e| e.to_string())?;
let baseline_floor = reducer.noise_floor();
let speech = vec![0.2f32; 8000];
let output = reducer.reduce(&speech, None).map_err(|e| e.to_string())?;
let updated_floor = reducer.noise_floor();
let floor_delta = (updated_floor - baseline_floor).abs();
assert!(
floor_delta < baseline_floor.max(1e-6) * 0.01,
"Noise floor changed when VAD context missing: baseline={baseline_floor}, \
updated={updated_floor}"
);
let output_rms =
(output.iter().map(|sample| sample * sample).sum::<f32>() / output.len() as f32).sqrt();
assert!(
output_rms > 0.08,
"Speech energy collapsed without VAD context (rms={output_rms})"
);
Ok(())
}
}