firewheel_core/dsp/
distance_attenuation.rs

1#[cfg(not(feature = "std"))]
2use num_traits::Float;
3
4use core::num::NonZeroU32;
5
6use firewheel_macros::{Diff, Patch};
7
8use crate::{
9    dsp::filter::single_pole_iir::{OnePoleIirLPF, OnePoleIirLPFCoeff},
10    param::smoother::{SmoothedParam, SmootherConfig},
11};
12
13pub const MUFFLE_CUTOFF_HZ_MIN: f32 = 20.0;
14pub const MUFFLE_CUTOFF_HZ_MAX: f32 = 20_480.0;
15const MUFFLE_CUTOFF_HZ_RANGE_RECIP: f32 = 1.0 / (MUFFLE_CUTOFF_HZ_MAX - MUFFLE_CUTOFF_HZ_MIN);
16const CALC_FILTER_COEFF_INTERVAL: usize = 8;
17
18/// The method in which to calculate the volume of a sound based on the distance from
19/// the listener.
20///
21/// Based on <https://developer.mozilla.org/en-US/docs/Web/API/PannerNode/distanceModel>
22///
23/// Interactive graph of the different models: <https://www.desmos.com/calculator/g1pbsc5m9y>
24#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Diff, Patch)]
25#[cfg_attr(feature = "bevy", derive(bevy_ecs::prelude::Component))]
26#[cfg_attr(feature = "bevy_reflect", derive(bevy_reflect::Reflect))]
27pub enum DistanceModel {
28    #[default]
29    /// A linear distance model calculates the gain by:
30    ///
31    /// `reference_distance / (reference_distance + rolloff_factor * (max(distance, reference_distance) - reference_distance))`
32    ///
33    /// This mostly closely matches how sound is attenuated in the real world, and is the default model.
34    Inverse,
35    /// A linear distance model calculates the gain by:
36    ///
37    /// `(1.0 - rolloff_factor * (distance - reference_distance) / (max_distance - reference_distance)).clamp(0.0, 1.0)`
38    Linear,
39    /// An exponential distance model calculates the gain by:
40    ///
41    /// `pow((max(distance, reference_distance) / reference_distance, -rolloff_factor)`
42    ///
43    /// This is equivalent to [`DistanceModel::Inverse`] when `rolloff_factor = 1.0`.
44    Exponential,
45}
46
47impl DistanceModel {
48    fn calculate_gain(
49        &self,
50        distance: f32,
51        distance_gain_factor: f32,
52        reference_distance: f32,
53        maximum_distance: f32,
54    ) -> f32 {
55        if distance <= reference_distance || distance_gain_factor <= 0.00001 {
56            return 1.0;
57        }
58
59        match self {
60            DistanceModel::Inverse => {
61                reference_distance
62                    / (reference_distance
63                        + (distance_gain_factor * (distance - reference_distance)))
64            }
65            DistanceModel::Linear => {
66                if maximum_distance <= reference_distance {
67                    1.0
68                } else {
69                    (1.0 - (distance_gain_factor * (distance - reference_distance)
70                        / (maximum_distance - reference_distance)))
71                        .clamp(0.0, 1.0)
72                }
73            }
74            DistanceModel::Exponential => {
75                (distance / reference_distance).powf(-distance_gain_factor)
76            }
77        }
78    }
79}
80
81/// The parameters which describe how to attenuate a sound based on its distance from
82/// the listener.
83#[derive(Diff, Patch, Debug, Clone, Copy, PartialEq)]
84#[cfg_attr(feature = "bevy", derive(bevy_ecs::prelude::Component))]
85#[cfg_attr(feature = "bevy_reflect", derive(bevy_reflect::Reflect))]
86pub struct DistanceAttenuation {
87    /// The method in which to calculate the volume of a sound based on the distance from
88    /// the listener.
89    ///
90    /// by default this is set to [`DistanceModel::Inverse`].
91    ///
92    /// Based on <https://developer.mozilla.org/en-US/docs/Web/API/PannerNode/distanceModel>
93    ///
94    /// Interactive graph of the different models: <https://www.desmos.com/calculator/g1pbsc5m9y>
95    pub distance_model: DistanceModel,
96
97    /// The factor by which the sound gets quieter the farther away it is from the
98    /// listener.
99    ///
100    /// Values less than `1.0` will attenuate the sound less per unit distance, and values
101    /// greater than `1.0` will attenuate the sound more per unit distance.
102    ///
103    /// Set to a value `<= 0.00001` to disable attenuating the sound.
104    ///
105    /// By default this is set to `1.0`.
106    ///
107    /// See <https://www.desmos.com/calculator/g1pbsc5m9y> for an interactive graph of
108    /// how these parameters affect the final volume of a sound for each distance model.
109    pub distance_gain_factor: f32,
110
111    /// The minimum distance at which a sound is considered to be at the maximum volume.
112    /// (Distances less than this value will be clamped at the maximum volume).
113    ///
114    /// If this value is `< 0.00001`, then it will be clamped to `0.00001`.
115    ///
116    /// By default this is set to `5.0`.
117    ///
118    /// See <https://www.desmos.com/calculator/g1pbsc5m9y> for an interactive graph of
119    /// how these parameters affect the final volume of a sound for each distance model.
120    pub reference_distance: f32,
121
122    /// When using [`DistanceModel::Linear`], the maximum reference distance (at a
123    /// rolloff factor of `1.0`) of a sound before it is considered to be "silent".
124    /// (Distances greater than this value will be clamped to silence).
125    ///
126    /// If this value is `< 0.0`, then it will be clamped to `0.0`.
127    ///
128    /// By default this is set to `200.0`.
129    ///
130    /// See <https://www.desmos.com/calculator/g1pbsc5m9y> for an interactive graph of
131    /// how these parameters affect the final volume of a sound for each distance model.
132    pub max_distance: f32,
133
134    /// The factor which determines the curve of the high frequency damping (lowpass)
135    /// in relation to distance.
136    ///
137    /// Higher values dampen the high frequencies faster, while smaller values dampen
138    /// the high frequencies slower.
139    ///
140    /// Set to a value `<= 0.00001` to disable muffling the sound based on distance.
141    ///
142    /// By default this is set to `1.9`.
143    ///
144    /// See <https://www.desmos.com/calculator/jxp8t9ero4> for an interactive graph of
145    /// how these parameters affect the final lowpass cuttoff frequency.
146    pub distance_muffle_factor: f32,
147
148    /// The distance at which the high frequencies of a sound become fully muffled
149    /// (lowpassed).
150    ///
151    /// Distances less than `reference_distance` will have no muffling.
152    ///
153    /// This has no effect if `muffle_factor` is `None`.
154    ///
155    /// By default this is set to `200.0`.
156    ///
157    /// See <https://www.desmos.com/calculator/jxp8t9ero4> for an interactive graph of
158    /// how these parameters affect the final lowpass cuttoff frequency.
159    pub max_muffle_distance: f32,
160
161    /// The amount of muffling (lowpass) at `max_muffle_distance` in the range
162    /// `[20.0, 20_480.0]`, where `20_480.0` is no muffling and `20.0` is maximum
163    /// muffling.
164    ///
165    /// This has no effect if `muffle_factor` is `None`.
166    ///
167    /// By default this is set to `20.0`.
168    ///
169    /// See <https://www.desmos.com/calculator/jxp8t9ero4> for an interactive graph of
170    /// how these parameters affect the final lowpass cuttoff frequency.
171    pub max_distance_muffle_cutoff_hz: f32,
172}
173
174impl Default for DistanceAttenuation {
175    fn default() -> Self {
176        Self {
177            distance_model: DistanceModel::Inverse,
178            distance_gain_factor: 1.0,
179            reference_distance: 5.0,
180            max_distance: 200.0,
181            distance_muffle_factor: 1.9,
182            max_muffle_distance: 200.0,
183            max_distance_muffle_cutoff_hz: 20.0,
184        }
185    }
186}
187
188pub struct DistanceAttenuatorStereoDsp {
189    pub gain: SmoothedParam,
190    pub muffle_cutoff_hz: SmoothedParam,
191    pub damping_disabled: bool,
192
193    pub filter_l: OnePoleIirLPF,
194    pub filter_r: OnePoleIirLPF,
195}
196
197impl DistanceAttenuatorStereoDsp {
198    pub fn new(smoother_config: SmootherConfig, sample_rate: NonZeroU32) -> Self {
199        Self {
200            gain: SmoothedParam::new(1.0, smoother_config, sample_rate),
201            muffle_cutoff_hz: SmoothedParam::new(
202                MUFFLE_CUTOFF_HZ_MAX,
203                smoother_config,
204                sample_rate,
205            ),
206            damping_disabled: true,
207            filter_l: OnePoleIirLPF::default(),
208            filter_r: OnePoleIirLPF::default(),
209        }
210    }
211
212    pub fn is_silent(&self) -> bool {
213        self.gain.target_value() == 0.0 && !self.gain.is_smoothing()
214    }
215
216    pub fn compute_values(
217        &mut self,
218        distance: f32,
219        params: &DistanceAttenuation,
220        muffle_cutoff_hz: f32,
221        min_gain: f32,
222    ) {
223        let reference_distance = params.reference_distance.max(0.00001);
224        let max_distance = params.max_distance.max(0.0);
225        let max_distance_muffle_cutoff_hz = params
226            .max_distance_muffle_cutoff_hz
227            .max(MUFFLE_CUTOFF_HZ_MIN);
228
229        let distance_gain = params.distance_model.calculate_gain(
230            distance,
231            params.distance_gain_factor,
232            reference_distance,
233            max_distance,
234        );
235
236        let gain = if distance_gain <= min_gain {
237            0.0
238        } else {
239            distance_gain
240        };
241
242        let distance_cutoff_norm = if params.distance_muffle_factor <= 0.00001
243            || distance <= reference_distance
244            || params.max_muffle_distance <= reference_distance
245            || max_distance_muffle_cutoff_hz >= MUFFLE_CUTOFF_HZ_MAX
246        {
247            1.0
248        } else {
249            let num = distance - reference_distance;
250            let den = params.max_muffle_distance - reference_distance;
251
252            let norm = 1.0 - (num / den).powf(params.distance_muffle_factor.recip());
253
254            let min_norm = (max_distance_muffle_cutoff_hz - MUFFLE_CUTOFF_HZ_MIN)
255                * MUFFLE_CUTOFF_HZ_RANGE_RECIP;
256
257            norm.max(min_norm)
258        };
259
260        let muffle_cutoff_hz = if (muffle_cutoff_hz < MUFFLE_CUTOFF_HZ_MAX - 0.01)
261            || distance_cutoff_norm < 1.0
262        {
263            let hz = if distance_cutoff_norm < 1.0 {
264                let muffle_cutoff_norm =
265                    (muffle_cutoff_hz - MUFFLE_CUTOFF_HZ_MIN) * MUFFLE_CUTOFF_HZ_RANGE_RECIP;
266                let final_norm = muffle_cutoff_norm * distance_cutoff_norm;
267
268                (final_norm * (MUFFLE_CUTOFF_HZ_MAX - MUFFLE_CUTOFF_HZ_MIN)) + MUFFLE_CUTOFF_HZ_MIN
269            } else {
270                muffle_cutoff_hz
271            };
272
273            Some(hz.clamp(MUFFLE_CUTOFF_HZ_MIN, MUFFLE_CUTOFF_HZ_MAX))
274        } else {
275            None
276        };
277
278        self.gain.set_value(gain);
279
280        if let Some(cutoff_hz) = muffle_cutoff_hz {
281            self.muffle_cutoff_hz.set_value(cutoff_hz);
282            self.damping_disabled = false;
283        } else {
284            self.muffle_cutoff_hz.set_value(MUFFLE_CUTOFF_HZ_MAX);
285            self.damping_disabled = true;
286        }
287    }
288
289    /// Returns `true` if the output buffers should be cleared with silence, `false`
290    /// otherwise.
291    pub fn process(
292        &mut self,
293        frames: usize,
294        out1: &mut [f32],
295        out2: &mut [f32],
296        sample_rate_recip: f64,
297    ) -> bool {
298        // Make doubly sure that the compiler optimizes away the bounds checking
299        // in the loop.
300        let out1 = &mut out1[..frames];
301        let out2 = &mut out2[..frames];
302
303        if !self.gain.is_smoothing() && !self.muffle_cutoff_hz.is_smoothing() {
304            if !self.gain.is_smoothing() && self.gain.target_value() == 0.0 {
305                self.gain.reset();
306                self.muffle_cutoff_hz.reset();
307                self.filter_l.reset();
308                self.filter_r.reset();
309
310                return true;
311            } else if self.damping_disabled {
312                for i in 0..frames {
313                    out1[i] = out1[i] * self.gain.target_value();
314                    out2[i] = out2[i] * self.gain.target_value();
315                }
316            } else {
317                // The cutoff parameter is not currently smoothing, so we can optimize by
318                // only updating the filter coefficients once.
319                let coeff = OnePoleIirLPFCoeff::new(
320                    self.muffle_cutoff_hz.target_value(),
321                    sample_rate_recip as f32,
322                );
323
324                for i in 0..frames {
325                    let l = out1[i] * self.gain.target_value();
326                    let r = out2[i] * self.gain.target_value();
327
328                    out1[i] = self.filter_l.process(l, coeff);
329                    out2[i] = self.filter_r.process(r, coeff);
330                }
331            }
332        } else {
333            if self.damping_disabled && !self.muffle_cutoff_hz.is_smoothing() {
334                for i in 0..frames {
335                    let gain = self.gain.next_smoothed();
336
337                    out1[i] = out1[i] * gain;
338                    out2[i] = out2[i] * gain;
339                }
340            } else {
341                let mut coeff = OnePoleIirLPFCoeff::default();
342
343                for i in 0..frames {
344                    let cutoff_hz = self.muffle_cutoff_hz.next_smoothed();
345                    let gain = self.gain.next_smoothed();
346
347                    let l = out1[i] * gain;
348                    let r = out2[i] * gain;
349
350                    // Because recalculating filter coefficients is expensive, a trick like
351                    // this can be use to only recalculate them every CALC_FILTER_COEFF_INTERVAL
352                    // frames.
353                    if i & (CALC_FILTER_COEFF_INTERVAL - 1) == 0 {
354                        coeff = OnePoleIirLPFCoeff::new(cutoff_hz, sample_rate_recip as f32);
355                    }
356
357                    out1[i] = self.filter_l.process(l, coeff);
358                    out2[i] = self.filter_r.process(r, coeff);
359                }
360            }
361
362            self.gain.settle();
363            self.muffle_cutoff_hz.settle();
364        }
365
366        false
367    }
368
369    pub fn reset(&mut self) {
370        self.gain.reset();
371        self.muffle_cutoff_hz.reset();
372        self.filter_l.reset();
373        self.filter_r.reset();
374    }
375
376    pub fn set_smooth_seconds(&mut self, seconds: f32, sample_rate: NonZeroU32) {
377        self.gain.set_smooth_seconds(seconds, sample_rate);
378        self.muffle_cutoff_hz
379            .set_smooth_seconds(seconds, sample_rate);
380    }
381
382    pub fn update_sample_rate(&mut self, sample_rate: NonZeroU32) {
383        self.gain.update_sample_rate(sample_rate);
384        self.muffle_cutoff_hz.update_sample_rate(sample_rate);
385    }
386}