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