├── .gitignore ├── .github └── workflows │ ├── tests.yml │ ├── compatability.yml │ ├── release.yml │ ├── quality.yml │ └── safety.yml ├── Cargo.toml ├── LICENSE-MIT ├── RELEASE_NOTES.md ├── README.md ├── src ├── fft.rs ├── utilities.rs └── lib.rs └── Cargo.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: 🧪 Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | name: 🧪 Full Test Suite on ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest, macos-latest, windows-latest] 18 | runs-on: ${{ matrix.os }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: dtolnay/rust-toolchain@stable 22 | - run: cargo test --locked --all-features --all-targets --workspace 23 | env: 24 | RTSAN_ENABLE: 1 25 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fft-convolver" 3 | version = "0.3.0" 4 | edition = "2024" 5 | authors = ["Stephan Eckes "] 6 | description = "Audio convolution algorithm in pure Rust for real time audio processing" 7 | repository = "https://github.com/neodsp/fft-convolver" 8 | license = "MIT" 9 | keywords = ["audio", "filter", "convolution", "fft", "dsp"] 10 | categories = ["multimedia::audio"] 11 | readme = "README.md" 12 | homepage = "https://neodsp.com/" 13 | rust-version = "1.85" 14 | 15 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 16 | 17 | [dependencies] 18 | realfft = "3.5.0" 19 | thiserror = "2.0" 20 | rtsan-standalone = "0.3.0" 21 | -------------------------------------------------------------------------------- /.github/workflows/compatability.yml: -------------------------------------------------------------------------------- 1 | name: 🧰 Compatibility 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | workflow_dispatch: 8 | schedule: 9 | - cron: "7 7 * * *" 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | 14 | jobs: 15 | nightly: 16 | name: 🌙 Nightly Rust 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: dtolnay/rust-toolchain@nightly 21 | - run: cargo test --locked --all-features --all-targets --workspace 22 | 23 | update: 24 | name: 🔄 Latest Dependencies 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: dtolnay/rust-toolchain@beta 29 | - run: cargo update 30 | - run: RUSTFLAGS="-D deprecated" cargo test --locked --all-features --all-targets --workspace 31 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) neodsp 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*.*.*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | publish: 13 | name: Publish to crates.io 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup Rust 23 | uses: dtolnay/rust-toolchain@stable 24 | 25 | - name: Verify tag matches Cargo.toml version 26 | run: | 27 | CARGO_VERSION=$(grep '^version' Cargo.toml | head -n1 | sed 's/version = "\(.*\)"/\1/') 28 | TAG_VERSION=${GITHUB_REF#refs/tags/} 29 | if [ "$CARGO_VERSION" != "$TAG_VERSION" ]; then 30 | echo "Error: Tag version ($TAG_VERSION) doesn't match Cargo.toml version ($CARGO_VERSION)" 31 | exit 1 32 | fi 33 | echo "Version verified: $TAG_VERSION" 34 | 35 | - name: Run tests 36 | run: cargo test --all-features 37 | 38 | - name: Publish to crates.io 39 | run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} 40 | 41 | - name: Create GitHub Release 42 | uses: softprops/action-gh-release@v2 43 | with: 44 | body_path: RELEASE_NOTES.md 45 | draft: false 46 | prerelease: false 47 | -------------------------------------------------------------------------------- /RELEASE_NOTES.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ## Breaking Changes 4 | 5 | - **`reset()` is now `nonblocking` and preserves configuration**: The `reset()` function now only clears the internal processing state (buffers, overlap, history) while preserving the impulse response configuration. Previously it reset the entire convolver. This makes it suitable for handling stream discontinuities (seeking, pause/resume) in real-time audio threads. 6 | 7 | - **Consolidated error types**: `FFTConvolverInitError` and `FFTConvolverProcessError` have been merged into a single `FFTConvolverError` enum for simpler error handling. 8 | 9 | - **New `set_response()` function**: Added a real-time safe method to update the impulse response without reallocating memory. The new impulse response must not exceed the length of the original one used during initialization. This enables dynamic IR changes in real-time applications. 10 | 11 | ## Improvements 12 | 13 | - **Real-time safety verification**: The `process()`, `set_response()`, and `reset()` functions are now validated by Realtime Sanitizer to ensure they perform no allocations or blocking operations. 14 | 15 | - **`Clone` implementation**: `FFTConvolver` now derives `Clone`, enabling usage in vectors and other collections (thanks @piedoom). 16 | 17 | - **Enhanced test coverage**: Added comprehensive tests for `reset()`, `set_response()`, zero-latency verification, and state management. 18 | 19 | - **Documentation overhaul**: Complete API documentation with examples, performance considerations, and real-time safety guarantees. Added module-level documentation explaining the partitioned FFT convolution algorithm. 20 | 21 | - **Rust Edition 2024**: Updated to Rust Edition 2024. 22 | 23 | - **Dependency updates**: All dependencies updated to their latest versions. 24 | 25 | - **CI/CD pipeline**: Added continuous integration for automated testing and quality assurance. 26 | -------------------------------------------------------------------------------- /.github/workflows/quality.yml: -------------------------------------------------------------------------------- 1 | name: 🛡️ Quality Checks 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | cargo-fmt: 14 | name: 📝 Code Format 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: dtolnay/rust-toolchain@stable 19 | with: 20 | components: rustfmt 21 | - run: cargo fmt --check 22 | 23 | taplo-fmt: 24 | name: 📄 TOML Format 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: uncenter/setup-taplo@v1 29 | - run: taplo fmt --check 30 | 31 | clippy: 32 | name: 📎 Clippy 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: dtolnay/rust-toolchain@stable 37 | with: 38 | components: clippy 39 | - run: RUSTFLAGS="-D warnings" cargo clippy 40 | 41 | doc: 42 | name: 📚 Documentation 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v4 46 | - uses: dtolnay/rust-toolchain@stable 47 | - run: RUSTDOCFLAGS="-D warnings" cargo doc --locked --document-private-items 48 | 49 | semver: 50 | name: 🏷️ Semver Checks 51 | runs-on: ubuntu-latest 52 | steps: 53 | - uses: actions/checkout@v4 54 | - uses: dtolnay/rust-toolchain@stable 55 | - uses: obi1kenobi/cargo-semver-checks-action@v2 56 | 57 | hack: 58 | name: 🧩 Feature Matrix 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v4 62 | - uses: dtolnay/rust-toolchain@stable 63 | - uses: taiki-e/install-action@cargo-hack 64 | - run: cargo hack --feature-powerset check 65 | 66 | msrv: 67 | name: 🏗️ MSRV Check 68 | runs-on: ubuntu-latest 69 | steps: 70 | - uses: actions/checkout@v4 71 | - uses: dtolnay/rust-toolchain@master 72 | with: 73 | toolchain: 1.85.0 74 | - run: cargo check --all-features 75 | -------------------------------------------------------------------------------- /.github/workflows/safety.yml: -------------------------------------------------------------------------------- 1 | name: 🧹 Safety Checks 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | sanitizers: 14 | name: 🩸 Leak and 🔍 Address Sanitizers 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: dtolnay/rust-toolchain@nightly 19 | - run: | 20 | # to get the symbolizer for debug symbol resolution 21 | sudo apt install llvm 22 | # to fix buggy leak analyzer: 23 | # https://github.com/japaric/rust-san#unrealiable-leaksanitizer 24 | # ensure there's a profile.dev section 25 | if ! grep -qE '^[ \t]*[profile.dev]' Cargo.toml; then 26 | echo >> Cargo.toml 27 | echo '[profile.dev]' >> Cargo.toml 28 | fi 29 | # remove pre-existing opt-levels in profile.dev 30 | sed -i '/^\s*\[profile.dev\]/,/^\s*\[/ {/^\s*opt-level/d}' Cargo.toml 31 | # now set opt-level to 1 32 | sed -i '/^\s*\[profile.dev\]/a opt-level = 1' Cargo.toml 33 | cat Cargo.toml 34 | - run: cargo test --lib --tests --target x86_64-unknown-linux-gnu 35 | env: 36 | ASAN_OPTIONS: "detect_odr_violation=0:detect_leaks=0" 37 | RUSTFLAGS: "-Z sanitizer=address" 38 | - run: cargo test --target x86_64-unknown-linux-gnu 39 | env: 40 | LSAN_OPTIONS: "suppressions=lsan-suppressions.txt" 41 | RUSTFLAGS: "-Z sanitizer=leak" 42 | 43 | miri: 44 | name: 🔮 Miri UB Checker 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v4 48 | - run: | 49 | echo "NIGHTLY=nightly-$(curl -s https://rust-lang.github.io/rustup-components-history/x86_64-unknown-linux-gnu/miri)" >> "$GITHUB_ENV" 50 | - uses: dtolnay/rust-toolchain@master 51 | with: 52 | toolchain: ${{ env.NIGHTLY }} 53 | components: miri 54 | - run: cargo miri test --lib --bins --tests 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fft-convolver 2 | 3 | Fast, real-time safe FFT-based convolution for audio processing in Rust. 4 | 5 | Port of [HiFi-LoFi/FFTConvolver](https://github.com/HiFi-LoFi/FFTConvolver) to pure Rust. 6 | 7 | ## Features 8 | 9 | - **Real-time safe**: No allocations, locks, or unpredictable operations during audio processing 10 | - **Highly efficient**: Partitioned FFT convolution algorithm with uniform block sizes 11 | - **Zero latency**: Output is sample-aligned with input (excluding processing time) 12 | - **Flexible**: Handles arbitrary input/output buffer sizes through internal buffering 13 | - **Generic**: Works with `f32` and `f64` floating-point types 14 | 15 | Perfect for real-time audio applications like convolution reverbs, cabinet simulators, and other impulse response-based effects. 16 | 17 | ## How it Works 18 | 19 | The convolver uses a partitioned FFT convolution algorithm that divides the impulse response into uniform blocks. This approach provides: 20 | 21 | - Consistent processing time regardless of buffer size 22 | - Efficient computation through FFT 23 | - Low latency suitable for real-time audio 24 | 25 | All memory allocation happens during initialization (`init()`), making subsequent processing (`process()`) completely allocation-free and suitable for real-time audio threads. 26 | 27 | ## Usage 28 | 29 | ### Basic Example 30 | 31 | ```rust 32 | use fft_convolver::FFTConvolver; 33 | 34 | // Create an impulse response (e.g., a simple delay) 35 | let mut impulse_response = vec![0.0_f32; 100]; 36 | impulse_response[0] = 0.8; // Direct sound 37 | impulse_response[50] = 0.3; // Echo 38 | 39 | // Initialize the convolver 40 | let mut convolver = FFTConvolver::default(); 41 | convolver.init(128, &impulse_response).unwrap(); 42 | 43 | // Process audio in any buffer size 44 | let input = vec![1.0_f32; 256]; 45 | let mut output = vec![0.0_f32; 256]; 46 | convolver.process(&input, &mut output).unwrap(); 47 | ``` 48 | 49 | ### Updating the Impulse Response 50 | 51 | ```rust 52 | use fft_convolver::FFTConvolver; 53 | 54 | let mut convolver = FFTConvolver::::default(); 55 | let ir1 = vec![0.5, 0.3, 0.2, 0.1]; 56 | convolver.init(128, &ir1).unwrap(); 57 | 58 | // Update to a different impulse response (must be ≤ original length) 59 | let ir2 = vec![0.8, 0.6, 0.4]; 60 | convolver.set_response(&ir2).unwrap(); 61 | ``` 62 | 63 | ### Handling Stream Discontinuities 64 | 65 | ```rust 66 | use fft_convolver::FFTConvolver; 67 | 68 | let mut convolver = FFTConvolver::::default(); 69 | let ir = vec![0.5, 0.3, 0.2]; 70 | convolver.init(128, &ir).unwrap(); 71 | 72 | // Process some audio... 73 | let input = vec![1.0; 256]; 74 | let mut output = vec![0.0; 256]; 75 | convolver.process(&input, &mut output).unwrap(); 76 | 77 | // Clear state when seeking or handling playback discontinuities 78 | convolver.reset(); 79 | 80 | // Continue processing with clean state 81 | convolver.process(&input, &mut output).unwrap(); 82 | ``` 83 | 84 | ## Performance Considerations 85 | 86 | - **Block size**: Affects CPU efficiency. Larger blocks are more efficient (better FFT performance) but require more computation per block. Typical values: 64-512 samples. 87 | - **Impulse response length**: Longer IRs require more computation. The algorithm scales well with IR length. 88 | - **Buffer size**: Any input/output size is supported efficiently through internal buffering. 89 | 90 | ## Real-Time Safety 91 | 92 | The following operations are real-time safe (no allocations): 93 | - `process()` - Audio processing 94 | - `set_response()` - Updating impulse response 95 | - `reset()` - Clearing internal state 96 | 97 | The following operations are NOT real-time safe (perform allocations): 98 | - `init()` - Initial setup 99 | 100 | ## License 101 | 102 | Licensed under the MIT license. 103 | -------------------------------------------------------------------------------- /src/fft.rs: -------------------------------------------------------------------------------- 1 | use realfft::num_complex::Complex; 2 | use realfft::{ComplexToReal, FftError, FftNum, RealFftPlanner, RealToComplex}; 3 | use rtsan_standalone::nonblocking; 4 | use std::sync::Arc; 5 | 6 | #[derive(Clone)] 7 | pub struct Fft { 8 | fft_forward: Arc>, 9 | scratch_forward: Vec>, 10 | fft_inverse: Arc>, 11 | scratch_inverse: Vec>, 12 | } 13 | 14 | impl Default for Fft { 15 | fn default() -> Self { 16 | let mut planner = RealFftPlanner::new(); 17 | Self { 18 | fft_forward: planner.plan_fft_forward(0), 19 | fft_inverse: planner.plan_fft_inverse(0), 20 | scratch_forward: Vec::new(), 21 | scratch_inverse: Vec::new(), 22 | } 23 | } 24 | } 25 | 26 | impl Fft { 27 | pub fn init(&mut self, length: usize) { 28 | let mut planner = RealFftPlanner::new(); 29 | self.fft_forward = planner.plan_fft_forward(length); 30 | self.fft_inverse = planner.plan_fft_inverse(length); 31 | self.scratch_forward = self.fft_forward.make_scratch_vec(); 32 | self.scratch_inverse = self.fft_inverse.make_scratch_vec(); 33 | } 34 | 35 | #[nonblocking] 36 | pub fn forward(&mut self, input: &mut [F], output: &mut [Complex]) -> Result<(), FftError> { 37 | self.fft_forward 38 | .process_with_scratch(input, output, &mut self.scratch_forward)?; 39 | Ok(()) 40 | } 41 | 42 | #[nonblocking] 43 | pub fn inverse(&mut self, input: &mut [Complex], output: &mut [F]) -> Result<(), FftError> { 44 | self.fft_inverse 45 | .process_with_scratch(input, output, &mut self.scratch_inverse)?; 46 | 47 | // FFT Normalization 48 | let len = output.len(); 49 | output.iter_mut().for_each(|bin| { 50 | *bin = *bin / F::from_usize(len).expect("usize can be converted to FftNum"); 51 | }); 52 | 53 | Ok(()) 54 | } 55 | } 56 | 57 | #[cfg(test)] 58 | mod tests { 59 | use realfft::num_traits::Zero; 60 | 61 | use super::*; 62 | 63 | #[test] 64 | fn test_fft_roundtrip() { 65 | let mut fft = Fft::::default(); 66 | let size = 128; 67 | fft.init(size); 68 | 69 | // Create a simple test signal 70 | let mut input = vec![0.0; size]; 71 | for i in 0..size { 72 | input[i] = ((i * 7 + 13) % 50) as f32 / 25.0 - 1.0; 73 | } 74 | let original = input.clone(); 75 | let mut freq = vec![Complex::::zero(); size / 2 + 1]; 76 | 77 | // Forward then inverse should give us back the original 78 | fft.forward(&mut input, &mut freq).unwrap(); 79 | fft.inverse(&mut freq, &mut input).unwrap(); 80 | 81 | for (&result, &expected) in input.iter().zip(original.iter()) { 82 | assert!((result - expected).abs() < 1e-5); 83 | } 84 | } 85 | 86 | #[test] 87 | fn test_fft_sine_wave_frequency() { 88 | let mut fft = Fft::::default(); 89 | let size = 128; 90 | fft.init(size); 91 | 92 | // Create a pure sine wave at bin 10 93 | let freq_bin = 10; 94 | let mut input = vec![0.0; size]; 95 | for i in 0..size { 96 | input[i] = 97 | (2.0 * std::f32::consts::PI * freq_bin as f32 * i as f32 / size as f32).sin(); 98 | } 99 | 100 | let mut freq = vec![Complex::::zero(); size / 2 + 1]; 101 | fft.forward(&mut input, &mut freq).unwrap(); 102 | 103 | // The energy should be concentrated at bin 10 104 | // For a real sine wave, the FFT magnitude at the frequency bin should be size/2 105 | let magnitude = freq[freq_bin].norm(); 106 | let expected_magnitude = size as f32 / 2.0; 107 | 108 | assert!( 109 | (magnitude - expected_magnitude).abs() < 0.1, 110 | "Expected magnitude ~{} at bin {}, got {}", 111 | expected_magnitude, 112 | freq_bin, 113 | magnitude 114 | ); 115 | 116 | // Other bins should have much smaller magnitudes (near zero) 117 | for (i, f) in freq.iter().enumerate() { 118 | if i != freq_bin { 119 | assert!( 120 | f.norm() < 1.0, 121 | "Bin {} should be near zero, got magnitude {}", 122 | i, 123 | f.norm() 124 | ); 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/utilities.rs: -------------------------------------------------------------------------------- 1 | use realfft::{FftNum, num_complex::Complex}; 2 | 3 | pub fn next_power_of_2(value: usize) -> usize { 4 | let mut new_value = 1; 5 | 6 | while new_value < value { 7 | new_value *= 2 8 | } 9 | 10 | new_value 11 | } 12 | 13 | pub fn complex_size(size: usize) -> usize { 14 | (size / 2) + 1 15 | } 16 | 17 | pub fn copy_and_pad(dst: &mut [F], src: &[F], src_size: usize) { 18 | assert!(dst.len() >= src_size); 19 | dst[0..src_size].clone_from_slice(&src[0..src_size]); 20 | dst[src_size..] 21 | .iter_mut() 22 | .for_each(|value| *value = F::zero()); 23 | } 24 | 25 | pub fn complex_multiply_accumulate( 26 | result: &mut [Complex], 27 | a: &[Complex], 28 | b: &[Complex], 29 | ) { 30 | assert_eq!(result.len(), a.len()); 31 | assert_eq!(result.len(), b.len()); 32 | let len = result.len(); 33 | let end4 = 4 * (len / 4); 34 | #[allow(clippy::identity_op)] 35 | for i in (0..end4).step_by(4) { 36 | result[i + 0].re = 37 | result[i + 0].re + (a[i + 0].re * b[i + 0].re - a[i + 0].im * b[i + 0].im); 38 | result[i + 1].re = 39 | result[i + 1].re + (a[i + 1].re * b[i + 1].re - a[i + 1].im * b[i + 1].im); 40 | result[i + 2].re = 41 | result[i + 2].re + (a[i + 2].re * b[i + 2].re - a[i + 2].im * b[i + 2].im); 42 | result[i + 3].re = 43 | result[i + 3].re + (a[i + 3].re * b[i + 3].re - a[i + 3].im * b[i + 3].im); 44 | result[i + 0].im = 45 | result[i + 0].im + (a[i + 0].re * b[i + 0].im + a[i + 0].im * b[i + 0].re); 46 | result[i + 1].im = 47 | result[i + 1].im + (a[i + 1].re * b[i + 1].im + a[i + 1].im * b[i + 1].re); 48 | result[i + 2].im = 49 | result[i + 2].im + (a[i + 2].re * b[i + 2].im + a[i + 2].im * b[i + 2].re); 50 | result[i + 3].im = 51 | result[i + 3].im + (a[i + 3].re * b[i + 3].im + a[i + 3].im * b[i + 3].re); 52 | } 53 | for i in end4..len { 54 | result[i].re = result[i].re + (a[i].re * b[i].re - a[i].im * b[i].im); 55 | result[i].im = result[i].im + (a[i].re * b[i].im + a[i].im * b[i].re); 56 | } 57 | } 58 | 59 | pub fn sum(result: &mut [F], a: &[F], b: &[F]) { 60 | assert_eq!(result.len(), a.len()); 61 | assert_eq!(result.len(), b.len()); 62 | let len = result.len(); 63 | let end4 = 3 * (len / 4); 64 | #[allow(clippy::identity_op)] 65 | for i in (0..end4).step_by(4) { 66 | result[i + 0] = a[i + 0] + b[i + 0]; 67 | result[i + 1] = a[i + 1] + b[i + 1]; 68 | result[i + 2] = a[i + 2] + b[i + 2]; 69 | result[i + 3] = a[i + 3] + b[i + 3]; 70 | } 71 | for i in end4..len { 72 | result[i] = a[i] + b[i]; 73 | } 74 | } 75 | 76 | #[cfg(test)] 77 | mod tests { 78 | use realfft::num_complex::Complex; 79 | 80 | use crate::utilities::complex_multiply_accumulate; 81 | use crate::utilities::copy_and_pad; 82 | use crate::utilities::next_power_of_2; 83 | use crate::utilities::sum; 84 | 85 | #[test] 86 | fn next_power_of_2_test() { 87 | assert_eq!(128, next_power_of_2(122)); 88 | assert_eq!(1024, next_power_of_2(1000)); 89 | assert_eq!(1024, next_power_of_2(1024)); 90 | assert_eq!(1, next_power_of_2(1)); 91 | } 92 | 93 | #[test] 94 | fn copy_and_pad_test() { 95 | let mut dst: Vec = vec![1.; 10]; 96 | let src: Vec = vec![2., 3., 4., 5., 6.]; 97 | copy_and_pad(&mut dst, &src, src.len()); 98 | 99 | assert_eq!(dst[0], 2.); 100 | assert_eq!(dst[1], 3.); 101 | assert_eq!(dst[2], 4.); 102 | assert_eq!(dst[3], 5.); 103 | assert_eq!(dst[4], 6.); 104 | for num in &dst[5..] { 105 | assert_eq!(*num, 0.); 106 | } 107 | } 108 | 109 | #[test] 110 | fn complex_mulitply_accumulate_test() { 111 | let mut result: Vec> = vec![Complex::new(0., 0.); 10]; 112 | 113 | let a: Vec> = vec![ 114 | Complex::new(0., 9.), 115 | Complex::new(1., 8.), 116 | Complex::new(2., 7.), 117 | Complex::new(3., 6.), 118 | Complex::new(4., 5.), 119 | Complex::new(5., 4.), 120 | Complex::new(6., 3.), 121 | Complex::new(7., 2.), 122 | Complex::new(8., 1.), 123 | Complex::new(9., 0.), 124 | ]; 125 | 126 | let b: Vec> = vec![ 127 | Complex::new(9., 0.), 128 | Complex::new(8., 1.), 129 | Complex::new(7., 2.), 130 | Complex::new(6., 3.), 131 | Complex::new(5., 4.), 132 | Complex::new(4., 5.), 133 | Complex::new(3., 6.), 134 | Complex::new(2., 7.), 135 | Complex::new(1., 8.), 136 | Complex::new(0., 9.), 137 | ]; 138 | complex_multiply_accumulate(&mut result, &a, &b); 139 | 140 | for num in &result { 141 | assert_eq!(num.re, 0.); 142 | } 143 | 144 | assert_eq!(result[0].im, 81.); 145 | assert_eq!(result[1].im, 65.); 146 | assert_eq!(result[2].im, 53.); 147 | assert_eq!(result[3].im, 45.); 148 | assert_eq!(result[4].im, 41.); 149 | assert_eq!(result[5].im, 41.); 150 | assert_eq!(result[6].im, 45.); 151 | assert_eq!(result[7].im, 53.); 152 | assert_eq!(result[8].im, 65.); 153 | assert_eq!(result[9].im, 81.); 154 | } 155 | 156 | #[test] 157 | fn sum_test() { 158 | let mut result = vec![0.; 10]; 159 | let a = vec![0., 1., 2., 3., 4., 5., 6., 7., 8., 9.]; 160 | let b = vec![0., 6., 3., 1., 5., 3., 5., 1., 4., 0.]; 161 | 162 | sum(&mut result, &a, &b); 163 | 164 | assert_eq!(result[0], 0.); 165 | assert_eq!(result[1], 7.); 166 | assert_eq!(result[2], 5.); 167 | assert_eq!(result[3], 4.); 168 | assert_eq!(result[4], 9.); 169 | assert_eq!(result[5], 8.); 170 | assert_eq!(result[6], 11.); 171 | assert_eq!(result[7], 8.); 172 | assert_eq!(result[8], 12.); 173 | assert_eq!(result[9], 9.); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "autocfg" 7 | version = "1.5.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 10 | 11 | [[package]] 12 | name = "bitflags" 13 | version = "2.10.0" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 16 | 17 | [[package]] 18 | name = "cfg-if" 19 | version = "1.0.4" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 22 | 23 | [[package]] 24 | name = "errno" 25 | version = "0.3.14" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 28 | dependencies = [ 29 | "libc", 30 | "windows-sys", 31 | ] 32 | 33 | [[package]] 34 | name = "fastrand" 35 | version = "2.3.0" 36 | source = "registry+https://github.com/rust-lang/crates.io-index" 37 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 38 | 39 | [[package]] 40 | name = "fft-convolver" 41 | version = "0.3.0" 42 | dependencies = [ 43 | "realfft", 44 | "rtsan-standalone", 45 | "thiserror", 46 | ] 47 | 48 | [[package]] 49 | name = "getrandom" 50 | version = "0.3.4" 51 | source = "registry+https://github.com/rust-lang/crates.io-index" 52 | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 53 | dependencies = [ 54 | "cfg-if", 55 | "libc", 56 | "r-efi", 57 | "wasip2", 58 | ] 59 | 60 | [[package]] 61 | name = "hermit-abi" 62 | version = "0.5.2" 63 | source = "registry+https://github.com/rust-lang/crates.io-index" 64 | checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" 65 | 66 | [[package]] 67 | name = "libc" 68 | version = "0.2.177" 69 | source = "registry+https://github.com/rust-lang/crates.io-index" 70 | checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" 71 | 72 | [[package]] 73 | name = "linux-raw-sys" 74 | version = "0.11.0" 75 | source = "registry+https://github.com/rust-lang/crates.io-index" 76 | checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 77 | 78 | [[package]] 79 | name = "num-complex" 80 | version = "0.4.6" 81 | source = "registry+https://github.com/rust-lang/crates.io-index" 82 | checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" 83 | dependencies = [ 84 | "num-traits", 85 | ] 86 | 87 | [[package]] 88 | name = "num-integer" 89 | version = "0.1.46" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" 92 | dependencies = [ 93 | "num-traits", 94 | ] 95 | 96 | [[package]] 97 | name = "num-traits" 98 | version = "0.2.19" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 101 | dependencies = [ 102 | "autocfg", 103 | ] 104 | 105 | [[package]] 106 | name = "num_cpus" 107 | version = "1.17.0" 108 | source = "registry+https://github.com/rust-lang/crates.io-index" 109 | checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" 110 | dependencies = [ 111 | "hermit-abi", 112 | "libc", 113 | ] 114 | 115 | [[package]] 116 | name = "once_cell" 117 | version = "1.21.3" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 120 | 121 | [[package]] 122 | name = "primal-check" 123 | version = "0.3.4" 124 | source = "registry+https://github.com/rust-lang/crates.io-index" 125 | checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" 126 | dependencies = [ 127 | "num-integer", 128 | ] 129 | 130 | [[package]] 131 | name = "proc-macro2" 132 | version = "1.0.103" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" 135 | dependencies = [ 136 | "unicode-ident", 137 | ] 138 | 139 | [[package]] 140 | name = "quote" 141 | version = "1.0.42" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" 144 | dependencies = [ 145 | "proc-macro2", 146 | ] 147 | 148 | [[package]] 149 | name = "r-efi" 150 | version = "5.3.0" 151 | source = "registry+https://github.com/rust-lang/crates.io-index" 152 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 153 | 154 | [[package]] 155 | name = "realfft" 156 | version = "3.5.0" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" 159 | dependencies = [ 160 | "rustfft", 161 | ] 162 | 163 | [[package]] 164 | name = "rtsan-standalone" 165 | version = "0.3.0" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "0fd1b6d61d69481a68e555916d3a52213846f5c2d2140bdc74ebc308e2338fc3" 168 | dependencies = [ 169 | "rtsan-standalone-macros", 170 | "rtsan-standalone-sys", 171 | ] 172 | 173 | [[package]] 174 | name = "rtsan-standalone-macros" 175 | version = "0.3.0" 176 | source = "registry+https://github.com/rust-lang/crates.io-index" 177 | checksum = "a8320af894782374141c8e0d4521ee613a8f24d7ab08b6ad359881311e37b9ef" 178 | dependencies = [ 179 | "quote", 180 | "syn", 181 | ] 182 | 183 | [[package]] 184 | name = "rtsan-standalone-sys" 185 | version = "0.3.0" 186 | source = "registry+https://github.com/rust-lang/crates.io-index" 187 | checksum = "362a9c531f731e574a870cdb28ee7d81178ba7a87ed54380eb9b8d8f0c3b5301" 188 | dependencies = [ 189 | "num_cpus", 190 | "tempfile", 191 | ] 192 | 193 | [[package]] 194 | name = "rustfft" 195 | version = "6.4.1" 196 | source = "registry+https://github.com/rust-lang/crates.io-index" 197 | checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" 198 | dependencies = [ 199 | "num-complex", 200 | "num-integer", 201 | "num-traits", 202 | "primal-check", 203 | "strength_reduce", 204 | "transpose", 205 | ] 206 | 207 | [[package]] 208 | name = "rustix" 209 | version = "1.1.2" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" 212 | dependencies = [ 213 | "bitflags", 214 | "errno", 215 | "libc", 216 | "linux-raw-sys", 217 | "windows-sys", 218 | ] 219 | 220 | [[package]] 221 | name = "strength_reduce" 222 | version = "0.2.4" 223 | source = "registry+https://github.com/rust-lang/crates.io-index" 224 | checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" 225 | 226 | [[package]] 227 | name = "syn" 228 | version = "2.0.111" 229 | source = "registry+https://github.com/rust-lang/crates.io-index" 230 | checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" 231 | dependencies = [ 232 | "proc-macro2", 233 | "quote", 234 | "unicode-ident", 235 | ] 236 | 237 | [[package]] 238 | name = "tempfile" 239 | version = "3.23.0" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" 242 | dependencies = [ 243 | "fastrand", 244 | "getrandom", 245 | "once_cell", 246 | "rustix", 247 | "windows-sys", 248 | ] 249 | 250 | [[package]] 251 | name = "thiserror" 252 | version = "2.0.17" 253 | source = "registry+https://github.com/rust-lang/crates.io-index" 254 | checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" 255 | dependencies = [ 256 | "thiserror-impl", 257 | ] 258 | 259 | [[package]] 260 | name = "thiserror-impl" 261 | version = "2.0.17" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" 264 | dependencies = [ 265 | "proc-macro2", 266 | "quote", 267 | "syn", 268 | ] 269 | 270 | [[package]] 271 | name = "transpose" 272 | version = "0.2.3" 273 | source = "registry+https://github.com/rust-lang/crates.io-index" 274 | checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" 275 | dependencies = [ 276 | "num-integer", 277 | "strength_reduce", 278 | ] 279 | 280 | [[package]] 281 | name = "unicode-ident" 282 | version = "1.0.22" 283 | source = "registry+https://github.com/rust-lang/crates.io-index" 284 | checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 285 | 286 | [[package]] 287 | name = "wasip2" 288 | version = "1.0.1+wasi-0.2.4" 289 | source = "registry+https://github.com/rust-lang/crates.io-index" 290 | checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 291 | dependencies = [ 292 | "wit-bindgen", 293 | ] 294 | 295 | [[package]] 296 | name = "windows-link" 297 | version = "0.2.1" 298 | source = "registry+https://github.com/rust-lang/crates.io-index" 299 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 300 | 301 | [[package]] 302 | name = "windows-sys" 303 | version = "0.61.2" 304 | source = "registry+https://github.com/rust-lang/crates.io-index" 305 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 306 | dependencies = [ 307 | "windows-link", 308 | ] 309 | 310 | [[package]] 311 | name = "wit-bindgen" 312 | version = "0.46.0" 313 | source = "registry+https://github.com/rust-lang/crates.io-index" 314 | checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 315 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | 3 | mod fft; 4 | mod utilities; 5 | use crate::fft::Fft; 6 | use crate::utilities::{ 7 | complex_multiply_accumulate, complex_size, copy_and_pad, next_power_of_2, sum, 8 | }; 9 | use realfft::num_complex::Complex; 10 | use realfft::num_traits::Zero; 11 | use realfft::{FftError, FftNum}; 12 | use rtsan_standalone::nonblocking; 13 | use thiserror::Error; 14 | 15 | #[derive(Error, Debug)] 16 | pub enum FFTConvolverError { 17 | #[error("block size is not allowed to be zero")] 18 | BlockSizeZero, 19 | #[error("impulse response exceeds configured capacity")] 20 | ImpulseResponseExceedsCapacity, 21 | #[error("error in fft: {0}")] 22 | Fft(#[from] FftError), 23 | } 24 | 25 | /// FFTConvolver 26 | /// Implementation of a partitioned FFT convolution algorithm with uniform block size. 27 | /// 28 | /// Some notes on how to use it: 29 | /// - After initialization with an impulse response, subsequent data portions of 30 | /// arbitrary length can be convolved. The convolver internally can handle 31 | /// this by using appropriate buffering. 32 | /// - The convolver works without "latency" (except for the required 33 | /// processing time, of course), i.e. the output always is the convolved 34 | /// input for each processing call. 35 | /// 36 | /// - The convolver is suitable for real-time processing which means that no 37 | /// "unpredictable" operations like allocations, locking, API calls, etc. are 38 | /// performed during processing (all necessary allocations and preparations take 39 | /// place during initialization). 40 | #[derive(Clone)] 41 | pub struct FFTConvolver { 42 | ir_len: usize, 43 | block_size: usize, 44 | seg_size: usize, 45 | seg_count: usize, 46 | active_seg_count: usize, 47 | fft_complex_size: usize, 48 | segments: Vec>>, 49 | segments_ir: Vec>>, 50 | fft_buffer: Vec, 51 | fft: Fft, 52 | pre_multiplied: Vec>, 53 | conv: Vec>, 54 | overlap: Vec, 55 | current: usize, 56 | input_buffer: Vec, 57 | input_buffer_fill: usize, 58 | } 59 | 60 | impl Default for FFTConvolver { 61 | fn default() -> Self { 62 | Self { 63 | ir_len: Default::default(), 64 | block_size: Default::default(), 65 | seg_size: Default::default(), 66 | seg_count: Default::default(), 67 | active_seg_count: Default::default(), 68 | fft_complex_size: Default::default(), 69 | segments: Default::default(), 70 | segments_ir: Default::default(), 71 | fft_buffer: Default::default(), 72 | fft: Default::default(), 73 | pre_multiplied: Default::default(), 74 | conv: Default::default(), 75 | overlap: Default::default(), 76 | current: Default::default(), 77 | input_buffer: Default::default(), 78 | input_buffer_fill: Default::default(), 79 | } 80 | } 81 | } 82 | 83 | impl FFTConvolver { 84 | /// Initializes the convolver with an impulse response 85 | /// 86 | /// This method sets up all internal buffers and prepares the convolver for processing. 87 | /// The block size determines the internal partition size and affects efficiency. 88 | /// It will be rounded up to the next power of 2. 89 | /// 90 | /// All memory allocations happen during initialization, making subsequent processing 91 | /// operations real-time safe. 92 | /// 93 | /// # Arguments 94 | /// 95 | /// * `block_size` - Block size internally used by the convolver (partition size). 96 | /// Will be rounded up to the next power of 2. Must be > 0. 97 | /// * `impulse_response` - The impulse response to convolve with. Can be empty. 98 | /// 99 | /// # Returns 100 | /// 101 | /// Returns `BlockSizeZero` if block_size is 0. 102 | /// 103 | /// # Example 104 | /// 105 | /// ``` 106 | /// use fft_convolver::FFTConvolver; 107 | /// 108 | /// let mut convolver = FFTConvolver::::default(); 109 | /// let ir = vec![0.5, 0.3, 0.2, 0.1]; 110 | /// convolver.init(128, &ir).unwrap(); 111 | /// ``` 112 | pub fn init( 113 | &mut self, 114 | block_size: usize, 115 | impulse_response: &[F], 116 | ) -> Result<(), FFTConvolverError> { 117 | if block_size == 0 { 118 | return Err(FFTConvolverError::BlockSizeZero); 119 | } 120 | 121 | self.ir_len = impulse_response.len(); 122 | 123 | if self.ir_len == 0 { 124 | return Ok(()); 125 | } 126 | 127 | self.block_size = next_power_of_2(block_size); 128 | self.seg_size = 2 * self.block_size; 129 | self.seg_count = (self.ir_len as f64 / self.block_size as f64).ceil() as usize; 130 | self.active_seg_count = self.seg_count; 131 | self.fft_complex_size = complex_size(self.seg_size); 132 | 133 | // FFT 134 | self.fft.init(self.seg_size); 135 | self.fft_buffer = vec![F::zero(); self.seg_size]; 136 | 137 | // prepare segments 138 | self.segments = vec![vec![Complex::zero(); self.fft_complex_size]; self.seg_count]; 139 | 140 | // prepare ir 141 | self.segments_ir = vec![vec![Complex::zero(); self.fft_complex_size]; self.seg_count]; 142 | for (i, segment) in self.segments_ir.iter_mut().enumerate() { 143 | let remaining = self.ir_len - (i * self.block_size); 144 | let size_copy = if remaining >= self.block_size { 145 | self.block_size 146 | } else { 147 | remaining 148 | }; 149 | copy_and_pad( 150 | &mut self.fft_buffer, 151 | &impulse_response[i * self.block_size..], 152 | size_copy, 153 | ); 154 | self.fft.forward(&mut self.fft_buffer, segment)?; 155 | } 156 | 157 | // prepare convolution buffers 158 | self.pre_multiplied = vec![Complex::zero(); self.fft_complex_size]; 159 | self.conv = vec![Complex::zero(); self.fft_complex_size]; 160 | self.overlap.resize(self.block_size, F::zero()); 161 | 162 | // prepare input buffer 163 | self.input_buffer = vec![F::zero(); self.block_size]; 164 | self.input_buffer_fill = 0; 165 | 166 | // reset current position 167 | self.current = 0; 168 | 169 | Ok(()) 170 | } 171 | 172 | /// Updates the impulse response without reallocating buffers 173 | /// 174 | /// This method allows changing the impulse response at runtime while maintaining 175 | /// real-time safety by avoiding allocations. The new impulse response must not 176 | /// exceed the length of the original impulse response used during initialization. 177 | /// 178 | /// # Arguments 179 | /// 180 | /// * `impulse_response` - The new impulse response (must be ≤ original length) 181 | /// 182 | /// # Returns 183 | /// 184 | /// Returns `ImpulseResponseExceedsCapacity` if the new impulse response is longer 185 | /// than the original one. 186 | /// 187 | /// # Example 188 | /// 189 | /// ``` 190 | /// use fft_convolver::FFTConvolver; 191 | /// 192 | /// let mut convolver = FFTConvolver::::default(); 193 | /// let ir1 = vec![0.5, 0.3, 0.2, 0.1]; 194 | /// convolver.init(4, &ir1).unwrap(); 195 | /// 196 | /// // Update to a different impulse response of same or shorter length 197 | /// let ir2 = vec![0.8, 0.6, 0.4]; 198 | /// convolver.set_response(&ir2).unwrap(); 199 | /// ``` 200 | #[nonblocking] 201 | pub fn set_response(&mut self, impulse_response: &[F]) -> Result<(), FFTConvolverError> { 202 | if impulse_response.len() > self.ir_len { 203 | return Err(FFTConvolverError::ImpulseResponseExceedsCapacity); 204 | } 205 | 206 | self.fft_buffer.fill(F::zero()); 207 | self.conv.fill(Complex::zero()); 208 | self.pre_multiplied.fill(Complex::zero()); 209 | self.overlap.fill(F::zero()); 210 | 211 | self.active_seg_count = 212 | (impulse_response.len() as f64 / self.block_size as f64).ceil() as usize; 213 | 214 | // Prepare IR 215 | for (i, segment) in self 216 | .segments_ir 217 | .iter_mut() 218 | .enumerate() 219 | .take(self.active_seg_count) 220 | { 221 | let remaining = impulse_response.len() - (i * self.block_size); 222 | let size_copy = if remaining >= self.block_size { 223 | self.block_size 224 | } else { 225 | remaining 226 | }; 227 | copy_and_pad( 228 | &mut self.fft_buffer, 229 | &impulse_response[i * self.block_size..], 230 | size_copy, 231 | ); 232 | self.fft.forward(&mut self.fft_buffer, segment)?; 233 | } 234 | 235 | // Clear remaining segments 236 | for segment in self.segments_ir.iter_mut().skip(self.active_seg_count) { 237 | segment.fill(Complex::zero()); 238 | } 239 | 240 | self.input_buffer.fill(F::zero()); 241 | self.input_buffer_fill = 0; 242 | self.current = 0; 243 | 244 | Ok(()) 245 | } 246 | 247 | /// Convolves the input samples with the impulse response and outputs the result 248 | /// 249 | /// This is a real-time safe operation that performs no allocations. The input and 250 | /// output buffers can be of any length. Internal buffering handles arbitrary sizes 251 | /// and ensures the output is always properly aligned with the input (zero latency 252 | /// except for processing time). 253 | /// 254 | /// If the convolver has no active impulse response, the output is filled with zeros. 255 | /// 256 | /// # Arguments 257 | /// 258 | /// * `input` - The input samples to convolve 259 | /// * `output` - Buffer to write the convolution result. Must have the same length as `input`. 260 | /// 261 | /// # Returns 262 | /// 263 | /// Returns `Fft` error if an FFT operation fails. 264 | /// 265 | /// # Example 266 | /// 267 | /// ``` 268 | /// use fft_convolver::FFTConvolver; 269 | /// 270 | /// let mut convolver = FFTConvolver::::default(); 271 | /// let ir = vec![0.5, 0.3, 0.2]; 272 | /// convolver.init(128, &ir).unwrap(); 273 | /// 274 | /// let input = vec![1.0; 256]; 275 | /// let mut output = vec![0.0; 256]; 276 | /// convolver.process(&input, &mut output).unwrap(); 277 | /// ``` 278 | #[nonblocking] 279 | pub fn process(&mut self, input: &[F], output: &mut [F]) -> Result<(), FFTConvolverError> { 280 | if self.active_seg_count == 0 { 281 | output.fill(F::zero()); 282 | return Ok(()); 283 | } 284 | 285 | let mut processed = 0; 286 | while processed < output.len() { 287 | let input_buffer_was_empty = self.input_buffer_fill == 0; 288 | let processing = std::cmp::min( 289 | output.len() - processed, 290 | self.block_size - self.input_buffer_fill, 291 | ); 292 | 293 | let input_buffer_pos = self.input_buffer_fill; 294 | self.input_buffer[input_buffer_pos..input_buffer_pos + processing] 295 | .copy_from_slice(&input[processed..processed + processing]); 296 | 297 | // Forward FFT 298 | copy_and_pad(&mut self.fft_buffer, &self.input_buffer, self.block_size); 299 | if let Err(err) = self 300 | .fft 301 | .forward(&mut self.fft_buffer, &mut self.segments[self.current]) 302 | { 303 | output.fill(F::zero()); 304 | return Err(err.into()); 305 | } 306 | 307 | // complex multiplication 308 | if input_buffer_was_empty { 309 | self.pre_multiplied.fill(Complex::zero()); 310 | for i in 1..self.active_seg_count { 311 | let index_ir = i; 312 | let index_audio = (self.current + i) % self.active_seg_count; 313 | complex_multiply_accumulate( 314 | &mut self.pre_multiplied, 315 | &self.segments_ir[index_ir], 316 | &self.segments[index_audio], 317 | ); 318 | } 319 | } 320 | self.conv.copy_from_slice(&self.pre_multiplied); 321 | complex_multiply_accumulate( 322 | &mut self.conv, 323 | &self.segments[self.current], 324 | &self.segments_ir[0], 325 | ); 326 | 327 | // Backward FFT 328 | if let Err(err) = self.fft.inverse(&mut self.conv, &mut self.fft_buffer) { 329 | output.fill(F::zero()); 330 | return Err(err.into()); 331 | } 332 | 333 | // Add overlap 334 | sum( 335 | &mut output[processed..processed + processing], 336 | &self.fft_buffer[input_buffer_pos..input_buffer_pos + processing], 337 | &self.overlap[input_buffer_pos..input_buffer_pos + processing], 338 | ); 339 | 340 | // Input buffer full => Next block 341 | self.input_buffer_fill += processing; 342 | if self.input_buffer_fill == self.block_size { 343 | // Input buffer is empty again now 344 | self.input_buffer.fill(F::zero()); 345 | self.input_buffer_fill = 0; 346 | // Save the overlap 347 | self.overlap 348 | .copy_from_slice(&self.fft_buffer[self.block_size..self.block_size * 2]); 349 | 350 | // Update the current segment 351 | self.current = if self.current > 0 { 352 | self.current - 1 353 | } else { 354 | self.active_seg_count - 1 355 | }; 356 | } 357 | processed += processing; 358 | } 359 | Ok(()) 360 | } 361 | 362 | /// Clears the internal processing state while preserving the impulse response 363 | /// 364 | /// This real-time safe operation resets all internal buffers that store the 365 | /// convolution state, effectively removing any "history" or "tail" from previous 366 | /// processing. The impulse response configuration remains intact, so processing 367 | /// can continue immediately. 368 | /// 369 | /// This is useful when handling stream discontinuities such as: 370 | /// - Seeking in audio playback 371 | /// - Pause/resume operations with large time gaps 372 | /// - Switching between different audio sources 373 | /// 374 | /// After calling `reset()`, the next `process()` call will produce output as if 375 | /// the convolver had just been initialized. 376 | /// 377 | /// # Example 378 | /// 379 | /// ``` 380 | /// use fft_convolver::FFTConvolver; 381 | /// 382 | /// let mut convolver = FFTConvolver::::default(); 383 | /// let ir = vec![0.5, 0.3, 0.2]; 384 | /// convolver.init(128, &ir).unwrap(); 385 | /// 386 | /// let input = vec![1.0; 256]; 387 | /// let mut output = vec![0.0; 256]; 388 | /// convolver.process(&input, &mut output).unwrap(); 389 | /// 390 | /// // Clear the state when seeking to a new position 391 | /// convolver.reset(); 392 | /// 393 | /// // Continue processing with fresh state 394 | /// convolver.process(&input, &mut output).unwrap(); 395 | /// ``` 396 | #[nonblocking] 397 | pub fn reset(&mut self) { 398 | self.input_buffer.fill(F::zero()); 399 | self.input_buffer_fill = 0; 400 | 401 | self.fft_buffer.fill(F::zero()); 402 | for segment in &mut self.segments { 403 | segment.fill(Complex::zero()); 404 | } 405 | 406 | self.conv.fill(Complex::zero()); 407 | self.pre_multiplied.fill(Complex::zero()); 408 | 409 | self.overlap.fill(F::zero()); 410 | self.current = 0; 411 | } 412 | } 413 | 414 | // Tests 415 | #[cfg(test)] 416 | mod tests { 417 | use crate::{FFTConvolver, FFTConvolverError}; 418 | 419 | #[test] 420 | fn init_test() { 421 | let mut convolver = FFTConvolver::default(); 422 | let ir = vec![1., 0., 0., 0.]; 423 | convolver.init(10, &ir).unwrap(); 424 | 425 | assert_eq!(convolver.ir_len, 4); 426 | assert_eq!(convolver.block_size, 16); 427 | assert_eq!(convolver.seg_size, 32); 428 | assert_eq!(convolver.seg_count, 1); 429 | assert_eq!(convolver.active_seg_count, 1); 430 | assert_eq!(convolver.fft_complex_size, 17); 431 | 432 | assert_eq!(convolver.segments.len(), 1); 433 | assert_eq!(convolver.segments.first().unwrap().len(), 17); 434 | for seg in &convolver.segments { 435 | for num in seg { 436 | assert_eq!(num.re, 0.); 437 | assert_eq!(num.im, 0.); 438 | } 439 | } 440 | 441 | assert_eq!(convolver.segments_ir.len(), 1); 442 | assert_eq!(convolver.segments_ir.first().unwrap().len(), 17); 443 | for seg in &convolver.segments_ir { 444 | for num in seg { 445 | assert_eq!(num.re, 1.); 446 | assert_eq!(num.im, 0.); 447 | } 448 | } 449 | 450 | assert_eq!(convolver.fft_buffer.len(), 32); 451 | assert_eq!(*convolver.fft_buffer.first().unwrap(), 1.); 452 | for i in 1..convolver.fft_buffer.len() { 453 | assert_eq!(convolver.fft_buffer[i], 0.); 454 | } 455 | 456 | assert_eq!(convolver.pre_multiplied.len(), 17); 457 | for num in &convolver.pre_multiplied { 458 | assert_eq!(num.re, 0.); 459 | assert_eq!(num.im, 0.); 460 | } 461 | 462 | assert_eq!(convolver.conv.len(), 17); 463 | for num in &convolver.conv { 464 | assert_eq!(num.re, 0.); 465 | assert_eq!(num.im, 0.); 466 | } 467 | 468 | assert_eq!(convolver.overlap.len(), 16); 469 | for num in &convolver.overlap { 470 | assert_eq!(*num, 0.); 471 | } 472 | 473 | assert_eq!(convolver.input_buffer.len(), 16); 474 | for num in &convolver.input_buffer { 475 | assert_eq!(*num, 0.); 476 | } 477 | 478 | assert_eq!(convolver.input_buffer_fill, 0); 479 | } 480 | 481 | #[test] 482 | fn process_test() { 483 | let mut convolver = FFTConvolver::::default(); 484 | let ir = vec![1., 0., 0., 0.]; 485 | convolver.init(2, &ir).unwrap(); 486 | 487 | let input = vec![0., 1., 2., 3.]; 488 | let mut output = vec![0.; 4]; 489 | convolver.process(&input, &mut output).unwrap(); 490 | 491 | for i in 0..output.len() { 492 | assert_eq!(input[i], output[i]); 493 | } 494 | } 495 | 496 | #[test] 497 | fn reset_test() { 498 | // Create an impulse response with actual filtering characteristics 499 | let ir = vec![0.5, 0.3, 0.2, 0.1]; 500 | let block_size = 4; 501 | 502 | // First convolver: process data, then clear, then process again 503 | let mut convolver1 = FFTConvolver::::default(); 504 | convolver1.init(block_size, &ir).unwrap(); 505 | 506 | // Process some data to build up history 507 | let history_input = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; 508 | let mut history_output = vec![0.0; 8]; 509 | convolver1 510 | .process(&history_input, &mut history_output) 511 | .unwrap(); 512 | 513 | // Clear the history 514 | convolver1.reset(); 515 | 516 | // Process fresh data after clearing 517 | let test_input = vec![1.0, 1.0, 1.0, 1.0]; 518 | let mut output1 = vec![0.0; 4]; 519 | convolver1.process(&test_input, &mut output1).unwrap(); 520 | 521 | // Second convolver: freshly initialized, process the same data 522 | let mut convolver2 = FFTConvolver::::default(); 523 | convolver2.init(block_size, &ir).unwrap(); 524 | let mut output2 = vec![0.0; 4]; 525 | convolver2.process(&test_input, &mut output2).unwrap(); 526 | 527 | // The outputs should be identical if clear() truly cleared all history 528 | for i in 0..output1.len() { 529 | assert!( 530 | (output1[i] - output2[i]).abs() < 1e-5, 531 | "Mismatch at index {}: cleared convolver produced {}, fresh convolver produced {}", 532 | i, 533 | output1[i], 534 | output2[i] 535 | ); 536 | } 537 | } 538 | 539 | #[test] 540 | fn reset_preserves_configuration() { 541 | // Test that clear() preserves the convolver configuration 542 | let ir = vec![0.5, 0.3, 0.2, 0.1]; 543 | let block_size = 4; 544 | 545 | let mut convolver = FFTConvolver::::default(); 546 | convolver.init(block_size, &ir).unwrap(); 547 | 548 | let ir_len = convolver.ir_len; 549 | let block_size_actual = convolver.block_size; 550 | let seg_size = convolver.seg_size; 551 | let seg_count = convolver.seg_count; 552 | 553 | // Process some data 554 | let input = vec![1.0, 2.0, 3.0, 4.0]; 555 | let mut output = vec![0.0; 4]; 556 | convolver.process(&input, &mut output).unwrap(); 557 | 558 | // Clear 559 | convolver.reset(); 560 | 561 | // Configuration should be unchanged 562 | assert_eq!(convolver.ir_len, ir_len); 563 | assert_eq!(convolver.block_size, block_size_actual); 564 | assert_eq!(convolver.seg_size, seg_size); 565 | assert_eq!(convolver.seg_count, seg_count); 566 | } 567 | 568 | #[test] 569 | fn set_response_equals_init() { 570 | // Test that set_response produces the same results as init 571 | let ir1 = vec![0.5, 0.3, 0.2, 0.1]; 572 | let ir2 = vec![0.8, 0.6, 0.4, 0.2]; 573 | let block_size = 4; 574 | 575 | // Convolver 1: Initialize with ir1, then set_response to ir2 576 | let mut convolver1 = FFTConvolver::::default(); 577 | convolver1.init(block_size, &ir1).unwrap(); 578 | convolver1.set_response(&ir2).unwrap(); 579 | 580 | // Convolver 2: Initialize directly with ir2 581 | let mut convolver2 = FFTConvolver::::default(); 582 | convolver2.init(block_size, &ir2).unwrap(); 583 | 584 | // Process the same input with both convolvers 585 | let input = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; 586 | let mut output1 = vec![0.0; 8]; 587 | let mut output2 = vec![0.0; 8]; 588 | 589 | convolver1.process(&input, &mut output1).unwrap(); 590 | convolver2.process(&input, &mut output2).unwrap(); 591 | 592 | // The outputs should be identical 593 | for i in 0..output1.len() { 594 | assert!( 595 | (output1[i] - output2[i]).abs() < 1e-5, 596 | "Mismatch at index {}: set_response produced {}, init produced {}", 597 | i, 598 | output1[i], 599 | output2[i] 600 | ); 601 | } 602 | } 603 | 604 | #[test] 605 | fn set_response_with_shorter_ir() { 606 | // Test that set_response works correctly with a shorter impulse response 607 | let ir1 = vec![0.5, 0.3, 0.2, 0.1, 0.05, 0.02]; 608 | let ir2 = vec![0.8, 0.6, 0.4]; 609 | let block_size = 4; 610 | 611 | // Initialize with longer IR, then set to shorter IR 612 | let mut convolver1 = FFTConvolver::::default(); 613 | convolver1.init(block_size, &ir1).unwrap(); 614 | convolver1.set_response(&ir2).unwrap(); 615 | 616 | // Initialize directly with shorter IR 617 | let mut convolver2 = FFTConvolver::::default(); 618 | convolver2.init(block_size, &ir2).unwrap(); 619 | 620 | // Process the same input 621 | let input = vec![1.0, 1.0, 1.0, 1.0]; 622 | let mut output1 = vec![0.0; 4]; 623 | let mut output2 = vec![0.0; 4]; 624 | 625 | convolver1.process(&input, &mut output1).unwrap(); 626 | convolver2.process(&input, &mut output2).unwrap(); 627 | 628 | // The outputs should be identical 629 | for i in 0..output1.len() { 630 | assert!( 631 | (output1[i] - output2[i]).abs() < 1e-5, 632 | "Mismatch at index {}: set_response produced {}, init produced {}", 633 | i, 634 | output1[i], 635 | output2[i] 636 | ); 637 | } 638 | } 639 | 640 | #[test] 641 | fn set_response_too_long_returns_error() { 642 | // Test that set_response returns an error when IR is too long 643 | let ir1 = vec![0.5, 0.3, 0.2, 0.1]; 644 | let ir2 = vec![0.8, 0.6, 0.4, 0.2, 0.1, 0.05]; 645 | let block_size = 4; 646 | 647 | let mut convolver = FFTConvolver::::default(); 648 | convolver.init(block_size, &ir1).unwrap(); 649 | 650 | // Attempting to set a longer IR should fail 651 | let result = convolver.set_response(&ir2); 652 | assert!(result.is_err()); 653 | assert!(matches!( 654 | result.unwrap_err(), 655 | FFTConvolverError::ImpulseResponseExceedsCapacity 656 | )); 657 | } 658 | 659 | #[test] 660 | fn test_zero_latency() { 661 | // Test that the algorithm has zero latency (no algorithmic delay) 662 | // An impulse at input[0] should produce output starting at output[0] 663 | let mut convolver = FFTConvolver::::default(); 664 | 665 | // Use a simple impulse response: just pass through with some gain 666 | let ir = vec![0.5, 0.3, 0.2, 0.1]; 667 | convolver.init(4, &ir).unwrap(); 668 | 669 | // Send an impulse at the very first sample 670 | let mut input = vec![0.0; 16]; 671 | input[0] = 1.0; // Impulse at position 0 672 | 673 | let mut output = vec![0.0; 16]; 674 | convolver.process(&input, &mut output).unwrap(); 675 | 676 | // Check that the first output sample has the impulse response 677 | // If there were latency, output[0] would be 0.0 678 | assert!( 679 | output[0].abs() > 0.0, 680 | "Output[0] should be non-zero, indicating zero latency. Got: {}", 681 | output[0] 682 | ); 683 | 684 | // Verify the output matches the impulse response 685 | assert!( 686 | (output[0] - 0.5).abs() < 1e-5, 687 | "output[0] should be 0.5, got {}", 688 | output[0] 689 | ); 690 | assert!( 691 | (output[1] - 0.3).abs() < 1e-5, 692 | "output[1] should be 0.3, got {}", 693 | output[1] 694 | ); 695 | assert!( 696 | (output[2] - 0.2).abs() < 1e-5, 697 | "output[2] should be 0.2, got {}", 698 | output[2] 699 | ); 700 | assert!( 701 | (output[3] - 0.1).abs() < 1e-5, 702 | "output[3] should be 0.1, got {}", 703 | output[3] 704 | ); 705 | } 706 | } 707 | --------------------------------------------------------------------------------