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        let chunk_len = samples.len();
273        if self.output_buffer.len() < chunk_len {
274            let mut out = self.output_buffer.clone();
275            if out.len() < chunk_len {
276                out.resize(chunk_len, 0.0);
277            }
278            self.output_buffer.clear();
279            return out;
280        }
281
282        self.output_buffer.drain(0..chunk_len).collect()
283    }
284}
285
286fn build_reverb_with_impulse_response(
287    channels: usize,
288    dry_wet: f32,
289    impulse_spec: Option<ImpulseResponseSpec>,
290    container_path: Option<&str>,
291    tail_db: f32,
292) -> Option<reverb::Reverb> {
293    let impulse_spec = impulse_spec?;
294
295    use self::impulse_response::{
296        load_impulse_response_from_file_with_tail,
297        load_impulse_response_from_prot_attachment_with_tail,
298    };
299
300    let result = match impulse_spec {
301        ImpulseResponseSpec::Attachment(name) => container_path
302            .ok_or_else(|| "missing container path for attachment".to_string())
303            .and_then(|path| {
304                load_impulse_response_from_prot_attachment_with_tail(path, &name, Some(tail_db))
305                    .map_err(|err| err.to_string())
306            }),
307        ImpulseResponseSpec::FilePath(path) => {
308            let resolved_path = resolve_impulse_response_path(container_path, &path);
309            if resolved_path.exists() {
310                load_impulse_response_from_file_with_tail(&resolved_path, Some(tail_db))
311                    .map_err(|err| err.to_string())
312            } else {
313                match container_path {
314                    Some(container_path) => {
315                        let fallback_name = Path::new(&path)
316                            .file_name()
317                            .and_then(|name| name.to_str())
318                            .map(|name| name.to_string());
319                        if let Some(fallback_name) = fallback_name {
320                            load_impulse_response_from_prot_attachment_with_tail(
321                                container_path,
322                                &fallback_name,
323                                Some(tail_db),
324                            )
325                            .map_err(|err| err.to_string())
326                        } else {
327                            Err(format!(
328                                "impulse response path not found: {}",
329                                resolved_path.display()
330                            ))
331                        }
332                    }
333                    None => Err(format!(
334                        "impulse response path not found: {}",
335                        resolved_path.display()
336                    )),
337                }
338            }
339        }
340    };
341
342    match result {
343        Ok(impulse_response) => Some(reverb::Reverb::new_with_impulse_response(
344            channels,
345            dry_wet,
346            &impulse_response,
347        )),
348        Err(err) => {
349            warn!(
350                "Failed to load impulse response ({}); skipping convolution reverb.",
351                err
352            );
353            None
354        }
355    }
356}
357
358fn resolve_impulse_response_path(container_path: Option<&str>, path: &str) -> PathBuf {
359    let path = Path::new(path);
360    if path.is_absolute() {
361        return path.to_path_buf();
362    }
363
364    if let Some(container_path) = container_path {
365        if let Some(parent) = Path::new(container_path).parent() {
366            return parent.join(path);
367        }
368    }
369
370    path.to_path_buf()
371}
372
373impl ConvolutionReverbSettings {
374    /// Resolve a tail trim value, falling back to the default.
375    pub fn tail_db_or_default(&self) -> f32 {
376        self.impulse_response_tail_db
377            .or(self.impulse_response_tail)
378            .unwrap_or(DEFAULT_TAIL_DB)
379    }
380}