Skip to main content

rustriff_lib/services/effects/cabinet/
cabinet.rs

1use crate::config::DEFAULT_IR_FILE;
2use crate::domain::audio_processor::AudioProcessor;
3use crate::domain::dto::effect::cabinet_dto::CabinetDto;
4use crate::domain::dto::effect::effect_dto::EffectDto;
5use crate::domain::effect::Effect;
6use crate::domain::validation::sanitize_wav_file_name;
7use crate::infrastructure::file_loader::{FileLoader, FileLoaderTrait};
8use crate::services::processors::resampler::resampler::ResamplerImpl;
9use rustfft::num_complex::Complex;
10use rustfft::{Fft, FftPlanner};
11use std::collections::VecDeque;
12use std::path::PathBuf;
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::Arc;
15use tracing::{info, warn};
16
17const CUSTOM_IR_ENV_KEY: &str = "RUSTRIFF_CUSTOM_IR_DIR";
18/// Chunk size used by IR resampling during initialization.
19const IR_RESAMPLER_CHUNK_SIZE: usize = 256;
20/// Number of input samples collected before one FFT convolution pass.
21const CONV_BLOCK_SIZE: usize = 256;
22/// Upper bound for IR length to keep real-time CPU usage predictable.
23const MAX_IR_SAMPLES: usize = 2048;
24/// Safety clamp applied to processed output to reduce hard digital clipping.
25const OUTPUT_CLAMP: f32 = 0.98;
26
27/// FFT-based cabinet simulator that convolves input audio with a loaded IR.
28///
29/// The effect uses block convolution:
30/// - gather `CONV_BLOCK_SIZE` input samples,
31/// - forward FFT,
32/// - multiply by precomputed IR FFT kernel,
33/// - inverse FFT,
34/// - overlap-add into an output queue.
35///
36/// `overlap-add` means each processed block contributes some samples that belong
37/// to the same time positions as the next block. Instead of overwriting those
38/// positions, we add them together in `output_queue`.
39/// This reconstructs the same linear-convolution result you would get from a
40/// full sample-by-sample convolution, but in a block-friendly way.
41///
42/// Public-facing behavior is sample-by-sample through [`AudioProcessor::process`],
43/// while internally processing is block-based for efficiency.
44pub struct Cabinet {
45    id: u32,
46    name: String,
47    is_active: Arc<AtomicBool>,
48    color: String,
49    /// Filename of the currently loaded IR (e.g. `"vintage-4x12.wav"`).
50    ///
51    /// Stored so it can be serialized into [`CabinetDto`] for persistence and
52    /// used to re-initialize the cabinet after a configuration reload.
53    ir_file_path: String,
54    /// Time-domain IR samples after optional resampling and truncation.
55    /// Retained in memory because `ir_buffer.len()` is needed on every block
56    /// to compute the correct overlap-add region length.
57    ir_buffer: Vec<f32>,
58    /// Frequency-domain FFT of the IR — the convolution kernel `H[k]`.
59    /// Pre-computed once in `new()` so the hot audio path only multiplies
60    /// rather than re-computing the IR FFT every block.
61    ir_fft_kernel: Vec<Complex<f32>>,
62    ir_fft_size: usize,
63    fft_forward: Arc<dyn Fft<f32>>,
64    fft_inverse: Arc<dyn Fft<f32>>,
65    /// Reusable working buffer of length `ir_fft_size` for in-place FFT ops.
66    /// Preallocated to avoid heap allocation on the real-time audio thread.
67    fft_scratch: Vec<Complex<f32>>,
68    /// Attenuation factor derived from the IR peak; prevents output clipping
69    /// caused by high-amplitude IR files.
70    cabinet_gain: f32,
71    /// Guards against flooding the log with repeated "IR unavailable" warnings.
72    has_logged_ir_unavailable: bool,
73    /// Accumulates incoming samples until a full `CONV_BLOCK_SIZE` block is
74    /// ready for FFT convolution.
75    input_block: Vec<f32>,
76    /// Ring buffer of ready-to-deliver processed samples.
77    /// Overlap-add writes ahead into positions that represent future blocks,
78    /// and [`AudioProcessor::process`] pops one sample per call.
79    output_queue: VecDeque<f32>,
80    /// Sample rate of the DSP pipeline; used to resample the IR if needed.
81    dsp_sample_rate: u32,
82}
83
84impl Cabinet {
85    /// Creates a new cabinet effect instance and prepares FFT convolution state.
86    ///
87    /// Initialization steps:
88    /// - load default IR file,
89    /// - optionally resample IR to the DSP sample rate,
90    /// - truncate very long IRs,
91    /// - precompute IR FFT kernel,
92    /// - preallocate buffers used by the audio thread.
93    pub fn new(
94        id: u32,
95        name: String,
96        is_active: bool,
97        color: String,
98        ir_file_path: String,
99        dsp_sample_rate: u32,
100    ) -> Self {
101        info!("init cabinet simulation");
102        let file_loader = FileLoader::new();
103
104        let selected_ir_file = if ir_file_path.trim().is_empty() {
105            DEFAULT_IR_FILE.to_string()
106        } else {
107            match sanitize_wav_file_name(&ir_file_path) {
108                Ok(sanitized) => sanitized,
109                Err(err) => {
110                    warn!(
111                        "Invalid cabinet IR file '{}': {}. Falling back to default '{}'.",
112                        ir_file_path, err, DEFAULT_IR_FILE
113                    );
114                    DEFAULT_IR_FILE.to_string()
115                }
116            }
117        };
118
119        let temp_file_path = Self::resolve_ir_file_path(&selected_ir_file).unwrap_or_else(|| {
120            warn!(
121                "Could not resolve IR '{}' in known directories. Falling back to default location.",
122                selected_ir_file
123            );
124            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
125                .join("resources")
126                .join("default_ir")
127                .join(&selected_ir_file)
128        });
129
130        let ir_buffer = file_loader.read_wav_to_buffer(&temp_file_path);
131        let ir_sample_rate = file_loader
132            .read_wav_sample_rate(&temp_file_path)
133            .unwrap_or(dsp_sample_rate);
134        let (mut ir_buffer, resampling_applied) =
135            Self::resample_if_needed(ir_buffer, ir_sample_rate, dsp_sample_rate);
136        if ir_buffer.is_empty() {
137            warn!(
138                "Cabinet IR buffer is empty. IR file may be missing, unreadable, unsupported, or corrupt. Falling back to passthrough."
139            );
140        }
141        if ir_buffer.len() > MAX_IR_SAMPLES {
142            info!(
143                "Cabinet IR too long ({} samples). Truncating to {} to keep real-time CPU stable.",
144                ir_buffer.len(),
145                MAX_IR_SAMPLES
146            );
147            ir_buffer.truncate(MAX_IR_SAMPLES);
148        }
149        let ir_fft_size = (CONV_BLOCK_SIZE + ir_buffer.len().saturating_sub(1))
150            .next_power_of_two()
151            .max(2);
152        let output_queue_capacity = CONV_BLOCK_SIZE + ir_buffer.len();
153        let (fft_forward, fft_inverse) = Self::build_fft_plans(ir_fft_size);
154        let ir_fft_kernel = Self::convert_ir_to_fft_kernel(&ir_buffer, ir_fft_size, &fft_forward);
155        let cabinet_gain = Self::compute_cabinet_gain(&ir_buffer);
156
157        info!(
158      "Cabinet rates -> ir_sample_rate={}, dsp_sample_rate={}, resampling_applied={}, ir_len={}, fft_size={}, cabinet_gain={}",
159			ir_sample_rate,
160			dsp_sample_rate,
161			resampling_applied,
162      ir_buffer.len(),
163      ir_fft_size,
164      cabinet_gain
165		);
166
167        Self {
168            id,
169            name,
170            is_active: Arc::new(AtomicBool::new(is_active)),
171            color,
172            ir_file_path: selected_ir_file,
173            ir_buffer,
174            ir_fft_kernel,
175            ir_fft_size,
176            fft_forward,
177            fft_inverse,
178            fft_scratch: vec![Complex::new(0.0_f32, 0.0_f32); ir_fft_size],
179            cabinet_gain,
180            has_logged_ir_unavailable: false,
181            input_block: Vec::with_capacity(CONV_BLOCK_SIZE),
182            output_queue: VecDeque::with_capacity(output_queue_capacity),
183            dsp_sample_rate,
184        }
185    }
186
187    /// Computes a conservative gain factor from IR peak amplitude.
188    ///
189    /// If IR peak is above unity, this returns an attenuation factor `1.0 / peak`.
190    /// Otherwise returns `1.0`.
191    fn compute_cabinet_gain(ir_buffer: &[f32]) -> f32 {
192        let peak = ir_buffer
193            .iter()
194            .fold(0.0_f32, |acc, sample| acc.max(sample.abs()));
195
196        if peak > 1.0 {
197            1.0 / peak
198        } else {
199            1.0
200        }
201    }
202
203    /// Builds forward and inverse FFT plans for a fixed FFT size.
204    fn build_fft_plans(fft_size: usize) -> (Arc<dyn Fft<f32>>, Arc<dyn Fft<f32>>) {
205        let mut planner = FftPlanner::<f32>::new();
206        let forward = planner.plan_fft_forward(fft_size);
207        let inverse = planner.plan_fft_inverse(fft_size);
208        (forward, inverse)
209    }
210
211    /// Searches several well-known directories for an IR file and returns the
212    /// first path that resolves to an existing regular file.
213    ///
214    /// Search order:
215    /// 1. `$RUSTRIFF_CUSTOM_IR_DIR/<file_name>` — user-defined override via the
216    ///    [`CUSTOM_IR_ENV_KEY`] environment variable (useful during development
217    ///    or when testing a custom cabinet).
218    /// 2. `CARGO_MANIFEST_DIR/resources/default_ir/<file_name>` — bundled
219    ///    default IRs when running under `cargo run` / `cargo tauri dev`.
220    /// 3. `<exe_dir>/resources/default_ir/<file_name>` — bundled resources
221    ///    relative to the installed executable in release builds.
222    ///
223    /// Returns `None` when no candidate exists; the caller is responsible for
224    /// logging a warning and supplying a fallback path.
225    fn resolve_ir_file_path(file_name: &str) -> Option<PathBuf> {
226        let sanitized_file_name = match sanitize_wav_file_name(file_name) {
227            Ok(name) => name,
228            Err(err) => {
229                warn!(
230                    "Refusing to resolve invalid IR file name '{}': {}",
231                    file_name, err
232                );
233                return None;
234            }
235        };
236
237        let mut candidates = Vec::new();
238
239        if let Ok(custom_dir) = std::env::var(CUSTOM_IR_ENV_KEY) {
240            candidates.push(PathBuf::from(custom_dir).join(&sanitized_file_name));
241        }
242
243        candidates.push(
244            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
245                .join("resources")
246                .join("default_ir")
247                .join(&sanitized_file_name),
248        );
249
250        if let Ok(exe_path) = std::env::current_exe() {
251            if let Some(exe_dir) = exe_path.parent() {
252                candidates.push(
253                    exe_dir
254                        .join("resources")
255                        .join("default_ir")
256                        .join(&sanitized_file_name),
257                );
258            }
259        }
260
261        candidates.into_iter().find(|path| path.is_file())
262    }
263
264    /// Converts the time-domain IR into a frequency-domain convolution kernel.
265    ///
266    /// The returned vector has length `fft_size` and is zero-padded when IR is shorter.
267    fn convert_ir_to_fft_kernel(
268        ir_buffer: &[f32],
269        fft_size: usize,
270        fft_forward: &Arc<dyn Fft<f32>>,
271    ) -> Vec<Complex<f32>> {
272        if ir_buffer.is_empty() {
273            return Vec::new();
274        }
275
276        let mut buffer = vec![Complex::new(0.0_f32, 0.0_f32); fft_size];
277        for (idx, sample) in ir_buffer.iter().enumerate() {
278            buffer[idx].re = *sample;
279        }
280
281        fft_forward.process(&mut buffer);
282        buffer
283    }
284
285    /// Pushes dry samples when IR data is unavailable (missing/unreadable/corrupt).
286    fn push_passthrough_block_for_ir_unavailable(&mut self) {
287        for &sample in &self.input_block {
288            self.output_queue.push_back(sample);
289        }
290    }
291
292    /// Copies the current input block into the reusable FFT scratch buffer.
293    ///
294    /// The remaining tail is zero-filled to represent block convolution padding.
295    fn prepare_fft_input_from_block(&mut self) {
296        self.fft_scratch.fill(Complex::new(0.0_f32, 0.0_f32));
297        for (sample_index, sample) in self.input_block.iter().enumerate() {
298            self.fft_scratch[sample_index].re = *sample;
299        }
300    }
301
302    /// Applies point-wise complex multiplication `X[k] *= H[k]` in frequency domain.
303    fn multiply_input_by_ir_in_frequency_domain(&mut self) {
304        for (input_bin, ir_bin) in self.fft_scratch.iter_mut().zip(self.ir_fft_kernel.iter()) {
305            *input_bin *= *ir_bin;
306        }
307    }
308
309    /// Performs overlap-add accumulation of the current IFFT block into output queue.
310    ///
311    /// This method:
312    /// - normalizes by FFT size,
313    /// - applies cabinet gain,
314    /// - accumulates into queued samples so block boundaries remain continuous.
315    ///
316    /// Why "add" and not "replace"?
317    ///
318    /// Convolving one input block with an IR produces an output that is longer than
319    /// the input block (`input_len + ir_len - 1`). The tail of the current block
320    /// lands in the same timeline region as the start of future blocks.
321    ///
322    /// If we replaced samples, that tail energy would be lost and you would hear
323    /// discontinuities (clicks/crackle) at block edges. By adding into existing
324    /// queued values, block outputs stitch together into one continuous signal.
325    fn overlap_add_ifft_block_into_queue(&mut self) {
326        let fft_normalization = self.ir_fft_size as f32;
327        let linear_conv_len = self.input_block.len() + self.ir_buffer.len().saturating_sub(1);
328
329        if self.output_queue.len() < linear_conv_len {
330            self.output_queue.resize(linear_conv_len, 0.0);
331        }
332
333        for sample_index in 0..linear_conv_len {
334            if let Some(output_slot) = self.output_queue.get_mut(sample_index) {
335                *output_slot +=
336                    (self.fft_scratch[sample_index].re / fft_normalization) * self.cabinet_gain;
337            }
338        }
339    }
340
341    /// Runs one full block convolution pass for the currently buffered input block.
342    ///
343    /// Signal flow in plain language:
344    ///
345    /// 1. Put the current time-domain input block (`x`) into the FFT buffer.
346    /// 2. Convert it to frequency domain with FFT (`X = FFT(x)`).
347    /// 3. Apply cabinet tone by multiplying each frequency bin with the precomputed
348    ///    IR kernel (`Y[k] = X[k] * H[k]`).
349    /// 4. Convert back to time domain (`y = IFFT(Y)`).
350    /// 5. Overlap-add `y` into `output_queue` so neighboring blocks combine correctly.
351    ///
352    /// Compact form: `x -> FFT(x) -> FFT(x) * FFT(h) -> IFFT -> overlap-add`.
353    fn convolve_current_block(&mut self) {
354        if self.input_block.is_empty() {
355            return;
356        }
357
358        if self.ir_fft_kernel.is_empty() {
359            if !self.has_logged_ir_unavailable {
360                warn!(
361                    "Cabinet IR kernel is empty. Using passthrough until a valid IR can be loaded."
362                );
363                self.has_logged_ir_unavailable = true;
364            }
365            self.push_passthrough_block_for_ir_unavailable();
366            self.input_block.clear();
367            return;
368        }
369
370        self.prepare_fft_input_from_block();
371        self.fft_forward.process(&mut self.fft_scratch);
372        self.multiply_input_by_ir_in_frequency_domain();
373        self.fft_inverse.process(&mut self.fft_scratch);
374        self.overlap_add_ifft_block_into_queue();
375
376        self.input_block.clear();
377    }
378
379    /// Resamples an IR buffer when source and target sample rates differ.
380    ///
381    /// Returns `(buffer, was_resampled)`.
382    /// On any resampler setup/processing failure, the original buffer is returned.
383    fn resample_if_needed(
384        buffer: Vec<f32>,
385        source_rate: u32,
386        target_rate: u32,
387    ) -> (Vec<f32>, bool) {
388        if buffer.len() < 2 || source_rate == 0 || target_rate == 0 || source_rate == target_rate {
389            return (buffer, false);
390        }
391
392        let mut resampler =
393            match ResamplerImpl::new(source_rate, target_rate, IR_RESAMPLER_CHUNK_SIZE) {
394                Ok(resampler) => resampler,
395                Err(err) => {
396                    warn!(
397					"Failed to initialize cabinet IR resampler ({} -> {}): {}. Using original IR buffer.",
398					source_rate,
399					target_rate,
400					err
401				);
402                    return (buffer, false);
403                }
404            };
405
406        let mut out = Vec::new();
407        for &sample in &buffer {
408            out.extend(resampler.process_sample(sample));
409        }
410        out.extend(resampler.flush());
411
412        if out.is_empty() {
413            warn!(
414                "Cabinet IR resampling produced no output ({} -> {}). Using original IR buffer.",
415                source_rate, target_rate
416            );
417            return (buffer, false);
418        }
419
420        (out, true)
421    }
422
423    /// Returns the sample rate at which cabinet DSP processing runs.
424    pub fn sample_rate(&self) -> u32 {
425        self.dsp_sample_rate
426    }
427
428    /// Returns the precomputed frequency-domain IR kernel.
429    ///
430    /// Mainly intended for diagnostics and tests.
431    pub fn ir_fft_kernel(&self) -> &[Complex<f32>] {
432        &self.ir_fft_kernel
433    }
434
435    /// Returns the FFT size used for cabinet convolution.
436    pub fn ir_fft_size(&self) -> usize {
437        self.ir_fft_size
438    }
439}
440
441impl AudioProcessor for Cabinet {
442    /// Processes one sample through the cabinet effect.
443    ///
444    /// Internally this is block-based:
445    /// - one sample is dequeued from `output_queue`,
446    /// - one sample is appended to `input_block`,
447    /// - when the block is full, a new convolution block is computed.
448    ///
449    /// If queue underruns, silence is returned until the next block result is available.
450    fn process(&mut self, sample: f32) -> f32 {
451        let output_sample = self.output_queue.pop_front().unwrap_or(0.0);
452
453        self.input_block.push(sample);
454        if self.input_block.len() == CONV_BLOCK_SIZE {
455            self.convolve_current_block();
456        }
457
458        output_sample.clamp(-OUTPUT_CLAMP, OUTPUT_CLAMP)
459    }
460}
461
462impl Effect for Cabinet {
463    fn id(&self) -> u32 {
464        self.id
465    }
466
467    fn name(&self) -> &str {
468        &self.name
469    }
470
471    fn get_color(&self) -> String {
472        self.color.clone()
473    }
474
475    /// Serializes the current cabinet state into a [`CabinetDto`].
476    ///
477    /// The DTO captures everything the frontend needs to re-render the pedal
478    /// and everything the persistence layer needs to restore it on next launch,
479    /// including the `ir_file_path` used to reload the correct IR.
480    fn to_dto(&self) -> EffectDto {
481        EffectDto::Cabinet(CabinetDto {
482            id: self.id,
483            name: self.name.clone(),
484            is_active: self.is_active.load(Ordering::Relaxed),
485            color: self.color.clone(),
486            ir_file_path: self.ir_file_path.clone(),
487        })
488    }
489
490    /// Returns a shared reference to the atomic active/bypass flag.
491    ///
492    /// The flag may be toggled from the UI thread without locking the audio
493    /// thread, because [`AtomicBool`] operations are lock-free.
494    fn active_flag(&self) -> Arc<AtomicBool> {
495        Arc::clone(&self.is_active)
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use crate::domain::audio_processor::AudioProcessor;
503    use crate::domain::effect::Effect;
504
505    const TEST_SAMPLE_RATE: u32 = 48_000;
506    const BLOCK_SIZE: usize = 256;
507
508    fn make_cabinet(ir_file: &str, is_active: bool) -> Cabinet {
509        Cabinet::new(
510            0,
511            "Test Cabinet".to_string(),
512            is_active,
513            "#112233".to_string(),
514            ir_file.to_string(),
515            TEST_SAMPLE_RATE,
516        )
517    }
518
519    fn feed_samples(cabinet: &mut Cabinet, samples: &[f32]) -> Vec<f32> {
520        samples.iter().map(|&s| cabinet.process(s)).collect()
521    }
522
523    #[cfg(test)]
524    mod success_path {
525        use super::*;
526
527        #[test]
528        fn new_with_valid_ir_initializes_non_empty_fft_kernel() {
529            let cab = make_cabinet("Vox-ac30.wav", true);
530            assert!(
531                !cab.ir_fft_kernel().is_empty(),
532                "A valid IR file should produce a non-empty FFT kernel"
533            );
534        }
535
536        #[test]
537        fn new_with_valid_ir_fft_size_is_a_power_of_two() {
538            let cab = make_cabinet("Vox-ac30.wav", true);
539            let size = cab.ir_fft_size();
540            assert!(size > 0);
541            assert_eq!(
542                size & (size - 1),
543                0,
544                "FFT size {size} is not a power of two"
545            );
546        }
547
548        #[test]
549        fn new_returns_the_configured_dsp_sample_rate() {
550            let cab = make_cabinet("Vox-ac30.wav", true);
551            assert_eq!(cab.sample_rate(), TEST_SAMPLE_RATE);
552        }
553
554        #[test]
555        fn to_dto_round_trips_all_cabinet_fields() {
556            let cab = make_cabinet("Vox-ac30.wav", true);
557            if let EffectDto::Cabinet(dto) = cab.to_dto() {
558                assert_eq!(dto.id, 0);
559                assert_eq!(dto.name, "Test Cabinet");
560                assert_eq!(dto.color, "#112233");
561                assert_eq!(dto.ir_file_path, "Vox-ac30.wav");
562                assert!(dto.is_active);
563            } else {
564                panic!("to_dto should return a Cabinet variant");
565            }
566        }
567
568        #[test]
569        fn process_produces_non_zero_output_after_one_full_block() {
570            let mut cab = make_cabinet("Vox-ac30.wav", true);
571
572            // Feed two blocks of constant input.  The first BLOCK_SIZE returns
573            // are all 0.0 (queue underrun).  From index BLOCK_SIZE onward the
574            // first convolution result emerges from the queue.
575            let input = vec![0.5_f32; BLOCK_SIZE * 2];
576            let output = feed_samples(&mut cab, &input);
577
578            let post_block = &output[BLOCK_SIZE..];
579            assert!(
580                post_block.iter().any(|&s| s.abs() > 1e-6),
581                "Convolution with a real IR should produce non-zero output after the first block"
582            );
583        }
584
585        #[test]
586        fn process_if_active_false_returns_input_sample_unchanged_without_block_delay() {
587            let mut cab = make_cabinet("Vox-ac30.wav", false);
588
589            let output = cab.process_if_active(0.75_f32);
590            assert!(
591                (output - 0.75).abs() < 1e-6,
592                "Bypassed cabinet should return the input sample unchanged"
593            );
594        }
595
596        #[test]
597        fn output_is_clamped_to_output_clamp_range() {
598            let mut cab = make_cabinet("Vox-ac30.wav", true);
599            let input = vec![1.0_f32; BLOCK_SIZE * 2];
600            let output = feed_samples(&mut cab, &input);
601            for &sample in &output {
602                assert!(
603                    sample.abs() <= OUTPUT_CLAMP + f32::EPSILON,
604                    "Output sample {sample} exceeds clamp limit {OUTPUT_CLAMP}"
605                );
606            }
607        }
608    }
609
610    #[cfg(test)]
611    mod failure_path {
612        use super::*;
613
614        #[test]
615        fn new_with_missing_ir_file_produces_empty_fft_kernel() {
616            let cab = make_cabinet("nonexistent-ir-file.wav", true);
617            assert!(
618                cab.ir_fft_kernel().is_empty(),
619                "A missing IR file should result in an empty FFT kernel (passthrough mode)"
620            );
621        }
622
623        #[test]
624        fn process_with_empty_kernel_passes_signal_through_after_one_block_delay() {
625            let mut cab = make_cabinet("nonexistent-ir-file.wav", true);
626            assert!(cab.ir_fft_kernel().is_empty());
627
628            let input: Vec<f32> = (0..BLOCK_SIZE * 2).map(|i| i as f32 * 0.001).collect();
629            let output = feed_samples(&mut cab, &input);
630
631            for (i, sample) in output.iter().enumerate().take(BLOCK_SIZE) {
632                assert!(
633                    sample.abs() < 1e-6,
634                    "Pre-block output should be silent (got {} at index {i})",
635                    sample
636                );
637            }
638
639            for i in 0..BLOCK_SIZE {
640                assert!(
641                    (output[BLOCK_SIZE + i] - input[i]).abs() < 1e-6,
642                    "Post-block output[{}] ({}) should equal input[{i}] ({})",
643                    BLOCK_SIZE + i,
644                    output[BLOCK_SIZE + i],
645                    input[i]
646                );
647            }
648        }
649
650        #[test]
651        fn new_with_empty_ir_path_does_not_panic() {
652            let cab = make_cabinet("", true);
653            let _ = cab.ir_fft_size();
654            let _ = cab.sample_rate();
655        }
656
657        #[test]
658        fn new_with_traversal_ir_path_falls_back_to_default_ir_file_name() {
659            let cab = make_cabinet("../secrets.wav", true);
660
661            if let EffectDto::Cabinet(dto) = cab.to_dto() {
662                assert_eq!(dto.ir_file_path, DEFAULT_IR_FILE);
663            } else {
664                panic!("Expected Cabinet effect DTO");
665            }
666        }
667
668        #[test]
669        fn new_with_non_wav_ir_path_falls_back_to_default_ir_file_name() {
670            let cab = make_cabinet("not-an-ir.mp3", true);
671
672            if let EffectDto::Cabinet(dto) = cab.to_dto() {
673                assert_eq!(dto.ir_file_path, DEFAULT_IR_FILE);
674            } else {
675                panic!("Expected Cabinet effect DTO");
676            }
677        }
678    }
679}