Skip to main content

rescale_core/
engine.rs

1//! Fondasi engine audio untuk pemrosesan block-based dan real-time friendly.
2
3use crate::buffer::delay_line::{DelayLine, InterpolationType};
4use crate::drc::LookaheadDetector;
5use crate::filter::ParametricEqualizer3Band;
6use crate::gain::Gain;
7use crate::safety;
8use crate::{Compressor, CompressorConfig};
9use std::error::Error;
10use std::fmt;
11
12/// Cadangan kapasitas DelayLine limiter agar pembacaan selalu aman.
13const LIMITER_DELAY_HEADROOM: usize = 8;
14
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct AudioSpec {
17    pub sample_rate: f32,
18    pub channels: usize,
19}
20
21impl AudioSpec {
22    pub fn new(sample_rate: f32, channels: usize) -> Result<Self, EngineError> {
23        if !sample_rate.is_finite() || sample_rate <= 0.0 {
24            return Err(EngineError::InvalidSampleRate(sample_rate));
25        }
26        if channels == 0 {
27            return Err(EngineError::NoChannels);
28        }
29        Ok(Self {
30            sample_rate,
31            channels,
32        })
33    }
34}
35
36#[derive(Debug, Clone, PartialEq)]
37pub enum EngineError {
38    InvalidSampleRate(f32),
39    NoChannels,
40    ChannelCountMismatch { expected: usize, actual: usize },
41    UnequalChannelLengths,
42}
43
44impl fmt::Display for EngineError {
45    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            Self::InvalidSampleRate(value) => write!(formatter, "Invalid sample rate: {value}"),
48            Self::NoChannels => write!(formatter, "Audio requires at least one channel"),
49            Self::ChannelCountMismatch { expected, actual } => {
50                write!(formatter, "Expected {expected} channels, received {actual}")
51            }
52            Self::UnequalChannelLengths => write!(formatter, "All channels in a block must match"),
53        }
54    }
55}
56
57impl Error for EngineError {}
58
59/// Tampilan planar atas sebuah block audio. Engine tidak mengambil ownership buffer.
60pub struct AudioBlock<'a> {
61    pub(crate) channels: &'a mut [&'a mut [f32]],
62    frames: usize,
63}
64
65impl<'a> AudioBlock<'a> {
66    pub fn new(channels: &'a mut [&'a mut [f32]]) -> Result<Self, EngineError> {
67        let Some(first_channel) = channels.first() else {
68            return Err(EngineError::NoChannels);
69        };
70        let frames = first_channel.len();
71        if channels.iter().any(|channel| channel.len() != frames) {
72            return Err(EngineError::UnequalChannelLengths);
73        }
74        Ok(Self { channels, frames })
75    }
76
77    pub fn channels(&self) -> usize {
78        self.channels.len()
79    }
80
81    pub fn frames(&self) -> usize {
82        self.frames
83    }
84
85    pub fn channel(&self, index: usize) -> Option<&[f32]> {
86        self.channels.get(index).map(|channel| &**channel as &[f32])
87    }
88
89    pub fn channel_mut(&mut self, index: usize) -> Option<&mut [f32]> {
90        self.channels.get_mut(index).map(|channel| &mut **channel)
91    }
92}
93
94/// Kontrak processor tanpa I/O atau alokasi di jalur pemrosesan.
95pub trait AudioProcessor {
96    fn prepare(&mut self, spec: AudioSpec) -> Result<(), EngineError>;
97    fn process(&mut self, block: &mut AudioBlock<'_>) -> Result<(), EngineError>;
98    fn reset(&mut self);
99    fn latency_samples(&self) -> usize {
100        0
101    }
102    /// Mengeluarkan sisa sampel internal (misal tail lookahead limiter) setelah input habis.
103    /// Mengembalikan jumlah frame valid yang ditulis ke dalam `block`.
104    fn flush(&mut self, _block: &mut AudioBlock<'_>) -> Result<usize, EngineError> {
105        Ok(0)
106    }
107}
108
109pub struct ProcessorChain {
110    processors: Vec<Box<dyn AudioProcessor>>,
111    /// Sisa frame tail yang belum ditiriskan oleh [`AudioProcessor::flush`].
112    tail_frames_remaining: usize,
113}
114
115impl ProcessorChain {
116    pub fn new() -> Self {
117        Self {
118            processors: Vec::new(),
119            tail_frames_remaining: 0,
120        }
121    }
122
123    pub fn push<P: AudioProcessor + 'static>(&mut self, processor: P) {
124        self.processors.push(Box::new(processor));
125    }
126
127    /// Menjalankan seluruh stage tanpa menyentuh penghitung tail.
128    ///
129    /// Dipakai bersama oleh `process` dan `flush`: keduanya mengalirkan block
130    /// melalui rantai yang sama, yang berbeda hanya asal data block dan cara
131    /// penghitung tail diperbarui.
132    fn process_stages(&mut self, block: &mut AudioBlock<'_>) -> Result<(), EngineError> {
133        for processor in &mut self.processors {
134            processor.process(block)?;
135        }
136        // Jaring pengaman terakhir: NaN/Inf/denormal tidak boleh lolos dari engine.
137        safety::sanitize_block(block);
138        Ok(())
139    }
140}
141
142impl Default for ProcessorChain {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148impl AudioProcessor for ProcessorChain {
149    fn prepare(&mut self, spec: AudioSpec) -> Result<(), EngineError> {
150        for processor in &mut self.processors {
151            processor.prepare(spec)?;
152        }
153        self.tail_frames_remaining = self.latency_samples();
154        Ok(())
155    }
156
157    fn process(&mut self, block: &mut AudioBlock<'_>) -> Result<(), EngineError> {
158        self.process_stages(block)?;
159        // Setiap block baru menambah tail sepanjang total latency chain.
160        self.tail_frames_remaining = self.latency_samples();
161        Ok(())
162    }
163
164    fn reset(&mut self) {
165        for processor in &mut self.processors {
166            processor.reset();
167        }
168        self.tail_frames_remaining = self.latency_samples();
169    }
170
171    fn latency_samples(&self) -> usize {
172        self.processors
173            .iter()
174            .map(|processor| processor.latency_samples())
175            .sum()
176    }
177
178    /// Meniriskan tail seluruh chain setelah input habis.
179    ///
180    /// Tail sebuah rantai processor sepanjang jumlah latency semua stage. Karena
181    /// itu cukup mengalirkan block berisi senyap melewati seluruh stage: setiap
182    /// delay internal (lookahead limiter, dst.) mengeluarkan sampel yang masih
183    /// tertahan, lalu stage berikutnya memproses tail tersebut seperti sinyal
184    /// biasa.
185    ///
186    /// Pendekatan ini benar untuk urutan stage apa pun — termasuk bila processor
187    /// tanpa latency berada sebelum maupun sesudah processor berlatency.
188    fn flush(&mut self, block: &mut AudioBlock<'_>) -> Result<usize, EngineError> {
189        if self.tail_frames_remaining == 0 {
190            return Ok(0);
191        }
192
193        let frames_flushed = block.frames().min(self.tail_frames_remaining);
194        if frames_flushed == 0 {
195            return Ok(0);
196        }
197
198        // Input senyap: tail dibangkitkan oleh state internal setiap stage.
199        for channel_index in 0..block.channels() {
200            block
201                .channel_mut(channel_index)
202                .expect("validated channel count")
203                .fill(0.0);
204        }
205        self.process_stages(block)?;
206        self.tail_frames_remaining -= frames_flushed;
207        Ok(frames_flushed)
208    }
209}
210
211pub struct GainProcessor {
212    gain_db: f32,
213    gain_linear: f32,
214    channels: usize,
215}
216
217impl GainProcessor {
218    pub fn new(gain_db: f32) -> Self {
219        Self {
220            gain_db,
221            gain_linear: Gain::db_to_linear(gain_db),
222            channels: 0,
223        }
224    }
225
226    pub fn set_gain_db(&mut self, gain_db: f32) {
227        self.gain_db = gain_db;
228        self.gain_linear = Gain::db_to_linear(gain_db);
229    }
230}
231
232impl AudioProcessor for GainProcessor {
233    fn prepare(&mut self, spec: AudioSpec) -> Result<(), EngineError> {
234        self.channels = spec.channels;
235        Ok(())
236    }
237
238    fn process(&mut self, block: &mut AudioBlock<'_>) -> Result<(), EngineError> {
239        if block.channels() != self.channels {
240            return Err(EngineError::ChannelCountMismatch {
241                expected: self.channels,
242                actual: block.channels(),
243            });
244        }
245        for ch in 0..block.channels() {
246            let channel = block.channel_mut(ch).expect("validated channel count");
247            Gain::apply_linear(channel, self.gain_linear);
248        }
249        Ok(())
250    }
251
252    fn reset(&mut self) {}
253}
254
255pub struct EqualizerProcessor {
256    eqs: Vec<ParametricEqualizer3Band>,
257    low_freq: f32,
258    low_gain_db: f32,
259    mid_freq: f32,
260    mid_q: f32,
261    mid_gain_db: f32,
262    high_freq: f32,
263    high_gain_db: f32,
264}
265
266impl EqualizerProcessor {
267    pub fn new(
268        low_freq: f32,
269        low_gain_db: f32,
270        mid_freq: f32,
271        mid_q: f32,
272        mid_gain_db: f32,
273        high_freq: f32,
274        high_gain_db: f32,
275    ) -> Self {
276        Self {
277            eqs: Vec::new(),
278            low_freq,
279            low_gain_db,
280            mid_freq,
281            mid_q,
282            mid_gain_db,
283            high_freq,
284            high_gain_db,
285        }
286    }
287}
288
289impl AudioProcessor for EqualizerProcessor {
290    fn prepare(&mut self, spec: AudioSpec) -> Result<(), EngineError> {
291        self.eqs = (0..spec.channels)
292            .map(|_| {
293                let mut eq = ParametricEqualizer3Band::new(spec.sample_rate);
294                eq.set_low_band(self.low_freq, self.low_gain_db);
295                eq.set_mid_band(self.mid_freq, self.mid_q, self.mid_gain_db);
296                eq.set_high_band(self.high_freq, self.high_gain_db);
297                eq
298            })
299            .collect();
300        Ok(())
301    }
302
303    fn process(&mut self, block: &mut AudioBlock<'_>) -> Result<(), EngineError> {
304        if block.channels() != self.eqs.len() {
305            return Err(EngineError::ChannelCountMismatch {
306                expected: self.eqs.len(),
307                actual: block.channels(),
308            });
309        }
310        for (index, eq) in self.eqs.iter_mut().enumerate() {
311            let channel = block.channel_mut(index).expect("validated channel count");
312            eq.process_buffer(channel);
313        }
314        Ok(())
315    }
316
317    fn reset(&mut self) {
318        for eq in &mut self.eqs {
319            eq.reset();
320        }
321    }
322}
323
324pub struct CompressorProcessor {
325    config: CompressorConfig,
326    processors: Vec<Compressor>,
327}
328
329impl CompressorProcessor {
330    pub fn new(config: CompressorConfig) -> Self {
331        Self {
332            config,
333            processors: Vec::new(),
334        }
335    }
336}
337
338impl AudioProcessor for CompressorProcessor {
339    fn prepare(&mut self, spec: AudioSpec) -> Result<(), EngineError> {
340        self.processors = (0..spec.channels)
341            .map(|_| Compressor::new(spec.sample_rate, self.config.clone()))
342            .collect();
343        Ok(())
344    }
345
346    fn process(&mut self, block: &mut AudioBlock<'_>) -> Result<(), EngineError> {
347        if block.channels() != self.processors.len() {
348            return Err(EngineError::ChannelCountMismatch {
349                expected: self.processors.len(),
350                actual: block.channels(),
351            });
352        }
353        for (index, processor) in self.processors.iter_mut().enumerate() {
354            let channel = block
355                .channel_mut(index)
356                .expect("channel count was validated");
357            processor.process_buffer(channel);
358        }
359        Ok(())
360    }
361
362    fn reset(&mut self) {
363        for processor in &mut self.processors {
364            processor.reset();
365        }
366    }
367}
368
369/// Limiter lookahead dengan deteksi ter-link antar channel.
370///
371/// Satu [`LookaheadDetector`] dipakai bersama semua channel: gain reduction
372/// ditentukan oleh puncak gabungan, sehingga keseimbangan stereo tetap terjaga
373/// saat limiting berat (stereo-linked limiting).
374pub struct LimiterProcessor {
375    ceiling_db: f32,
376    release_ms: f32,
377    lookahead_ms: f32,
378    detector: Option<LookaheadDetector>,
379    delays: Vec<DelayLine>,
380    ceiling_linear: f32,
381    latency_samples: usize,
382    pending_flush_samples: usize,
383}
384
385impl LimiterProcessor {
386    pub fn new(ceiling_db: f32, release_ms: f32, lookahead_ms: f32) -> Self {
387        Self {
388            ceiling_db,
389            release_ms,
390            lookahead_ms,
391            detector: None,
392            delays: Vec::new(),
393            ceiling_linear: Gain::db_to_linear(ceiling_db.min(0.0)),
394            latency_samples: 0,
395            pending_flush_samples: 0,
396        }
397    }
398
399    /// Memproses satu frame: deteksi puncak gabungan, lalu terapkan gain yang
400    /// sama ke semua channel (linked limiting) sambil menulis tail delay.
401    fn process_frame(&mut self, block: &mut AudioBlock<'_>, frame: usize) {
402        let mut frame_peak = 0.0_f32;
403        for channel_index in 0..self.delays.len() {
404            let sample = block
405                .channel(channel_index)
406                .expect("validated channel count")[frame];
407            frame_peak = frame_peak.max(sample.abs());
408        }
409
410        let detector = self.detector.as_mut().expect("limiter prepared");
411        let gain = detector.process_input_peak(frame_peak);
412        let delay_samples = self.latency_samples as f32;
413
414        for (channel_index, delay) in self.delays.iter_mut().enumerate() {
415            let input = block
416                .channel(channel_index)
417                .expect("validated channel count")[frame];
418            delay.push(input);
419            let delayed = delay.read_delay(delay_samples, InterpolationType::None);
420            let limited = (delayed * gain).clamp(-self.ceiling_linear, self.ceiling_linear);
421            block
422                .channel_mut(channel_index)
423                .expect("validated channel count")[frame] = limited;
424        }
425    }
426}
427
428impl AudioProcessor for LimiterProcessor {
429    fn prepare(&mut self, spec: AudioSpec) -> Result<(), EngineError> {
430        let detector = LookaheadDetector::new(
431            spec.sample_rate,
432            self.ceiling_db,
433            self.release_ms,
434            self.lookahead_ms,
435        );
436        self.latency_samples = detector.latency_samples();
437        self.ceiling_linear = detector.ceiling_linear();
438        self.delays = (0..spec.channels)
439            .map(|_| DelayLine::new(self.latency_samples + LIMITER_DELAY_HEADROOM))
440            .collect();
441        self.detector = Some(detector);
442        self.pending_flush_samples = self.latency_samples;
443        Ok(())
444    }
445
446    fn process(&mut self, block: &mut AudioBlock<'_>) -> Result<(), EngineError> {
447        if block.channels() != self.delays.len() {
448            return Err(EngineError::ChannelCountMismatch {
449                expected: self.delays.len(),
450                actual: block.channels(),
451            });
452        }
453        self.pending_flush_samples = self.latency_samples;
454        for frame in 0..block.frames() {
455            self.process_frame(block, frame);
456        }
457        Ok(())
458    }
459
460    fn reset(&mut self) {
461        for delay in &mut self.delays {
462            delay.clear();
463        }
464        if let Some(detector) = self.detector.as_mut() {
465            detector.reset();
466        }
467        self.pending_flush_samples = self.latency_samples;
468    }
469
470    fn latency_samples(&self) -> usize {
471        self.latency_samples
472    }
473
474    fn flush(&mut self, block: &mut AudioBlock<'_>) -> Result<usize, EngineError> {
475        if block.channels() != self.delays.len() {
476            return Err(EngineError::ChannelCountMismatch {
477                expected: self.delays.len(),
478                actual: block.channels(),
479            });
480        }
481        if self.pending_flush_samples == 0 {
482            return Ok(0);
483        }
484
485        let frames_to_flush = block.frames().min(self.pending_flush_samples);
486        for channel_index in 0..self.delays.len() {
487            block
488                .channel_mut(channel_index)
489                .expect("validated channel count")
490                .fill(0.0);
491        }
492        // Input senyap: delay line mengeluarkan tail, detektor tetap ter-link.
493        for frame in 0..frames_to_flush {
494            self.process_frame(block, frame);
495        }
496        self.pending_flush_samples -= frames_to_flush;
497        Ok(frames_to_flush)
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use crate::drc::DetectionMode;
505
506    const SAMPLE_RATE: f32 = 44_100.0;
507    const CHANNELS: usize = 1;
508    const LIMITER_CEILING_DB: f32 = -1.0;
509    const LIMITER_RELEASE_MS: f32 = 50.0;
510    const LIMITER_LOOKAHEAD_MS: f32 = 2.0;
511    const TEST_SIGNAL_FRAMES: usize = 2_048;
512
513    fn audio_spec() -> AudioSpec {
514        AudioSpec::new(SAMPLE_RATE, CHANNELS).expect("spec audio valid")
515    }
516
517    fn limiter_latency_samples() -> usize {
518        (LIMITER_LOOKAHEAD_MS * 0.001 * SAMPLE_RATE).round() as usize
519    }
520
521    fn limiter() -> LimiterProcessor {
522        LimiterProcessor::new(LIMITER_CEILING_DB, LIMITER_RELEASE_MS, LIMITER_LOOKAHEAD_MS)
523    }
524
525    fn transparent_compressor() -> CompressorProcessor {
526        CompressorProcessor::new(CompressorConfig {
527            threshold_db: 0.0,
528            ratio: 1.0,
529            knee_db: 0.0,
530            attack_ms: 1.0,
531            release_ms: 10.0,
532            makeup_gain_db: 0.0,
533            mode: DetectionMode::Peak,
534        })
535    }
536
537    fn full_mastering_chain() -> ProcessorChain {
538        let mut chain = ProcessorChain::new();
539        chain.push(GainProcessor::new(-3.0));
540        chain.push(CompressorProcessor::new(CompressorConfig {
541            threshold_db: -12.0,
542            ratio: 3.0,
543            knee_db: 4.0,
544            attack_ms: 5.0,
545            release_ms: 80.0,
546            makeup_gain_db: 0.0,
547            mode: DetectionMode::Peak,
548        }));
549        chain.push(limiter());
550        chain.prepare(audio_spec()).expect("prepare chain");
551        chain
552    }
553
554    /// Chain dengan/tanpa processor tanpa latency di depan limiter.
555    fn limiter_chain(with_prefix_processor: bool) -> ProcessorChain {
556        let mut chain = ProcessorChain::new();
557        if with_prefix_processor {
558            chain.push(GainProcessor::new(0.0));
559        }
560        chain.push(transparent_compressor());
561        chain.push(limiter());
562        chain.prepare(audio_spec()).expect("prepare chain");
563        chain
564    }
565
566    fn process_block(chain: &mut ProcessorChain, samples: &mut [f32]) {
567        let mut channel_views: Vec<&mut [f32]> = vec![samples];
568        let mut block = AudioBlock::new(&mut channel_views).expect("block valid");
569        chain.process(&mut block).expect("process block");
570    }
571
572    /// Meniriskan tail sampai habis, mengembalikan sampel yang dihasilkan.
573    fn flush_all(chain: &mut ProcessorChain, block_size: usize) -> Vec<f32> {
574        let mut tail = Vec::new();
575        loop {
576            let mut samples = vec![0.0_f32; block_size];
577            let mut channel_views: Vec<&mut [f32]> = vec![samples.as_mut_slice()];
578            let mut block = AudioBlock::new(&mut channel_views).expect("flush block");
579            let flushed = chain.flush(&mut block).expect("flush");
580            if flushed == 0 {
581                break;
582            }
583            tail.extend_from_slice(&samples[..flushed]);
584        }
585        tail
586    }
587
588    fn test_signal(frames: usize) -> Vec<f32> {
589        (0..frames)
590            .map(|index| {
591                let time = index as f32 / SAMPLE_RATE;
592                (2.0 * std::f32::consts::PI * 220.0 * time).sin() * 0.3
593            })
594            .collect()
595    }
596
597    /// Merender sinyal uji dengan ukuran block tertentu, termasuk tail hasil flush.
598    fn render_with_block_size(block_size: usize) -> Vec<f32> {
599        let input = test_signal(TEST_SIGNAL_FRAMES);
600        let mut chain = full_mastering_chain();
601        let mut output = Vec::with_capacity(TEST_SIGNAL_FRAMES + limiter_latency_samples());
602
603        for chunk in input.chunks(block_size) {
604            let mut samples = chunk.to_vec();
605            process_block(&mut chain, &mut samples);
606            output.extend_from_slice(&samples);
607        }
608        output.extend(flush_all(&mut chain, block_size));
609        output
610    }
611
612    #[test]
613    fn flush_drains_limiter_tail_regardless_of_prefix_processors() {
614        let latency = limiter_latency_samples();
615        let mut tails = Vec::new();
616
617        for with_prefix_processor in [false, true] {
618            let mut chain = limiter_chain(with_prefix_processor);
619            let mut impulse = vec![0.0_f32; 64];
620            impulse[0] = 0.5;
621            process_block(&mut chain, &mut impulse);
622            tails.push(flush_all(&mut chain, latency.max(1)));
623        }
624
625        assert_eq!(
626            tails[0].len(),
627            latency,
628            "tail harus sepanjang latency limiter"
629        );
630        assert_eq!(tails[1].len(), latency);
631
632        // Gain 0 dB tidak boleh mengubah tail: hasil kedua chain harus identik.
633        for (plain, prefixed) in tails[0].iter().zip(tails[1].iter()) {
634            assert!((plain - prefixed).abs() < 1e-6);
635        }
636
637        let impulse_survives = tails[0]
638            .iter()
639            .any(|sample| (sample.abs() - 0.5).abs() < 1e-3);
640        assert!(
641            impulse_survives,
642            "tail harus memuat impulse 0.5 yang tertahan lookahead"
643        );
644    }
645
646    #[test]
647    fn flush_drains_the_tail_of_every_latency_stage() {
648        let mut chain = ProcessorChain::new();
649        chain.push(LimiterProcessor::new(
650            LIMITER_CEILING_DB,
651            LIMITER_RELEASE_MS,
652            1.0,
653        ));
654        chain.push(limiter());
655        chain.prepare(audio_spec()).expect("prepare chain");
656
657        let expected_latency = chain.latency_samples();
658        assert_eq!(
659            expected_latency,
660            chain.latency_samples(),
661            "total latency = jumlah latency setiap stage"
662        );
663
664        let mut impulse = vec![0.0_f32; 32];
665        impulse[0] = 0.5;
666        process_block(&mut chain, &mut impulse);
667
668        let tail = flush_all(&mut chain, expected_latency + 16);
669        assert_eq!(
670            tail.len(),
671            expected_latency,
672            "flush harus meniriskan tail dari semua stage berlatency"
673        );
674        let impulse_survives = tail.iter().any(|sample| (sample.abs() - 0.5).abs() < 1e-3);
675        assert!(impulse_survives, "impulse harus keluar dari kedua limiter");
676    }
677
678    #[test]
679    fn flush_returns_zero_when_chain_has_no_latency() {
680        let mut chain = ProcessorChain::new();
681        chain.push(GainProcessor::new(0.0));
682        chain.prepare(audio_spec()).expect("prepare chain");
683
684        let mut samples = vec![0.1_f32; 16];
685        process_block(&mut chain, &mut samples);
686
687        assert_eq!(chain.latency_samples(), 0);
688        assert!(flush_all(&mut chain, 16).is_empty());
689    }
690
691    #[test]
692    fn block_size_does_not_change_output() {
693        let reference = render_with_block_size(TEST_SIGNAL_FRAMES);
694
695        for block_size in [64, 256, 1_024] {
696            let rendered = render_with_block_size(block_size);
697
698            assert_eq!(
699                rendered.len(),
700                reference.len(),
701                "panjang output berbeda pada blok {block_size}"
702            );
703            for (frame, (expected, actual)) in reference.iter().zip(rendered.iter()).enumerate() {
704                assert!(
705                    (expected - actual).abs() < 1e-6,
706                    "blok {block_size}: frame {frame} berbeda ({expected} vs {actual})"
707                );
708            }
709        }
710    }
711
712    #[test]
713    fn limiter_links_channels_to_the_loudest_peak() {
714        let spec = AudioSpec::new(SAMPLE_RATE, 2).expect("spec stereo");
715        let mut left = vec![0.1_f32; 512];
716        let mut right = vec![0.5_f32; 512];
717        let mut channel_views: Vec<&mut [f32]> = vec![&mut left, &mut right];
718        let mut block = AudioBlock::new(&mut channel_views).expect("block");
719
720        let mut limiter = LimiterProcessor::new(-12.0, 50.0, LIMITER_LOOKAHEAD_MS);
721        limiter.prepare(spec).expect("prepare limiter");
722        limiter.process(&mut block).expect("process");
723
724        let ceiling = Gain::db_to_linear(-12.0);
725        let expected_gain = ceiling / 0.5;
726        let steady_frame = 400;
727
728        assert!(
729            (right[steady_frame] - 0.5 * expected_gain).abs() < 1e-3,
730            "channel kanan: {}",
731            right[steady_frame]
732        );
733        assert!(
734            (left[steady_frame] - 0.1 * expected_gain).abs() < 1e-3,
735            "channel kiri: {}",
736            left[steady_frame]
737        );
738        // Rasio antar channel terjaga: stereo image tidak bergeser.
739        let ratio = left[steady_frame] / right[steady_frame];
740        assert!((ratio - 0.2).abs() < 1e-3, "rasio kiri/kanan {ratio}");
741    }
742
743    #[test]
744    fn limiter_processor_ceiling_holds_for_percussive_input() {
745        let mut samples = vec![0.0_f32; 1_000];
746        for index in [100, 300, 301, 700] {
747            samples[index] = 0.95;
748        }
749        let mut channel_views: Vec<&mut [f32]> = vec![&mut samples];
750        let mut block = AudioBlock::new(&mut channel_views).expect("block");
751
752        let mut limiter =
753            LimiterProcessor::new(LIMITER_CEILING_DB, LIMITER_RELEASE_MS, LIMITER_LOOKAHEAD_MS);
754        limiter.prepare(audio_spec()).expect("prepare limiter");
755        limiter.process(&mut block).expect("process");
756
757        let peak = samples
758            .iter()
759            .fold(0.0_f32, |acc, sample| acc.max(sample.abs()));
760        assert!(
761            peak <= Gain::db_to_linear(LIMITER_CEILING_DB) + 1e-6,
762            "peak {peak} melampaui ceiling"
763        );
764    }
765}