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