Skip to main content

lowpass_filter/
lib.rs

1/*
2MIT License
3
4Copyright (c) 2026 Philipp Schuster
5
6Permission is hereby granted, free of charge, to any person obtaining a copy
7of this software and associated documentation files (the "Software"), to deal
8in the Software without restriction, including without limitation the rights
9to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10copies of the Software, and to permit persons to whom the Software is
11furnished to do so, subject to the following conditions:
12
13The above copyright notice and this permission notice shall be included in all
14copies or substantial portions of the Software.
15
16THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22SOFTWARE.
23*/
24//! High performance `no_std` lowpass filter for digital signal processing.
25//!
26//! This crate implements a simple first-order digital lowpass filter for
27//! `f32` and `f64` samples. Use it, for example, to extract the bass from a
28//! song or to smooth noisy sensor data. It has no dependencies, no `unsafe`
29//! code, and performs no allocations, making it suitable for any target from
30//! desktop to embedded.
31//!
32//! Samples must be in range `-1.0..=1.0`, which is the default in DSP.
33//!
34//! ## Usage
35//!
36//! To filter a buffer of samples in one go, use [`lowpass_filter_slice`]
37//! (or [`lowpass_filter_slice_f64`]). This is the fastest option: it
38//! processes samples in blocks that compilers auto-vectorize (SIMD),
39//! several times faster than per-sample processing.
40//!
41//! ```rust
42//! use lowpass_filter::lowpass_filter_slice;
43//!
44//! // Mono audio samples, recorded at 44.1 kHz sample rate.
45//! let mut samples = [0.0, 0.3, -0.6, 0.8, 0.5, -0.2];
46//! // Only keep frequencies below 120 Hz; mutates the buffer in-place.
47//! lowpass_filter_slice(&mut samples, 44100.0, 120.0);
48//! ```
49//!
50//! For streaming data, e.g. in an audio callback, keep a [`LowpassFilter`]
51//! around: its state carries over between calls, so chunked processing
52//! equals processing everything at once. It also filters single samples,
53//! e.g. inside iterator chains.
54//!
55//! ```rust
56//! use lowpass_filter::LowpassFilter;
57//!
58//! let mut filter = LowpassFilter::<f32>::new(44100.0, 120.0);
59//! // Process data as it arrives (fast block processing) ...
60//! for mut chunk in [[0.0, 0.3, -0.6, 0.8], [0.5, -0.2, 0.1, 0.4]] {
61//!     filter.run_slice(&mut chunk);
62//! }
63//! // ... or one sample at a time.
64//! let filtered = filter.run(0.25);
65//! ```
66//!
67//! The iterator-based [`lowpass_filter`] and [`lowpass_filter_f64`]
68//! functions are convenient when the samples do not live in a slice.
69
70#![deny(
71    clippy::all,
72    clippy::cargo,
73    clippy::nursery,
74    clippy::must_use_candidate,
75    // clippy::restriction,
76    // clippy::pedantic
77)]
78// now allow a few rules which are denied by the above statement
79// --> they are ridiculous and not necessary
80#![allow(
81    clippy::suboptimal_flops,
82    clippy::redundant_pub_crate,
83    clippy::fallible_impl_from
84)]
85#![deny(missing_debug_implementations)]
86#![deny(rustdoc::all)]
87#![no_std]
88
89#[cfg_attr(test, macro_use)]
90#[cfg(test)]
91extern crate std;
92
93use core::fmt::{Debug, Display};
94use core::ops::{Add, AddAssign, Div, Mul, Neg, RangeInclusive, Sub};
95
96mod sealed {
97    /// Seals [`super::Sample`] so it cannot be implemented outside this
98    /// crate.
99    pub trait Sealed {}
100    impl Sealed for f32 {}
101    impl Sealed for f64 {}
102}
103
104/// A sample type [`LowpassFilter`] can operate on: [`f32`] or [`f64`].
105///
106/// This trait is sealed and cannot be implemented outside of this crate.
107pub trait Sample:
108    sealed::Sealed
109    + Copy
110    + PartialOrd
111    + Debug
112    + Display
113    + Add<Output = Self>
114    + AddAssign
115    + Sub<Output = Self>
116    + Mul<Output = Self>
117    + Div<Output = Self>
118    + Neg<Output = Self>
119{
120    /// `0.0`
121    const ZERO: Self;
122    /// `1.0`
123    const ONE: Self;
124    /// `2.0`
125    const TWO: Self;
126    /// Archimedes' constant (π).
127    const PI: Self;
128
129    /// See [`f32::clamp`].
130    #[must_use]
131    fn clamp(self, min: Self, max: Self) -> Self;
132}
133
134impl Sample for f32 {
135    const ZERO: Self = 0.0;
136    const ONE: Self = 1.0;
137    const TWO: Self = 2.0;
138    const PI: Self = core::f32::consts::PI;
139
140    #[inline]
141    fn clamp(self, min: Self, max: Self) -> Self {
142        Self::clamp(self, min, max)
143    }
144}
145
146impl Sample for f64 {
147    const ZERO: Self = 0.0;
148    const ONE: Self = 1.0;
149    const TWO: Self = 2.0;
150    const PI: Self = core::f64::consts::PI;
151
152    #[inline]
153    fn clamp(self, min: Self, max: Self) -> Self {
154        Self::clamp(self, min, max)
155    }
156}
157
158/// A first-order lowpass filter compatible with `f32` and `f64`.
159///
160/// It can consume and filter items one-by-one (iterator-style API) or operate
161/// on slices ([`LowpassFilter::run_slice`]).
162///
163/// It is mandatory to operate on values in range `-1.0..=1.0`, which is also
164/// the default in DSP.
165///
166/// # More Info
167/// - <https://en.wikipedia.org/wiki/Low-pass_filter#Simple_infinite_impulse_response_filter>
168#[derive(Debug, Clone)]
169pub struct LowpassFilter<T> {
170    alpha: T,
171    /// Precomputed `1 - alpha`.
172    beta: T,
173    prev: T,
174    next_is_first: bool,
175}
176
177impl<T: Sample> LowpassFilter<T> {
178    /// Create a new lowpass filter.
179    ///
180    /// # Arguments
181    /// - `sample_rate_hz`: Sample rate in Hz (e.g., 48000.0).
182    /// - `cutoff_frequency_hz`: Cutoff frequency in Hz (e.g., 1000.0).
183    #[must_use]
184    pub fn new(sample_rate_hz: T, cutoff_frequency_hz: T) -> Self {
185        // Nyquist rule
186        assert!(cutoff_frequency_hz * T::TWO <= sample_rate_hz);
187
188        let rc = T::ONE / (cutoff_frequency_hz * T::TWO * T::PI);
189        let dt = T::ONE / sample_rate_hz;
190        let alpha = dt / (rc + dt);
191
192        Self {
193            alpha,
194            beta: T::ONE - alpha,
195            prev: T::ZERO,
196            next_is_first: true,
197        }
198    }
199
200    /// Filter a single sample and return the filtered result.
201    ///
202    /// It is mandatory to operate on values in range `-1.0..=1.0`, which is
203    /// also the default in DSP. The returned value is also guaranteed to be in
204    /// that range.
205    #[inline]
206    pub fn run(&mut self, input: T) -> T {
207        let range: RangeInclusive<T> = -T::ONE..=T::ONE;
208        debug_assert!(
209            range.contains(&input),
210            "samples must be in range {range:?}: {input}"
211        );
212
213        let value = if self.next_is_first {
214            self.next_is_first = false;
215            self.prev = input;
216            input * self.alpha
217        } else {
218            // Re-associated form of `prev + alpha * (input - prev)`:
219            self.prev = self.alpha * input + self.beta * self.prev;
220            self.prev
221        };
222
223        // very small deviations caused by floating point operations
224        // are tolerable; just truncate the value
225        value.clamp(-T::ONE, T::ONE)
226    }
227
228    /// Filter a whole slice of samples in-place.
229    ///
230    /// Matches calling [`Self::run`] per sample up to tiny floating
231    /// point rounding differences (roughly `1e-6` for `f32`), but
232    /// is significantly faster. The filter state is updated, so
233    /// consecutive calls compose, also when mixed with
234    /// [`Self::run`].
235    ///
236    /// # Math
237    /// Unrolling `y[n] = alpha * x[n] + beta * y[n-1]` over a block
238    /// of samples yields
239    ///
240    /// ```text
241    /// y[i] = beta^(i+1) * prev + sum(alpha * beta^(i-j) * x[j] for j <= i)
242    /// ```
243    ///
244    /// so within a block, samples only depend on the state `prev`
245    /// from before the block and can be computed in parallel, which
246    /// enables compiler auto-vectorization (SIMD). Only `prev`
247    /// propagates serially between blocks.
248    ///
249    /// # Arguments
250    /// - `samples`: Samples to filter in-place, in range `-1.0..=1.0`.
251    pub fn run_slice(&mut self, samples: &mut [T]) {
252        // Block size. 8 measured fastest on x86-64 for f32 and f64.
253        const LANES: usize = 8;
254
255        let mut samples = samples;
256        // The first sample is special-cased in `run`; handle it
257        // there so the block form below is uniform.
258        if self.next_is_first {
259            if let Some((first, rest)) = samples.split_first_mut() {
260                *first = self.run(*first);
261                samples = rest;
262            } else {
263                return;
264            }
265        }
266
267        // Coefficients of the closed block form (see doc comment):
268        //   y[i] = beta^(i+1) * prev + sum(alpha * beta^(i-j) * x[j] for j <= i)
269        // pow[k] = beta^k
270        let mut pow = [T::ONE; LANES];
271        for k in 1..LANES {
272            pow[k] = pow[k - 1] * self.beta;
273        }
274        // carry_coeffs[i] = beta^(i+1), the weight of `prev` in y[i]
275        let carry_coeffs = pow.map(|p| p * self.beta);
276
277        // cols[j][i] = alpha * beta^(i-j): the weight of input x[j]
278        // in output y[i], stored as one "column" per input j so
279        // that the hot loop below can apply one sample to all
280        // outputs at once. Entries for i < j stay 0, as later
281        // inputs cannot affect earlier outputs.
282        let mut cols = [[T::ZERO; LANES]; LANES];
283        for (j, col) in cols.iter_mut().enumerate() {
284            for (i, weight) in col.iter_mut().enumerate().skip(j) {
285                *weight = self.alpha * pow[i - j];
286            }
287        }
288
289        // Hot loop. `acc[i]` accumulates y[i] of the current block.
290        // Its shape helps the compilers auto-vectorizer.
291        let (chunks, remainder) = samples.as_chunks_mut::<LANES>();
292        for chunk in chunks {
293            let mut acc = [T::ZERO; LANES];
294            // acc[i] = sum(cols[j][i] * x[j] for all j)
295            for (col, &sample) in cols.iter().zip(chunk.iter()) {
296                for (acc, &coeff) in acc.iter_mut().zip(col.iter()) {
297                    *acc += coeff * sample;
298                }
299            }
300            // acc[i] += beta^(i+1) * prev; the only place where
301            // state from before the block enters.
302            for (acc, &coeff) in acc.iter_mut().zip(carry_coeffs.iter()) {
303                *acc += coeff * self.prev;
304            }
305            // like in `run`, `prev` keeps the unclamped value
306            self.prev = acc[LANES - 1];
307            for (sample, acc) in chunk.iter_mut().zip(acc.iter()) {
308                *sample = acc.clamp(-T::ONE, T::ONE);
309            }
310        }
311        // Process the up to LANES - 1 leftover samples sequentially.
312        for sample in remainder {
313            *sample = self.run(*sample);
314        }
315    }
316
317    /// Reset the internal filter state.
318    pub const fn reset(&mut self) {
319        self.prev = T::ZERO;
320        self.next_is_first = true;
321    }
322}
323
324/// Applies a [`LowpassFilter`] to the data provided in the mutable buffer and
325/// changes the items in-place.
326///
327/// It is mandatory to operate on f32 values in range `-1.0..=1.0`, which is
328/// also the default in DSP.
329///
330/// # Arguments
331/// - `sample_iter`: Iterator over the samples. This can also be a
332///   `[1.0, ...]`-style slice
333/// - `sample_rate_hz`: Sample rate in Hz (e.g., 48000.0).
334/// - `cutoff_frequency_hz`: Cutoff frequency in Hz (e.g., 1000.0).
335#[inline]
336pub fn lowpass_filter<'a, I: IntoIterator<Item = &'a mut f32>>(
337    sample_iter: I,
338    sample_rate_hz: f32,
339    cutoff_frequency_hz: f32,
340) {
341    let mut filter = LowpassFilter::<f32>::new(sample_rate_hz, cutoff_frequency_hz);
342
343    for sample in sample_iter.into_iter() {
344        let new_sample = filter.run(*sample);
345        *sample = new_sample;
346    }
347}
348
349/// Applies a [`LowpassFilter`] to the data provided in the mutable buffer and
350/// changes the items in-place.
351///
352/// It is mandatory to operate on f64 values in range `-1.0..=1.0`, which is
353/// also the default in DSP.
354///
355/// # Arguments
356/// - `sample_iter`: Iterator over the samples. This can also be a
357///   `[1.0, ...]`-style slice
358/// - `sample_rate_hz`: Sample rate in Hz (e.g., 48000.0).
359/// - `cutoff_frequency_hz`: Cutoff frequency in Hz (e.g., 1000.0).
360#[inline]
361pub fn lowpass_filter_f64<'a, I: IntoIterator<Item = &'a mut f64>>(
362    sample_iter: I,
363    sample_rate_hz: f64,
364    cutoff_frequency_hz: f64,
365) {
366    let mut filter = LowpassFilter::<f64>::new(sample_rate_hz, cutoff_frequency_hz);
367
368    for sample in sample_iter.into_iter() {
369        let new_sample = filter.run(*sample);
370        *sample = new_sample;
371    }
372}
373
374/// Applies a [`LowpassFilter`] to the slice in-place via
375/// [`LowpassFilter::run_slice`].
376///
377/// Significantly faster than [`lowpass_filter`], with results equal up to
378/// tiny floating point rounding differences (roughly `1e-6`).
379///
380/// It is mandatory to operate on f32 values in range `-1.0..=1.0`, which is
381/// also the default in DSP.
382///
383/// # Arguments
384/// - `samples`: Samples to filter in-place.
385/// - `sample_rate_hz`: Sample rate in Hz (e.g., 48000.0).
386/// - `cutoff_frequency_hz`: Cutoff frequency in Hz (e.g., 1000.0).
387#[inline]
388pub fn lowpass_filter_slice(samples: &mut [f32], sample_rate_hz: f32, cutoff_frequency_hz: f32) {
389    let mut filter = LowpassFilter::<f32>::new(sample_rate_hz, cutoff_frequency_hz);
390    filter.run_slice(samples);
391}
392
393/// Applies a [`LowpassFilter`] to the slice in-place via
394/// [`LowpassFilter::run_slice`].
395///
396/// Significantly faster than [`lowpass_filter_f64`], with results equal up
397/// to tiny floating point rounding differences.
398///
399/// It is mandatory to operate on f64 values in range `-1.0..=1.0`, which is
400/// also the default in DSP.
401///
402/// # Arguments
403/// - `samples`: Samples to filter in-place.
404/// - `sample_rate_hz`: Sample rate in Hz (e.g., 48000.0).
405/// - `cutoff_frequency_hz`: Cutoff frequency in Hz (e.g., 1000.0).
406#[inline]
407pub fn lowpass_filter_slice_f64(
408    samples: &mut [f64],
409    sample_rate_hz: f64,
410    cutoff_frequency_hz: f64,
411) {
412    let mut filter = LowpassFilter::<f64>::new(sample_rate_hz, cutoff_frequency_hz);
413    filter.run_slice(samples);
414}
415
416#[cfg(test)]
417mod test_util;
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use crate::test_util::{calculate_power, sine_wave_samples, target_dir_test_artifacts};
423    use audio_visualizer::Channels;
424    use audio_visualizer::waveform::plotters_png_file::waveform_static_plotters_png_visualize;
425    use std::vec::Vec;
426
427    #[test]
428    fn test_lpf_and_visualize() {
429        let samples_l_orig = sine_wave_samples(120.0, 44100.0);
430        let samples_h_orig = sine_wave_samples(350.0, 44100.0);
431
432        waveform_static_plotters_png_visualize(
433            &samples_l_orig.iter().map(|x| *x as i16).collect::<Vec<_>>(),
434            Channels::Mono,
435            target_dir_test_artifacts().to_str().unwrap(),
436            "test_lpf_l_orig.png",
437        );
438        waveform_static_plotters_png_visualize(
439            &samples_h_orig.iter().map(|x| *x as i16).collect::<Vec<_>>(),
440            Channels::Mono,
441            target_dir_test_artifacts().to_str().unwrap(),
442            "test_lpf_h_orig.png",
443        );
444
445        let mut samples_l_lowpassed = samples_l_orig.clone();
446        let mut samples_h_lowpassed = samples_h_orig.clone();
447
448        let power_l_orig = calculate_power(&samples_l_orig);
449        let power_h_orig = calculate_power(&samples_h_orig);
450
451        lowpass_filter_f64(samples_l_lowpassed.as_mut_slice(), 44100.0, 90.0);
452        lowpass_filter_f64(samples_h_lowpassed.as_mut_slice(), 44100.0, 90.0);
453
454        let power_l_lowpassed = calculate_power(&samples_l_lowpassed);
455        let power_h_lowpassed = calculate_power(&samples_h_lowpassed);
456
457        waveform_static_plotters_png_visualize(
458            &samples_l_lowpassed
459                .iter()
460                .map(|x| *x as i16)
461                .collect::<Vec<_>>(),
462            Channels::Mono,
463            target_dir_test_artifacts().to_str().unwrap(),
464            "test_lpf_l_after.png",
465        );
466        waveform_static_plotters_png_visualize(
467            &samples_h_lowpassed
468                .iter()
469                .map(|x| *x as i16)
470                .collect::<Vec<_>>(),
471            Channels::Mono,
472            target_dir_test_artifacts().to_str().unwrap(),
473            "test_lpf_h_after.png",
474        );
475
476        assert!(power_h_lowpassed < power_h_orig);
477        assert!(power_l_lowpassed < power_l_orig);
478
479        assert!(
480            power_h_lowpassed * 3.0 <= power_l_lowpassed,
481            "LPF must actively remove frequencies above threshold"
482        );
483    }
484
485    /// Tests that the SIMD slice path produces the same results as the
486    /// per-sample path, including all tail lengths around the block size.
487    #[test]
488    fn test_run_slice_matches_run() {
489        for n in [0_usize, 1, 3, 7, 8, 9, 16, 17, 41, 1003] {
490            let samples_f64 = (0..n)
491                .map(|i| (i as f64 * 0.37).sin() * 0.9)
492                .collect::<Vec<_>>();
493            let samples_f32 = samples_f64.iter().map(|&x| x as f32).collect::<Vec<_>>();
494
495            let mut expected_f32 = samples_f32.clone();
496            let mut actual_f32 = samples_f32.clone();
497            lowpass_filter(expected_f32.as_mut_slice(), 44100.0, 120.0);
498            lowpass_filter_slice(actual_f32.as_mut_slice(), 44100.0, 120.0);
499            for (i, (e, a)) in expected_f32.iter().zip(&actual_f32).enumerate() {
500                assert!((e - a).abs() < 1e-5, "f32, n={n}, i={i}: {e} vs {a}");
501            }
502
503            let mut expected_f64 = samples_f64.clone();
504            let mut actual_f64 = samples_f64.clone();
505            lowpass_filter_f64(expected_f64.as_mut_slice(), 44100.0, 120.0);
506            lowpass_filter_slice_f64(actual_f64.as_mut_slice(), 44100.0, 120.0);
507            for (i, (e, a)) in expected_f64.iter().zip(&actual_f64).enumerate() {
508                assert!((e - a).abs() < 1e-12, "f64, n={n}, i={i}: {e} vs {a}");
509            }
510        }
511    }
512
513    /// Tests that the filter state carries over between `run_slice` calls,
514    /// so chunked processing equals processing everything at once.
515    #[test]
516    fn test_run_slice_chunked_equals_whole() {
517        let samples = (0..500)
518            .map(|i| (i as f32 * 0.37).sin() * 0.9)
519            .collect::<Vec<_>>();
520
521        let mut whole = samples.clone();
522        let mut filter = LowpassFilter::<f32>::new(44100.0, 120.0);
523        filter.run_slice(whole.as_mut_slice());
524
525        let mut chunked = samples;
526        let mut filter = LowpassFilter::<f32>::new(44100.0, 120.0);
527        // odd chunk size on purpose, so blocks span call boundaries
528        for chunk in chunked.chunks_mut(13) {
529            filter.run_slice(chunk);
530        }
531
532        for (i, (w, c)) in whole.iter().zip(&chunked).enumerate() {
533            assert!((w - c).abs() < 1e-5, "i={i}: {w} vs {c}");
534        }
535    }
536
537    /// Tests if the functions with f32 and f64 behave similar.
538    #[test]
539    fn test_lpf_f32_f64() {
540        let samples_h_orig = sine_wave_samples(350.0, 44100.0);
541        let mut lowpassed_f32 = samples_h_orig.iter().map(|x| *x as f32).collect::<Vec<_>>();
542        #[allow(clippy::redundant_clone)]
543        let mut lowpassed_f64 = samples_h_orig.clone();
544
545        lowpass_filter(lowpassed_f32.as_mut_slice(), 44100.0, 90.0);
546        lowpass_filter_f64(lowpassed_f64.as_mut_slice(), 44100.0, 90.0);
547
548        let power_f32 =
549            calculate_power(&lowpassed_f32.iter().map(|x| *x as f64).collect::<Vec<_>>());
550        let power_f64 = calculate_power(&lowpassed_f64);
551
552        assert!((power_f32 - power_f64).abs() <= 0.00024);
553    }
554}