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