Skip to main content

audio_engine_core/processor/loudness/
ramp.rs

1//! Linear gain ramp for smooth track-to-track transitions.
2
3/// Linear gain ramp for smooth transitions between tracks.
4/// Caches the current gain and per-sample delta so accessors stay cheap.
5///
6/// Use cases:
7/// - Track-to-track gain changes
8/// - Mute/unmute transitions
9/// - Bypass switching
10pub struct GainRamp {
11    /// Starting gain value (linear)
12    from: f64,
13    /// Target gain value (linear)
14    to: f64,
15    /// Current gain value (linear)
16    current: f64,
17    /// Per-sample gain delta
18    step: f64,
19    /// Total samples in the ramp
20    total_samples: usize,
21    /// Remaining samples in the ramp
22    remaining: usize,
23}
24
25impl GainRamp {
26    /// Create a new gain ramp
27    ///
28    /// # Arguments
29    /// * `from` - Starting gain (linear)
30    /// * `to` - Target gain (linear)
31    /// * `sample_rate` - Sample rate in Hz
32    /// * `ramp_ms` - Ramp duration in milliseconds
33    pub fn new(from: f64, to: f64, sample_rate: u32, ramp_ms: u32) -> Self {
34        let total_samples = (sample_rate as u64 * ramp_ms as u64 / 1000) as usize;
35        let total_samples = total_samples.max(1);
36
37        Self {
38            from,
39            to,
40            current: from,
41            step: (to - from) / total_samples as f64,
42            total_samples,
43            remaining: total_samples,
44        }
45    }
46
47    /// Create a ramp from 0 to target (fade in)
48    pub fn fade_in(target: f64, sample_rate: u32, ramp_ms: u32) -> Self {
49        Self::new(0.0, target, sample_rate, ramp_ms)
50    }
51
52    /// Create a ramp from current to 0 (fade out)
53    pub fn fade_out(from: f64, sample_rate: u32, ramp_ms: u32) -> Self {
54        Self::new(from, 0.0, sample_rate, ramp_ms)
55    }
56
57    /// Get the next gain value (call once per sample)
58    /// Uses a cached per-sample delta and snaps to the target at ramp end.
59    #[inline(always)]
60    pub fn next_gain(&mut self) -> f64 {
61        if self.remaining > 0 {
62            let gain = self.current;
63            self.remaining -= 1;
64            if self.remaining == 0 {
65                self.current = self.to;
66            } else {
67                self.current += self.step;
68            }
69            gain
70        } else {
71            self.to
72        }
73    }
74
75    /// Apply gain ramp to a buffer (more efficient than per-sample calls)
76    pub fn apply(&mut self, samples: &mut [f64]) {
77        if samples.is_empty() {
78            return;
79        }
80
81        let ramp_samples = samples.len().min(self.remaining);
82
83        if ramp_samples > 0 {
84            let mut gain = self.current;
85            for sample in &mut samples[..ramp_samples] {
86                *sample *= gain;
87                gain += self.step;
88            }
89
90            self.remaining -= ramp_samples;
91            if self.remaining == 0 {
92                self.current = self.to;
93            } else {
94                self.current = gain;
95            }
96        }
97
98        if ramp_samples < samples.len() && self.to != 1.0 {
99            for sample in &mut samples[ramp_samples..] {
100                *sample *= self.to;
101            }
102        }
103    }
104
105    /// Check if ramp is complete
106    pub fn is_done(&self) -> bool {
107        self.remaining == 0
108    }
109
110    /// Get remaining samples
111    pub fn remaining_samples(&self) -> usize {
112        self.remaining
113    }
114
115    /// Get current gain
116    pub fn current(&self) -> f64 {
117        self.current
118    }
119
120    /// Get target gain
121    pub fn target(&self) -> f64 {
122        self.to
123    }
124
125    /// Set a new target, starting from current position
126    pub fn retarget(&mut self, new_target: f64, sample_rate: u32, ramp_ms: u32) {
127        let current = self.current();
128        self.from = current;
129        self.to = new_target;
130        let total_samples = (sample_rate as u64 * ramp_ms as u64 / 1000) as usize;
131        self.total_samples = total_samples.max(1);
132        self.remaining = self.total_samples;
133        self.current = current;
134        self.step = (self.to - self.from) / self.total_samples as f64;
135    }
136
137    /// Jump immediately to target (no ramp)
138    pub fn jump(&mut self, target: f64) {
139        self.from = target;
140        self.to = target;
141        self.current = target;
142        self.step = 0.0;
143        self.total_samples = 1;
144        self.remaining = 0;
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::GainRamp;
151
152    #[test]
153    fn apply_matches_next_gain_loop_across_ramp_and_tail() {
154        let mut apply_ramp = GainRamp::new(0.05, 0.95, 48_000, 10);
155        let mut loop_ramp = GainRamp::new(0.05, 0.95, 48_000, 10);
156        let mut apply_samples = synthetic_samples(2_048);
157        let mut loop_samples = apply_samples.clone();
158
159        for chunk in apply_samples.chunks_mut(127) {
160            apply_ramp.apply(chunk);
161        }
162        for sample in &mut loop_samples {
163            *sample *= loop_ramp.next_gain();
164        }
165
166        assert_eq!(
167            apply_ramp.remaining_samples(),
168            loop_ramp.remaining_samples()
169        );
170        assert_eq!(
171            apply_ramp.current().to_bits(),
172            loop_ramp.current().to_bits()
173        );
174        assert_samples_match(&apply_samples, &loop_samples);
175    }
176
177    #[test]
178    fn apply_uses_target_gain_after_ramp_is_done() {
179        let mut ramp = GainRamp::new(0.0, 0.5, 1_000, 1);
180        let mut samples = vec![2.0; 5];
181
182        ramp.apply(&mut samples);
183
184        assert_eq!(samples, vec![0.0, 1.0, 1.0, 1.0, 1.0]);
185        assert!(ramp.is_done());
186        assert_eq!(ramp.current(), 0.5);
187    }
188
189    #[test]
190    fn apply_does_not_change_empty_buffer_state() {
191        let mut ramp = GainRamp::new(0.0, 1.0, 48_000, 100);
192        let current = ramp.current();
193        let remaining = ramp.remaining_samples();
194
195        ramp.apply(&mut []);
196
197        assert_eq!(ramp.current(), current);
198        assert_eq!(ramp.remaining_samples(), remaining);
199    }
200
201    fn synthetic_samples(len: usize) -> Vec<f64> {
202        (0..len)
203            .map(|idx| {
204                let t = idx as f64 / 48_000.0;
205                (std::f64::consts::TAU * 997.0 * t).sin() * 0.35
206            })
207            .collect()
208    }
209
210    fn assert_samples_match(left: &[f64], right: &[f64]) {
211        assert_eq!(left.len(), right.len());
212        for (idx, (left, right)) in left.iter().zip(right).enumerate() {
213            assert_eq!(
214                left.to_bits(),
215                right.to_bits(),
216                "sample mismatch at {idx}: {left} != {right}"
217            );
218        }
219    }
220}