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}