Skip to main content

rescale_core/drc/
limiter.rs

1//! Lookahead Brickwall Limiter dengan sliding maximum-hold gain reduction.
2//!
3//! Desain lama menurunkan gain reduction dari envelope eksponensial, sehingga
4//! transient perkusif bisa lolos lalu dipotong hard clamp (distorsi). Desain ini
5//! memakai nilai maksimum absolut di seluruh window lookahead: pelemahan sudah
6//! aktif sebelum transient mencapai output, dan hard clamp hanya menjadi jaring
7//! pengaman terakhir.
8
9use super::max_hold::SlidingMax;
10use crate::buffer::delay_line::{DelayLine, InterpolationType};
11use crate::gain::Gain;
12
13/// Ambang minimum ceiling linear agar pembagian tetap valid.
14const MIN_CEILING_LINEAR: f32 = 1e-6;
15/// Cadangan kapasitas DelayLine agar pembacaan interpolasi selalu aman.
16const DELAY_CAPACITY_HEADROOM: usize = 8;
17/// Waktu release minimum (ms) agar koefisien smoothing tetap stabil.
18const MIN_RELEASE_MS: f32 = 0.1;
19
20/// Detektor gain reduction berbasis sliding maximum pada window lookahead.
21///
22/// Detektor dipisahkan dari delay audio supaya beberapa channel bisa berbagi
23/// satu detektor — dasar untuk stereo-linked limiting (`LimiterProcessor`).
24pub struct LookaheadDetector {
25    ceiling_linear: f32,
26    lookahead_samples: usize,
27    max_peak: SlidingMax,
28    current_gain: f32,
29    release_coeff: f32,
30    hold_frames_remaining: usize,
31}
32
33impl LookaheadDetector {
34    /// # Arguments
35    /// * `sample_rate` - Frekuensi sampling (Hz)
36    /// * `ceiling_db` - Batas keluaran (dBFS, mis. -1.0)
37    /// * `release_ms` - Waktu pemulihan gain reduction (ms)
38    /// * `lookahead_ms` - Panjang window lookahead (ms)
39    pub fn new(sample_rate: f32, ceiling_db: f32, release_ms: f32, lookahead_ms: f32) -> Self {
40        let ceiling_linear = Gain::db_to_linear(ceiling_db.min(0.0));
41        let lookahead_samples = ((lookahead_ms * 0.001 * sample_rate).round() as usize).max(1);
42        let release_seconds = (release_ms.max(MIN_RELEASE_MS) * 0.001).max(MIN_RELEASE_MS * 0.001);
43        let release_coeff = (-1.0 / (release_seconds * sample_rate)).exp();
44
45        Self {
46            ceiling_linear,
47            lookahead_samples,
48            // Window mencakup sampel yang sedang keluar + seluruh lookahead ke depan.
49            max_peak: SlidingMax::new(lookahead_samples + 1),
50            current_gain: 1.0,
51            release_coeff,
52            hold_frames_remaining: 0,
53        }
54    }
55
56    /// Jumlah sample delay audio yang menyertai detektor ini.
57    pub fn latency_samples(&self) -> usize {
58        self.lookahead_samples
59    }
60
61    pub fn current_gain(&self) -> f32 {
62        self.current_gain
63    }
64
65    pub fn ceiling_linear(&self) -> f32 {
66        self.ceiling_linear
67    }
68
69    /// Memasukkan puncak input satu frame dan mengembalikan gain yang harus dipakai.
70    ///
71    /// Attack bersifat instan: begitu puncak baru masuk window, gain langsung
72    /// turun sehingga tidak ada sampel yang lolos di atas ceiling. Release
73    /// dihaluskan eksponensial, dengan min-hold agar gain tidak naik sebelum
74    /// transient melewati output.
75    pub fn process_input_peak(&mut self, input_peak: f32) -> f32 {
76        let peak = if input_peak.is_finite() {
77            input_peak.abs()
78        } else {
79            0.0
80        };
81        self.max_peak.push(peak);
82
83        let window_peak = self.max_peak.max();
84        let target_gain =
85            if window_peak > self.ceiling_linear && self.ceiling_linear > MIN_CEILING_LINEAR {
86                self.ceiling_linear / window_peak
87            } else {
88                1.0
89            };
90
91        if target_gain < self.current_gain {
92            self.current_gain = target_gain;
93            self.hold_frames_remaining = self.lookahead_samples;
94        } else if self.hold_frames_remaining > 0 {
95            self.hold_frames_remaining -= 1;
96        } else {
97            self.current_gain =
98                self.release_coeff * self.current_gain + (1.0 - self.release_coeff) * target_gain;
99        }
100
101        self.current_gain.clamp(0.0, 1.0)
102    }
103
104    pub fn reset(&mut self) {
105        self.max_peak.reset();
106        self.current_gain = 1.0;
107        self.hold_frames_remaining = 0;
108    }
109}
110
111/// Limiter single-channel berbasis lookahead delay + [`LookaheadDetector`].
112pub struct BrickwallLimiter {
113    ceiling_db: f32,
114    ceiling_linear: f32,
115    lookahead_delay: DelayLine,
116    detector: LookaheadDetector,
117}
118
119impl BrickwallLimiter {
120    /// Membuat Limiter dengan lookahead time.
121    ///
122    /// # Arguments
123    /// * `sample_rate` - Frekuensi sampling (Hz)
124    /// * `ceiling_db` - Batas maksimum keluaran sinyal (dBFS, misal: -0.1 atau -1.0)
125    /// * `release_ms` - Waktu pemulihan limiter (ms)
126    /// * `lookahead_ms` - Jeda intip buffer (ms, misal: 2.0 s/d 5.0 ms)
127    pub fn new(sample_rate: f32, ceiling_db: f32, release_ms: f32, lookahead_ms: f32) -> Self {
128        let detector = LookaheadDetector::new(sample_rate, ceiling_db, release_ms, lookahead_ms);
129        Self {
130            ceiling_db,
131            ceiling_linear: detector.ceiling_linear(),
132            lookahead_delay: DelayLine::new(detector.latency_samples() + DELAY_CAPACITY_HEADROOM),
133            detector,
134        }
135    }
136
137    pub fn ceiling_db(&self) -> f32 {
138        self.ceiling_db
139    }
140
141    pub fn ceiling_linear(&self) -> f32 {
142        self.ceiling_linear
143    }
144
145    pub fn latency_samples(&self) -> usize {
146        self.detector.latency_samples()
147    }
148
149    /// Memproses satu sampel audio dengan jaminan proteksi ceiling.
150    #[inline(always)]
151    pub fn process_sample(&mut self, input: f32) -> f32 {
152        let delay_samples = self.detector.latency_samples() as f32;
153        self.lookahead_delay.push(input);
154        let delayed_input = self
155            .lookahead_delay
156            .read_delay(delay_samples, InterpolationType::None);
157        let gain = self.detector.process_input_peak(input);
158        let limited_output = delayed_input * gain;
159
160        // Jaring pengaman terakhir: pembulatan f32 tidak boleh melewati ceiling.
161        limited_output.clamp(-self.ceiling_linear, self.ceiling_linear)
162    }
163
164    /// Memproses seluruh buffer in-place.
165    pub fn process_buffer(&mut self, buffer: &mut [f32]) {
166        for sample in buffer.iter_mut() {
167            *sample = self.process_sample(*sample);
168        }
169    }
170
171    pub fn reset(&mut self) {
172        self.lookahead_delay.clear();
173        self.detector.reset();
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    const SAMPLE_RATE: f32 = 44_100.0;
182    const CEILING_DB: f32 = -1.0;
183    const LOOKAHEAD_MS: f32 = 2.0;
184
185    fn ceiling_linear() -> f32 {
186        Gain::db_to_linear(CEILING_DB)
187    }
188
189    fn lookahead_samples() -> usize {
190        (LOOKAHEAD_MS * 0.001 * SAMPLE_RATE).round() as usize
191    }
192
193    #[test]
194    fn lookahead_detector_gain_matches_window_peak() {
195        // Window 4 sample: puncak 0.8 dengan ceiling 0.5 => gain 0.625.
196        let mut detector = LookaheadDetector::new(1_000.0, Gain::linear_to_db(0.5), 50.0, 4.0);
197
198        assert!((detector.process_input_peak(0.2) - 1.0).abs() < 1e-6);
199        assert!((detector.process_input_peak(0.4) - 1.0).abs() < 1e-6);
200        assert!((detector.process_input_peak(0.8) - 0.625).abs() < 1e-6);
201    }
202
203    #[test]
204    fn percussive_transients_never_exceed_ceiling() {
205        let mut limiter = BrickwallLimiter::new(SAMPLE_RATE, CEILING_DB, 50.0, LOOKAHEAD_MS);
206        let mut signal = vec![0.0_f32; 2_000];
207        for index in [100, 400, 401, 900, 1_500] {
208            signal[index] = 0.95;
209        }
210
211        limiter.process_buffer(&mut signal);
212
213        let peak = signal
214            .iter()
215            .fold(0.0_f32, |acc, sample| acc.max(sample.abs()));
216        assert!(
217            peak <= ceiling_linear() + 1e-6,
218            "peak {peak} melebihi ceiling {}",
219            ceiling_linear()
220        );
221    }
222
223    #[test]
224    fn gain_reduction_is_active_before_the_transient_reaches_output() {
225        let mut limiter = BrickwallLimiter::new(SAMPLE_RATE, CEILING_DB, 50.0, LOOKAHEAD_MS);
226        let latency = lookahead_samples();
227        let quiet_level = 0.1_f32;
228        let burst_level = 0.95_f32;
229        let burst_index = 200;
230
231        let mut signal = vec![quiet_level; 1_000];
232        signal[burst_index] = burst_level;
233        limiter.process_buffer(&mut signal);
234
235        // Jauh sebelum burst masuk window: tidak ada pelemahan.
236        assert!(
237            (signal[100] - quiet_level).abs() < 1e-4,
238            "sampel awal tidak boleh teredam, dapat {}",
239            signal[100]
240        );
241        // Saat burst sudah masuk window tetapi belum sampai output: gain turun.
242        let attenuated = quiet_level * (ceiling_linear() / burst_level);
243        assert!(
244            (signal[burst_index + 40] - attenuated).abs() < 1e-3,
245            "gain reduction harus aktif sebelum transient mencapai output, dapat {}",
246            signal[burst_index + 40]
247        );
248        // Sampel burst sendiri keluar tepat di ceiling.
249        assert!(
250            (signal[burst_index + latency] - ceiling_linear()).abs() < 1e-4,
251            "transient harus keluar tepat di ceiling, dapat {}",
252            signal[burst_index + latency]
253        );
254    }
255
256    #[test]
257    fn reset_restores_unity_gain() {
258        let mut limiter = BrickwallLimiter::new(SAMPLE_RATE, CEILING_DB, 50.0, LOOKAHEAD_MS);
259        let mut hot = vec![0.95_f32; 200];
260        limiter.process_buffer(&mut hot);
261        limiter.reset();
262
263        let mut quiet = vec![0.0_f32; 200];
264        limiter.process_buffer(&mut quiet);
265        assert!(quiet.iter().all(|&sample| sample.abs() < 1e-6));
266    }
267}