firewheel_nodes/
spatial_basic.rs

1//! A 3D spatial positioning node using a basic (and naive) algorithm. It does
2//! not make use of any fancy binaural algorithms, rather it just applies basic
3//! panning and filtering.
4
5#[cfg(not(feature = "std"))]
6use num_traits::Float;
7
8use firewheel_core::{
9    channel_config::{ChannelConfig, ChannelCount},
10    diff::{Diff, Patch},
11    dsp::{
12        coeff_update::CoeffUpdateFactor,
13        distance_attenuation::{
14            DistanceAttenuation, DistanceAttenuatorStereoDsp, MUFFLE_CUTOFF_HZ_MAX,
15        },
16        fade::FadeCurve,
17        filter::smoothing_filter::DEFAULT_SMOOTH_SECONDS,
18        volume::Volume,
19    },
20    event::ProcEvents,
21    mask::ConnectedMask,
22    node::{
23        AudioNode, AudioNodeInfo, AudioNodeProcessor, ConstructProcessorContext, EmptyConfig,
24        ProcBuffers, ProcExtra, ProcInfo, ProcStreamCtx, ProcessStatus,
25    },
26    param::smoother::{SmoothedParam, SmootherConfig},
27    vector::Vec3,
28};
29
30/// A 3D spatial positioning node using a basic but fast algorithm. It does not make use
31/// of any fancy binaural algorithms, rather it just applies basic panning and filtering.
32#[derive(Diff, Patch, Debug, Clone, Copy, PartialEq)]
33#[cfg_attr(feature = "bevy", derive(bevy_ecs::prelude::Component))]
34#[cfg_attr(feature = "bevy_reflect", derive(bevy_reflect::Reflect))]
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36pub struct SpatialBasicNode {
37    /// The overall volume. This is applied before the spatialization algorithm.
38    pub volume: Volume,
39
40    /// A 3D vector representing the offset between the listener and the
41    /// sound source.
42    ///
43    /// The coordinates are `(x, y, z)`.
44    ///
45    /// * `-x` is to the left of the listener, and `+x` is the the right of the listener
46    /// * `-y` is below the listener, and `+y` is above the listener.
47    /// * `-z` is in front of the listener, and `+z` is behind the listener
48    ///
49    /// By default this is set to `(0.0, 0.0, 0.0)`
50    pub offset: Vec3,
51
52    /// The threshold for the maximum amount of panning that can occur, in the range
53    /// `[0.0, 1.0]`, where `0.0` is no panning and `1.0` is full panning (where one
54    /// of the channels is fully silent when panned hard left or right).
55    ///
56    /// Setting this to a value less than `1.0` can help remove some of the
57    /// jarringness of having a sound playing in only one ear.
58    ///
59    /// By default this is set to `0.6`.
60    pub panning_threshold: f32,
61
62    /// If `true`, then any stereo input signals will be downmixed to mono before
63    /// going throught the spatialization algorithm. If `false` then the left and
64    /// right channels will be processed independently.
65    ///
66    /// This has no effect if only one input channel is connected.
67    ///
68    /// By default this is set to `true`.
69    pub downmix: bool,
70
71    /// The amount of muffling (lowpass) in the range `[20.0, 20_480.0]`,
72    /// where `20_480.0` is no muffling and `20.0` is maximum muffling.
73    ///
74    /// This can be used to give the effect of a sound being played behind a wall
75    /// or underwater.
76    ///
77    /// By default this is set to `20_480.0`.
78    ///
79    /// See <https://www.desmos.com/calculator/jxp8t9ero4> for an interactive graph of
80    /// how these parameters affect the final lowpass cuttoff frequency.
81    pub muffle_cutoff_hz: f32,
82
83    /// The parameters which describe how to attenuate a sound based on its distance from
84    /// the listener.
85    pub distance_attenuation: DistanceAttenuation,
86
87    /// The time in seconds of the internal smoothing filter.
88    ///
89    /// By default this is set to `0.015` (15ms).
90    pub smooth_seconds: f32,
91    /// If the resutling gain (in raw amplitude, not decibels) is less than or equal
92    /// to this value, the the gain will be clamped to `0` (silence).
93    ///
94    /// By default this is set to "0.0001" (-80 dB).
95    pub min_gain: f32,
96    /// An exponent representing the rate at which DSP coefficients are
97    /// updated when parameters are being smoothed.
98    ///
99    /// Smaller values will produce less "stair-stepping" artifacts,
100    /// but will also consume more CPU.
101    ///
102    /// The resulting number of frames (samples in a single channel of audio)
103    /// that will elapse between each update is calculated as
104    /// `2^coeff_update_factor`.
105    ///
106    /// By default this is set to `5`.
107    pub coeff_update_factor: CoeffUpdateFactor,
108}
109
110impl Default for SpatialBasicNode {
111    fn default() -> Self {
112        Self {
113            volume: Volume::default(),
114            offset: Vec3::new(0.0, 0.0, 0.0),
115            panning_threshold: 0.6,
116            downmix: true,
117            distance_attenuation: DistanceAttenuation::default(),
118            muffle_cutoff_hz: MUFFLE_CUTOFF_HZ_MAX,
119            smooth_seconds: DEFAULT_SMOOTH_SECONDS,
120            min_gain: 0.0001,
121            coeff_update_factor: CoeffUpdateFactor::default(),
122        }
123    }
124}
125
126impl SpatialBasicNode {
127    pub fn from_volume_offset(volume: Volume, offset: impl Into<Vec3>) -> Self {
128        Self {
129            volume,
130            offset: offset.into(),
131            ..Default::default()
132        }
133    }
134
135    /// Set the given volume in a linear scale, where `0.0` is silence and
136    /// `1.0` is unity gain.
137    ///
138    /// These units are suitable for volume sliders (simply convert percent
139    /// volume to linear volume by diving the percent volume by 100).
140    pub const fn set_volume_linear(&mut self, linear: f32) {
141        self.volume = Volume::Linear(linear);
142    }
143
144    /// Set the given volume in percentage, where `0.0` is silence and
145    /// `100.0` is unity gain.
146    ///
147    /// These units are suitable for volume sliders.
148    pub const fn set_volume_percent(&mut self, percent: f32) {
149        self.volume = Volume::from_percent(percent);
150    }
151
152    /// Set the given volume in decibels, where `0.0` is unity gain and
153    /// `f32::NEG_INFINITY` is silence.
154    pub const fn set_volume_decibels(&mut self, decibels: f32) {
155        self.volume = Volume::Decibels(decibels);
156    }
157
158    fn compute_values(&self) -> ComputedValues {
159        let x2_z2 = (self.offset.x * self.offset.x) + (self.offset.z * self.offset.z);
160        let xz_distance = x2_z2.sqrt();
161        let distance = (x2_z2 + (self.offset.y * self.offset.y)).sqrt();
162
163        let pan = if xz_distance > 0.0 {
164            (self.offset.x / xz_distance) * self.panning_threshold.clamp(0.0, 1.0)
165        } else {
166            0.0
167        };
168        let (pan_gain_l, pan_gain_r) = FadeCurve::EqualPower3dB.compute_gains_neg1_to_1(pan);
169
170        let mut volume_gain = self.volume.amp();
171        if volume_gain > 0.99999 && volume_gain < 1.00001 {
172            volume_gain = 1.0;
173        }
174
175        let mut gain_l = pan_gain_l * volume_gain;
176        let mut gain_r = pan_gain_r * volume_gain;
177
178        if gain_l <= self.min_gain {
179            gain_l = 0.0;
180        }
181        if gain_r <= self.min_gain {
182            gain_r = 0.0;
183        }
184
185        ComputedValues {
186            distance,
187            gain_l,
188            gain_r,
189        }
190    }
191}
192
193struct ComputedValues {
194    distance: f32,
195    gain_l: f32,
196    gain_r: f32,
197}
198
199impl AudioNode for SpatialBasicNode {
200    type Configuration = EmptyConfig;
201
202    fn info(&self, _config: &Self::Configuration) -> AudioNodeInfo {
203        AudioNodeInfo::new()
204            .debug_name("spatial_basic")
205            .channel_config(ChannelConfig {
206                num_inputs: ChannelCount::STEREO,
207                num_outputs: ChannelCount::STEREO,
208            })
209    }
210
211    fn construct_processor(
212        &self,
213        _config: &Self::Configuration,
214        cx: ConstructProcessorContext,
215    ) -> impl AudioNodeProcessor {
216        let computed_values = self.compute_values();
217
218        Processor {
219            gain_l: SmoothedParam::new(
220                computed_values.gain_l,
221                SmootherConfig {
222                    smooth_seconds: self.smooth_seconds,
223                    ..Default::default()
224                },
225                cx.stream_info.sample_rate,
226            ),
227            gain_r: SmoothedParam::new(
228                computed_values.gain_r,
229                SmootherConfig {
230                    smooth_seconds: self.smooth_seconds,
231                    ..Default::default()
232                },
233                cx.stream_info.sample_rate,
234            ),
235            distance_attenuator: DistanceAttenuatorStereoDsp::new(
236                SmootherConfig {
237                    smooth_seconds: self.smooth_seconds,
238                    ..Default::default()
239                },
240                cx.stream_info.sample_rate,
241                self.coeff_update_factor,
242            ),
243            params: *self,
244        }
245    }
246}
247
248struct Processor {
249    gain_l: SmoothedParam,
250    gain_r: SmoothedParam,
251
252    distance_attenuator: DistanceAttenuatorStereoDsp,
253
254    params: SpatialBasicNode,
255}
256
257impl AudioNodeProcessor for Processor {
258    fn process(
259        &mut self,
260        info: &ProcInfo,
261        buffers: ProcBuffers,
262        events: &mut ProcEvents,
263        extra: &mut ProcExtra,
264    ) -> ProcessStatus {
265        let mut updated = false;
266        for mut patch in events.drain_patches::<SpatialBasicNode>() {
267            match &mut patch {
268                SpatialBasicNodePatch::Offset(offset) => {
269                    if !(offset.x.is_finite() && offset.y.is_finite() && offset.z.is_finite()) {
270                        *offset = Vec3::default();
271                    }
272                }
273                SpatialBasicNodePatch::PanningThreshold(threshold) => {
274                    *threshold = threshold.clamp(0.0, 1.0);
275                }
276                SpatialBasicNodePatch::SmoothSeconds(seconds) => {
277                    self.gain_l.set_smooth_seconds(*seconds, info.sample_rate);
278                    self.gain_r.set_smooth_seconds(*seconds, info.sample_rate);
279                    self.distance_attenuator
280                        .set_smooth_seconds(*seconds, info.sample_rate);
281                }
282                SpatialBasicNodePatch::MinGain(g) => {
283                    *g = g.clamp(0.0, 1.0);
284                }
285                SpatialBasicNodePatch::CoeffUpdateFactor(f) => {
286                    self.distance_attenuator.set_coeff_update_factor(*f);
287                }
288                _ => {}
289            }
290
291            self.params.apply(patch);
292            updated = true;
293        }
294
295        if updated {
296            let computed_values = self.params.compute_values();
297
298            self.gain_l.set_value(computed_values.gain_l);
299            self.gain_r.set_value(computed_values.gain_r);
300
301            self.distance_attenuator.compute_values(
302                computed_values.distance,
303                &self.params.distance_attenuation,
304                self.params.muffle_cutoff_hz,
305                self.params.min_gain,
306            );
307
308            if info.prev_output_was_silent {
309                // Previous block was silent, so no need to smooth.
310                self.gain_l.reset_to_target();
311                self.gain_r.reset_to_target();
312                self.distance_attenuator.reset();
313            }
314        }
315
316        if info.in_silence_mask.all_channels_silent(2) {
317            self.gain_l.reset_to_target();
318            self.gain_r.reset_to_target();
319            self.distance_attenuator.reset();
320
321            return ProcessStatus::ClearAllOutputs;
322        }
323
324        let scratch_buffer = extra.scratch_buffers.first_mut();
325
326        let (in1, in2) = if info.in_connected_mask == ConnectedMask::STEREO_CONNECTED {
327            if self.params.downmix {
328                // Downmix the stereo signal to mono.
329                for (scratch_s, (&in1, &in2)) in scratch_buffer[..info.frames].iter_mut().zip(
330                    buffers.inputs[0][..info.frames]
331                        .iter()
332                        .zip(buffers.inputs[1][..info.frames].iter()),
333                ) {
334                    *scratch_s = (in1 + in2) * 0.5;
335                }
336
337                (
338                    &scratch_buffer[..info.frames],
339                    &scratch_buffer[..info.frames],
340                )
341            } else {
342                (
343                    &buffers.inputs[0][..info.frames],
344                    &buffers.inputs[1][..info.frames],
345                )
346            }
347        } else {
348            // Only one (or none) channels are connected, so just use the first
349            // channel as input.
350            (
351                &buffers.inputs[0][..info.frames],
352                &buffers.inputs[0][..info.frames],
353            )
354        };
355
356        // Make doubly sure that the compiler optimizes away the bounds checking
357        // in the loop.
358        let in1 = &in1[..info.frames];
359        let in2 = &in2[..info.frames];
360
361        let (out1, out2) = buffers.outputs.split_first_mut().unwrap();
362        let out1 = &mut out1[..info.frames];
363        let out2 = &mut out2[0][..info.frames];
364
365        if self.gain_l.has_settled() && self.gain_r.has_settled() {
366            if self.gain_l.target_value() <= self.params.min_gain
367                && self.gain_r.target_value() <= self.params.min_gain
368                && self.distance_attenuator.is_silent()
369            {
370                self.gain_l.reset_to_target();
371                self.gain_r.reset_to_target();
372                self.distance_attenuator.reset();
373
374                return ProcessStatus::ClearAllOutputs;
375            } else {
376                for i in 0..info.frames {
377                    out1[i] = in1[i] * self.gain_l.target_value();
378                    out2[i] = in2[i] * self.gain_r.target_value();
379                }
380            }
381        } else {
382            for i in 0..info.frames {
383                let gain_l = self.gain_l.next_smoothed();
384                let gain_r = self.gain_r.next_smoothed();
385
386                out1[i] = in1[i] * gain_l;
387                out2[i] = in2[i] * gain_r;
388            }
389
390            self.gain_l.settle();
391            self.gain_r.settle();
392        }
393
394        let clear_outputs =
395            self.distance_attenuator
396                .process(info.frames, out1, out2, info.sample_rate_recip);
397
398        if clear_outputs {
399            self.gain_l.reset_to_target();
400            self.gain_r.reset_to_target();
401            self.distance_attenuator.reset();
402
403            return ProcessStatus::ClearAllOutputs;
404        } else {
405            ProcessStatus::OutputsModified
406        }
407    }
408
409    fn new_stream(
410        &mut self,
411        stream_info: &firewheel_core::StreamInfo,
412        _context: &mut ProcStreamCtx,
413    ) {
414        self.gain_l.update_sample_rate(stream_info.sample_rate);
415        self.gain_r.update_sample_rate(stream_info.sample_rate);
416        self.distance_attenuator
417            .update_sample_rate(stream_info.sample_rate);
418    }
419}