Skip to main content

clawft_plugin/voice/
quality.rs

1//! Audio quality monitoring for voice pipeline.
2//!
3//! Provides real-time metrics: RMS level, peak level, clipping detection,
4//! signal-to-noise ratio estimation.
5
6/// Audio quality metrics for a single frame.
7#[derive(Debug, Clone, Default)]
8pub struct AudioMetrics {
9    /// Root mean square level (0.0 - 1.0 for normalized audio).
10    pub rms_level: f32,
11    /// Peak absolute sample value.
12    pub peak_level: f32,
13    /// Whether clipping was detected (samples at +/-1.0).
14    pub clipping_detected: bool,
15    /// Number of clipped samples in frame.
16    pub clipped_sample_count: usize,
17    /// Estimated signal-to-noise ratio in dB (higher is better).
18    pub estimated_snr_db: f32,
19}
20
21/// Computes audio quality metrics for a frame of samples.
22///
23/// # Arguments
24/// * `samples` - Audio samples (typically normalized to -1.0..1.0)
25/// * `noise_floor` - Estimated noise floor level from NoiseSuppressor
26///
27/// # Returns
28/// `AudioMetrics` with computed values for the frame.
29pub fn analyze_frame(samples: &[f32], noise_floor: f32) -> AudioMetrics {
30    if samples.is_empty() {
31        return AudioMetrics::default();
32    }
33
34    let rms = (samples.iter().map(|s| s * s).sum::<f32>()
35        / samples.len() as f32)
36        .sqrt();
37    let peak = samples
38        .iter()
39        .map(|s| s.abs())
40        .fold(0.0f32, f32::max);
41
42    let clipping_threshold = 0.99;
43    let clipped = samples
44        .iter()
45        .filter(|s| s.abs() >= clipping_threshold)
46        .count();
47
48    let snr = if noise_floor > 0.0001 {
49        20.0 * (rms / noise_floor).log10()
50    } else {
51        60.0 // Assume good SNR if no noise
52    };
53
54    AudioMetrics {
55        rms_level: rms,
56        peak_level: peak,
57        clipping_detected: clipped > 0,
58        clipped_sample_count: clipped,
59        estimated_snr_db: snr.clamp(-20.0, 80.0),
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    #[test]
68    fn silence_metrics() {
69        let silence = vec![0.0f32; 160];
70        let metrics = analyze_frame(&silence, 0.0);
71        assert!((metrics.rms_level - 0.0).abs() < f32::EPSILON);
72        assert!((metrics.peak_level - 0.0).abs() < f32::EPSILON);
73        assert!(!metrics.clipping_detected);
74        assert_eq!(metrics.clipped_sample_count, 0);
75        // With zero noise floor, SNR defaults to 60
76        assert!((metrics.estimated_snr_db - 60.0).abs() < f32::EPSILON);
77    }
78
79    #[test]
80    fn sine_wave_metrics() {
81        // Generate a simple sine wave at ~1kHz for 10ms at 16kHz
82        let samples: Vec<f32> = (0..160)
83            .map(|i| {
84                let t = i as f32 / 16000.0;
85                (2.0 * std::f32::consts::PI * 1000.0 * t).sin() * 0.5
86            })
87            .collect();
88
89        let metrics = analyze_frame(&samples, 0.01);
90
91        // RMS of a 0.5 amplitude sine wave = 0.5 / sqrt(2) ~ 0.354
92        assert!(
93            (metrics.rms_level - 0.354).abs() < 0.02,
94            "RMS should be ~0.354, got {}",
95            metrics.rms_level
96        );
97        // Peak should be close to 0.5
98        assert!(
99            (metrics.peak_level - 0.5).abs() < 0.01,
100            "Peak should be ~0.5, got {}",
101            metrics.peak_level
102        );
103        assert!(!metrics.clipping_detected);
104        // SNR should be positive (signal is much louder than noise floor)
105        assert!(metrics.estimated_snr_db > 20.0);
106    }
107
108    #[test]
109    fn clipping_detection() {
110        let mut samples = vec![0.5f32; 160];
111        // Add some clipped samples
112        samples[0] = 1.0;
113        samples[1] = -1.0;
114        samples[2] = 0.995;
115
116        let metrics = analyze_frame(&samples, 0.0);
117        assert!(metrics.clipping_detected);
118        assert_eq!(metrics.clipped_sample_count, 3);
119    }
120
121    #[test]
122    fn snr_estimation() {
123        // Signal at 0.5 RMS with noise floor at 0.01
124        let samples = vec![0.5f32; 160];
125        let metrics = analyze_frame(&samples, 0.01);
126
127        // SNR = 20 * log10(0.5 / 0.01) = 20 * log10(50) ~ 34 dB
128        assert!(
129            (metrics.estimated_snr_db - 34.0).abs() < 1.0,
130            "SNR should be ~34 dB, got {}",
131            metrics.estimated_snr_db
132        );
133    }
134
135    #[test]
136    fn empty_frame_returns_defaults() {
137        let metrics = analyze_frame(&[], 0.0);
138        assert!((metrics.rms_level - 0.0).abs() < f32::EPSILON);
139        assert!((metrics.peak_level - 0.0).abs() < f32::EPSILON);
140        assert!(!metrics.clipping_detected);
141        assert_eq!(metrics.clipped_sample_count, 0);
142        assert!((metrics.estimated_snr_db - 0.0).abs() < f32::EPSILON);
143    }
144
145    #[test]
146    fn snr_clamped_to_range() {
147        // Very low signal with high noise floor -> low SNR, should clamp at -20
148        let samples = vec![0.0001f32; 160];
149        let metrics = analyze_frame(&samples, 1.0);
150        assert!(
151            metrics.estimated_snr_db >= -20.0,
152            "SNR should be clamped at -20, got {}",
153            metrics.estimated_snr_db
154        );
155    }
156}