Skip to main content

audio_engine_core/processor/
saturation.rs

1//! Tube Saturation / Soft Clipping Processor
2//!
3//! Provides analog-style warmth through non-linear waveshaping.
4//! Uses tanh-based soft clipping to add harmonics without harsh distortion.
5//!
6//! # Design
7//!
8//! - Threshold-based: only affects samples above threshold
9//! - Tanh waveshaping: smooth, musical saturation curve
10//! - Drive control: intensity of the effect
11//! - Mix control: blend between dry and saturated signal
12//! - High-pass mode: only saturate high frequencies (exciter mode)
13//!
14//! # Use Cases
15//!
16//! - Add warmth to digital recordings
17//! - Restore transient energy lost in limiting
18//! - Simulate analog console coloration
19//! - High-frequency exciter for presence boost
20
21/// Saturation type / character
22#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)]
23pub enum SaturationType {
24    #[default]
25    Tape, // Warm, gentle compression
26    Tube,       // Rich even harmonics
27    Transistor, // Edgy, odd harmonics
28}
29
30/// Tube Saturation processor with configurable drive and mix
31///
32/// When highpass_mode is enabled, only high frequencies (>4kHz) are saturated,
33/// creating a more transparent "exciter" effect without muddying the low end.
34///
35/// Configuration is done through the `set_*` methods; current values can be read
36/// back with [`Saturation::get_settings`]. For shared mutable access from another
37/// thread, wrap this in `Arc<Mutex<Saturation>>`.
38pub struct Saturation {
39    /// Saturation type
40    sat_type: SaturationType,
41    /// Drive amount (0.0 - 2.0, default 0.25)
42    drive: f64,
43    /// Threshold where saturation begins (linear, default 0.88)
44    threshold: f64,
45    /// Mix between dry and wet (0.0 - 1.0, default 0.2)
46    mix: f64,
47    /// Input gain (dB, applied before saturation, default 0.0)
48    input_gain_db: f64,
49    /// Output gain compensation (dB, default 0.0)
50    output_gain_db: f64,
51    /// Cached linear input gain.
52    input_gain_linear: f64,
53    /// Cached linear output gain.
54    output_gain_linear: f64,
55    /// Enable/disable
56    enabled: bool,
57
58    // High-pass mode for exciter functionality
59    /// Enable high-pass separation (only saturate highs)
60    highpass_mode: bool,
61    /// HPF cutoff frequency in Hz (default: 4000)
62    highpass_cutoff: f64,
63
64    // Sample rate for HPF coefficient calculation
65    sample_rate: f64,
66    // Cached HPF coefficient (recalculated when sample_rate or cutoff changes)
67    hpf_coef: f64,
68
69    // P1-5 fix: Per-channel HPF state (supports arbitrary channel count, not just stereo)
70    /// HPF filter state per channel (y[n-1])
71    hpf_states: Vec<f64>,
72    /// Previous input per channel (x[n-1])
73    prev_inputs: Vec<f64>,
74}
75
76impl Saturation {
77    /// Create a new saturation processor with default settings
78    pub fn new() -> Self {
79        let mut instance = Self {
80            sat_type: SaturationType::Tube,
81            drive: 0.25,
82            threshold: 0.88,
83            mix: 0.2,
84            input_gain_db: 0.0,
85            output_gain_db: 0.0,
86            input_gain_linear: 1.0,
87            output_gain_linear: 1.0,
88            enabled: true,
89            highpass_mode: false,
90            highpass_cutoff: 4000.0,
91            sample_rate: 44100.0,
92            hpf_coef: 0.0, // Will be calculated below
93            // P1-5 fix: Initialize for 2 channels by default, grows on demand
94            hpf_states: vec![0.0; 2],
95            prev_inputs: vec![0.0; 2],
96        };
97        // Initialize HPF coefficient immediately (fixes MINOR-03)
98        instance.update_hpf_coef();
99        instance
100    }
101
102    /// Create with specific saturation type
103    pub fn with_type(sat_type: SaturationType) -> Self {
104        Self {
105            sat_type,
106            ..Self::new()
107        }
108    }
109
110    /// Set drive amount (0.0 - 2.0)
111    pub fn set_drive(&mut self, drive: f64) {
112        self.drive = drive.clamp(0.0, 2.0);
113    }
114
115    /// Set threshold (0.0 - 1.0)
116    pub fn set_threshold(&mut self, threshold: f64) {
117        self.threshold = threshold.clamp(0.0, 1.0);
118    }
119
120    /// Set mix amount (0.0 - 1.0)
121    pub fn set_mix(&mut self, mix: f64) {
122        self.mix = mix.clamp(0.0, 1.0);
123    }
124
125    /// Set input gain (dB) - applied before saturation
126    pub fn set_input_gain(&mut self, gain_db: f64) {
127        self.input_gain_db = gain_db;
128        self.input_gain_linear = db_to_linear(gain_db);
129    }
130
131    /// Set output gain (dB) - applied only to saturated samples for compensation
132    pub fn set_output_gain(&mut self, gain_db: f64) {
133        self.output_gain_db = gain_db;
134        self.output_gain_linear = db_to_linear(gain_db);
135    }
136
137    /// Enable/disable saturation
138    pub fn set_enabled(&mut self, enabled: bool) {
139        self.enabled = enabled;
140    }
141
142    /// Set saturation type
143    pub fn set_type(&mut self, sat_type: SaturationType) {
144        self.sat_type = sat_type;
145    }
146
147    /// Enable/disable high-pass mode (exciter mode)
148    pub fn set_highpass_mode(&mut self, enabled: bool) {
149        self.highpass_mode = enabled;
150    }
151
152    /// Set high-pass cutoff frequency in Hz
153    pub fn set_highpass_cutoff(&mut self, hz: f64) {
154        self.highpass_cutoff = hz.clamp(1000.0, 12000.0);
155        self.update_hpf_coef();
156    }
157
158    /// Update sample rate and recalculate HPF coefficient
159    pub fn set_sample_rate(&mut self, sr: f64) {
160        self.sample_rate = sr;
161        self.update_hpf_coef();
162    }
163
164    /// Pre-size the per-channel HPF state for `channels`, off the audio thread.
165    ///
166    /// Call this during setup (when the processor is built for a stream) so
167    /// `process_highpass` never resizes `hpf_states`/`prev_inputs` on the realtime
168    /// audio thread. Defaults keep the stereo size when `channels == 0`.
169    pub fn set_channel_count(&mut self, channels: usize) {
170        let channels = channels.max(1);
171        if self.hpf_states.len() != channels {
172            self.hpf_states.resize(channels, 0.0);
173            self.prev_inputs.resize(channels, 0.0);
174        }
175    }
176
177    /// Recalculate HPF coefficient based on current cutoff and sample rate
178    fn update_hpf_coef(&mut self) {
179        // Correct first-order RC HPF: α = fs / (fs + 2π·fc)
180        // For difference equation y[n] = α·y[n-1] + α·(x[n] - x[n-1])
181        // α close to 1.0 = low cutoff (passes more), α close to 0.0 = high cutoff
182        self.hpf_coef =
183            self.sample_rate / (self.sample_rate + std::f64::consts::TAU * self.highpass_cutoff);
184    }
185
186    /// Process interleaved f64 samples in-place
187    pub fn process(&mut self, samples: &mut [f64]) {
188        self.process_with_channels(samples, 2) // Default to stereo
189    }
190
191    /// Process interleaved f64 samples with specified channel count
192    pub fn process_with_channels(&mut self, samples: &mut [f64], channels: usize) {
193        if !self.enabled {
194            return;
195        }
196
197        if self.highpass_mode {
198            self.process_highpass(samples, channels);
199        } else {
200            self.process_fullband(samples);
201        }
202    }
203
204    /// Process with explicit sample rate (for cases where SR differs from cached value)
205    pub fn process_with_sr(&mut self, samples: &mut [f64], channels: usize, sample_rate: f64) {
206        if (self.sample_rate - sample_rate).abs() > 1.0 {
207            self.set_sample_rate(sample_rate);
208        }
209        self.process_with_channels(samples, channels);
210    }
211
212    /// Full-band saturation (original behavior)
213    fn process_fullband(&mut self, samples: &mut [f64]) {
214        let input_gain = self.input_gain_linear;
215        let output_gain = self.output_gain_linear;
216        let threshold = self.threshold;
217        let drive_plus1 = 1.0 + self.drive;
218        let mix = self.mix;
219        let one_minus_mix = 1.0 - mix;
220        let sat_type = self.sat_type;
221
222        for sample in samples.iter_mut() {
223            let dry = *sample * input_gain;
224
225            if dry.abs() > threshold {
226                let driven = dry * drive_plus1;
227                let saturated = Self::apply_saturation_type(sat_type, driven);
228                *sample = (dry * one_minus_mix + saturated * mix) * output_gain;
229            } else {
230                *sample = dry;
231            }
232        }
233    }
234
235    /// High-pass separated saturation (exciter mode)
236    /// Only saturates frequencies above the cutoff.
237    /// P1-5 fix: Supports arbitrary channel count (was hardcoded to L/R only).
238    fn process_highpass(&mut self, samples: &mut [f64], channels: usize) {
239        let input_gain = self.input_gain_linear;
240        let output_gain = self.output_gain_linear;
241        let alpha = self.hpf_coef;
242        let threshold = self.threshold;
243        let drive_plus1 = 1.0 + self.drive;
244        let mix = self.mix;
245        let sat_type = self.sat_type;
246
247        // HPF state is sized off the audio thread via `set_channel_count`; never
248        // resize here, which would allocate on the realtime audio thread. If this
249        // fires, a caller processed more channels than it was set up for.
250        debug_assert!(
251            self.hpf_states.len() >= channels,
252            "Saturation HPF state undersized for {} channels (have {}); call set_channel_count during setup",
253            channels,
254            self.hpf_states.len()
255        );
256
257        let frames = samples.len() / channels;
258        for frame in 0..frames {
259            for ch in 0..channels {
260                let idx = frame * channels + ch;
261                if idx >= samples.len() {
262                    break;
263                }
264
265                let input = samples[idx] * input_gain;
266
267                // First-order HPF: y[n] = α·y[n-1] + α·(x[n] - x[n-1])
268                let high = alpha * self.hpf_states[ch] + alpha * (input - self.prev_inputs[ch]);
269                self.hpf_states[ch] = high;
270                self.prev_inputs[ch] = input;
271                #[cfg(not(any(
272                    target_arch = "x86",
273                    target_arch = "x86_64",
274                    target_arch = "aarch64"
275                )))]
276                {
277                    self.hpf_states[ch] =
278                        crate::runtime::flush_subnormal_sample(self.hpf_states[ch]);
279                    self.prev_inputs[ch] =
280                        crate::runtime::flush_subnormal_sample(self.prev_inputs[ch]);
281                }
282
283                // Apply saturation to high frequencies only
284                let saturated_high = if high.abs() > threshold {
285                    let driven = high * drive_plus1;
286                    Self::apply_saturation_type(sat_type, driven)
287                } else {
288                    high
289                };
290
291                // Mix: input + (saturated_high - high) * mix
292                samples[idx] = (input + (saturated_high - high) * mix) * output_gain;
293            }
294        }
295    }
296
297    #[inline(always)]
298    fn apply_saturation_type(sat_type: SaturationType, x: f64) -> f64 {
299        match sat_type {
300            SaturationType::Tape => x.signum() * (1.0 - (-x.abs()).exp()),
301            SaturationType::Tube => x.tanh(),
302            SaturationType::Transistor => {
303                // Piecewise cubic: x - x³/3 for |x| ≤ 1.5, then smoothly limited
304                // Fix discontinuity: clamp to value at boundary (1.5 - 1.5³/3 = 0.375)
305                if x.abs() <= 1.5 {
306                    x - (x * x * x) / 3.0
307                } else {
308                    x.signum() * 0.375
309                }
310            }
311        }
312    }
313
314    /// Reset filter state
315    pub fn reset(&mut self) {
316        self.hpf_states.fill(0.0);
317        self.prev_inputs.fill(0.0);
318    }
319
320    /// Get current settings as a struct
321    pub fn get_settings(&self) -> SaturationSettings {
322        SaturationSettings {
323            sat_type: self.sat_type,
324            drive: self.drive,
325            threshold: self.threshold,
326            mix: self.mix,
327            input_gain_db: self.input_gain_db,
328            output_gain_db: self.output_gain_db,
329            enabled: self.enabled,
330            highpass_mode: self.highpass_mode,
331            highpass_cutoff: self.highpass_cutoff,
332        }
333    }
334}
335
336impl Default for Saturation {
337    fn default() -> Self {
338        Self::new()
339    }
340}
341
342/// Settings struct for API responses
343#[derive(Debug, Clone, serde::Serialize)]
344pub struct SaturationSettings {
345    pub sat_type: SaturationType,
346    pub drive: f64,
347    pub threshold: f64,
348    pub mix: f64,
349    pub input_gain_db: f64,
350    pub output_gain_db: f64,
351    pub enabled: bool,
352    pub highpass_mode: bool,
353    pub highpass_cutoff: f64,
354}
355
356// P1-4 fix: Use centralized db_to_linear from dsp module instead of local duplicate
357use super::dsp::db_to_linear;
358
359// ============================================================================
360// Tests
361// ============================================================================
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn test_tube_saturation() {
369        let mut sat = Saturation::with_type(SaturationType::Tube);
370        sat.set_enabled(true);
371        sat.set_mix(1.0); // 100% wet for testing
372
373        // Test that loud signals are compressed
374        let mut samples = vec![0.9, -0.9, 0.5, -0.5];
375        sat.process(&mut samples);
376
377        // tanh(0.9) ≈ 0.716
378        assert!(samples[0].abs() < 0.9);
379        assert!(samples[1].abs() < 0.9);
380
381        // Lower signals should pass through relatively unchanged
382        // tanh(0.5) ≈ 0.462, which is close to 0.5
383        assert!((samples[2].abs() - 0.5).abs() < 0.1);
384    }
385
386    #[test]
387    fn test_disabled() {
388        let mut sat = Saturation::new();
389        sat.set_enabled(false);
390
391        let mut samples = vec![0.9, -0.9, 0.5, -0.5];
392        sat.process(&mut samples);
393
394        // Should pass through unchanged when disabled
395        assert!((samples[0] - 0.9).abs() < 1e-10);
396        assert!((samples[1] - (-0.9)).abs() < 1e-10);
397    }
398
399    #[test]
400    fn test_cached_linear_gains_update_with_db_setters() {
401        let mut sat = Saturation::new();
402
403        sat.set_input_gain(6.0);
404        sat.set_output_gain(-3.0);
405
406        assert!((sat.input_gain_linear - db_to_linear(6.0)).abs() < 1e-12);
407        assert!((sat.output_gain_linear - db_to_linear(-3.0)).abs() < 1e-12);
408        assert_eq!(sat.input_gain_db, 6.0);
409        assert_eq!(sat.output_gain_db, -3.0);
410    }
411
412    #[test]
413    fn test_threshold() {
414        let mut sat = Saturation::with_type(SaturationType::Tube);
415        sat.set_enabled(true);
416        sat.set_threshold(0.8);
417        sat.set_mix(1.0);
418
419        // Below threshold should pass unchanged
420        let mut samples = vec![0.5];
421        sat.process(&mut samples);
422        assert!((samples[0] - 0.5).abs() < 1e-10);
423
424        // Above threshold should be saturated
425        let mut samples = vec![0.9];
426        sat.process(&mut samples);
427        assert!(samples[0].abs() < 0.9);
428    }
429
430    #[test]
431    fn test_mix() {
432        let mut sat = Saturation::with_type(SaturationType::Tube);
433        sat.set_enabled(true);
434        sat.set_drive(0.0); // No drive for this test
435        sat.set_mix(0.5);
436
437        let mut samples = vec![1.0];
438        sat.process(&mut samples);
439
440        // Mix of tanh(1) ≈ 0.762 and 1.0
441        // Result should be between the two
442        let expected = (1.0 + 1.0_f64.tanh()) * 0.5;
443        assert!((samples[0] - expected).abs() < 0.01);
444    }
445
446    #[test]
447    fn test_hpf_coefficient() {
448        let mut sat = Saturation::new();
449        sat.set_sample_rate(44100.0);
450        sat.set_highpass_cutoff(4000.0);
451
452        // Correct HPF coefficient: fs/(fs + 2π*fc) ≈ 0.637 (old) -> 0.637 (same formula value)
453        // Actually: 44100 / (44100 + 2π*4000) = 44100 / 69231.9 ≈ 0.637
454        // Wait - the old formula 1/(1 + 2π*fc/fs) = 1/(1 + 2π*4000/44100) = 1/(1.5697) = 0.6371
455        // The new formula fs/(fs + 2π*fc) = 44100/(44100 + 25131.9) = 44100/69231.9 = 0.6371
456        // These are algebraically identical! The fix is about the comment and usage context.
457        let expected = 44100.0 / (44100.0 + std::f64::consts::TAU * 4000.0);
458        assert!((sat.hpf_coef - expected).abs() < 0.001);
459    }
460
461    #[test]
462    fn test_hpf_dc_rejection() {
463        let mut sat = Saturation::new();
464        sat.set_highpass_mode(true);
465        sat.set_highpass_cutoff(4000.0);
466        sat.set_sample_rate(44100.0);
467        sat.set_mix(0.5); // With mix
468        sat.set_threshold(2.0); // Don't trigger saturation
469
470        // DC signal - HPF should reject DC, so high component → 0
471        // Output should be close to input (low freq passes through)
472        let mut samples: Vec<f64> = vec![0.0; 200]; // 100 stereo samples
473        for i in 0..100 {
474            samples[i * 2] = 1.0; // L = 1.0 (DC)
475            samples[i * 2 + 1] = 1.0; // R = 1.0 (DC)
476        }
477        sat.process_with_channels(&mut samples, 2);
478
479        // For DC input: high freq → 0, low freq ≈ input
480        // Output ≈ input because low passes through and high is near 0
481        // After initial transient, output should be close to DC input (1.0)
482        let last_l: f64 = samples.iter().skip(180).step_by(2).take(10).sum::<f64>() / 10.0;
483        let last_r: f64 = samples.iter().skip(181).step_by(2).take(10).sum::<f64>() / 10.0;
484
485        // DC should pass through (high freq blocked, low freq = DC)
486        assert!(
487            (last_l - 1.0).abs() < 0.1,
488            "L output should be close to 1.0, got {}",
489            last_l
490        );
491        assert!(
492            (last_r - 1.0).abs() < 0.1,
493            "R output should be close to 1.0, got {}",
494            last_r
495        );
496    }
497
498    #[test]
499    fn test_highpass_flushes_denormals_with_audio_thread_init() {
500        crate::runtime::audio_thread_init();
501        if !crate::runtime::audio_thread_float_mode_is_enabled() {
502            return;
503        }
504
505        let mut sat = Saturation::new();
506        sat.set_highpass_mode(true);
507        let subnormal = f64::from_bits(1);
508        sat.hpf_states[0] = subnormal;
509        sat.prev_inputs[0] = -subnormal;
510        let mut samples = vec![0.0, 0.0];
511        sat.process_with_channels(&mut samples, 2);
512        assert_eq!(sat.hpf_states[0], 0.0);
513        assert_eq!(sat.prev_inputs[0], 0.0);
514    }
515
516    #[test]
517    fn test_highpass_multichannel_after_set_channel_count_does_not_panic() {
518        let mut sat = Saturation::new();
519        sat.set_highpass_mode(true);
520        sat.set_channel_count(6);
521
522        // 6-channel interleaved buffer (8 frames). Before the fix this would
523        // resize hpf_states/prev_inputs on the (would-be) audio thread; now the
524        // state is pre-sized and process_highpass must not resize.
525        let mut samples = vec![0.5; 6 * 8];
526        sat.process_with_channels(&mut samples, 6);
527
528        assert_eq!(sat.hpf_states.len(), 6);
529        assert_eq!(sat.prev_inputs.len(), 6);
530    }
531
532    #[test]
533    fn test_set_channel_count_resizes_state_off_rt() {
534        let mut sat = Saturation::new();
535        assert_eq!(sat.hpf_states.len(), 2);
536        sat.set_channel_count(8);
537        assert_eq!(sat.hpf_states.len(), 8);
538        assert_eq!(sat.prev_inputs.len(), 8);
539        // Zero channels falls back to a mono-safe size rather than emptying state.
540        sat.set_channel_count(0);
541        assert_eq!(sat.hpf_states.len(), 1);
542    }
543}