firewheel_nodes/fast_filters/
lowpass.rs

1use firewheel_core::{
2    channel_config::{ChannelConfig, ChannelCount},
3    diff::{Diff, Patch},
4    dsp::{
5        coeff_update::{CoeffUpdateFactor, CoeffUpdateMask},
6        declick::{DeclickFadeCurve, Declicker},
7        filter::{
8            single_pole_iir::{OnePoleIirLPFCoeff, OnePoleIirLPFCoeffSimd, OnePoleIirLPFSimd},
9            smoothing_filter::DEFAULT_SMOOTH_SECONDS,
10        },
11    },
12    event::ProcEvents,
13    node::{
14        AudioNode, AudioNodeInfo, AudioNodeProcessor, ConstructProcessorContext, EmptyConfig,
15        ProcBuffers, ProcExtra, ProcInfo, ProcStreamCtx, ProcessStatus,
16    },
17    param::smoother::{SmoothedParam, SmootherConfig},
18    StreamInfo,
19};
20
21use super::{MAX_HZ, MIN_HZ};
22
23pub type FastLowpassMonoNode = FastLowpassNode<1>;
24pub type FastLowpassStereoNode = FastLowpassNode<2>;
25
26/// A simple single-pole IIR lowpass filter that is computationally efficient
27#[derive(Diff, Patch, Debug, Clone, Copy, PartialEq)]
28#[cfg_attr(feature = "bevy", derive(bevy_ecs::prelude::Component))]
29#[cfg_attr(feature = "bevy_reflect", derive(bevy_reflect::Reflect))]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub struct FastLowpassNode<const CHANNELS: usize> {
32    /// The cutoff frequency in hertz in the range `[20.0, 20480.0]`.
33    pub cutoff_hz: f32,
34    /// Whether or not this node is enabled.
35    pub enabled: bool,
36
37    /// The time in seconds of the internal smoothing filter.
38    ///
39    /// By default this is set to `0.015` (15ms).
40    pub smooth_seconds: f32,
41
42    /// An exponent representing the rate at which DSP coefficients are
43    /// updated when parameters are being smoothed.
44    ///
45    /// Smaller values will produce less "stair-stepping" artifacts,
46    /// but will also consume more CPU.
47    ///
48    /// The resulting number of frames (samples in a single channel of audio)
49    /// that will elapse between each update is calculated as
50    /// `2^coeff_update_factor`.
51    ///
52    /// By default this is set to `5`.
53    pub coeff_update_factor: CoeffUpdateFactor,
54}
55
56impl<const CHANNELS: usize> Default for FastLowpassNode<CHANNELS> {
57    fn default() -> Self {
58        Self {
59            cutoff_hz: 1_000.0,
60            enabled: true,
61            smooth_seconds: DEFAULT_SMOOTH_SECONDS,
62            coeff_update_factor: CoeffUpdateFactor::default(),
63        }
64    }
65}
66
67impl<const CHANNELS: usize> FastLowpassNode<CHANNELS> {
68    /// Construct a new `FastLowpassNode` from the given parameters.
69    ///
70    /// * `cutoff_hz` - The cutoff frequency in hertz in the range `[20.0, 20480.0]`
71    /// * `enabled` - Whether or not this node is enabled
72    pub const fn from_cutoff_hz(cutoff_hz: f32, enabled: bool) -> Self {
73        Self {
74            cutoff_hz,
75            enabled,
76            smooth_seconds: DEFAULT_SMOOTH_SECONDS,
77            coeff_update_factor: CoeffUpdateFactor::DEFAULT,
78        }
79    }
80}
81
82impl<const CHANNELS: usize> AudioNode for FastLowpassNode<CHANNELS> {
83    type Configuration = EmptyConfig;
84
85    fn info(&self, _config: &Self::Configuration) -> AudioNodeInfo {
86        AudioNodeInfo::new()
87            .debug_name("fast_lowpass")
88            .channel_config(ChannelConfig {
89                num_inputs: ChannelCount::new(CHANNELS as u32).unwrap(),
90                num_outputs: ChannelCount::new(CHANNELS as u32).unwrap(),
91            })
92    }
93
94    fn construct_processor(
95        &self,
96        _config: &Self::Configuration,
97        cx: ConstructProcessorContext,
98    ) -> impl AudioNodeProcessor {
99        let sample_rate_recip = cx.stream_info.sample_rate_recip as f32;
100
101        let cutoff_hz = self.cutoff_hz.clamp(MIN_HZ, MAX_HZ);
102
103        Processor {
104            filter: OnePoleIirLPFSimd::default(),
105            coeff: OnePoleIirLPFCoeffSimd::<CHANNELS>::splat(OnePoleIirLPFCoeff::new(
106                cutoff_hz,
107                sample_rate_recip,
108            )),
109            cutoff_hz: SmoothedParam::new(
110                cutoff_hz,
111                SmootherConfig {
112                    smooth_seconds: self.smooth_seconds,
113                    ..Default::default()
114                },
115                cx.stream_info.sample_rate,
116            ),
117            enable_declicker: Declicker::from_enabled(self.enabled),
118            coeff_update_mask: self.coeff_update_factor.mask(),
119        }
120    }
121}
122
123struct Processor<const CHANNELS: usize> {
124    filter: OnePoleIirLPFSimd<CHANNELS>,
125    coeff: OnePoleIirLPFCoeffSimd<CHANNELS>,
126
127    cutoff_hz: SmoothedParam,
128    enable_declicker: Declicker,
129    coeff_update_mask: CoeffUpdateMask,
130}
131
132impl<const CHANNELS: usize> AudioNodeProcessor for Processor<CHANNELS> {
133    fn process(
134        &mut self,
135        info: &ProcInfo,
136        buffers: ProcBuffers,
137        events: &mut ProcEvents,
138        extra: &mut ProcExtra,
139    ) -> ProcessStatus {
140        let mut cutoff_changed = false;
141
142        for patch in events.drain_patches::<FastLowpassNode<CHANNELS>>() {
143            match patch {
144                FastLowpassNodePatch::CutoffHz(cutoff) => {
145                    cutoff_changed = true;
146                    self.cutoff_hz.set_value(cutoff.clamp(MIN_HZ, MAX_HZ));
147                }
148                FastLowpassNodePatch::Enabled(enabled) => {
149                    // Tell the declicker to crossfade.
150                    self.enable_declicker
151                        .fade_to_enabled(enabled, &extra.declick_values);
152                }
153                FastLowpassNodePatch::SmoothSeconds(seconds) => {
154                    self.cutoff_hz.set_smooth_seconds(seconds, info.sample_rate);
155                }
156                FastLowpassNodePatch::CoeffUpdateFactor(f) => {
157                    self.coeff_update_mask = f.mask();
158                }
159            }
160        }
161
162        if self.enable_declicker.disabled() {
163            // Disabled. Bypass this node.
164            return ProcessStatus::Bypass;
165        }
166
167        if info.in_silence_mask.all_channels_silent(CHANNELS) && self.enable_declicker.has_settled()
168        {
169            // Outputs will be silent, so no need to process.
170
171            // Reset the smoothers and filters since they don't need to smooth any
172            // output.
173            self.cutoff_hz.reset_to_target();
174            self.filter.reset();
175            self.enable_declicker.reset_to_target();
176
177            return ProcessStatus::ClearAllOutputs;
178        }
179
180        assert!(buffers.inputs.len() == CHANNELS);
181        assert!(buffers.outputs.len() == CHANNELS);
182        for ch in buffers.inputs.iter() {
183            assert!(ch.len() >= info.frames);
184        }
185        for ch in buffers.outputs.iter() {
186            assert!(ch.len() >= info.frames);
187        }
188
189        if self.cutoff_hz.is_smoothing() {
190            for i in 0..info.frames {
191                let cutoff_hz = self.cutoff_hz.next_smoothed();
192
193                // Because recalculating filter coefficients is expensive, a trick like
194                // this can be used to only recalculate them every few frames.
195                //
196                // TODO: use core::hint::cold_path() once that stabilizes
197                //
198                // TODO: Alternatively, this could be optimized using a lookup table
199                if self.coeff_update_mask.do_update(i) {
200                    self.coeff = OnePoleIirLPFCoeffSimd::splat(OnePoleIirLPFCoeff::new(
201                        cutoff_hz,
202                        info.sample_rate_recip as f32,
203                    ));
204                }
205
206                let s: [f32; CHANNELS] = core::array::from_fn(|ch_i| {
207                    // Safety: These bounds have been checked above.
208                    unsafe { *buffers.inputs.get_unchecked(ch_i).get_unchecked(i) }
209                });
210
211                let out = self.filter.process(s, &self.coeff);
212
213                for ch_i in 0..CHANNELS {
214                    // Safety: These bounds have been checked above.
215                    unsafe {
216                        *buffers.outputs.get_unchecked_mut(ch_i).get_unchecked_mut(i) = out[ch_i];
217                    }
218                }
219            }
220
221            if self.cutoff_hz.settle() {
222                self.coeff = OnePoleIirLPFCoeffSimd::splat(OnePoleIirLPFCoeff::new(
223                    self.cutoff_hz.target_value(),
224                    info.sample_rate_recip as f32,
225                ));
226            }
227        } else {
228            // The cutoff parameter is not currently smoothing, so we can optimize by
229            // only updating the filter coefficients once.
230            if cutoff_changed {
231                self.coeff = OnePoleIirLPFCoeffSimd::splat(OnePoleIirLPFCoeff::new(
232                    self.cutoff_hz.target_value(),
233                    info.sample_rate_recip as f32,
234                ));
235            }
236
237            for i in 0..info.frames {
238                let s: [f32; CHANNELS] = core::array::from_fn(|ch_i| {
239                    // Safety: These bounds have been checked above.
240                    unsafe { *buffers.inputs.get_unchecked(ch_i).get_unchecked(i) }
241                });
242
243                let out = self.filter.process(s, &self.coeff);
244
245                for ch_i in 0..CHANNELS {
246                    // Safety: These bounds have been checked above.
247                    unsafe {
248                        *buffers.outputs.get_unchecked_mut(ch_i).get_unchecked_mut(i) = out[ch_i];
249                    }
250                }
251            }
252        }
253
254        // Crossfade between the wet and dry signals to declick enabling/disabling.
255        self.enable_declicker.process_crossfade(
256            buffers.inputs,
257            buffers.outputs,
258            info.frames,
259            &extra.declick_values,
260            DeclickFadeCurve::Linear,
261        );
262
263        ProcessStatus::OutputsModified
264    }
265
266    fn new_stream(&mut self, stream_info: &StreamInfo, _context: &mut ProcStreamCtx) {
267        self.cutoff_hz.update_sample_rate(stream_info.sample_rate);
268        self.coeff = OnePoleIirLPFCoeffSimd::splat(OnePoleIirLPFCoeff::new(
269            self.cutoff_hz.target_value(),
270            stream_info.sample_rate_recip as f32,
271        ));
272    }
273}