rustriff_lib/services/effects/cabinet/
cabinet.rs1use 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";
18const IR_RESAMPLER_CHUNK_SIZE: usize = 256;
20const CONV_BLOCK_SIZE: usize = 256;
22const MAX_IR_SAMPLES: usize = 2048;
24const OUTPUT_CLAMP: f32 = 0.98;
26
27pub struct Cabinet {
45 id: u32,
46 name: String,
47 is_active: Arc<AtomicBool>,
48 color: String,
49 ir_file_path: String,
54 ir_buffer: Vec<f32>,
58 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 fft_scratch: Vec<Complex<f32>>,
68 cabinet_gain: f32,
71 has_logged_ir_unavailable: bool,
73 input_block: Vec<f32>,
76 output_queue: VecDeque<f32>,
80 dsp_sample_rate: u32,
82}
83
84impl Cabinet {
85 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 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 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 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 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 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 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 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 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 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 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 pub fn sample_rate(&self) -> u32 {
425 self.dsp_sample_rate
426 }
427
428 pub fn ir_fft_kernel(&self) -> &[Complex<f32>] {
432 &self.ir_fft_kernel
433 }
434
435 pub fn ir_fft_size(&self) -> usize {
437 self.ir_fft_size
438 }
439}
440
441impl AudioProcessor for Cabinet {
442 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 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 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 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}