stratum_dsp/preprocessing/
normalization.rs

1//! Audio normalization utilities
2//!
3//! Supports multiple normalization methods:
4//! - Peak normalization (fast)
5//! - RMS normalization
6//! - LUFS normalization (ITU-R BS.1770-4, accurate)
7//!
8//! # Example
9//!
10//! ```no_run
11//! use stratum_dsp::preprocessing::normalization::{
12//!     normalize, NormalizationConfig, NormalizationMethod
13//! };
14//!
15//! let mut samples = vec![0.5f32; 44100];
16//! let config = NormalizationConfig {
17//!     method: NormalizationMethod::Peak,
18//!     target_loudness_lufs: -14.0,
19//!     max_headroom_db: 1.0,
20//! };
21//!
22//! let metadata = normalize(&mut samples, config, 44100.0)?;
23//! # Ok::<(), stratum_dsp::AnalysisError>(())
24//! ```
25
26use crate::error::AnalysisError;
27
28/// Normalization method
29#[derive(Debug, Clone, Copy)]
30pub enum NormalizationMethod {
31    /// Simple peak normalization (fast, scales to max peak)
32    Peak,
33    /// RMS-based normalization (scales to target RMS level)
34    RMS,
35    /// ITU-R BS.1770-4 loudness normalization (LUFS, accurate)
36    Loudness,
37}
38
39/// Normalization configuration
40#[derive(Debug, Clone)]
41pub struct NormalizationConfig {
42    /// Target loudness in LUFS (default: -14.0, YouTube standard)
43    pub target_loudness_lufs: f32,
44    
45    /// Maximum headroom in dB (default: 1.0)
46    pub max_headroom_db: f32,
47    
48    /// Normalization method
49    pub method: NormalizationMethod,
50}
51
52impl Default for NormalizationConfig {
53    fn default() -> Self {
54        Self {
55            target_loudness_lufs: -14.0,
56            max_headroom_db: 1.0,
57            method: NormalizationMethod::Peak,
58        }
59    }
60}
61
62/// Loudness metadata returned from normalization
63#[derive(Debug, Clone)]
64pub struct LoudnessMetadata {
65    /// Measured loudness in LUFS (before normalization)
66    pub measured_lufs: Option<f32>,
67    /// Peak level in dB (before normalization)
68    pub peak_db: f32,
69    /// RMS level in dB (before normalization)
70    pub rms_db: f32,
71    /// Gain applied in dB
72    pub gain_db: f32,
73}
74
75impl Default for LoudnessMetadata {
76    fn default() -> Self {
77        Self {
78            measured_lufs: None,
79            peak_db: f32::NEG_INFINITY,
80            rms_db: f32::NEG_INFINITY,
81            gain_db: 0.0,
82        }
83    }
84}
85
86/// Numerical stability epsilon for divisions
87const EPSILON: f32 = 1e-10;
88
89/// Gate threshold for LUFS calculation (ITU-R BS.1770-4)
90const LUFS_GATE_THRESHOLD: f32 = -70.0;
91
92/// Block size for LUFS integration (400ms at 48kHz = 19200 samples)
93/// We'll use a configurable block size based on sample rate
94const LUFS_BLOCK_DURATION_MS: f32 = 400.0;
95
96/// K-weighting filter for ITU-R BS.1770-4 loudness measurement
97///
98/// Implements the high-pass shelving filter component of the K-weighting filter
99/// as specified in ITU-R BS.1770-4 Annex 2. The filter models human perception
100/// of loudness by boosting frequencies around 1681.97 Hz.
101///
102/// # Reference
103///
104/// ITU-R BS.1770-4 (2015). Algorithms to measure audio programme loudness and true-peak audio level.
105/// International Telecommunication Union.
106///
107/// # Note
108///
109/// This implementation uses a single high-pass shelving filter. The full ITU-R BS.1770-4
110/// standard includes both high-shelf and low-shelf filters, but the high-pass component
111/// is the primary contributor to the K-weighting response.
112struct KWeightingFilter {
113    // High-pass filter state (shelving filter)
114    // Direct Form II transposed only needs x1 and x2
115    x1: f32,
116    x2: f32,
117    // Coefficients for 48kHz
118    b0: f32,
119    b1: f32,
120    b2: f32,
121    a1: f32,
122    a2: f32,
123}
124
125impl KWeightingFilter {
126    /// Create a new K-weighting filter for the given sample rate
127    /// 
128    /// The K-weighting filter consists of:
129    /// 1. High-pass filter (shelving filter) with specific frequency response
130    /// 2. Pre-filtering stage as per ITU-R BS.1770-4
131    fn new(sample_rate: f32) -> Self {
132        // K-weighting filter coefficients for ITU-R BS.1770-4
133        // These are standard coefficients for the high-pass shelving filter
134        // Reference: ITU-R BS.1770-4 Annex 2
135        
136        // For 48kHz, the coefficients are:
137        let w0 = 2.0 * std::f32::consts::PI * 1681.974450955533 / sample_rate;
138        let cos_w0 = w0.cos();
139        let sin_w0 = w0.sin();
140        let alpha = sin_w0 / 2.0 * (1.0f32 / 0.707f32).sqrt(); // Q = 0.707
141        
142        let b0 = (1.0 + cos_w0) / 2.0;
143        let b1 = -(1.0 + cos_w0);
144        let b2 = (1.0 + cos_w0) / 2.0;
145        let a0 = 1.0 + alpha;
146        let a1 = -2.0 * cos_w0;
147        let a2 = 1.0 - alpha;
148        
149        Self {
150            x1: 0.0,
151            x2: 0.0,
152            b0: b0 / a0,
153            b1: b1 / a0,
154            b2: b2 / a0,
155            a1: a1 / a0,
156            a2: a2 / a0,
157        }
158    }
159    
160    /// Process a single sample through the K-weighting filter
161    fn process(&mut self, sample: f32) -> f32 {
162        // Direct Form II transposed implementation
163        let output = self.b0 * sample + self.x1;
164        self.x1 = self.b1 * sample + self.x2 - self.a1 * output;
165        self.x2 = self.b2 * sample - self.a2 * output;
166        output
167    }
168    
169    /// Reset filter state
170    #[allow(dead_code)] // Useful for testing and reuse
171    fn reset(&mut self) {
172        self.x1 = 0.0;
173        self.x2 = 0.0;
174    }
175}
176
177/// Calculate LUFS (Loudness Units relative to Full Scale) using ITU-R BS.1770-4
178///
179/// Algorithm:
180/// 1. Apply K-weighting filter
181/// 2. Compute mean square over 400ms blocks
182/// 3. Apply gate at -70 LUFS
183/// 4. Integrate gated blocks
184/// 5. Convert to LUFS
185fn calculate_lufs(samples: &[f32], sample_rate: f32) -> Result<f32, AnalysisError> {
186    if samples.is_empty() {
187        return Err(AnalysisError::InvalidInput("Empty audio samples".to_string()));
188    }
189    
190    if sample_rate <= 0.0 {
191        return Err(AnalysisError::InvalidInput("Invalid sample rate".to_string()));
192    }
193    
194    let block_size = (sample_rate * LUFS_BLOCK_DURATION_MS / 1000.0) as usize;
195    if block_size == 0 {
196        return Err(AnalysisError::InvalidInput("Sample rate too low for LUFS calculation".to_string()));
197    }
198    
199    // Apply K-weighting filter
200    let mut filter = KWeightingFilter::new(sample_rate);
201    let filtered: Vec<f32> = samples.iter().map(|&s| filter.process(s)).collect();
202    
203    // Compute mean square for each 400ms block
204    let num_blocks = (filtered.len() + block_size - 1) / block_size;
205    let mut block_energies = Vec::with_capacity(num_blocks);
206    
207    for i in 0..num_blocks {
208        let start = i * block_size;
209        let end = (start + block_size).min(filtered.len());
210        
211        // Mean square of block
212        let sum_sq: f32 = filtered[start..end].iter().map(|&x| x * x).sum();
213        let mean_sq = sum_sq / (end - start) as f32;
214        block_energies.push(mean_sq);
215    }
216    
217    if block_energies.is_empty() {
218        return Err(AnalysisError::ProcessingError("No blocks computed for LUFS".to_string()));
219    }
220    
221    // Convert to LUFS and apply gate
222    // LUFS = -0.691 + 10 * log10(mean_square)
223    // Gate: only include blocks above -70 LUFS
224    let gate_threshold_linear = 10.0_f32.powf((LUFS_GATE_THRESHOLD + 0.691) / 10.0);
225    
226    let mut gated_energies = Vec::new();
227    for &energy in &block_energies {
228        if energy > gate_threshold_linear {
229            gated_energies.push(energy);
230        }
231    }
232    
233    // If all blocks are below gate, return very quiet value
234    if gated_energies.is_empty() {
235        log::warn!("All audio blocks below LUFS gate threshold (-70 LUFS)");
236        return Ok(f32::NEG_INFINITY);
237    }
238    
239    // Average of gated blocks
240    let mean_gated: f32 = gated_energies.iter().sum::<f32>() / gated_energies.len() as f32;
241    
242    // Convert to LUFS: LUFS = -0.691 + 10 * log10(mean_square)
243    if mean_gated <= EPSILON {
244        return Err(AnalysisError::NumericalError("Mean square too small for LUFS calculation".to_string()));
245    }
246    
247    let lufs = -0.691 + 10.0 * mean_gated.log10();
248    Ok(lufs)
249}
250
251/// Normalize audio samples using peak normalization
252fn normalize_peak(samples: &mut [f32], max_headroom_db: f32) -> Result<LoudnessMetadata, AnalysisError> {
253    if samples.is_empty() {
254        return Err(AnalysisError::InvalidInput("Empty audio samples".to_string()));
255    }
256    
257    // Find peak value
258    let peak = samples.iter()
259        .map(|&x| x.abs())
260        .fold(0.0f32, f32::max);
261    
262    if peak <= EPSILON {
263        log::warn!("Audio is silent or extremely quiet, cannot normalize");
264        return Ok(LoudnessMetadata {
265            measured_lufs: None,
266            peak_db: f32::NEG_INFINITY,
267            rms_db: f32::NEG_INFINITY,
268            gain_db: 0.0,
269        });
270    }
271    
272    // Calculate peak in dB
273    let peak_db = 20.0 * peak.log10();
274    
275    // Calculate target peak (leave headroom)
276    // max_headroom_db is the headroom below 0 dB, so target is 0 dB - headroom
277    let target_peak_linear = 10.0_f32.powf((0.0 - max_headroom_db) / 20.0);
278    let gain_linear = target_peak_linear / peak;
279    let gain_db = 20.0 * gain_linear.log10();
280    
281    // Ensure we don't exceed 1.0
282    let gain_linear = gain_linear.min(1.0 / peak);
283    
284    // Apply gain
285    for sample in samples.iter_mut() {
286        *sample *= gain_linear;
287    }
288    
289    // Calculate RMS for metadata
290    let rms = (samples.iter().map(|&x| x * x).sum::<f32>() / samples.len() as f32).sqrt();
291    let rms_db = if rms > EPSILON {
292        20.0 * rms.log10()
293    } else {
294        f32::NEG_INFINITY
295    };
296    
297    log::debug!("Peak normalization: peak={:.2} dB, gain={:.2} dB", peak_db, gain_db);
298    
299    Ok(LoudnessMetadata {
300        measured_lufs: None,
301        peak_db,
302        rms_db,
303        gain_db,
304    })
305}
306
307/// Normalize audio samples using RMS normalization
308fn normalize_rms(samples: &mut [f32], target_rms_db: f32, max_headroom_db: f32) -> Result<LoudnessMetadata, AnalysisError> {
309    if samples.is_empty() {
310        return Err(AnalysisError::InvalidInput("Empty audio samples".to_string()));
311    }
312    
313    // Calculate current RMS
314    let rms_sq = samples.iter().map(|&x| x * x).sum::<f32>() / samples.len() as f32;
315    let rms = rms_sq.sqrt();
316    
317    if rms <= EPSILON {
318        log::warn!("Audio is silent or extremely quiet, cannot normalize");
319        return Ok(LoudnessMetadata {
320            measured_lufs: None,
321            peak_db: f32::NEG_INFINITY,
322            rms_db: f32::NEG_INFINITY,
323            gain_db: 0.0,
324        });
325    }
326    
327    let rms_db = 20.0 * rms.log10();
328    
329    // Find peak to check headroom
330    let peak = samples.iter().map(|&x| x.abs()).fold(0.0f32, f32::max);
331    let peak_db = 20.0 * peak.log10();
332    
333    // Calculate target RMS
334    let target_rms_linear = 10.0_f32.powf((target_rms_db - max_headroom_db) / 20.0);
335    let gain_linear = target_rms_linear / rms;
336    let gain_db = 20.0 * gain_linear.log10();
337    
338    // Check if gain would cause clipping
339    let new_peak = peak * gain_linear;
340    if new_peak > 1.0 {
341        log::warn!("RMS normalization would cause clipping, limiting gain");
342        let max_gain_linear = 1.0 / peak;
343        let max_gain_db = 20.0 * max_gain_linear.log10();
344        
345        // Apply limited gain
346        for sample in samples.iter_mut() {
347            *sample *= max_gain_linear;
348        }
349        
350        return Ok(LoudnessMetadata {
351            measured_lufs: None,
352            peak_db,
353            rms_db,
354            gain_db: max_gain_db,
355        });
356    }
357    
358    // Apply gain
359    for sample in samples.iter_mut() {
360        *sample *= gain_linear;
361    }
362    
363    log::debug!("RMS normalization: rms={:.2} dB, gain={:.2} dB", rms_db, gain_db);
364    
365    Ok(LoudnessMetadata {
366        measured_lufs: None,
367        peak_db,
368        rms_db,
369        gain_db,
370    })
371}
372
373/// Normalize audio samples using LUFS (ITU-R BS.1770-4)
374fn normalize_lufs(
375    samples: &mut [f32],
376    target_lufs: f32,
377    max_headroom_db: f32,
378    sample_rate: f32,
379) -> Result<LoudnessMetadata, AnalysisError> {
380    if samples.is_empty() {
381        return Err(AnalysisError::InvalidInput("Empty audio samples".to_string()));
382    }
383    
384    // Calculate current LUFS
385    let measured_lufs = calculate_lufs(samples, sample_rate)?;
386    
387    if measured_lufs == f32::NEG_INFINITY {
388        log::warn!("Audio is too quiet for LUFS measurement, using peak normalization fallback");
389        return normalize_peak(samples, max_headroom_db);
390    }
391    
392    // Calculate gain needed to reach target LUFS
393    let lufs_diff = target_lufs - measured_lufs;
394    let gain_db = lufs_diff;
395    let gain_linear = 10.0_f32.powf(gain_db / 20.0);
396    
397    // Check peak to ensure we don't clip
398    let peak = samples.iter().map(|&x| x.abs()).fold(0.0f32, f32::max);
399    let peak_db = 20.0 * peak.log10();
400    
401    let new_peak = peak * gain_linear;
402    let target_peak_linear = 10.0_f32.powf((0.0 - max_headroom_db) / 20.0);
403    if new_peak > target_peak_linear {
404        log::warn!("LUFS normalization would cause clipping, limiting gain to preserve headroom");
405        let max_gain_linear = target_peak_linear / peak;
406        let max_gain_db = 20.0 * max_gain_linear.log10();
407        
408        // Apply limited gain
409        for sample in samples.iter_mut() {
410            *sample *= max_gain_linear;
411        }
412        
413        // Calculate RMS for metadata
414        let rms = (samples.iter().map(|&x| x * x).sum::<f32>() / samples.len() as f32).sqrt();
415        let rms_db = if rms > EPSILON {
416            20.0 * rms.log10()
417        } else {
418            f32::NEG_INFINITY
419        };
420        
421        return Ok(LoudnessMetadata {
422            measured_lufs: Some(measured_lufs),
423            peak_db,
424            rms_db,
425            gain_db: max_gain_db,
426        });
427    }
428    
429    // Apply gain
430    for sample in samples.iter_mut() {
431        *sample *= gain_linear;
432    }
433    
434    // Calculate RMS for metadata
435    let rms = (samples.iter().map(|&x| x * x).sum::<f32>() / samples.len() as f32).sqrt();
436    let rms_db = if rms > EPSILON {
437        20.0 * rms.log10()
438    } else {
439        f32::NEG_INFINITY
440    };
441    
442    log::debug!("LUFS normalization: measured={:.2} LUFS, target={:.2} LUFS, gain={:.2} dB", 
443                measured_lufs, target_lufs, gain_db);
444    
445    Ok(LoudnessMetadata {
446        measured_lufs: Some(measured_lufs),
447        peak_db,
448        rms_db,
449        gain_db,
450    })
451}
452
453/// Normalize audio samples
454///
455/// # Arguments
456///
457/// * `samples` - Audio samples to normalize (modified in-place)
458/// * `config` - Normalization configuration
459/// * `sample_rate` - Sample rate in Hz (required for LUFS method)
460///
461/// # Returns
462///
463/// `LoudnessMetadata` containing loudness information and applied gain
464///
465/// # Errors
466///
467/// Returns `AnalysisError` if normalization fails (empty samples, invalid config, etc.)
468///
469/// # Example
470///
471/// ```no_run
472/// use stratum_dsp::preprocessing::normalization::{
473///     normalize, NormalizationConfig, NormalizationMethod
474/// };
475///
476/// let mut samples = vec![0.5f32; 44100];
477/// let config = NormalizationConfig {
478///     method: NormalizationMethod::Peak,
479///     target_loudness_lufs: -14.0,
480///     max_headroom_db: 1.0,
481/// };
482///
483/// let metadata = normalize(&mut samples, config, 44100.0)?;
484/// println!("Applied gain: {:.2} dB", metadata.gain_db);
485/// # Ok::<(), stratum_dsp::AnalysisError>(())
486/// ```
487pub fn normalize(
488    samples: &mut [f32],
489    config: NormalizationConfig,
490    sample_rate: f32,
491) -> Result<LoudnessMetadata, AnalysisError> {
492    log::debug!("Normalizing {} samples using {:?} at {} Hz", 
493                samples.len(), config.method, sample_rate);
494    
495    match config.method {
496        NormalizationMethod::Peak => {
497            normalize_peak(samples, config.max_headroom_db)
498        }
499        NormalizationMethod::RMS => {
500            // Convert target LUFS to approximate RMS dB
501            // This is a rough approximation: LUFS ≈ RMS - 3 dB for typical music
502            let target_rms_db = config.target_loudness_lufs + 3.0;
503            normalize_rms(samples, target_rms_db, config.max_headroom_db)
504        }
505        NormalizationMethod::Loudness => {
506            normalize_lufs(samples, config.target_loudness_lufs, config.max_headroom_db, sample_rate)
507        }
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    
515    /// Generate a test signal: sine wave at 440 Hz
516    fn generate_test_signal(length: usize, amplitude: f32, sample_rate: f32) -> Vec<f32> {
517        let freq = 440.0;
518        (0..length)
519            .map(|i| {
520                let t = i as f32 / sample_rate;
521                amplitude * (2.0 * std::f32::consts::PI * freq * t).sin()
522            })
523            .collect()
524    }
525    
526    #[test]
527    fn test_peak_normalization() {
528        let mut samples = generate_test_signal(44100, 0.5, 44100.0);
529        
530        let config = NormalizationConfig {
531            method: NormalizationMethod::Peak,
532            target_loudness_lufs: -14.0,
533            max_headroom_db: 1.0,
534        };
535        
536        let _metadata = normalize(&mut samples, config, 44100.0).unwrap();
537        
538        // Check that peak is approximately at target (with headroom)
539        let new_peak = samples.iter().map(|&x| x.abs()).fold(0.0f32, f32::max);
540        let target_peak = 10.0_f32.powf((0.0 - 1.0) / 20.0); // 1 dB headroom from 0 dB
541        
542        assert!((new_peak - target_peak).abs() < 0.01, 
543                "Peak normalization failed: expected ~{:.3}, got {:.3}", target_peak, new_peak);
544        assert!(new_peak <= 1.0, "Peak normalization caused clipping: peak = {:.3}", new_peak);
545    }
546    
547    #[test]
548    fn test_rms_normalization() {
549        let mut samples = generate_test_signal(44100, 0.3, 44100.0);
550        
551        let config = NormalizationConfig {
552            method: NormalizationMethod::RMS,
553            target_loudness_lufs: -14.0,
554            max_headroom_db: 1.0,
555        };
556        
557        let _metadata = normalize(&mut samples, config, 44100.0).unwrap();
558        
559        // Check that RMS is approximately at target
560        let rms = (samples.iter().map(|&x| x * x).sum::<f32>() / samples.len() as f32).sqrt();
561        let target_rms_db = -14.0 + 3.0; // Approximate conversion
562        let target_rms = 10.0_f32.powf((target_rms_db - 1.0) / 20.0); // With headroom
563        
564        assert!((rms - target_rms).abs() < 0.1, 
565                "RMS normalization failed: expected ~{:.3}, got {:.3}", target_rms, rms);
566        
567        // Check no clipping
568        let peak = samples.iter().map(|&x| x.abs()).fold(0.0f32, f32::max);
569        assert!(peak <= 1.0, "RMS normalization caused clipping");
570    }
571    
572    #[test]
573    fn test_lufs_calculation() {
574        // Generate a louder signal for LUFS testing
575        let samples = generate_test_signal(48000 * 2, 0.8, 48000.0); // 2 seconds at 48kHz
576        
577        let lufs = calculate_lufs(&samples, 48000.0).unwrap();
578        
579        // For a 0.8 amplitude sine wave, LUFS depends on K-weighting filter response
580        // K-weighting reduces low frequencies, so a pure tone at 440Hz will have lower LUFS
581        // than the peak level. The value should be finite and reasonable.
582        assert!(lufs.is_finite(), "LUFS should be finite: {:.2} LUFS", lufs);
583        assert!(lufs < 0.0, "LUFS should be negative for normalized audio: {:.2} LUFS", lufs);
584    }
585    
586    #[test]
587    fn test_lufs_normalization() {
588        // Generate test signal
589        let mut samples = generate_test_signal(48000 * 2, 0.5, 48000.0); // 2 seconds
590        
591        let config = NormalizationConfig {
592            method: NormalizationMethod::Loudness,
593            target_loudness_lufs: -14.0,
594            max_headroom_db: 1.0,
595        };
596        
597        let metadata = normalize(&mut samples, config, 48000.0).unwrap();
598        
599        // Verify metadata
600        assert!(metadata.measured_lufs.is_some(), "LUFS normalization should return measured LUFS");
601        assert!(metadata.gain_db != 0.0, "Gain should be applied");
602        
603        // Verify no clipping
604        let peak = samples.iter().map(|&x| x.abs()).fold(0.0f32, f32::max);
605        assert!(peak <= 1.0, "LUFS normalization caused clipping: peak = {:.3}", peak);
606    }
607    
608    #[test]
609    fn test_silent_audio() {
610        let mut samples = vec![0.0f32; 44100];
611        
612        let config = NormalizationConfig {
613            method: NormalizationMethod::Peak,
614            target_loudness_lufs: -14.0,
615            max_headroom_db: 1.0,
616        };
617        
618        // Should handle silently without error
619        let metadata = normalize(&mut samples, config, 44100.0).unwrap();
620        assert_eq!(metadata.gain_db, 0.0, "Silent audio should not apply gain");
621        assert_eq!(metadata.peak_db, f32::NEG_INFINITY);
622    }
623    
624    #[test]
625    fn test_ultra_quiet_audio() {
626        // Very quiet signal (near silence)
627        let mut samples = generate_test_signal(44100, 1e-6, 44100.0);
628        
629        let config = NormalizationConfig {
630            method: NormalizationMethod::Peak,
631            target_loudness_lufs: -14.0,
632            max_headroom_db: 1.0,
633        };
634        
635        // Should handle gracefully
636        let _metadata = normalize(&mut samples, config, 44100.0).unwrap();
637        // May or may not apply gain depending on epsilon threshold
638    }
639    
640    #[test]
641    fn test_empty_samples() {
642        let mut samples = vec![];
643        
644        let config = NormalizationConfig::default();
645        
646        let result = normalize(&mut samples, config, 44100.0);
647        assert!(result.is_err(), "Empty samples should return error");
648    }
649    
650    #[test]
651    fn test_k_weighting_filter() {
652        let sample_rate = 48000.0;
653        let mut filter = KWeightingFilter::new(sample_rate);
654        
655        // Test with impulse
656        let output = filter.process(1.0);
657        assert!(!output.is_nan() && !output.is_infinite(), 
658                "Filter output should be finite");
659        
660        // Reset and test with sine wave
661        filter.reset();
662        let test_signal = generate_test_signal(1000, 0.5, sample_rate);
663        let filtered: Vec<f32> = test_signal.iter().map(|&s| filter.process(s)).collect();
664        
665        // Filtered signal should be different from input (K-weighting changes frequency response)
666        assert_ne!(filtered, test_signal, "K-weighting filter should modify signal");
667        
668        // All outputs should be finite
669        for &x in &filtered {
670            assert!(x.is_finite(), "Filter output should be finite");
671        }
672    }
673}
674