zk-audio 0.1.0

Audio processing library for voice recording and enhancement
Documentation
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)
        );
    }
}