use crate::core::AudioProfile;
use crate::profiles::profile_tuning;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FrameClass {
NoiseOnly,
Transitional,
SpeechLike,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct FrameFeatures {
pub rms: f32,
pub peak: f32,
pub speechiness: f32,
pub crest_factor: f32,
pub zero_crossing_rate: f32,
}
pub(crate) fn analyze_frame(samples: &[f32], noise_floor: f32) -> FrameFeatures {
if samples.is_empty() {
return FrameFeatures {
rms: 0.0,
peak: 0.0,
speechiness: 0.0,
crest_factor: 0.0,
zero_crossing_rate: 0.0,
};
}
let rms =
(samples.iter().map(|sample| sample * sample).sum::<f32>() / samples.len() as f32).sqrt();
let peak = samples
.iter()
.map(|sample| sample.abs())
.fold(0.0f32, f32::max);
let speechiness = peak / (noise_floor + 1e-4);
let crest_factor = peak / rms.max(1e-4);
let zero_crossings = samples
.windows(2)
.filter(|pair| (pair[0] >= 0.0 && pair[1] < 0.0) || (pair[0] < 0.0 && pair[1] >= 0.0))
.count();
let zero_crossing_rate = zero_crossings as f32 / samples.len().max(1) as f32;
FrameFeatures {
rms,
peak,
speechiness,
crest_factor,
zero_crossing_rate,
}
}
pub(crate) fn classify_frame(
profile: AudioProfile,
features: FrameFeatures,
noise_floor: f32,
) -> FrameClass {
let tuning = profile_tuning(profile);
let noise_gate = noise_floor.max(0.0005);
let low_energy_noise = features.rms <= noise_gate * 2.6;
let broadband_like = features.zero_crossing_rate >= 0.10 && features.crest_factor <= 4.8;
let strong_speech = features.speechiness >= tuning.adaptive_min_speechiness;
let voiced_shape = features.crest_factor >= 2.2 && features.zero_crossing_rate <= 0.22;
let mid_energy = features.rms >= noise_gate * 1.8;
if low_energy_noise && broadband_like {
FrameClass::NoiseOnly
} else if strong_speech || (mid_energy && voiced_shape) {
FrameClass::SpeechLike
} else {
FrameClass::Transitional
}
}
pub(crate) fn band_weight_for_voice(profile: AudioProfile, frequency_hz: f32) -> f32 {
if frequency_hz < 140.0 {
return match profile {
AudioProfile::VoiceHvac => 1.55,
AudioProfile::VoiceNoisyRoom => 1.35,
AudioProfile::VoiceClean => 1.15,
AudioProfile::Raw => 1.0,
};
}
if (250.0..=4_000.0).contains(&frequency_hz) {
return match profile {
AudioProfile::VoiceHvac => 0.48,
AudioProfile::VoiceNoisyRoom => 0.55,
AudioProfile::VoiceClean => 0.65,
AudioProfile::Raw => 1.0,
};
}
if frequency_hz > 5_500.0 {
return match profile {
AudioProfile::VoiceHvac => 1.22,
AudioProfile::VoiceNoisyRoom => 1.25,
AudioProfile::VoiceClean => 1.05,
AudioProfile::Raw => 1.0,
};
}
1.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifier_distinguishes_noise_from_speech_like_frames() {
let noise = vec![0.02, -0.02, 0.02, -0.02, 0.02, -0.02, 0.02, -0.02];
let noise_features = analyze_frame(&noise, 0.01);
assert_eq!(
classify_frame(AudioProfile::VoiceHvac, noise_features, 0.01),
FrameClass::NoiseOnly
);
let speech_like = vec![0.0, 0.18, 0.29, 0.12, -0.04, -0.16, -0.22, -0.09];
let speech_features = analyze_frame(&speech_like, 0.01);
assert_eq!(
classify_frame(AudioProfile::VoiceHvac, speech_features, 0.01),
FrameClass::SpeechLike
);
}
#[test]
fn voice_hvac_profile_is_more_aggressive_on_noise_bands() {
assert!(
band_weight_for_voice(AudioProfile::VoiceHvac, 80.0)
> band_weight_for_voice(AudioProfile::VoiceNoisyRoom, 80.0)
);
assert!(
band_weight_for_voice(AudioProfile::VoiceHvac, 6_500.0)
< band_weight_for_voice(AudioProfile::VoiceNoisyRoom, 6_500.0)
);
}
}