Skip to main content

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::{DEFAULT_GAIN_SPAN, 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, DEFAULT_GAIN_SPAN, smoother_config, sample_rate),
207            muffle_cutoff_hz: SmoothedParam::new(
208                MUFFLE_CUTOFF_HZ_MAX,
209                MUFFLE_CUTOFF_HZ_MAX - MUFFLE_CUTOFF_HZ_MIN,
210                smoother_config,
211                sample_rate,
212            ),
213            damping_disabled: true,
214            filter: OnePoleIirLPFSimd::default(),
215            coeff_update_mask: coeff_update_factor.mask(),
216        }
217    }
218
219    pub fn set_coeff_update_factor(&mut self, coeff_update_factor: CoeffUpdateFactor) {
220        self.coeff_update_mask = coeff_update_factor.mask();
221    }
222
223    pub fn is_silent(&self) -> bool {
224        self.gain.target_value() == 0.0 && !self.gain.is_smoothing()
225    }
226
227    pub fn compute_values(
228        &mut self,
229        distance: f32,
230        params: &DistanceAttenuation,
231        muffle_cutoff_hz: f32,
232        min_gain: f32,
233    ) {
234        let reference_distance = params.reference_distance.max(0.00001);
235        let max_distance = params.max_distance.max(0.0);
236        let max_distance_muffle_cutoff_hz = params
237            .max_distance_muffle_cutoff_hz
238            .max(MUFFLE_CUTOFF_HZ_MIN);
239
240        let distance_gain = params.distance_model.calculate_gain(
241            distance,
242            params.distance_gain_factor,
243            reference_distance,
244            max_distance,
245        );
246
247        let gain = if distance_gain <= min_gain {
248            0.0
249        } else {
250            distance_gain
251        };
252
253        let distance_cutoff_norm = if params.distance_muffle_factor <= 0.00001
254            || distance <= reference_distance
255            || params.max_muffle_distance <= reference_distance
256            || max_distance_muffle_cutoff_hz >= MUFFLE_CUTOFF_HZ_MAX
257        {
258            1.0
259        } else {
260            let num = distance - reference_distance;
261            let den = params.max_muffle_distance - reference_distance;
262
263            let norm = 1.0 - (num / den).powf(params.distance_muffle_factor.recip());
264
265            let min_norm = (max_distance_muffle_cutoff_hz - MUFFLE_CUTOFF_HZ_MIN)
266                * MUFFLE_CUTOFF_HZ_RANGE_RECIP;
267
268            norm.max(min_norm)
269        };
270
271        let muffle_cutoff_hz = if (muffle_cutoff_hz < MUFFLE_CUTOFF_HZ_MAX - 0.01)
272            || distance_cutoff_norm < 1.0
273        {
274            let hz = if distance_cutoff_norm < 1.0 {
275                let muffle_cutoff_norm =
276                    (muffle_cutoff_hz - MUFFLE_CUTOFF_HZ_MIN) * MUFFLE_CUTOFF_HZ_RANGE_RECIP;
277                let final_norm = muffle_cutoff_norm * distance_cutoff_norm;
278
279                (final_norm * (MUFFLE_CUTOFF_HZ_MAX - MUFFLE_CUTOFF_HZ_MIN)) + MUFFLE_CUTOFF_HZ_MIN
280            } else {
281                muffle_cutoff_hz
282            };
283
284            Some(hz.clamp(MUFFLE_CUTOFF_HZ_MIN, MUFFLE_CUTOFF_HZ_MAX))
285        } else {
286            None
287        };
288
289        self.gain.set_value(gain);
290
291        if let Some(cutoff_hz) = muffle_cutoff_hz {
292            self.muffle_cutoff_hz.set_value(cutoff_hz);
293            self.damping_disabled = false;
294        } else {
295            self.muffle_cutoff_hz.set_value(MUFFLE_CUTOFF_HZ_MAX);
296            self.damping_disabled = true;
297        }
298    }
299
300    /// Returns `true` if the output buffers should be cleared with silence, `false`
301    /// otherwise.
302    pub fn process(
303        &mut self,
304        frames: usize,
305        out1: &mut [f32],
306        out2: &mut [f32],
307        sample_rate_recip: f64,
308    ) -> bool {
309        // Make doubly sure that the compiler optimizes away the bounds checking
310        // in the loop.
311        let out1 = &mut out1[..frames];
312        let out2 = &mut out2[..frames];
313
314        if !self.gain.is_smoothing() && !self.muffle_cutoff_hz.is_smoothing() {
315            if !self.gain.is_smoothing() && self.gain.target_value() == 0.0 {
316                self.gain.reset_to_target();
317                self.muffle_cutoff_hz.reset_to_target();
318                self.filter.reset();
319
320                return true;
321            } else if self.damping_disabled {
322                for i in 0..frames {
323                    out1[i] *= self.gain.target_value();
324                    out2[i] *= self.gain.target_value();
325                }
326            } else {
327                // The cutoff parameter is not currently smoothing, so we can optimize by
328                // only updating the filter coefficients once.
329                let coeff = OnePoleIirLPFCoeffSimd::splat(OnePoleIirLPFCoeff::new(
330                    self.muffle_cutoff_hz.target_value(),
331                    sample_rate_recip as f32,
332                ));
333
334                for i in 0..frames {
335                    let s = [
336                        out1[i] * self.gain.target_value(),
337                        out2[i] * self.gain.target_value(),
338                    ];
339
340                    let [l, r] = self.filter.process(s, &coeff);
341
342                    out1[i] = l;
343                    out2[i] = r;
344                }
345            }
346        } else {
347            if self.damping_disabled && !self.muffle_cutoff_hz.is_smoothing() {
348                for i in 0..frames {
349                    let gain = self.gain.next_smoothed();
350
351                    out1[i] *= gain;
352                    out2[i] *= gain;
353                }
354            } else {
355                let mut coeff = OnePoleIirLPFCoeffSimd::default();
356
357                for i in 0..frames {
358                    let cutoff_hz = self.muffle_cutoff_hz.next_smoothed();
359                    let gain = self.gain.next_smoothed();
360
361                    // Because recalculating filter coefficients is expensive, a trick like
362                    // this can be used to only recalculate them every few frames.
363                    //
364                    // TODO: use core::hint::cold_path() once that stabilizes
365                    //
366                    // TODO: Alternatively, this could be optimized using a lookup table
367                    if self.coeff_update_mask.do_update(i) {
368                        coeff = OnePoleIirLPFCoeffSimd::splat(OnePoleIirLPFCoeff::new(
369                            cutoff_hz,
370                            sample_rate_recip as f32,
371                        ));
372                    }
373
374                    let s = [out1[i] * gain, out2[i] * gain];
375
376                    let [l, r] = self.filter.process(s, &coeff);
377
378                    out1[i] = l;
379                    out2[i] = r;
380                }
381            }
382
383            self.gain.settle();
384            self.muffle_cutoff_hz.settle();
385        }
386
387        false
388    }
389
390    pub fn reset(&mut self) {
391        self.gain.reset_to_target();
392        self.muffle_cutoff_hz.reset_to_target();
393        self.filter.reset();
394    }
395
396    pub fn set_smooth_seconds(&mut self, seconds: f32, sample_rate: NonZeroU32) {
397        self.gain.set_smooth_seconds(seconds, sample_rate);
398        self.muffle_cutoff_hz
399            .set_smooth_seconds(seconds, sample_rate);
400    }
401
402    pub fn update_sample_rate(&mut self, sample_rate: NonZeroU32) {
403        self.gain.update_sample_rate(sample_rate);
404        self.muffle_cutoff_hz.update_sample_rate(sample_rate);
405    }
406}