Skip to main content

wavekat_vad/preprocessing/
normalize.rs

1//! RMS-based audio normalization.
2//!
3//! Normalizes audio amplitude to a target level in dBFS (decibels relative
4//! to full scale), ensuring consistent VAD thresholds regardless of input gain.
5
6/// Full scale reference for i16 audio.
7const FULL_SCALE: f64 = 32768.0;
8
9/// Minimum RMS threshold to avoid amplifying silence/noise floor.
10/// Below this level (-60 dBFS), we don't apply gain.
11const MIN_RMS_THRESHOLD: f64 = 0.001; // ~-60 dBFS
12
13/// Audio normalizer that adjusts amplitude to a target dBFS level.
14///
15/// Uses RMS (root mean square) measurement with smoothing to avoid
16/// sudden gain changes. Includes peak limiting to prevent clipping.
17#[derive(Debug, Clone)]
18pub struct Normalizer {
19    /// Target RMS level as linear amplitude (derived from dBFS).
20    target_rms: f64,
21    /// Current gain being applied (smoothed).
22    current_gain: f64,
23    /// Smoothing factor for gain changes (0-1, higher = faster).
24    smoothing: f64,
25    /// Whether to apply peak limiting.
26    peak_limit: bool,
27}
28
29impl Normalizer {
30    /// Create a new normalizer with the given target level.
31    ///
32    /// # Arguments
33    /// * `target_dbfs` - Target RMS level in dBFS (e.g., -20.0)
34    ///
35    /// # Panics
36    /// Panics if target_dbfs > 0 (would exceed full scale).
37    pub fn new(target_dbfs: f32) -> Self {
38        assert!(
39            target_dbfs <= 0.0,
40            "Target dBFS must be <= 0, got {target_dbfs}"
41        );
42
43        // Convert dBFS to linear amplitude
44        // dBFS = 20 * log10(amplitude / full_scale)
45        // amplitude = full_scale * 10^(dBFS/20)
46        let target_rms = FULL_SCALE * 10_f64.powf(target_dbfs as f64 / 20.0);
47
48        Self {
49            target_rms,
50            current_gain: 1.0,
51            smoothing: 0.1, // Moderate smoothing
52            peak_limit: true,
53        }
54    }
55
56    /// Create a normalizer with custom settings.
57    pub fn with_settings(target_dbfs: f32, smoothing: f64, peak_limit: bool) -> Self {
58        let mut normalizer = Self::new(target_dbfs);
59        normalizer.smoothing = smoothing.clamp(0.01, 1.0);
60        normalizer.peak_limit = peak_limit;
61        normalizer
62    }
63
64    /// Calculate RMS of the samples.
65    fn calculate_rms(samples: &[i16]) -> f64 {
66        if samples.is_empty() {
67            return 0.0;
68        }
69
70        let sum_squares: f64 = samples.iter().map(|&s| (s as f64).powi(2)).sum();
71        (sum_squares / samples.len() as f64).sqrt()
72    }
73
74    /// Convert RMS to dBFS.
75    #[allow(dead_code)]
76    pub fn rms_to_dbfs(rms: f64) -> f64 {
77        if rms <= 0.0 {
78            return -96.0; // Practical minimum
79        }
80        20.0 * (rms / FULL_SCALE).log10()
81    }
82
83    /// Process audio samples with normalization.
84    ///
85    /// Returns a new buffer with normalized amplitude.
86    pub fn process(&mut self, samples: &[i16]) -> Vec<i16> {
87        if samples.is_empty() {
88            return Vec::new();
89        }
90
91        let input_rms = Self::calculate_rms(samples);
92
93        // Don't amplify very quiet signals (likely silence or noise floor)
94        if input_rms < MIN_RMS_THRESHOLD * FULL_SCALE {
95            return samples.to_vec();
96        }
97
98        // Calculate target gain
99        let target_gain = self.target_rms / input_rms;
100
101        // Smooth gain changes to avoid sudden jumps
102        self.current_gain += self.smoothing * (target_gain - self.current_gain);
103
104        // Apply gain and optionally limit peaks
105        samples
106            .iter()
107            .map(|&s| {
108                let amplified = s as f64 * self.current_gain;
109
110                if self.peak_limit {
111                    // Soft clipping using tanh for smoother limiting
112                    let normalized = amplified / FULL_SCALE;
113                    let limited = if normalized.abs() > 0.9 {
114                        // Apply soft limiting above 90% of full scale
115                        let sign = normalized.signum();
116                        let magnitude = normalized.abs();
117                        let compressed = 0.9 + 0.1 * ((magnitude - 0.9) / 0.1).tanh();
118                        sign * compressed * FULL_SCALE
119                    } else {
120                        amplified
121                    };
122                    limited.round().clamp(-32768.0, 32767.0) as i16
123                } else {
124                    // Hard clipping
125                    amplified.round().clamp(-32768.0, 32767.0) as i16
126                }
127            })
128            .collect()
129    }
130
131    /// Reset the normalizer state.
132    pub fn reset(&mut self) {
133        self.current_gain = 1.0;
134    }
135
136    /// Get the current gain being applied.
137    pub fn current_gain(&self) -> f64 {
138        self.current_gain
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_normalizer_creation() {
148        let norm = Normalizer::new(-20.0);
149        assert!(norm.target_rms > 0.0);
150        assert!(norm.target_rms < FULL_SCALE);
151    }
152
153    #[test]
154    #[should_panic(expected = "Target dBFS must be <= 0")]
155    fn test_normalizer_invalid_target() {
156        Normalizer::new(6.0);
157    }
158
159    #[test]
160    fn test_rms_calculation() {
161        // DC signal should have RMS equal to the DC value
162        let dc: Vec<i16> = vec![1000; 100];
163        let rms = Normalizer::calculate_rms(&dc);
164        assert!((rms - 1000.0).abs() < 1.0);
165
166        // Silence should have zero RMS
167        let silence: Vec<i16> = vec![0; 100];
168        let rms = Normalizer::calculate_rms(&silence);
169        assert_eq!(rms, 0.0);
170    }
171
172    #[test]
173    fn test_normalizer_amplifies_quiet() {
174        let mut norm = Normalizer::new(-20.0);
175
176        // Very quiet signal (about -40 dBFS)
177        let quiet: Vec<i16> = vec![100; 480];
178        let output = norm.process(&quiet);
179
180        // Should be amplified
181        let input_rms = Normalizer::calculate_rms(&quiet);
182        let output_rms = Normalizer::calculate_rms(&output);
183        assert!(
184            output_rms > input_rms,
185            "Output RMS {output_rms} should be > input RMS {input_rms}"
186        );
187    }
188
189    #[test]
190    fn test_normalizer_attenuates_loud() {
191        let mut norm = Normalizer::new(-20.0);
192
193        // Loud signal (about -6 dBFS)
194        let loud: Vec<i16> = vec![16000; 480];
195        let output = norm.process(&loud);
196
197        // Should be attenuated
198        let input_rms = Normalizer::calculate_rms(&loud);
199        let output_rms = Normalizer::calculate_rms(&output);
200        assert!(
201            output_rms < input_rms,
202            "Output RMS {output_rms} should be < input RMS {input_rms}"
203        );
204    }
205
206    #[test]
207    fn test_normalizer_skips_silence() {
208        let mut norm = Normalizer::new(-20.0);
209
210        // Very quiet (below threshold)
211        let silence: Vec<i16> = vec![1; 480];
212        let output = norm.process(&silence);
213
214        // Should pass through unchanged
215        assert_eq!(output, silence);
216    }
217
218    #[test]
219    fn test_normalizer_peak_limiting() {
220        let mut norm = Normalizer::with_settings(-6.0, 1.0, true);
221
222        // Signal that will be amplified significantly
223        let input: Vec<i16> = vec![10000; 480];
224        let output = norm.process(&input);
225
226        // All samples should be valid i16 values (limiting worked)
227        // This is implicitly true since output is Vec<i16>, but we verify
228        // the limiting didn't cause any issues
229        assert!(!output.is_empty());
230    }
231
232    #[test]
233    fn test_normalizer_reset() {
234        let mut norm = Normalizer::new(-20.0);
235
236        // Process some audio to change gain
237        let samples: Vec<i16> = vec![1000; 480];
238        norm.process(&samples);
239        assert!(norm.current_gain() != 1.0);
240
241        // Reset
242        norm.reset();
243        assert_eq!(norm.current_gain(), 1.0);
244    }
245
246    #[test]
247    fn test_dbfs_conversion() {
248        // Full scale should be 0 dBFS
249        let dbfs = Normalizer::rms_to_dbfs(FULL_SCALE);
250        assert!((dbfs - 0.0).abs() < 0.01);
251
252        // Half amplitude should be about -6 dBFS
253        let dbfs = Normalizer::rms_to_dbfs(FULL_SCALE / 2.0);
254        assert!((dbfs - (-6.02)).abs() < 0.1);
255    }
256}