diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a9b9702..b92edc4 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -55,6 +55,20 @@ jobs: - run: cargo run --release --example mp3-samples + miri: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly + components: miri + - uses: Swatinem/rust-cache@v2 + - name: Install required Linux packages for "audio-visualizer"/cpal/minifb + run: sudo apt update && sudo apt -y install libasound2-dev libxkbcommon-dev + - run: cargo miri test --all-features + style_checks: runs-on: ubuntu-latest strategy: diff --git a/CHANGELOG.md b/CHANGELOG.md index cfa7b2c..0994967 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,14 @@ # Changelog -## Unreleased -- Added FFT buffer size of 32768 +## Unreleased (yet) + -## 1.6.0 (2024-12-16) +## 1.6.0 (2024-12-17) - dependency updates - MSRV bump but only for the tests and examples, not library users +- Added FFT buffer size of 32768 +- Optimized implementation, resulting in less unnecessary copying of data +- Removed excessive stack usage for large input data ## 1.5.0 (2023-09-21) - fixed the build by updating the dependencies diff --git a/examples/bench.rs b/examples/bench.rs deleted file mode 100644 index 635875a..0000000 --- a/examples/bench.rs +++ /dev/null @@ -1,99 +0,0 @@ -/* -MIT License - -Copyright (c) 2023 Philipp Schuster - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ - -#![deny( - clippy::all, - clippy::cargo, - clippy::nursery, - // clippy::restriction, - // clippy::pedantic -)] -// now allow a few rules which are denied by the above statement -// --> they are ridiculous and not necessary -#![allow( - clippy::suboptimal_flops, - clippy::redundant_pub_crate, - clippy::fallible_impl_from -)] -#![deny(missing_debug_implementations)] -#![deny(rustdoc::all)] - -use std::time::Instant; - -use spectrum_analyzer::*; - -/// Benchmark can be used to check how changes effect the performance. -/// Always execute with release flag! -fn main() { - // create 2048 random samples - let samples = (0..2048) - .map(|_| rand::random::()) - .map(|x| x as f32) - .collect::>(); - let hann_window = windows::hann_window(&samples); - - let bench_res_without_scaling = bench_without_scaling(hann_window.clone()); - let bench_res_with_scaling = bench_with_scaling(hann_window); - - println!( - "Bench without scaling: avg = {}us per Iteration", - bench_res_without_scaling - ); - println!( - "Bench with scaling: avg = {}us per Iteration", - bench_res_with_scaling - ); -} - -fn bench_without_scaling(samples: Vec) -> u64 { - let fnc = move || samples_fft_to_spectrum(&samples, 44100, FrequencyLimit::All, None).unwrap(); - bench_fnc(Box::new(fnc)) -} - -fn bench_with_scaling(samples: Vec) -> u64 { - let fnc = move || { - samples_fft_to_spectrum( - &samples, - 44100, - FrequencyLimit::All, - Some(&scaling::divide_by_N), - ) - .unwrap() - }; - bench_fnc(Box::new(fnc)) -} - -fn bench_fnc(fnc: Box FrequencySpectrum>) -> u64 { - // warm-up - for _ in 0..10 { - let _ = fnc(); - } - let now = Instant::now(); - let runs = 10000; - for _ in 0..runs { - let _ = fnc(); - } - let duration = now.elapsed(); - (duration.as_micros() / runs) as u64 -} diff --git a/examples/mp3-samples.rs b/examples/mp3-samples.rs index 2f976e7..8c2885b 100644 --- a/examples/mp3-samples.rs +++ b/examples/mp3-samples.rs @@ -45,9 +45,24 @@ use spectrum_analyzer::scaling::scale_to_zero_to_one; use spectrum_analyzer::windows::{blackman_harris_4term, hamming_window, hann_window}; use spectrum_analyzer::{samples_fft_to_spectrum, FrequencyLimit}; use std::fs::File; +use std::path::PathBuf; use std::time::Instant; -const TEST_OUT_DIR: &str = "test/out"; +/// Returns the location where tests should store files they produce. +fn test_out_dir() -> PathBuf { + let path = std::env::var("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| { + let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let dir = PathBuf::from(dir); + dir.join("target") + }); + let path = path.join("test_generated"); + if !path.exists() { + std::fs::create_dir(path.clone()).unwrap(); + } + path +} fn main() { println!("bass drum example:"); @@ -213,31 +228,31 @@ fn to_spectrum_and_plot( spectrum_static_plotters_png_visualize( &spectrum_no_window.to_map(), - TEST_OUT_DIR, + test_out_dir().to_str().unwrap(), &format!("{}--no-window.png", filename), ); spectrum_static_plotters_png_visualize( &spectrum_hamming_window.to_map(), - TEST_OUT_DIR, + test_out_dir().to_str().unwrap(), &format!("{}--hamming-window.png", filename), ); spectrum_static_plotters_png_visualize( &spectrum_hann_window.to_map(), - TEST_OUT_DIR, + test_out_dir().to_str().unwrap(), &format!("{}--hann-window.png", filename), ); spectrum_static_plotters_png_visualize( &spectrum_blackman_harris_4term_window.to_map(), - TEST_OUT_DIR, + test_out_dir().to_str().unwrap(), &format!("{}--blackman-harris-4-term-window.png", filename), ); spectrum_static_plotters_png_visualize( &spectrum_blackman_harris_7term_window.to_map(), - TEST_OUT_DIR, + test_out_dir().to_str().unwrap(), &format!("{}--blackman-harris-7-term-window.png", filename), ); } diff --git a/src/fft.rs b/src/fft.rs index 1437540..9419c3a 100644 --- a/src/fft.rs +++ b/src/fft.rs @@ -22,46 +22,47 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -//! Real FFT using [`microfft::real`] that is very fast and also works in `no_std` -//! environments. It is faster than regular fft (with the `rustfft` crate for -//! example). The difference to a complex FFT, as with `rustfft` is, that the -//! result vector contains less results as there are no mirrored frequencies. +//! Real FFT using [`microfft::real`] that is very fast and also works in +//! `no_std` environments. It is faster than regular fft (with the `rustfft` +//! crate for example). The difference to a complex FFT, as with `rustfft` is, +//! that the result vector contains fewer results as there are no mirrored +//! frequencies. + +/// FFT base result type. +pub use microfft::Complex32; use alloc::vec::Vec; use core::convert::TryInto; +use core::mem; use microfft::real; -/// The result of a FFT is always complex but because different FFT crates might -/// use different versions of "num-complex", each implementation exports -/// it's own version that gets used in lib.rs for binary compatibility. -pub use microfft::Complex32; - -/// Calculates the real FFT by invoking the proper function corresponding to the -/// buffer length. +/// Calculates the FFT by invoking the function of [`microfft::real`] that +/// corresponds to the input size. macro_rules! real_fft_n { ($buffer:expr, $( $i:literal ),*) => { match $buffer.len() { $( $i => { - let mut buffer: [_; $i] = $buffer.try_into().unwrap(); + let fixed_size_view = $buffer.as_mut_slice().try_into().unwrap(); paste::paste! ( real::[] - )(&mut buffer).to_vec() + )(fixed_size_view) } )* - _ => { unimplemented!("unexpected buffer len") } + _ => { unimplemented!("should be one of the supported buffer lengths, but was {}", $buffer.len()) } } }; } -/// Real FFT using [`microfft::real`]. +/// FFT using [`microfft::real`]. pub struct FftImpl; impl FftImpl { - /// Calculates the FFT For the given input samples and returns a Vector of - /// of [`Complex32`] with length `samples.len() / 2 + 1`, where the first - /// index corresponds to the DC component and the last index to the Nyquist - /// frequency. + /// Calculates the FFT For the given input samples and returns a [`Vec`] of + /// [`Complex32`] with length `samples.len() / 2 + 1`. + /// + /// The first index corresponds to the DC component and the last index to + /// the Nyquist frequency. /// /// # Parameters /// - `samples`: Array with samples. Each value must be a regular floating @@ -69,16 +70,72 @@ impl FftImpl { /// a power of two. Otherwise, the function panics. #[inline] pub(crate) fn calc(samples: &[f32]) -> Vec { - let mut fft_res: Vec = real_fft_n!( - samples, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768 + assert_eq!( + samples.len() % 2, + 0, + "buffer length must be a multiple of two!" + ); + let mut vec_buffer = Vec::with_capacity(samples.len() + 2 /* Nyquist */); + assert_eq!( + vec_buffer.capacity() % 2, + 0, + "vector capacity must be a multiple of two for safe casting!" ); + vec_buffer.extend_from_slice(samples); + + // The result is a view into the buffer. + // We discard the view and directly operate on the buffer. + let _fft_res: &mut [Complex32] = real_fft_n!( + &mut vec_buffer, + 2, + 4, + 8, + 16, + 32, + 64, + 128, + 256, + 512, + 1024, + 2048, + 4096, + 8192, + 16384, + 32768 + ); + + // We transform the original vector while preserving its memory, to + // prevent any reallocation or unnecessary copying. + let mut buffer = { + let ptr = vec_buffer.as_mut_ptr().cast::(); + let len = vec_buffer.len() / 2; + let capacity = vec_buffer.capacity() / 2; + let new_buffer_view = unsafe { Vec::from_raw_parts(ptr, len, capacity) }; + mem::forget(vec_buffer); + new_buffer_view + }; + // `microfft::real` documentation says: the Nyquist frequency real value // is packed inside the imaginary part of the DC component. - let nyquist_fr_pos_val = fft_res[0].im; - fft_res[0].im = 0.0; - // manually add the nyquist frequency - fft_res.push(Complex32::new(nyquist_fr_pos_val, 0.0)); - fft_res + let nyquist_fr_pos_val = buffer[0].im; + buffer[0].im = 0.0; + // manually add the Nyquist frequency + buffer.push(Complex32::new(nyquist_fr_pos_val, 0.0)); + buffer + } +} + +#[cfg(test)] +mod tests { + use crate::fft::FftImpl; + + /// This test is primarily for miri. + #[test] + fn test_memory_safety() { + let samples = [1.0, 2.0, 3.0, 4.0]; + let fft = FftImpl::calc(&samples); + + assert_eq!(fft.len(), 2 + 1); } } diff --git a/src/lib.rs b/src/lib.rs index b50cc11..bcc9d3a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -73,26 +73,23 @@ SOFTWARE. #![deny(rustdoc::all)] #![no_std] -// enable std in tests (println!() for example) #[cfg_attr(test, macro_use)] #[cfg(test)] extern crate std; -// We use alloc crate, because this is no_std -// The macros are only needed when we test #[macro_use] extern crate alloc; -use alloc::vec::Vec; - -use crate::error::SpectrumAnalyzerError; -use crate::fft::{Complex32, FftImpl}; pub use crate::frequency::{Frequency, FrequencyValue}; pub use crate::limit::FrequencyLimit; pub use crate::limit::FrequencyLimitError; -use crate::scaling::SpectrumScalingFunction; pub use crate::spectrum::FrequencySpectrum; +use crate::error::SpectrumAnalyzerError; +use crate::fft::{Complex32, FftImpl}; +use crate::scaling::SpectrumScalingFunction; +use alloc::vec::Vec; + pub mod error; mod fft; mod frequency; @@ -311,7 +308,7 @@ fn fft_result_to_spectrum( .map(|(fr, complex_res)| (fr, complex_to_magnitude(complex_res))) // transform to my thin convenient orderable f32 wrappers .map(|(fr, val)| (Frequency::from(fr), FrequencyValue::from(val))) - // collect all into an sorted vector (from lowest frequency to highest) + // collect all into a sorted vector (from lowest frequency to highest) .collect::>(); let mut working_buffer = vec![(0.0.into(), 0.0.into()); frequency_vec.len()]; @@ -353,9 +350,9 @@ fn fft_calc_frequency_resolution(sampling_rate: u32, samples_len: u32) -> f32 { sampling_rate as f32 / samples_len as f32 } -/// Maps a [`Complex32`] to it's magnitude as `f32`. This is done -/// by calculating `sqrt(re*re + im*im)`. This is required to convert -/// the complex FFT result back to real values. +/// Maps a [`Complex32`] to its magnitude as `f32`. This is done by calculating +/// `sqrt(re*re + im*im)`. This is required to convert the complex FFT results +/// back to real values. /// /// ## Parameters /// * `val` A single value from the FFT output buffer of type [`Complex32`]. diff --git a/src/tests/mod.rs b/src/tests/mod.rs index ddaf43f..e43ead7 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -33,15 +33,28 @@ use audio_visualizer::spectrum::plotters_png_file::spectrum_static_plotters_png_ use audio_visualizer::waveform::plotters_png_file::waveform_static_plotters_png_visualize; use audio_visualizer::Channels; use core::cmp::max; - -// /// Directory with test samples (e.g. mp3) can be found here. -// const TEST_SAMPLES_DIR: &str = "test/samples"; -/// If tests create files, they should be stored here. -const TEST_OUT_DIR: &str = "test/out"; +use std::path::PathBuf; + +/// Returns the location where tests should store files they produce. +fn test_out_dir() -> PathBuf { + let path = std::env::var("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| { + let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let dir = PathBuf::from(dir); + dir.join("target") + }); + let path = path.join("test_generated"); + if !path.exists() { + std::fs::create_dir(path.clone()).unwrap(); + } + path +} mod sine; #[test] +#[cfg_attr(miri, ignore)] // runs forever + no real value add fn test_spectrum_and_visualize_sine_waves_50_1000_3777hz() { let sine_audio = sine_wave_audio_data_multiple(&[50.0, 1000.0, 3777.0], 44100, 1000); @@ -49,7 +62,7 @@ fn test_spectrum_and_visualize_sine_waves_50_1000_3777hz() { waveform_static_plotters_png_visualize( &sine_audio, Channels::Mono, - TEST_OUT_DIR, + test_out_dir().to_str().unwrap(), "test_spectrum_and_visualize_sine_waves_50_1000_3777hz--WAVEFORM.png", ); @@ -96,21 +109,21 @@ fn test_spectrum_and_visualize_sine_waves_50_1000_3777hz() { spectrum_static_plotters_png_visualize( // spectrum_static_png_visualize( &spectrum_no_window.to_map(), - TEST_OUT_DIR, + test_out_dir().to_str().unwrap(), "test_spectrum_and_visualize_sine_waves_50_1000_3777hz--no-window.png", ); spectrum_static_plotters_png_visualize( // spectrum_static_png_visualize( &spectrum_hamming_window.to_map(), - TEST_OUT_DIR, + test_out_dir().to_str().unwrap(), "test_spectrum_and_visualize_sine_waves_50_1000_3777hz--hamming-window.png", ); spectrum_static_plotters_png_visualize( // spectrum_static_png_visualize( &spectrum_hann_window.to_map(), - TEST_OUT_DIR, + test_out_dir().to_str().unwrap(), "test_spectrum_and_visualize_sine_waves_50_1000_3777hz--hann-window.png", ); @@ -140,6 +153,7 @@ fn test_spectrum_and_visualize_sine_waves_50_1000_3777hz() { /// divided by their corresponding N (length of samples), the values must match /// (with a small delta). #[test] +#[cfg_attr(miri, ignore)] // runs forever + no real value add fn test_spectrum_power() { let interesting_frequency = 2048.0; let sine_audio = sine_wave_audio_data_multiple(&[interesting_frequency], 44100, 1000); @@ -180,17 +194,17 @@ fn test_spectrum_power() { spectrum_static_plotters_png_visualize( &spectrum_short_window.to_map(), - TEST_OUT_DIR, + test_out_dir().to_str().unwrap(), "test_spectrum_power__short_window.png", ); spectrum_static_plotters_png_visualize( &spectrum_long_window.to_map(), - TEST_OUT_DIR, + test_out_dir().to_str().unwrap(), "test_spectrum_power__long_window.png", ); /*spectrum_static_plotters_png_visualize( &spectrum_long_window.to_map(), - TEST_OUT_DIR, + test_out_dir().to_str().unwrap(), "test_spectrum_power__very_long_window.png", );*/ @@ -283,6 +297,7 @@ fn test_spectrum_frequency_limit_inclusive() { /// Tests that the spectrum contains the Nyquist frequency. #[test] +#[cfg_attr(miri, ignore)] // runs forever + no real value add fn test_spectrum_nyquist_theorem() { let dummy_audio_samples = vec![0.0; 4096]; let spectrum = @@ -313,6 +328,7 @@ fn test_spectrum_nyquist_theorem() { /// Tests that the spectrum contains the Nyquist frequency using a sine wave at almost Nyquist /// frequency. #[test] +#[cfg_attr(miri, ignore)] // runs forever + no real value add fn test_spectrum_nyquist_theorem2() { let sine_audio = sine_wave_audio_data_multiple( // 22050.0 results in aliasing and no good results @@ -426,6 +442,7 @@ fn test_scaling_produces_error() { /// Test that the scaling actually has the effect that we expect it to have. #[test] +#[cfg_attr(miri, ignore)] // runs forever + no real value add fn test_divide_by_n_has_effect() { let audio_data = sine_wave_audio_data_multiple(&[100.0, 200.0, 400.0], 1000, 2000); let audio_data = audio_data.into_iter().map(|x| x as f32).collect::>(); diff --git a/test/out/.gitignore b/test/out/.gitignore deleted file mode 100644 index ceabd5c..0000000 --- a/test/out/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -* -!.gitignore -!readme.txt \ No newline at end of file