Skip to main content

proteus_lib/dsp/effects/convolution_reverb/
mod.rs

1//! Convolution reverb effect wrapper for the DSP chain.
2
3use std::path::{Path, PathBuf};
4
5use log::{info, warn};
6use serde::{Deserialize, Serialize};
7
8use super::EffectContext;
9
10pub mod convolution;
11pub mod impulse_response;
12pub mod reverb;
13mod spec;
14
15pub(crate) use spec::{parse_impulse_response_spec, parse_impulse_response_tail_db};
16pub use spec::{parse_impulse_response_string, ImpulseResponseSpec};
17
18const DEFAULT_DRY_WET: f32 = 0.000001;
19const DEFAULT_TAIL_DB: f32 = -60.0;
20pub(crate) const REVERB_BATCH_BLOCKS: usize = 2;
21
22/// Preferred processing batch size in interleaved samples for the reverb.
23pub fn preferred_batch_samples(channels: usize) -> usize {
24    reverb::preferred_batch_samples(channels)
25}
26
27/// Serialized configuration for convolution reverb impulse response selection.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(default)]
30pub struct ConvolutionReverbSettings {
31    pub impulse_response: Option<String>,
32    pub impulse_response_attachment: Option<String>,
33    pub impulse_response_path: Option<String>,
34    pub impulse_response_tail_db: Option<f32>,
35    pub impulse_response_tail: Option<f32>,
36}
37
38impl Default for ConvolutionReverbSettings {
39    fn default() -> Self {
40        Self {
41            impulse_response: None,
42            impulse_response_attachment: None,
43            impulse_response_path: None,
44            impulse_response_tail_db: None,
45            impulse_response_tail: None,
46        }
47    }
48}
49
50/// Configured convolution reverb effect with runtime state.
51#[derive(Clone, Serialize, Deserialize)]
52#[serde(default)]
53pub struct ConvolutionReverbEffect {
54    pub enabled: bool,
55    #[serde(alias = "wet_dry", alias = "mix")]
56    pub dry_wet: f32,
57    #[serde(flatten)]
58    pub settings: ConvolutionReverbSettings,
59    #[serde(skip)]
60    state: Option<ConvolutionReverbState>,
61    #[serde(skip)]
62    resolved_config: Option<ResolvedConfig>,
63}
64
65impl std::fmt::Debug for ConvolutionReverbEffect {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("ConvolutionReverbEffect")
68            .field("enabled", &self.enabled)
69            .field("dry_wet", &self.dry_wet)
70            .field("settings", &self.settings)
71            .finish()
72    }
73}
74
75impl Default for ConvolutionReverbEffect {
76    fn default() -> Self {
77        Self {
78            enabled: true,
79            dry_wet: DEFAULT_DRY_WET,
80            settings: ConvolutionReverbSettings::default(),
81            state: None,
82            resolved_config: None,
83        }
84    }
85}
86
87impl ConvolutionReverbEffect {
88    /// Create a new convolution reverb effect.
89    pub fn new(dry_wet: f32) -> Self {
90        Self {
91            dry_wet: dry_wet.clamp(0.0, 1.0),
92            ..Default::default()
93        }
94    }
95
96    /// Return the stored impulse response settings.
97    pub fn settings(&self) -> &ConvolutionReverbSettings {
98        &self.settings
99    }
100
101    /// Mutable access to the stored impulse response settings.
102    pub fn settings_mut(&mut self) -> &mut ConvolutionReverbSettings {
103        &mut self.settings
104    }
105
106    /// Process interleaved samples through the reverb.
107    ///
108    /// # Arguments
109    /// - `samples`: Interleaved input samples.
110    /// - `context`: Environment details (sample rate, channels, etc.).
111    /// - `drain`: When true, flush buffered tail data if present.
112    ///
113    /// # Returns
114    /// Processed interleaved samples.
115    pub fn process(&mut self, samples: &[f32], context: &EffectContext, drain: bool) -> Vec<f32> {
116        self.ensure_state(context);
117        if !self.enabled || self.dry_wet <= 0.0 {
118            return samples.to_vec();
119        }
120
121        let Some(state) = self.state.as_mut() else {
122            return samples.to_vec();
123        };
124
125        state.reverb.set_dry_wet(self.dry_wet);
126        state.process(samples, drain)
127    }
128
129    /// Clear all internal buffers and convolution history.
130    ///
131    /// # Returns
132    /// Nothing.
133    pub fn reset_state(&mut self) {
134        if let Some(state) = self.state.as_mut() {
135            state.reset();
136        }
137        self.state = None;
138        self.resolved_config = None;
139    }
140
141    fn ensure_state(&mut self, context: &EffectContext) {
142        let config = self.resolve_config(context);
143        if self.resolved_config.as_ref() == Some(&config) && self.state.is_some() {
144            return;
145        }
146
147        let start = std::time::Instant::now();
148        let reverb = build_reverb_with_impulse_response(
149            config.channels,
150            self.dry_wet,
151            config.impulse_spec.clone(),
152            config.container_path.as_deref(),
153            config.tail_db,
154        );
155        let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
156        log::info!(
157            "Convolution reverb init: {:.2}ms (ir={:?} channels={})",
158            elapsed_ms,
159            config.impulse_spec,
160            config.channels
161        );
162
163        self.state = reverb.map(ConvolutionReverbState::new);
164        self.resolved_config = Some(config);
165    }
166
167    fn resolve_config(&self, context: &EffectContext) -> ResolvedConfig {
168        let impulse_spec = self
169            .settings
170            .impulse_response
171            .as_deref()
172            .and_then(parse_impulse_response_string)
173            .or_else(|| {
174                self.settings
175                    .impulse_response_attachment
176                    .as_deref()
177                    .and_then(parse_impulse_response_string)
178            })
179            .or_else(|| {
180                self.settings
181                    .impulse_response_path
182                    .as_deref()
183                    .and_then(parse_impulse_response_string)
184            })
185            .or_else(|| context.impulse_response_spec.clone());
186
187        let tail_db = self
188            .settings
189            .impulse_response_tail_db
190            .or(self.settings.impulse_response_tail)
191            .unwrap_or(context.impulse_response_tail_db);
192
193        ResolvedConfig {
194            channels: context.channels,
195            container_path: context.container_path.clone(),
196            impulse_spec,
197            tail_db,
198        }
199    }
200}
201
202#[derive(Debug, Clone, PartialEq)]
203struct ResolvedConfig {
204    channels: usize,
205    container_path: Option<String>,
206    impulse_spec: Option<ImpulseResponseSpec>,
207    tail_db: f32,
208}
209
210#[derive(Clone)]
211struct ConvolutionReverbState {
212    reverb: reverb::Reverb,
213    input_buffer: Vec<f32>,
214    output_buffer: Vec<f32>,
215    block_out: Vec<f32>,
216    block_samples: usize,
217}
218
219impl ConvolutionReverbState {
220    fn new(mut reverb: reverb::Reverb) -> Self {
221        info!("Using Convolution Reverb!");
222        let block_samples = reverb.block_size_samples();
223        reverb.set_dry_wet(DEFAULT_DRY_WET);
224        Self {
225            reverb,
226            input_buffer: Vec::new(),
227            output_buffer: Vec::new(),
228            block_out: Vec::new(),
229            block_samples,
230        }
231    }
232
233    fn reset(&mut self) {
234        self.reverb.clear_state();
235        self.input_buffer.clear();
236        self.output_buffer.clear();
237        self.block_out.clear();
238        self.block_samples = self.reverb.block_size_samples();
239    }
240
241    fn process(&mut self, samples: &[f32], drain: bool) -> Vec<f32> {
242        if samples.is_empty() {
243            if drain && !self.output_buffer.is_empty() {
244                let out = self.output_buffer.clone();
245                self.output_buffer.clear();
246                return out;
247            }
248            return Vec::new();
249        }
250
251        if self.block_samples == 0 {
252            return self.reverb.process(samples);
253        }
254
255        self.input_buffer.extend_from_slice(samples);
256        let batch_samples = self.block_samples * REVERB_BATCH_BLOCKS;
257        let should_flush = drain && !self.input_buffer.is_empty();
258        while self.input_buffer.len() >= batch_samples || should_flush {
259            let take = if self.input_buffer.len() >= batch_samples {
260                batch_samples
261            } else {
262                self.input_buffer.len()
263            };
264            let block: Vec<f32> = self.input_buffer.drain(0..take).collect();
265            self.reverb.process_into(&block, &mut self.block_out);
266            self.output_buffer.extend_from_slice(&self.block_out);
267            if take < batch_samples {
268                break;
269            }
270        }
271
272        // Keep output continuous for small chunks (e.g. around shuffle boundaries).
273        // If batch processing did not yield enough samples yet, process the pending
274        // input immediately instead of emitting silence.
275        while self.output_buffer.len() < samples.len() && !self.input_buffer.is_empty() {
276            let take = self.input_buffer.len().min(batch_samples.max(1));
277            let block: Vec<f32> = self.input_buffer.drain(0..take).collect();
278            self.reverb.process_into(&block, &mut self.block_out);
279            self.output_buffer.extend_from_slice(&self.block_out);
280        }
281
282        let chunk_len = samples.len();
283        if self.output_buffer.len() < chunk_len {
284            let mut out: Vec<f32> = self.output_buffer.drain(..).collect();
285            let out_len = out.len();
286            if out_len < chunk_len {
287                out.extend_from_slice(&samples[out_len..chunk_len]);
288            }
289            self.output_buffer.clear();
290            return out;
291        }
292
293        self.output_buffer.drain(0..chunk_len).collect()
294    }
295}
296
297fn build_reverb_with_impulse_response(
298    channels: usize,
299    dry_wet: f32,
300    impulse_spec: Option<ImpulseResponseSpec>,
301    container_path: Option<&str>,
302    tail_db: f32,
303) -> Option<reverb::Reverb> {
304    let impulse_spec = impulse_spec?;
305
306    use self::impulse_response::{
307        load_impulse_response_from_file_with_tail,
308        load_impulse_response_from_prot_attachment_with_tail,
309    };
310
311    let result = match impulse_spec {
312        ImpulseResponseSpec::Attachment(name) => container_path
313            .ok_or_else(|| "missing container path for attachment".to_string())
314            .and_then(|path| {
315                load_impulse_response_from_prot_attachment_with_tail(path, &name, Some(tail_db))
316                    .map_err(|err| err.to_string())
317            }),
318        ImpulseResponseSpec::FilePath(path) => {
319            let resolved_path = resolve_impulse_response_path(container_path, &path);
320            if resolved_path.exists() {
321                load_impulse_response_from_file_with_tail(&resolved_path, Some(tail_db))
322                    .map_err(|err| err.to_string())
323            } else {
324                match container_path {
325                    Some(container_path) => {
326                        let fallback_name = Path::new(&path)
327                            .file_name()
328                            .and_then(|name| name.to_str())
329                            .map(|name| name.to_string());
330                        if let Some(fallback_name) = fallback_name {
331                            load_impulse_response_from_prot_attachment_with_tail(
332                                container_path,
333                                &fallback_name,
334                                Some(tail_db),
335                            )
336                            .map_err(|err| err.to_string())
337                        } else {
338                            Err(format!(
339                                "impulse response path not found: {}",
340                                resolved_path.display()
341                            ))
342                        }
343                    }
344                    None => Err(format!(
345                        "impulse response path not found: {}",
346                        resolved_path.display()
347                    )),
348                }
349            }
350        }
351    };
352
353    match result {
354        Ok(impulse_response) => Some(reverb::Reverb::new_with_impulse_response(
355            channels,
356            dry_wet,
357            &impulse_response,
358        )),
359        Err(err) => {
360            warn!(
361                "Failed to load impulse response ({}); skipping convolution reverb.",
362                err
363            );
364            None
365        }
366    }
367}
368
369fn resolve_impulse_response_path(container_path: Option<&str>, path: &str) -> PathBuf {
370    let path = Path::new(path);
371    if path.is_absolute() {
372        return path.to_path_buf();
373    }
374
375    if let Some(container_path) = container_path {
376        if let Some(parent) = Path::new(container_path).parent() {
377            return parent.join(path);
378        }
379    }
380
381    path.to_path_buf()
382}
383
384impl ConvolutionReverbSettings {
385    /// Resolve a tail trim value, falling back to the default.
386    pub fn tail_db_or_default(&self) -> f32 {
387        self.impulse_response_tail_db
388            .or(self.impulse_response_tail)
389            .unwrap_or(DEFAULT_TAIL_DB)
390    }
391}