Skip to main content

audio_engine_core/processor/
dsp.rs

1//! DSP utilities - Volume control and Noise shaping
2//!
3//! NoiseShaper implementation based on SoX dither.c coefficients
4//! with NTF-verified stability and realtime-safe xorshift64 RNG.
5
6use serde::{Deserialize, Serialize};
7
8const VOLUME_SMOOTHING_TIME_MS: f64 = 20.0;
9const INV_U64_MAX: f64 = 1.0 / u64::MAX as f64;
10
11// ============================================================================
12// Common DSP Utility Functions (P1-4: centralized, previously duplicated)
13// ============================================================================
14
15/// Convert dB to linear gain. Shared across all processor modules.
16#[inline(always)]
17pub fn db_to_linear(db: f64) -> f64 {
18    10.0_f64.powf(db / 20.0)
19}
20
21/// Convert linear gain to dB. Shared across all processor modules.
22#[inline(always)]
23pub fn linear_to_db(linear: f64) -> f64 {
24    if linear > 0.0 {
25        20.0 * linear.log10()
26    } else {
27        f64::NEG_INFINITY
28    }
29}
30
31/// Volume controller with anti-zipper smoothing
32///
33/// FIX for Defect 36: Smoothing coefficient is now sample-rate aware.
34/// The smoothing time constant is ~20ms regardless of sample rate.
35pub struct VolumeController {
36    current: f64,
37    target: f64,
38    smoothing: f64,
39    one_minus_smoothing: f64,
40    sample_rate: u32,
41}
42
43impl VolumeController {
44    /// Create a new VolumeController with default sample rate (44100 Hz)
45    pub fn new() -> Self {
46        Self::with_sample_rate(44100)
47    }
48
49    /// Create a new VolumeController with specified sample rate
50    ///
51    /// FIX for Defect 36: Calculate smoothing coefficient based on sample rate
52    /// to maintain consistent ~20ms smoothing time.
53    pub fn with_sample_rate(sample_rate: u32) -> Self {
54        // Target: ~20ms smoothing time
55        // smoothing = exp(-1 / tau) where tau = samples for 20ms
56        let smoothing_samples = (VOLUME_SMOOTHING_TIME_MS / 1000.0) * sample_rate as f64;
57        let smoothing = (-1.0 / smoothing_samples).exp();
58        let one_minus_smoothing = 1.0 - smoothing;
59
60        Self {
61            current: 1.0,
62            target: 1.0,
63            smoothing,
64            one_minus_smoothing,
65            sample_rate,
66        }
67    }
68
69    /// Update sample rate (recalculates smoothing coefficient)
70    pub fn set_sample_rate(&mut self, sample_rate: u32) {
71        if sample_rate != self.sample_rate {
72            self.sample_rate = sample_rate;
73            let smoothing_samples = (VOLUME_SMOOTHING_TIME_MS / 1000.0) * sample_rate as f64;
74            self.smoothing = (-1.0 / smoothing_samples).exp();
75            self.one_minus_smoothing = 1.0 - self.smoothing;
76        }
77    }
78
79    pub fn set_target(&mut self, volume: f64) {
80        self.target = volume.clamp(0.0, 1.0);
81    }
82
83    #[inline(always)]
84    pub fn next_volume(&mut self) -> f64 {
85        self.current += (self.target - self.current) * self.one_minus_smoothing;
86        self.current
87    }
88
89    #[inline]
90    pub fn process(&mut self, buffer: &mut [f64], channels: usize) {
91        let frames = buffer.len() / channels;
92        for frame in 0..frames {
93            let vol = self.next_volume();
94            for ch in 0..channels {
95                buffer[frame * channels + ch] *= vol;
96            }
97        }
98    }
99}
100
101impl Default for VolumeController {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107/// Noise shaping curve presets
108/// All coefficients from SoX src/dither.c, NTF zeros verified |z| < 1
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
110pub enum NoiseShaperCurve {
111    /// Lipshitz 5-tap - general purpose, works well at 44.1/48kHz
112    /// NTF max|z| = 0.961, 4kHz notch -27.2dB
113    #[default]
114    Lipshitz5,
115
116    /// F-weighted 9-tap - psychoacoustically optimized for 44.1kHz
117    /// Deepest notch in 2-5kHz region (human hearing most sensitive)
118    /// NTF max|z| = 0.914
119    FWeighted9,
120
121    /// Modified-E 9-tap - moderate high-frequency push
122    /// NTF max|z| = 0.916
123    ModifiedE9,
124
125    /// Improved-E 9-tap - most aggressive HF noise shaping
126    /// NTF max|z| = 0.959
127    ImprovedE9,
128
129    /// Pure TPDF only, no noise shaping
130    /// Recommended for 96kHz+ where shaping benefit diminishes
131    TpdfOnly,
132}
133
134impl NoiseShaperCurve {
135    /// Auto-select curve based on sample rate
136    /// - 44.1kHz: Lipshitz5 (safe default)
137    /// - 48kHz: Lipshitz5 (acceptable 8.8% offset)
138    /// - 88.2/96kHz+: TpdfOnly (shaping benefit diminishes)
139    pub fn auto_select(sample_rate: u32) -> Self {
140        if sample_rate <= 50_000 {
141            Self::Lipshitz5
142        } else {
143            Self::TpdfOnly
144        }
145    }
146
147    /// Get verified SoX coefficients
148    /// Returns 9-element array (lower-order curves pad with zeros)
149    pub fn coeffs(&self) -> [f64; 9] {
150        match self {
151            // SoX lip44 - Lipshitz 1992, 5-tap
152            // Verified: NTF zeros all inside unit circle
153            Self::Lipshitz5 => [2.033, -2.165, 1.959, -1.590, 0.6149, 0.0, 0.0, 0.0, 0.0],
154
155            // SoX fwe44 - F-weighted, 9-tap
156            // Best psychoacoustic performance at 44.1kHz
157            Self::FWeighted9 => [
158                2.412, -3.370, 3.937, -4.174, 3.353, -2.205, 1.281, -0.569, 0.0847,
159            ],
160
161            // SoX mew44 - Modified-E, 9-tap
162            Self::ModifiedE9 => [
163                1.662, -1.263, 0.4827, -0.2913, 0.1268, -0.1124, 0.03252, -0.01265, -0.03524,
164            ],
165
166            // SoX iew44 - Improved-E, 9-tap (most aggressive)
167            Self::ImprovedE9 => [
168                2.847, -4.685, 6.214, -7.184, 6.639, -5.032, 3.263, -1.632, 0.4191,
169            ],
170
171            // Pure TPDF - no noise shaping
172            Self::TpdfOnly => [0.0; 9],
173        }
174    }
175
176    #[inline]
177    fn active_taps(&self) -> usize {
178        match self {
179            Self::Lipshitz5 => 5,
180            Self::TpdfOnly => 0,
181            Self::FWeighted9 | Self::ModifiedE9 | Self::ImprovedE9 => 9,
182        }
183    }
184
185    /// Check if this curve is recommended for given sample rate
186    /// Unified boundary at 50_000 Hz based on NTF degradation analysis:
187    /// - At 48kHz: all curves have ≤6dB 4kHz notch degradation (acceptable)
188    /// - At 88.2kHz: only FWeighted9/ImprovedE9 stay ≤6dB, others exceed
189    /// - Conservative choice: recommend TpdfOnly for all rates >50kHz
190    pub fn is_recommended_for(&self, sample_rate: u32) -> bool {
191        match self {
192            Self::TpdfOnly => true,     // Always safe
193            _ => sample_rate <= 50_000, // Unified boundary
194        }
195    }
196}
197
198/// High-order noise shaping quantizer with SoX-verified coefficients
199///
200/// Features:
201/// - 9-tap error feedback (supports all SoX curves)
202/// - Internal xorshift64 RNG (realtime-safe, no thread_rng overhead)
203/// - TPDF dither at ±1 LSB (standard amplitude)
204/// - Error clamp ±2 LSB (prevents burst noise)
205/// - Runtime curve switching with history reset
206pub struct NoiseShaper {
207    /// Per-channel error history (9 samples each)
208    error_history: Vec<[f64; 9]>,
209    /// Per-channel duplicated ring history for 9-tap curves.
210    error_history_9tap: Vec<[f64; 18]>,
211    /// Current head index in the duplicated 9-tap ring per channel.
212    error_history_9tap_heads: Vec<usize>,
213    /// Current coefficients
214    coeffs: [f64; 9],
215    /// Number of non-zero feedback taps for the current curve
216    active_taps: usize,
217    /// Target bit depth
218    bits: u32,
219    /// Cached 2^(bits-1)
220    cached_scale: f64,
221    /// Cached reciprocal of `cached_scale`
222    cached_lsb: f64,
223    /// Enable/disable flag
224    enabled: bool,
225    /// Current curve preset
226    curve: NoiseShaperCurve,
227    /// Sample rate for auto-selection
228    sample_rate: u32,
229    /// Per-channel xorshift64 state for TPDF generation. Each channel owns an
230    /// independent, decorrelated stream so L/R dither is not identical (a single
231    /// shared stream leaves the channels' dither correlated, which images as a
232    /// center-panned noise artifact instead of diffuse noise).
233    rng_state: Vec<u64>,
234}
235
236/// Base seed for the per-channel TPDF RNG streams.
237const NOISE_SHAPER_RNG_SEED: u64 = 0x1234_5678_9ABC_DEF0;
238
239impl NoiseShaper {
240    /// Create new NoiseShaper with auto-selected curve
241    pub fn new(channels: usize, sample_rate: u32, bits: u32) -> Self {
242        let curve = NoiseShaperCurve::auto_select(sample_rate);
243        let coeffs = curve.coeffs();
244        let bits = bits.clamp(8, 32);
245        let (cached_scale, cached_lsb) = Self::scale_for_bits(bits);
246
247        Self {
248            error_history: vec![[0.0; 9]; channels],
249            error_history_9tap: vec![[0.0; 18]; channels],
250            error_history_9tap_heads: vec![0; channels],
251            coeffs,
252            active_taps: curve.active_taps(),
253            bits,
254            cached_scale,
255            cached_lsb,
256            enabled: true,
257            curve,
258            sample_rate,
259            rng_state: (0..channels).map(Self::channel_seed).collect(),
260        }
261    }
262
263    /// Derive a distinct, non-zero, decorrelated xorshift64 seed for a channel by
264    /// running the base seed mixed with the channel index through the splitmix64
265    /// finalizer. Independent seeds keep each channel's dither stream uncorrelated.
266    fn channel_seed(ch: usize) -> u64 {
267        let mut z =
268            NOISE_SHAPER_RNG_SEED.wrapping_add((ch as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15));
269        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
270        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
271        z ^= z >> 31;
272        // xorshift64 requires a non-zero state.
273        if z == 0 {
274            NOISE_SHAPER_RNG_SEED
275        } else {
276            z
277        }
278    }
279
280    /// Enable or disable noise shaping
281    pub fn set_enabled(&mut self, enabled: bool) {
282        self.enabled = enabled;
283    }
284
285    /// Set target bit depth (Defect 37 fix)
286    ///
287    /// Reachable from the audio thread via `NoiseShaperProcessor::sync_params`, so
288    /// this must stay allocation- and log-free.
289    pub fn set_bits(&mut self, bits: u32) {
290        if bits != self.bits && (8..=32).contains(&bits) {
291            self.bits = bits;
292            let (cached_scale, cached_lsb) = Self::scale_for_bits(bits);
293            self.cached_scale = cached_scale;
294            self.cached_lsb = cached_lsb;
295        }
296    }
297
298    #[inline]
299    fn scale_for_bits(bits: u32) -> (f64, f64) {
300        let scale = 2.0_f64.powi(bits as i32 - 1);
301        (scale, 1.0 / scale)
302    }
303
304    /// Get current curve
305    pub fn curve(&self) -> NoiseShaperCurve {
306        self.curve
307    }
308
309    /// Get current sample rate
310    pub fn sample_rate(&self) -> u32 {
311        self.sample_rate
312    }
313
314    /// Get current bit depth
315    pub fn bits(&self) -> u32 {
316        self.bits
317    }
318
319    /// Switch to a different noise shaping curve.
320    ///
321    /// Clears error history to prevent artifacts from coefficient mismatch, and
322    /// respects the caller's explicit choice even when the curve is not recommended
323    /// for the current sample rate (callers can query [`NoiseShaperCurve::is_recommended_for`]
324    /// beforehand). Reachable from the audio thread via
325    /// `NoiseShaperProcessor::sync_params`, so it stays allocation- and log-free.
326    pub fn set_curve(&mut self, curve: NoiseShaperCurve) {
327        self.curve = curve;
328        self.coeffs = curve.coeffs();
329        self.active_taps = curve.active_taps();
330
331        // MUST clear history when switching curves
332        for h in &mut self.error_history {
333            *h = [0.0; 9];
334        }
335        for h in &mut self.error_history_9tap {
336            *h = [0.0; 18];
337        }
338        self.error_history_9tap_heads.fill(0);
339    }
340
341    /// Update sample rate (triggers curve auto-selection)
342    pub fn set_sample_rate(&mut self, sample_rate: u32) {
343        if sample_rate != self.sample_rate {
344            self.sample_rate = sample_rate;
345            let new_curve = NoiseShaperCurve::auto_select(sample_rate);
346            self.set_curve(new_curve);
347        }
348    }
349
350    /// xorshift64 PRNG for channel `ch` - fast, deterministic, period 2^64-1
351    #[inline(always)]
352    fn next_u64(&mut self, ch: usize) -> u64 {
353        // Classic xorshift64 parameters (13, 7, 17)
354        let s = &mut self.rng_state[ch];
355        *s ^= *s << 13;
356        *s ^= *s >> 7;
357        *s ^= *s << 17;
358        *s
359    }
360
361    /// Generate TPDF sample for channel `ch`: triangular distribution over (-1, 1)
362    /// This gives ±1 LSB amplitude when multiplied by lsb
363    /// Standard TPDF: two independent uniform samples subtracted
364    #[inline(always)]
365    fn tpdf(&mut self, ch: usize) -> f64 {
366        // Two independent U(0,1) samples from this channel's stream
367        let r1 = self.next_u64(ch) as f64 * INV_U64_MAX;
368        let r2 = self.next_u64(ch) as f64 * INV_U64_MAX;
369        // Triangular distribution: U(0,1) - U(0,1) = T(-1, 1)
370        r1 - r2
371    }
372
373    /// Process a single sample with noise shaping and dither
374    ///
375    /// # Arguments
376    /// * `sample` - Input sample in [-1, 1] range
377    /// * `ch` - Channel index for error history
378    ///
379    /// # Returns
380    /// * Quantized sample in [-1, 1] range
381    #[inline(always)]
382    pub fn process_sample(&mut self, sample: f64, ch: usize) -> f64 {
383        match self.active_taps {
384            0 => self.process_sample_with_taps::<0>(sample, ch),
385            5 => self.process_sample_with_taps::<5>(sample, ch),
386            _ => self.process_sample_9tap_ring(sample, ch),
387        }
388    }
389
390    #[inline(always)]
391    fn process_sample_with_taps<const TAPS: usize>(&mut self, sample: f64, ch: usize) -> f64 {
392        if !self.enabled || ch >= self.error_history.len() {
393            return sample;
394        }
395
396        // Adaptive dither: skip dither and noise shaping in silence regions
397        // Threshold: -120 dBFS (1e-6)
398        // Rationale: 24-bit TPDF dither RMS ≈ -146 dBFS, so -120 dBFS is far below
399        // perceptible range. This avoids audible dither noise in quiet passages.
400        const SILENCE_THRESHOLD: f64 = 1e-6; // -120 dBFS
401
402        if sample.abs() < SILENCE_THRESHOLD {
403            // Clear error history to prevent burst noise when audio resumes
404            // If we don't do this, accumulated error from silence would suddenly
405            // be released when signal returns, causing an audible click
406            self.error_history[ch] = [0.0; 9];
407            return sample;
408        }
409
410        // 1. Generate TPDF dither FIRST (before borrowing error_history)
411        //    tpdf() returns (-1, 1) which is ±1 LSB in the integer domain
412        //    This is the standard TPDF amplitude for dither
413        let dither = self.tpdf(ch);
414
415        // 2. Get error history and compute feedback
416        let e = &mut self.error_history[ch];
417        let feedback = if TAPS == 0 {
418            0.0
419        } else if TAPS == 5 {
420            self.coeffs[0] * e[0]
421                + self.coeffs[1] * e[1]
422                + self.coeffs[2] * e[2]
423                + self.coeffs[3] * e[3]
424                + self.coeffs[4] * e[4]
425        } else {
426            self.coeffs[0] * e[0]
427                + self.coeffs[1] * e[1]
428                + self.coeffs[2] * e[2]
429                + self.coeffs[3] * e[3]
430                + self.coeffs[4] * e[4]
431                + self.coeffs[5] * e[5]
432                + self.coeffs[6] * e[6]
433                + self.coeffs[7] * e[7]
434                + self.coeffs[8] * e[8]
435        };
436
437        // 3. Quantize
438        //    x is in the integer domain (sample * scale shifts to integer range)
439        //    dither adds ±1 LSB to prevent quantization distortion
440        let x = sample * self.cached_scale + feedback;
441        let quantized = (x + dither).round();
442
443        // 4. Update error history with clamp
444        //    Clamping prevents error accumulation that could cause burst noise
445        //    With ±1 LSB dither, max error is ~1.5 LSB, clamp at ±2 for safety margin
446        let raw_error = x - quantized;
447        let clamped_error = raw_error.clamp(-2.0, 2.0);
448
449        // Shift only the active feedback window. TPDF-only has no feedback state.
450        if TAPS == 5 {
451            e[4] = e[3];
452            e[3] = e[2];
453            e[2] = e[1];
454            e[1] = e[0];
455            e[0] = clamped_error;
456        } else if TAPS == 9 {
457            e[8] = e[7];
458            e[7] = e[6];
459            e[6] = e[5];
460            e[5] = e[4];
461            e[4] = e[3];
462            e[3] = e[2];
463            e[2] = e[1];
464            e[1] = e[0];
465            e[0] = clamped_error;
466        }
467
468        quantized * self.cached_lsb
469    }
470
471    #[inline(always)]
472    fn process_sample_lipshitz5(&mut self, sample: f64, ch: usize) -> f64 {
473        self.process_sample_with_taps::<5>(sample, ch)
474    }
475
476    #[inline(always)]
477    fn process_sample_tpdf_only(&mut self, sample: f64, ch: usize) -> f64 {
478        self.process_sample_with_taps::<0>(sample, ch)
479    }
480
481    #[inline(always)]
482    fn process_sample_9tap(&mut self, sample: f64, ch: usize) -> f64 {
483        self.process_sample_9tap_ring(sample, ch)
484    }
485
486    #[inline(always)]
487    fn process_sample_9tap_ring(&mut self, sample: f64, ch: usize) -> f64 {
488        if !self.enabled || ch >= self.error_history_9tap.len() {
489            return sample;
490        }
491
492        const SILENCE_THRESHOLD: f64 = 1e-6;
493
494        if sample.abs() < SILENCE_THRESHOLD {
495            self.error_history_9tap[ch] = [0.0; 18];
496            self.error_history_9tap_heads[ch] = 0;
497            return sample;
498        }
499
500        let dither = self.tpdf(ch);
501        let head = self.error_history_9tap_heads[ch];
502        let e = &mut self.error_history_9tap[ch];
503        let feedback = self.coeffs[0] * e[head]
504            + self.coeffs[1] * e[head + 1]
505            + self.coeffs[2] * e[head + 2]
506            + self.coeffs[3] * e[head + 3]
507            + self.coeffs[4] * e[head + 4]
508            + self.coeffs[5] * e[head + 5]
509            + self.coeffs[6] * e[head + 6]
510            + self.coeffs[7] * e[head + 7]
511            + self.coeffs[8] * e[head + 8];
512        let x = sample * self.cached_scale + feedback;
513        let quantized = (x + dither).round();
514        let clamped_error = (x - quantized).clamp(-2.0, 2.0);
515        let next_head = if head == 0 { 8 } else { head - 1 };
516        e[next_head] = clamped_error;
517        e[next_head + 9] = clamped_error;
518        self.error_history_9tap_heads[ch] = next_head;
519
520        quantized * self.cached_lsb
521    }
522
523    /// Process a buffer of samples (convenience method)
524    pub fn process(&mut self, buffer: &mut [f64], channels: usize) {
525        if !self.enabled {
526            return;
527        }
528
529        let frames = buffer.len() / channels;
530        match self.active_taps {
531            0 => {
532                for frame in 0..frames {
533                    for ch in 0..channels {
534                        let idx = frame * channels + ch;
535                        buffer[idx] = self.process_sample_tpdf_only(buffer[idx], ch);
536                    }
537                }
538            }
539            5 => {
540                for frame in 0..frames {
541                    for ch in 0..channels {
542                        let idx = frame * channels + ch;
543                        buffer[idx] = self.process_sample_lipshitz5(buffer[idx], ch);
544                    }
545                }
546            }
547            _ => {
548                for frame in 0..frames {
549                    for ch in 0..channels {
550                        let idx = frame * channels + ch;
551                        buffer[idx] = self.process_sample_9tap(buffer[idx], ch);
552                    }
553                }
554            }
555        }
556    }
557
558    /// Reset error history (useful when starting new track)
559    pub fn reset(&mut self) {
560        for h in &mut self.error_history {
561            *h = [0.0; 9];
562        }
563        for h in &mut self.error_history_9tap {
564            *h = [0.0; 18];
565        }
566        self.error_history_9tap_heads.fill(0);
567        // Reset each channel's RNG stream to its seed for reproducibility
568        for (ch, state) in self.rng_state.iter_mut().enumerate() {
569            *state = Self::channel_seed(ch);
570        }
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    fn active_history(ns: &NoiseShaper, ch: usize) -> Vec<f64> {
579        match ns.active_taps {
580            0 => Vec::new(),
581            5 => ns.error_history[ch][..5].to_vec(),
582            _ => {
583                let head = ns.error_history_9tap_heads[ch];
584                ns.error_history_9tap[ch][head..head + 9].to_vec()
585            }
586        }
587    }
588
589    fn legacy_process_sample(ns: &mut NoiseShaper, sample: f64, ch: usize) -> f64 {
590        if !ns.enabled || ch >= ns.error_history.len() {
591            return sample;
592        }
593
594        const SILENCE_THRESHOLD: f64 = 1e-6;
595
596        if sample.abs() < SILENCE_THRESHOLD {
597            ns.error_history[ch] = [0.0; 9];
598            return sample;
599        }
600
601        let dither = ns.tpdf(ch);
602        let e = &mut ns.error_history[ch];
603        let feedback: f64 = ns.coeffs.iter().zip(e.iter()).map(|(c, ei)| c * ei).sum();
604        let x = sample * ns.cached_scale + feedback;
605        let quantized = (x + dither).round();
606        let raw_error = x - quantized;
607        let clamped_error = raw_error.clamp(-2.0, 2.0);
608
609        e.copy_within(0..8, 1);
610        e[0] = clamped_error;
611
612        quantized * ns.cached_lsb
613    }
614
615    #[test]
616    fn test_tpdf_distribution() {
617        // TPDF should have triangular distribution centered at 0
618        let mut ns = NoiseShaper::new(1, 44100, 24);
619        let n_samples = 100_000;
620        let mut sum = 0.0;
621        let mut sum_sq = 0.0;
622        let mut min = f64::MAX;
623        let mut max = f64::MIN;
624
625        for _ in 0..n_samples {
626            let t = ns.tpdf(0);
627            sum += t;
628            sum_sq += t * t;
629            min = min.min(t);
630            max = max.max(t);
631        }
632
633        let mean = sum / n_samples as f64;
634        let variance = sum_sq / n_samples as f64 - mean * mean;
635
636        // TPDF (-1, 1): mean ≈ 0, variance = 1/6 ≈ 0.1667
637        assert!(mean.abs() < 0.01, "TPDF mean should be ~0, got {}", mean);
638        assert!(
639            (variance - 1.0 / 6.0).abs() < 0.01,
640            "TPDF variance should be ~0.1667, got {}",
641            variance
642        );
643        assert!(
644            min > -1.01 && max < 1.01,
645            "TPDF range should be (-1, 1), got [{}, {}]",
646            min,
647            max
648        );
649    }
650
651    #[test]
652    fn test_stability_with_full_scale() {
653        // Full-scale square wave should not cause error divergence
654        let mut ns = NoiseShaper::new(1, 44100, 24);
655
656        for i in 0..44100 {
657            // Alternating full-scale signal (worst case for stability)
658            let sample = if i % 2 == 0 { 1.0 } else { -1.0 };
659            let out = ns.process_sample(sample, 0);
660
661            // Output should stay in valid range (allow small overshoot from dither)
662            assert!(out.abs() <= 1.001, "Output diverged: {}", out);
663
664            // Check error history stays bounded by clamp
665            let e = &ns.error_history[0];
666            for &ei in e.iter() {
667                assert!(ei.abs() <= 2.0, "Error history exceeds clamp: {}", ei);
668            }
669        }
670    }
671
672    #[test]
673    fn test_curve_switch_clears_history() {
674        let mut ns = NoiseShaper::new(1, 44100, 24);
675
676        // Process some samples to build up error history
677        for i in 0..100 {
678            ns.process_sample(0.5 * (i as f64 / 100.0).sin(), 0);
679        }
680
681        // Verify history is non-zero
682        let has_nonzero = ns.error_history[0].iter().any(|&e| e != 0.0);
683        assert!(has_nonzero, "Error history should have non-zero values");
684
685        // Switch curve
686        ns.set_curve(NoiseShaperCurve::FWeighted9);
687
688        // Verify history is cleared
689        for &e in ns.error_history[0].iter() {
690            assert_eq!(e, 0.0, "Error history should be cleared after curve switch");
691        }
692    }
693
694    #[test]
695    fn test_curve_switch_clears_9tap_ring_history() {
696        let mut ns = NoiseShaper::new(1, 44100, 24);
697        ns.set_curve(NoiseShaperCurve::FWeighted9);
698
699        for i in 0..100 {
700            ns.process_sample(0.5 * (i as f64 / 100.0).sin(), 0);
701        }
702
703        assert!(ns.error_history_9tap[0].iter().any(|&e| e != 0.0));
704
705        ns.set_curve(NoiseShaperCurve::ImprovedE9);
706
707        assert!(ns.error_history_9tap[0].iter().all(|&e| e == 0.0));
708        assert_eq!(ns.error_history_9tap_heads[0], 0);
709    }
710
711    #[test]
712    fn test_idle_tone_free() {
713        // With adaptive dither, zero input returns zero output (silence bypass)
714        // Test that near-silence (above threshold) produces dithered output
715        let mut ns = NoiseShaper::new(1, 44100, 24);
716        let n_samples = 44100;
717        let mut samples = Vec::with_capacity(n_samples);
718
719        // Use a signal just above the silence threshold
720        let above_threshold = 2e-6; // -114 dBFS, above -120 dBFS threshold
721
722        for _ in 0..n_samples {
723            samples.push(ns.process_sample(above_threshold, 0));
724        }
725
726        // Check 1: Output should be non-zero (dither is working)
727        let non_zero_count = samples.iter().filter(|&&x| x != 0.0).count();
728        assert!(
729            non_zero_count > n_samples / 2,
730            "Dither not working: only {}/{} samples non-zero",
731            non_zero_count,
732            n_samples
733        );
734
735        // Check 2: Output should have reasonable variance (dither is adding noise)
736        let mean = samples.iter().sum::<f64>() / n_samples as f64;
737        let variance = samples.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n_samples as f64;
738
739        // For 24-bit with TPDF dither at ±1 LSB, expect some variance
740        let lsb = 1.0 / 2.0_f64.powi(23);
741        assert!(
742            variance > lsb * lsb * 0.01,
743            "Variance too low ({:.2e}), possible idle tone or stuck output",
744            variance
745        );
746    }
747
748    #[test]
749    fn test_adaptive_dither_silence() {
750        // Test that silence below threshold bypasses dither
751        let mut ns = NoiseShaper::new(1, 44100, 24);
752
753        // Zero input should return zero output
754        assert_eq!(ns.process_sample(0.0, 0), 0.0);
755
756        // Very low input below threshold should return input unchanged
757        let below_threshold = 0.5e-6; // -126 dBFS, below -120 dBFS threshold
758        assert_eq!(ns.process_sample(below_threshold, 0), below_threshold);
759
760        // Error history should be cleared after silence
761        ns.process_sample(1e-3, 0); // First, build up some error history
762        let has_nonzero = ns.error_history[0].iter().any(|&e| e != 0.0);
763        assert!(has_nonzero, "Error history should be non-zero after signal");
764
765        // Now feed silence
766        ns.process_sample(0.0, 0);
767
768        // Error history should be cleared
769        for &e in ns.error_history[0].iter() {
770            assert_eq!(e, 0.0, "Error history should be cleared after silence");
771        }
772    }
773
774    #[test]
775    fn test_noise_shaper_unrolled_history_matches_legacy_update() {
776        for curve in [
777            NoiseShaperCurve::Lipshitz5,
778            NoiseShaperCurve::FWeighted9,
779            NoiseShaperCurve::ModifiedE9,
780            NoiseShaperCurve::ImprovedE9,
781            NoiseShaperCurve::TpdfOnly,
782        ] {
783            let mut optimized = NoiseShaper::new(2, 44100, 16);
784            let mut legacy = NoiseShaper::new(2, 44100, 16);
785            optimized.set_curve(curve);
786            legacy.set_curve(curve);
787
788            for frame in 0..256 {
789                for ch in 0..2 {
790                    let sample = ((frame * 2 + ch + 1) as f64 * 0.037).sin() * 0.4;
791                    let optimized_out = optimized.process_sample(sample, ch);
792                    let legacy_out = legacy_process_sample(&mut legacy, sample, ch);
793
794                    assert_eq!(
795                        optimized_out.to_bits(),
796                        legacy_out.to_bits(),
797                        "curve {:?}, frame {}, channel {} output mismatch",
798                        curve,
799                        frame,
800                        ch
801                    );
802                    assert_eq!(
803                        active_history(&optimized, ch),
804                        legacy.error_history[ch][..curve.active_taps()].to_vec(),
805                        "curve {:?}, frame {}, channel {} active history mismatch",
806                        curve,
807                        frame,
808                        ch
809                    );
810                }
811            }
812        }
813    }
814
815    #[test]
816    fn test_tpdf_only_does_not_update_error_history() {
817        let mut ns = NoiseShaper::new(1, 96_000, 24);
818        ns.set_curve(NoiseShaperCurve::TpdfOnly);
819
820        for i in 0..128 {
821            let sample = ((i + 1) as f64 * 0.031).sin() * 0.5;
822            let _ = ns.process_sample(sample, 0);
823        }
824
825        assert!(ns.error_history[0].iter().all(|&e| e == 0.0));
826    }
827
828    #[test]
829    fn test_noise_shaper_silence_reset_is_channel_local() {
830        let mut ns = NoiseShaper::new(2, 44100, 24);
831
832        for _ in 0..16 {
833            ns.process_sample(0.25, 0);
834            ns.process_sample(-0.25, 1);
835        }
836        assert!(ns.error_history[0].iter().any(|&e| e != 0.0));
837        assert!(ns.error_history[1].iter().any(|&e| e != 0.0));
838
839        ns.process_sample(0.0, 0);
840
841        assert!(ns.error_history[0].iter().all(|&e| e == 0.0));
842        assert!(ns.error_history[1].iter().any(|&e| e != 0.0));
843    }
844
845    #[test]
846    fn test_noise_shaper_9tap_ring_silence_reset_is_channel_local() {
847        let mut ns = NoiseShaper::new(2, 44100, 24);
848        ns.set_curve(NoiseShaperCurve::FWeighted9);
849
850        for _ in 0..16 {
851            ns.process_sample(0.25, 0);
852            ns.process_sample(-0.25, 1);
853        }
854        assert!(ns.error_history_9tap[0].iter().any(|&e| e != 0.0));
855        assert!(ns.error_history_9tap[1].iter().any(|&e| e != 0.0));
856
857        ns.process_sample(0.0, 0);
858
859        assert!(ns.error_history_9tap[0].iter().all(|&e| e == 0.0));
860        assert_eq!(ns.error_history_9tap_heads[0], 0);
861        assert!(ns.error_history_9tap[1].iter().any(|&e| e != 0.0));
862    }
863
864    #[test]
865    fn test_noise_shaper_cached_scale_updates_with_bits() {
866        let mut ns = NoiseShaper::new(1, 44100, 24);
867        assert_eq!(ns.cached_scale, 2.0_f64.powi(23));
868        assert_eq!(ns.cached_lsb, 1.0 / 2.0_f64.powi(23));
869
870        ns.set_bits(16);
871        assert_eq!(ns.bits(), 16);
872        assert_eq!(ns.cached_scale, 2.0_f64.powi(15));
873        assert_eq!(ns.cached_lsb, 1.0 / 2.0_f64.powi(15));
874
875        ns.set_bits(64);
876        assert_eq!(ns.bits(), 16);
877        assert_eq!(ns.cached_scale, 2.0_f64.powi(15));
878        assert_eq!(ns.cached_lsb, 1.0 / 2.0_f64.powi(15));
879    }
880
881    #[test]
882    fn test_channel_dither_streams_use_independent_seeds() {
883        // Same channel is reproducible across fresh instances (deterministic seed).
884        let mut a = NoiseShaper::new(2, 44_100, 24);
885        let mut b = NoiseShaper::new(2, 44_100, 24);
886        assert_eq!(a.tpdf(0).to_bits(), b.tpdf(0).to_bits());
887
888        // Different channels must draw from independently seeded streams. A single
889        // shared RNG would make channel 1's first draw equal channel 0's, leaving
890        // the two channels' dither correlated.
891        let mut c = NoiseShaper::new(2, 44_100, 24);
892        let ch0_first = c.tpdf(0);
893        let mut d = NoiseShaper::new(2, 44_100, 24);
894        let ch1_first = d.tpdf(1);
895        assert!(
896            (ch0_first - ch1_first).abs() > 1e-12,
897            "channel 0 and 1 must use independent seeds (got identical first draws)"
898        );
899    }
900
901    #[test]
902    fn test_volume_controller_one_minus_smoothing_updates_with_sample_rate() {
903        let mut volume = VolumeController::with_sample_rate(44_100);
904        let initial = volume.one_minus_smoothing;
905
906        volume.set_sample_rate(96_000);
907
908        assert_ne!(volume.one_minus_smoothing, initial);
909        assert_eq!(volume.one_minus_smoothing, 1.0 - volume.smoothing);
910    }
911
912    #[test]
913    fn test_all_curves_stable() {
914        // Each curve should process without divergence
915        for curve in [
916            NoiseShaperCurve::Lipshitz5,
917            NoiseShaperCurve::FWeighted9,
918            NoiseShaperCurve::ModifiedE9,
919            NoiseShaperCurve::ImprovedE9,
920            NoiseShaperCurve::TpdfOnly,
921        ] {
922            let mut ns = NoiseShaper::new(1, 44100, 24);
923            ns.set_curve(curve);
924
925            // Process 1 second of full-scale sine wave
926            for i in 0..44100 {
927                let t = i as f64 / 44100.0;
928                let sample = 0.9 * (2.0 * std::f64::consts::PI * 440.0 * t).sin();
929                let out = ns.process_sample(sample, 0);
930                assert!(out.abs() <= 1.0, "Curve {:?} diverged: {}", curve, out);
931            }
932        }
933    }
934}