Skip to main content

firewheel_nodes/
spatial_basic.rs

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