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