├── .cargo └── config.toml ├── .editorconfig ├── .envrc ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── qa.yml │ └── rust.yml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── benches ├── beat_detection_bench.rs └── general.rs ├── check-build.sh ├── demo.gif ├── examples ├── _modules │ └── example_utils.rs ├── cpal-info.rs ├── live-input-minimal.rs └── live-input-visualize.rs ├── res ├── holiday_lowpassed--excerpt.wav ├── holiday_lowpassed--long.wav ├── holiday_lowpassed--single-beat.wav ├── sample1_lowpassed--double-beat.wav ├── sample1_lowpassed--long.wav └── sample1_lowpassed--single-beat.wav ├── shell.nix └── src ├── audio_history.rs ├── beat_detector.rs ├── envelope_iterator.rs ├── lib.rs ├── max_min_iterator.rs ├── root_iterator.rs ├── stdlib ├── mod.rs └── recording.rs ├── test_utils.rs └── util.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | # https://doc.rust-lang.org/cargo/reference/config.html 2 | # 3 | # Compile for maximum performance. Only relevant for example binaries in this 4 | # repository. 5 | 6 | [build] 7 | rustflags = [ 8 | "-C", 9 | "target-cpu=native", 10 | ] 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 4 11 | trim_trailing_whitespace = true 12 | max_line_length = 80 13 | 14 | [{*.nix,*.yml}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use nix 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: phip1611 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: "*" 10 | update-types: [ "version-update:semver-patch" ] 11 | - package-ecosystem: github-actions 12 | directory: "/" 13 | schedule: 14 | interval: monthly 15 | open-pull-requests-limit: 10 16 | -------------------------------------------------------------------------------- /.github/workflows/qa.yml: -------------------------------------------------------------------------------- 1 | name: QA 2 | on: [merge_group, push, pull_request] 3 | jobs: 4 | spellcheck: 5 | name: Spellcheck 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | # Executes "typos ." 10 | - uses: crate-ci/typos@v1.31.2 11 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | # Triggers the workflow on push or pull request events (for any branch in a repository) 4 | on: [ push, pull_request, merge_group ] 5 | 6 | env: 7 | CARGO_TERM_COLOR: always 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | rust: 15 | - stable 16 | - nightly 17 | - 1.76.0 # MSRV 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Setup Rust toolchain 21 | uses: dtolnay/rust-toolchain@stable 22 | with: 23 | toolchain: ${{ matrix.rust }} 24 | - uses: Swatinem/rust-cache@v2 25 | with: 26 | key: "${{ matrix.runs-on }}-${{ matrix.rust }}" 27 | # required because of "cpal" 28 | - run: sudo apt update && sudo apt install -y libasound2-dev 29 | - run: cargo build --all-targets 30 | - run: cargo test 31 | 32 | build_nostd: 33 | runs-on: ubuntu-latest 34 | needs: 35 | # Only logical dependency 36 | - build 37 | strategy: 38 | matrix: 39 | rust: 40 | - stable 41 | - nightly 42 | - 1.76.0 # MSRV 43 | steps: 44 | - uses: actions/checkout@v4 45 | - name: Setup Rust toolchain 46 | uses: dtolnay/rust-toolchain@stable 47 | with: 48 | toolchain: ${{ matrix.rust }} 49 | - uses: Swatinem/rust-cache@v2 50 | with: 51 | key: "${{ matrix.runs-on }}-${{ matrix.rust }}" 52 | # required because of "cpal" 53 | - run: sudo apt update && sudo apt install -y libasound2-dev 54 | # install some no_std target 55 | - run: rustup target add thumbv7em-none-eabihf 56 | # Reset target-cpu=native .cargo/config.toml 57 | - run: RUSTFLAGS="-C target-cpu=" cargo build --no-default-features --target thumbv7em-none-eabihf 58 | 59 | benchmarks: 60 | runs-on: ubuntu-latest 61 | needs: 62 | # Only logical dependency 63 | - build 64 | strategy: 65 | matrix: 66 | rust: 67 | - stable 68 | steps: 69 | - uses: actions/checkout@v4 70 | - name: Setup Rust toolchain 71 | uses: dtolnay/rust-toolchain@stable 72 | with: 73 | toolchain: ${{ matrix.rust }} 74 | - uses: Swatinem/rust-cache@v2 75 | with: 76 | key: "${{ matrix.runs-on }}-${{ matrix.rust }}" 77 | # required because of "cpal" 78 | - run: sudo apt update && sudo apt install -y libasound2-dev 79 | - run: cargo bench 80 | 81 | style_checks: 82 | runs-on: ubuntu-latest 83 | strategy: 84 | matrix: 85 | rust: 86 | - stable 87 | steps: 88 | - uses: actions/checkout@v4 89 | - name: Setup Rust toolchain 90 | uses: dtolnay/rust-toolchain@stable 91 | with: 92 | toolchain: ${{ matrix.rust }} 93 | - uses: Swatinem/rust-cache@v2 94 | with: 95 | key: "${{ matrix.runs-on }}-${{ matrix.rust }}" 96 | # required because of "cpal" 97 | - run: sudo apt update && sudo apt install -y libasound2-dev 98 | - name: rustfmt 99 | run: cargo fmt -- --check 100 | - name: Clippy 101 | run: cargo clippy --all-targets --all-features 102 | - name: Rustdoc 103 | run: cargo doc --no-deps --document-private-items --all-features 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/vscode,rust,clion+all,windows,macos,linux 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=vscode,rust,clion+all,windows,macos,linux 4 | 5 | ### CLion+all ### 6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 8 | 9 | # User-specific stuff 10 | .idea/**/workspace.xml 11 | .idea/**/tasks.xml 12 | .idea/**/usage.statistics.xml 13 | .idea/**/dictionaries 14 | .idea/**/shelf 15 | 16 | # Generated files 17 | .idea/**/contentModel.xml 18 | 19 | # Sensitive or high-churn files 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.local.xml 23 | .idea/**/sqlDataSources.xml 24 | .idea/**/dynamic.xml 25 | .idea/**/uiDesigner.xml 26 | .idea/**/dbnavigator.xml 27 | 28 | # Gradle 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # Gradle and Maven with auto-import 33 | # When using Gradle or Maven with auto-import, you should exclude module files, 34 | # since they will be recreated, and may cause churn. Uncomment if using 35 | # auto-import. 36 | # .idea/artifacts 37 | # .idea/compiler.xml 38 | # .idea/jarRepositories.xml 39 | # .idea/modules.xml 40 | # .idea/*.iml 41 | # .idea/modules 42 | # *.iml 43 | # *.ipr 44 | 45 | # CMake 46 | cmake-build-*/ 47 | 48 | # Mongo Explorer plugin 49 | .idea/**/mongoSettings.xml 50 | 51 | # File-based project format 52 | *.iws 53 | 54 | 55 | # mpeltonen/sbt-idea plugin 56 | .idea_modules/ 57 | 58 | # JIRA plugin 59 | atlassian-ide-plugin.xml 60 | 61 | # Cursive Clojure plugin 62 | .idea/replstate.xml 63 | 64 | # Crashlytics plugin (for Android Studio and IntelliJ) 65 | com_crashlytics_export_strings.xml 66 | crashlytics.properties 67 | crashlytics-build.properties 68 | fabric.properties 69 | 70 | # Editor-based Rest Client 71 | .idea/httpRequests 72 | 73 | # Android studio 3.1+ serialized cache file 74 | .idea/caches/build_file_checksums.ser 75 | 76 | ### CLion+all Patch ### 77 | # Ignores the whole .idea folder and all .iml files 78 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 79 | 80 | .idea/ 81 | 82 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 83 | 84 | *.iml 85 | modules.xml 86 | .idea/misc.xml 87 | *.ipr 88 | 89 | # Sonarlint plugin 90 | .idea/sonarlint 91 | 92 | ### Linux ### 93 | *~ 94 | 95 | # temporary files which can be created if a process still has a handle open of a deleted file 96 | .fuse_hidden* 97 | 98 | # KDE directory preferences 99 | .directory 100 | 101 | # Linux trash folder which might appear on any partition or disk 102 | .Trash-* 103 | 104 | # .nfs files are created when an open file is removed but is still being accessed 105 | .nfs* 106 | 107 | ### macOS ### 108 | # General 109 | .DS_Store 110 | .AppleDouble 111 | .LSOverride 112 | 113 | # Icon must end with two \r 114 | Icon 115 | 116 | 117 | # Thumbnails 118 | ._* 119 | 120 | # Files that might appear in the root of a volume 121 | .DocumentRevisions-V100 122 | .fseventsd 123 | .Spotlight-V100 124 | .TemporaryItems 125 | .Trashes 126 | .VolumeIcon.icns 127 | .com.apple.timemachine.donotpresent 128 | 129 | # Directories potentially created on remote AFP share 130 | .AppleDB 131 | .AppleDesktop 132 | Network Trash Folder 133 | Temporary Items 134 | .apdisk 135 | 136 | ### Rust ### 137 | # Generated by Cargo 138 | # will have compiled files and executables 139 | /target/ 140 | 141 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 142 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 143 | Cargo.lock 144 | 145 | ### vscode ### 146 | .vscode/* 147 | !.vscode/settings.json 148 | !.vscode/tasks.json 149 | !.vscode/launch.json 150 | !.vscode/extensions.json 151 | *.code-workspace 152 | 153 | ### Windows ### 154 | # Windows thumbnail cache files 155 | Thumbs.db 156 | Thumbs.db:encryptable 157 | ehthumbs.db 158 | ehthumbs_vista.db 159 | 160 | # Dump file 161 | *.stackdump 162 | 163 | # Folder config file 164 | [Dd]esktop.ini 165 | 166 | # Recycle Bin used on file shares 167 | $RECYCLE.BIN/ 168 | 169 | # Windows Installer files 170 | *.cab 171 | *.msi 172 | *.msix 173 | *.msm 174 | *.msp 175 | 176 | # Windows shortcuts 177 | *.lnk 178 | 179 | # End of https://www.toptal.com/developers/gitignore/api/vscode,rust,clion+all,windows,macos,linux 180 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "beat-detector" 3 | description = """ 4 | beat-detector detects beats in live audio, but can also be used for post 5 | analysis of audio data. It is a library written in Rust that is 6 | `no_std`-compatible and doesn't need `alloc`. 7 | """ 8 | version = "0.2.0" 9 | authors = ["Philipp Schuster "] 10 | edition = "2021" 11 | license = "MIT" 12 | keywords = ["audio", "beat", "beat-detection"] 13 | categories = ["multimedia::audio", "no-std"] 14 | readme = "README.md" 15 | homepage = "https://github.com/phip1611/beat-detector" 16 | repository = "https://github.com/phip1611/beat-detector" 17 | documentation = "https://docs.rs/beat-detector" 18 | exclude = [ 19 | ".cargo", 20 | ".editorconfig", 21 | ".github", 22 | "check-build.sh", 23 | "demo.gif", 24 | "src/bin", # only internal binaries, if any 25 | "res" 26 | ] 27 | rust-version = "1.76.0" 28 | 29 | [features] 30 | default = ["recording"] 31 | 32 | # Marker/helper 33 | std = [] 34 | 35 | # Actual features 36 | recording = ["std", "dep:cpal"] 37 | 38 | [[bench]] 39 | name = "beat_detection_bench" 40 | harness = false 41 | 42 | [[bench]] 43 | name = "general" 44 | harness = false 45 | 46 | [[example]] 47 | name = "live-input-minimal" 48 | required-features = ["recording"] 49 | 50 | [[example]] 51 | name = "live-input-visualize" 52 | required-features = ["recording"] 53 | 54 | [dependencies] 55 | # +++ NOSTD DEPENDENCIES +++ 56 | biquad = { version = "0.4", default-features = false } # lowpass filter 57 | libm = { version = "0.2.8", default-features = false } 58 | log = { version = "0.4", default-features = false } 59 | ringbuffer = { version = "0.15.0", default-features = false } 60 | 61 | # +++ STD DEPENDENCIES +++ 62 | cpal = { version = "0.15", default-features = false, features = [], optional = true } 63 | 64 | 65 | [dev-dependencies] 66 | assert2 = "0.3.14" 67 | ctrlc = { version = "3.4", features = ["termination"] } 68 | criterion = { version = "0.5", features = [] } 69 | float-cmp = "0.10.0" 70 | hound = "3.5.1" 71 | itertools = "0.14.0" 72 | simple_logger = "5.0" 73 | minifb = "0.27.0" 74 | rand = "0.8.5" 75 | 76 | 77 | [profile.dev] 78 | # otherwise many code is too slow 79 | # remove when using the debugger 80 | # opt-level = 1 81 | 82 | [profile.release] 83 | # Trimmed to maximum performance. 84 | # 85 | # These changes only affects examples and tests build inside this crate but 86 | # not libraries that use this. 87 | codegen-units = 1 88 | lto = true 89 | 90 | [profile.bench] 91 | codegen-units = 1 92 | lto = true 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Philipp Schuster 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Beat Detector - Audio Beat Detection Library Written In Rust 2 | 3 | beat-detector detects beats in live audio, but can also be used for post 4 | analysis of audio data. It is a library written in Rust that is 5 | `no_std`-compatible and doesn't need `alloc`. 6 | 7 | beat-detector was developed with typical sampling rates and bit depths in 8 | mind, namely 44.1 kHz, 48.0 kHz, and 16 bit. Other input sources might work 9 | as well. 10 | 11 | # Performance / Latency 12 | On a realistic workload each analysis step of my algorithm, i.e., on each new audio input, took 0.5ms on a Raspberry 13 | Pi and 0.05ms on an Intel i5-10600K. The benchmark binary was build as optimized release build. Thus, this is the 14 | minimum latency you have to expect plus additional latency from the audio input interface. 15 | The benchmark can be executed with: `cargo run --release --example --bench` 16 | 17 | TODO: performance/latency over memory usage. Thus higher memory usage and more buffers for maximum performance 18 | 19 | --- 20 | 21 | This is a Rust library that enables beat detection on live audio data input. 22 | One use case is that you have an audio/aux-splitter on your computer where one 23 | end goes into the sound system whereas the other goes into the microphone input 24 | of a Raspberry Pi. 25 | 26 | TODO outdated 27 | 28 | The crate provides multiple strategies that you can connect to the audio source. 29 | So far it offers two strategies: 30 | - **Simple Lowpass Filter** 31 | - not really good, must be more fine-tuned 32 | - **Simple Spectrum Analysis** 33 | - good enough for most "simple" songs, like 90s pop hits or "Kids" by "MGMT" 34 | - Super Awesome Analysis (TODO) - **CODE CONTRIBUTIONS ARE WELCOME** 35 | 36 | I'm not an expert in audio analysis, but I'm proud what I achieved so far with the spectrum strategy. 37 | This library needs a more "bulletproof" strategy, to cope with complex and fast songs. 38 | 39 | Here's a demo I recorded in my room. Of course, it was synced to music, when I recorded it. :) 40 | 41 | ![Beat Detection Demo With WS2812 RGBs](demo.gif "Beat Detection Demo With WS2812 RGBs") 42 | 43 | ## How To Use 44 | **Cargo.toml** 45 | ```toml 46 | beat-detector = "" 47 | ``` 48 | 49 | **code.rs** 50 | (also see `examples/` in repository!) 51 | ```rust 52 | //! Minimum example on how to use this library. Sets up the "callback loop". 53 | 54 | use cpal::Device; 55 | use beat_detector::StrategyKind; 56 | use std::sync::atomic::{AtomicBool, Ordering}; 57 | use std::sync::Arc; 58 | 59 | /// Minimum example on how to use this library. Sets up the "callback loop". 60 | fn main() { 61 | let recording = Arc::new(AtomicBool::new(true)); 62 | 63 | let recording_cpy = recording.clone(); 64 | ctrlc::set_handler(move || { 65 | eprintln!("Stopping recording"); 66 | recording_cpy.store(false, Ordering::SeqCst); 67 | }).unwrap(); 68 | 69 | let dev = select_input_device(); 70 | let strategy = select_strategy(); 71 | let on_beat = |info| { 72 | println!("Found beat at {:?}ms", info); 73 | }; 74 | // actually start listening in thread 75 | let handle = beat_detector::record::start_listening( 76 | on_beat, 77 | Some(dev), 78 | strategy, 79 | recording, 80 | ).unwrap(); 81 | 82 | handle.join().unwrap(); 83 | } 84 | 85 | fn select_input_device() -> Device { 86 | // todo implement user selection 87 | beat_detector::record::audio_input_device_list().into_iter().next().expect("At least one audio input device must be available.").1 88 | } 89 | 90 | fn select_strategy() -> StrategyKind { 91 | // todo implement user selection 92 | StrategyKind::Spectrum 93 | } 94 | ``` 95 | 96 | ## MSRV (Minimal Supported Rust Version) 97 | 98 | 1.76 stable 99 | -------------------------------------------------------------------------------- /benches/beat_detection_bench.rs: -------------------------------------------------------------------------------- 1 | use beat_detector::BeatDetector; 2 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; 3 | 4 | fn criterion_benchmark(c: &mut Criterion) { 5 | let (samples, header) = samples::holiday_long(); 6 | // Chosen a value in the middle with lots of peaks, so lots of calculations 7 | // to be done. 8 | let slice_of_interest = &samples[28000..28000 + 4096]; 9 | 10 | let mut detector = BeatDetector::new(header.sample_rate as f32, true); 11 | c.bench_function( 12 | "simulate beat detection (with lowpass) with 4096 samples per invocation", 13 | |b| { 14 | b.iter(|| { 15 | // We do not care about the correct detection. Using this, I just want 16 | // to find out overall calculation time and do profiling to see which 17 | // functions can be optimized. 18 | let _ = 19 | detector.update_and_detect_beat(black_box(slice_of_interest.iter().copied())); 20 | }) 21 | }, 22 | ); 23 | 24 | let mut detector = BeatDetector::new(header.sample_rate as f32, false); 25 | c.bench_function( 26 | "simulate beat detection (no lowpass) with 4096 samples per invocation", 27 | |b| { 28 | b.iter(|| { 29 | // We do not care about the correct detection. Using this, I just want 30 | // to find out overall calculation time and do profiling to see which 31 | // functions can be optimized. 32 | let _ = 33 | detector.update_and_detect_beat(black_box(slice_of_interest.iter().copied())); 34 | }) 35 | }, 36 | ); 37 | } 38 | 39 | criterion_group!(benches, criterion_benchmark); 40 | criterion_main!(benches); 41 | 42 | mod samples { 43 | use crate::helpers::read_wav_to_mono; 44 | 45 | /// Returns the mono samples of the holiday sample (long version) 46 | /// together with the sampling rate. 47 | pub fn holiday_long() -> (Vec, hound::WavSpec) { 48 | read_wav_to_mono("res/holiday_lowpassed--long.wav") 49 | } 50 | } 51 | 52 | /// Copy and paste from `test_utils.rs`. 53 | mod helpers { 54 | use beat_detector::util::stereo_to_mono; 55 | use itertools::Itertools; 56 | use std::path::Path; 57 | 58 | pub fn read_wav_to_mono>(file: T) -> (Vec, hound::WavSpec) { 59 | let mut reader = hound::WavReader::open(file).unwrap(); 60 | let header = reader.spec(); 61 | 62 | // owning vector with original data in i16 format 63 | let data = reader 64 | .samples::() 65 | .map(|s| s.unwrap()) 66 | .collect::>(); 67 | 68 | if header.channels == 1 { 69 | (data, header) 70 | } else if header.channels == 2 { 71 | let data = data 72 | .into_iter() 73 | .chunks(2) 74 | .into_iter() 75 | .map(|mut lr| { 76 | let l = lr.next().unwrap(); 77 | let r = lr 78 | .next() 79 | .expect("should have an even number of LRLR samples"); 80 | stereo_to_mono(l, r) 81 | }) 82 | .collect::>(); 83 | (data, header) 84 | } else { 85 | panic!("unsupported format!"); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /benches/general.rs: -------------------------------------------------------------------------------- 1 | //! Benchmarks a few general audio transformations relevant in the field of this 2 | //! crate. Useful to run this on a host platform to see the roughly costs. 3 | //! 4 | //! To run bench these, run `$ cargo bench "convert samples"` 5 | 6 | use beat_detector::util::{f32_sample_to_i16, i16_sample_to_f32, stereo_to_mono}; 7 | use criterion::{criterion_group, criterion_main, Criterion}; 8 | use itertools::Itertools; 9 | use std::hint::black_box; 10 | 11 | fn criterion_benchmark(c: &mut Criterion) { 12 | let typical_sampling_rate = 44100; 13 | let sample_count = typical_sampling_rate; 14 | let mut samples_f32 = vec![0.0; sample_count]; 15 | samples_f32.fill_with(rand::random::); 16 | let mut samples_i16 = vec![0; sample_count]; 17 | samples_i16.fill_with(rand::random::); 18 | 19 | assert_eq!(samples_f32.len(), sample_count); 20 | assert_eq!(samples_i16.len(), sample_count); 21 | 22 | c.bench_function( 23 | &format!("{sample_count} convert samples (i16 to f32)"), 24 | |b| { 25 | b.iter(|| { 26 | let _res = black_box( 27 | samples_i16 28 | .iter() 29 | .copied() 30 | .map(|s| i16_sample_to_f32(black_box(s))) 31 | .collect::>(), 32 | ); 33 | }) 34 | }, 35 | ); 36 | 37 | c.bench_function( 38 | &format!("{sample_count} convert samples (i16 to f32 (just cast))"), 39 | |b| { 40 | b.iter(|| { 41 | let _res = black_box( 42 | samples_i16 43 | .iter() 44 | .copied() 45 | .map(|s| black_box(s as f32)) 46 | .collect::>(), 47 | ); 48 | }) 49 | }, 50 | ); 51 | 52 | c.bench_function( 53 | &format!("{sample_count} convert samples (f32 to i16)"), 54 | |b| { 55 | b.iter(|| { 56 | let _res = black_box( 57 | samples_f32 58 | .iter() 59 | .copied() 60 | .map(|s| f32_sample_to_i16(black_box(s)).unwrap()) 61 | .collect::>(), 62 | ); 63 | }) 64 | }, 65 | ); 66 | 67 | c.bench_function( 68 | &format!("{sample_count} convert samples (i16 stereo to mono)"), 69 | |b| { 70 | b.iter(|| { 71 | let _res = black_box( 72 | samples_i16 73 | .iter() 74 | .copied() 75 | // We pretend the data is interleaved (LRLR pattern). 76 | .chunks(2) 77 | .into_iter() 78 | .map(|mut lr| { 79 | let l = lr.next().unwrap(); 80 | let r = lr 81 | .next() 82 | .expect("should have an even number of LRLR samples"); 83 | stereo_to_mono(black_box(l), black_box(r)) 84 | }) 85 | .collect::>(), 86 | ); 87 | }) 88 | }, 89 | ); 90 | } 91 | 92 | criterion_group!(benches, criterion_benchmark); 93 | criterion_main!(benches); 94 | -------------------------------------------------------------------------------- /check-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "checks that this builds on std+no_std + that all tests run" 4 | 5 | cargo build --all-targets # build works 6 | cargo test --all-targets # tests work 7 | # install some no_std target 8 | rustup target add thumbv7em-none-eabihf 9 | # test no_std-build 10 | RUSTFLAGS="-C target-cpu=" cargo build --no-default-features --target thumbv7em-none-eabihf 11 | 12 | cargo doc 13 | cargo fmt -- --check 14 | cargo clippy --all-targets 15 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phip1611/beat-detector/925862a1c24825d396566a28a8073dccfcab6ebd/demo.gif -------------------------------------------------------------------------------- /examples/_modules/example_utils.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused)] 2 | 3 | use cpal::traits::{DeviceTrait, HostTrait}; 4 | use log::LevelFilter; 5 | use std::io::{Read, Write}; 6 | use std::process::exit; 7 | 8 | pub fn init_logger() { 9 | simple_logger::SimpleLogger::new() 10 | .with_level(LevelFilter::Debug) 11 | .with_colors(true) 12 | .with_utc_timestamps() 13 | .init() 14 | .unwrap(); 15 | } 16 | 17 | /// Returns all valid and available input devices. 18 | fn get_input_devices() -> Vec<(cpal::HostId, Vec)> { 19 | cpal::available_hosts() 20 | .into_iter() 21 | .map(|host_id| { 22 | let host = cpal::host_from_id(host_id).expect("should know the just queried host"); 23 | (host_id, host) 24 | }) 25 | .map(|(host_id, host)| (host_id, host.devices())) 26 | .filter(|(_, devices)| devices.is_ok()) 27 | .map(|(host_id, devices)| (host_id, devices.unwrap())) 28 | .map(|(host_id, devices)| { 29 | ( 30 | host_id, 31 | devices 32 | .into_iter() 33 | // check: is input device? 34 | .filter(|dev| dev.default_input_config().is_ok()) 35 | // check: can we get its name? 36 | .filter(|dev| dev.name().is_ok()) 37 | .collect::>(), 38 | ) 39 | }) 40 | .collect::>() 41 | } 42 | 43 | fn get_input_devices_flat() -> Vec<(cpal::HostId, cpal::Device)> { 44 | get_input_devices() 45 | .into_iter() 46 | .flat_map(|(host_id, devices)| { 47 | devices 48 | .into_iter() 49 | .map(|dev| (host_id, dev)) 50 | .collect::>() 51 | }) 52 | .collect::>() 53 | } 54 | 55 | /// Prompts the user in the terminal to choose an audio backend. 56 | pub fn select_audio_device() -> cpal::Device { 57 | let mut devices = get_input_devices_flat(); 58 | 59 | if devices.is_empty() { 60 | println!("No audio input device available"); 61 | exit(0); 62 | } 63 | 64 | if devices.len() == 1 { 65 | return devices.swap_remove(0).1; 66 | } 67 | 68 | println!("Available input devices:"); 69 | for (device_i, (host_id, device)) in devices.iter().enumerate() { 70 | println!( 71 | "[{}]: {:?} - {}", 72 | device_i, 73 | host_id, 74 | device 75 | .name() 76 | .expect("should be existent at that point due to the filtering") 77 | ); 78 | } 79 | 80 | print!("Type a number: "); 81 | std::io::stdout().flush().unwrap(); 82 | 83 | let mut buf = [0]; 84 | std::io::stdin().read_exact(&mut buf).unwrap(); 85 | println!(); // newline 86 | let buf = std::str::from_utf8(&buf).unwrap(); 87 | let choice = str::parse::(buf).unwrap(); 88 | 89 | // Remove element and take ownership. 90 | devices.swap_remove(choice).1 91 | } 92 | -------------------------------------------------------------------------------- /examples/cpal-info.rs: -------------------------------------------------------------------------------- 1 | use cpal::traits::DeviceTrait; 2 | 3 | #[path = "_modules/example_utils.rs"] 4 | mod example_utils; 5 | 6 | /// Minimal example to explore the structure of the audio input samples we get 7 | /// from cpal. This binary does nothing with the beat detection library. 8 | fn main() { 9 | let input_device = example_utils::select_audio_device(); 10 | let supported_configs = input_device 11 | .supported_input_configs() 12 | .unwrap() 13 | .collect::>(); 14 | println!("Supported input configs:"); 15 | for cfg in supported_configs { 16 | println!( 17 | "channels: {:>2}, format: {format:>3}, min_rate: {:06?}, max_rate: {:06?}, buffer: {:?}", 18 | cfg.channels(), 19 | cfg.min_sample_rate(), 20 | cfg.max_sample_rate(), 21 | cfg.buffer_size(), 22 | format = format!("{:?}", cfg.sample_format(),) 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/live-input-minimal.rs: -------------------------------------------------------------------------------- 1 | use beat_detector::recording; 2 | use cpal::traits::StreamTrait; 3 | use std::sync::atomic::{AtomicBool, Ordering}; 4 | use std::sync::Arc; 5 | 6 | #[path = "_modules/example_utils.rs"] 7 | mod example_utils; 8 | 9 | fn main() { 10 | example_utils::init_logger(); 11 | let input_device = example_utils::select_audio_device(); 12 | 13 | let stop_recording = Arc::new(AtomicBool::new(false)); 14 | { 15 | let stop_recording = stop_recording.clone(); 16 | ctrlc::set_handler(move || { 17 | stop_recording.store(true, Ordering::SeqCst); 18 | }) 19 | .unwrap(); 20 | } 21 | 22 | let handle = recording::start_detector_thread( 23 | |info| { 24 | println!("beat: {info:?}"); 25 | }, 26 | Some(input_device), 27 | ) 28 | .unwrap(); 29 | 30 | log::info!("Start recording"); 31 | while !stop_recording.load(Ordering::SeqCst) {} 32 | handle.pause().unwrap(); 33 | log::info!("Stopped recording"); 34 | } 35 | -------------------------------------------------------------------------------- /examples/live-input-visualize.rs: -------------------------------------------------------------------------------- 1 | use beat_detector::recording; 2 | use cpal::traits::StreamTrait; 3 | use minifb::{Key, Window, WindowOptions}; 4 | use std::sync::atomic::{AtomicBool, Ordering}; 5 | use std::sync::{Arc, Mutex}; 6 | 7 | #[path = "_modules/example_utils.rs"] 8 | mod example_utils; 9 | 10 | const WIDTH: usize = 600; 11 | const HEIGHT: usize = 400; 12 | 13 | fn main() { 14 | example_utils::init_logger(); 15 | let input_device = example_utils::select_audio_device(); 16 | 17 | let ctrlc_pressed = Arc::new(AtomicBool::new(false)); 18 | { 19 | let stop_recording = ctrlc_pressed.clone(); 20 | ctrlc::set_handler(move || { 21 | stop_recording.store(true, Ordering::SeqCst); 22 | }) 23 | .unwrap(); 24 | } 25 | 26 | // Each Pixel is encoded as "<:8>". 27 | let rgb_buffer: Vec = vec![0 /* black */; WIDTH * HEIGHT]; 28 | let mut rgb_copy_buffer = rgb_buffer.clone(); 29 | let rgb_buffer = Arc::new(Mutex::new(rgb_buffer)); 30 | 31 | let mut window = Window::new( 32 | "Live Beat Visualizer - ESC to exit", 33 | WIDTH, 34 | HEIGHT, 35 | WindowOptions::default(), 36 | ) 37 | .unwrap_or_else(|e| { 38 | panic!("{}", e); 39 | }); 40 | 41 | window.set_target_fps(60); 42 | 43 | let handle = { 44 | let rgb_buffer = rgb_buffer.clone(); 45 | recording::start_detector_thread( 46 | move |_info| { 47 | println!("found beat!"); 48 | let mut rgb_buffer_locked = rgb_buffer.lock().unwrap(); 49 | for xrgb_pxl in rgb_buffer_locked.iter_mut() { 50 | *xrgb_pxl = 0x00ffffffff; 51 | } 52 | }, 53 | Some(input_device), 54 | ) 55 | .unwrap() 56 | }; 57 | 58 | log::info!("Start recording"); 59 | 60 | while window.is_open() 61 | && !window.is_key_down(Key::Escape) 62 | && !ctrlc_pressed.load(Ordering::SeqCst) 63 | { 64 | let mut rgb_buffer_locked = rgb_buffer.lock().unwrap(); 65 | for (i, xrgb_pxl) in rgb_buffer_locked.iter_mut().enumerate() { 66 | *xrgb_pxl = u32::from_ne_bytes([ 67 | (xrgb_pxl.to_ne_bytes()[0] as f32 * 0.95) as u8, 68 | (xrgb_pxl.to_ne_bytes()[1] as f32 * 0.95) as u8, 69 | (xrgb_pxl.to_ne_bytes()[2] as f32 * 0.95) as u8, 70 | 0, 71 | ]); 72 | // Update copy buffer. 73 | rgb_copy_buffer[i] = *xrgb_pxl; 74 | } 75 | 76 | // drop lock as early as possible to unblock beat detection thread. 77 | drop(rgb_buffer_locked); 78 | 79 | // We unwrap here as we want this code to exit if it fails. 80 | window 81 | .update_with_buffer(&rgb_copy_buffer, WIDTH, HEIGHT) 82 | .unwrap(); 83 | } 84 | handle.pause().unwrap(); 85 | log::info!("Stopped recording"); 86 | } 87 | -------------------------------------------------------------------------------- /res/holiday_lowpassed--excerpt.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phip1611/beat-detector/925862a1c24825d396566a28a8073dccfcab6ebd/res/holiday_lowpassed--excerpt.wav -------------------------------------------------------------------------------- /res/holiday_lowpassed--long.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phip1611/beat-detector/925862a1c24825d396566a28a8073dccfcab6ebd/res/holiday_lowpassed--long.wav -------------------------------------------------------------------------------- /res/holiday_lowpassed--single-beat.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phip1611/beat-detector/925862a1c24825d396566a28a8073dccfcab6ebd/res/holiday_lowpassed--single-beat.wav -------------------------------------------------------------------------------- /res/sample1_lowpassed--double-beat.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phip1611/beat-detector/925862a1c24825d396566a28a8073dccfcab6ebd/res/sample1_lowpassed--double-beat.wav -------------------------------------------------------------------------------- /res/sample1_lowpassed--long.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phip1611/beat-detector/925862a1c24825d396566a28a8073dccfcab6ebd/res/sample1_lowpassed--long.wav -------------------------------------------------------------------------------- /res/sample1_lowpassed--single-beat.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phip1611/beat-detector/925862a1c24825d396566a28a8073dccfcab6ebd/res/sample1_lowpassed--single-beat.wav -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import { } }: 2 | 3 | let 4 | libDeps = with pkgs; [ 5 | # gui examples (minifb) 6 | libxkbcommon 7 | xorg.libXcursor 8 | xorg.libX11 9 | ]; 10 | in 11 | pkgs.mkShell { 12 | packages = with pkgs; [ 13 | # Base deps 14 | alsa-lib 15 | pkg-config 16 | 17 | # benchmarks 18 | gnuplot 19 | 20 | # Development 21 | nixpkgs-fmt 22 | rustup 23 | ] ++ libDeps; 24 | 25 | LD_LIBRARY_PATH = "${pkgs.lib.makeLibraryPath libDeps}"; 26 | } 27 | -------------------------------------------------------------------------------- /src/audio_history.rs: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2024 Philipp Schuster 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | use crate::envelope_iterator::ENVELOPE_MIN_DURATION_MS; 25 | use core::cmp::Ordering; 26 | use core::time::Duration; 27 | use ringbuffer::{ConstGenericRingBuffer, RingBuffer}; 28 | 29 | const SAFETY_BUFFER_FACTOR: f64 = 3.0; 30 | /// Length in ms of the captured audio history used for analysis. 31 | pub(crate) const DEFAULT_AUDIO_HISTORY_WINDOW_MS: usize = 32 | (ENVELOPE_MIN_DURATION_MS as f64 * SAFETY_BUFFER_FACTOR) as usize; 33 | 34 | /// Based on the de-facto default sampling rate of 44100 Hz / 44.1 kHz. 35 | const DEFAULT_SAMPLES_PER_SECOND: usize = 44100; 36 | const MS_PER_SECOND: usize = 1000; 37 | 38 | /// Default buffer size for [`AudioHistory`]. The size is a trade-off between 39 | /// memory efficiency and effectiveness in detecting envelops properly. 40 | pub const DEFAULT_BUFFER_SIZE: usize = 41 | (DEFAULT_AUDIO_HISTORY_WINDOW_MS * DEFAULT_SAMPLES_PER_SECOND) / MS_PER_SECOND; 42 | 43 | /// Sample info with time context. 44 | #[derive(Copy, Clone, Debug, Default)] 45 | pub struct SampleInfo { 46 | /// The value of the sample. 47 | pub value: i16, 48 | /// Absolute value of `value`. 49 | pub value_abs: i16, 50 | /// The current index in [`AudioHistory`]. 51 | pub index: usize, 52 | /// The total index since the beginning of audio history. 53 | pub total_index: usize, 54 | /// Relative timestamp since beginning of audio history. 55 | pub timestamp: Duration, 56 | /// The time the sample is behind the latest data. 57 | pub duration_behind: Duration, 58 | } 59 | 60 | impl PartialEq for SampleInfo { 61 | fn eq(&self, other: &Self) -> bool { 62 | self.total_index.eq(&other.total_index) 63 | } 64 | } 65 | 66 | impl PartialOrd for SampleInfo { 67 | fn partial_cmp(&self, other: &Self) -> Option { 68 | Some(self.cmp(other)) 69 | } 70 | } 71 | 72 | impl Eq for SampleInfo {} 73 | 74 | impl Ord for SampleInfo { 75 | fn cmp(&self, other: &Self) -> Ordering { 76 | self.total_index 77 | .partial_cmp(&other.total_index) 78 | .expect("Should be comparable") 79 | } 80 | } 81 | 82 | /// Accessor over the captured audio history that helps to identify the 83 | /// timestamp of each sample. 84 | /// 85 | /// Users are supposed to add new data in chunks that are less than the buffer 86 | /// size, to slowly fade out old data from the underlying ringbuffer. 87 | #[derive(Debug)] 88 | pub struct AudioHistory { 89 | audio_buffer: ConstGenericRingBuffer, 90 | total_consumed_samples: usize, 91 | time_per_sample: f32, 92 | } 93 | 94 | impl AudioHistory { 95 | pub fn new(sampling_frequency: f32) -> Self { 96 | let audio_buffer = ConstGenericRingBuffer::new(); 97 | assert!(sampling_frequency.is_normal() && sampling_frequency.is_sign_positive()); 98 | Self { 99 | audio_buffer, 100 | time_per_sample: 1.0 / sampling_frequency, 101 | total_consumed_samples: 0, 102 | } 103 | } 104 | 105 | /// Update the audio history with fresh samples. The audio samples are 106 | /// expected to be in mono channel format. 107 | #[inline] 108 | pub fn update>(&mut self, mono_samples_iter: I) { 109 | let mut len = 0; 110 | mono_samples_iter.for_each(|sample| { 111 | self.audio_buffer.push(sample); 112 | len += 1; 113 | }); 114 | 115 | self.total_consumed_samples += len; 116 | 117 | if len >= self.audio_buffer.capacity() { 118 | log::warn!( 119 | "Adding {} samples to the audio buffer that only has a capacity for {} samples.", 120 | len, 121 | self.audio_buffer.capacity() 122 | ); 123 | #[cfg(test)] 124 | std::eprintln!( 125 | "WARN: AudioHistory::update: Adding {} samples to the audio buffer that only has a capacity for {} samples.", 126 | len, 127 | self.audio_buffer.capacity() 128 | ); 129 | } 130 | } 131 | 132 | /// Get the passed time in seconds. 133 | #[inline] 134 | pub fn passed_time(&self) -> Duration { 135 | let seconds = self.time_per_sample * self.total_consumed_samples as f32; 136 | Duration::from_secs_f32(seconds) 137 | } 138 | 139 | /// Access the underlying data storage. 140 | #[inline] 141 | pub const fn data(&self) -> &ConstGenericRingBuffer { 142 | &self.audio_buffer 143 | } 144 | 145 | /// Returns the [`SampleInfo`] about a sample from the current index of that 146 | /// sample. 147 | #[inline] 148 | pub fn index_to_sample_info(&self, index: usize) -> SampleInfo { 149 | assert!(index < self.data().capacity()); 150 | 151 | let timestamp = self.timestamp_of_index(index); 152 | let value = self.data()[index]; 153 | SampleInfo { 154 | index, 155 | timestamp, 156 | value, 157 | value_abs: value.abs(), 158 | total_index: self.index_to_sample_number(index), 159 | duration_behind: self.timestamp_of_index(self.data().len() - 1) - timestamp, 160 | } 161 | } 162 | 163 | /// Returns the index in the current captured audio window from the total 164 | /// index of the given sample, if present. 165 | #[inline] 166 | pub fn total_index_to_index(&self, total_index: usize) -> Option { 167 | // TODO this looks way too complicated. Probably can be simplified. 168 | if self.lost_samples() == 0 { 169 | if total_index < self.total_consumed_samples { 170 | Some(total_index) 171 | } else { 172 | None 173 | } 174 | } else if total_index < self.lost_samples() { 175 | None 176 | } else { 177 | let index = total_index - self.lost_samples(); 178 | if index <= self.data().capacity() { 179 | Some(index) 180 | } else { 181 | None 182 | } 183 | } 184 | } 185 | 186 | /// Returns the sample number that an index belongs to. Note that a higher 187 | /// index and a higher sample number means fresher data. 188 | /// 189 | /// This function takes care of the fact that the underlying ringbuffer will 190 | /// overflow over time and indices will change. 191 | #[inline] 192 | fn index_to_sample_number(&self, index: usize) -> usize { 193 | assert!(index <= self.data().len()); 194 | index + self.lost_samples() 195 | } 196 | 197 | /// Returns the amount of lost samples, i.e., samples that are no in the 198 | /// underlying ringbuffer anymore. 199 | #[inline] 200 | fn lost_samples(&self) -> usize { 201 | if self.total_consumed_samples <= self.data().capacity() { 202 | 0 203 | } else { 204 | self.total_consumed_samples - self.data().capacity() 205 | } 206 | } 207 | 208 | /// Returns the relative timestamp (passed duration) of the given sample, 209 | /// it is in the range. 210 | #[inline] 211 | fn timestamp_of_sample(&self, sample_num: usize) -> Duration { 212 | if sample_num > self.total_consumed_samples { 213 | return Duration::default(); 214 | }; 215 | 216 | let seconds = sample_num as f32 * self.time_per_sample; 217 | Duration::from_secs_f32(seconds) 218 | } 219 | 220 | /// Convenient accessor over [`Self::timestamp_of_sample`] and 221 | /// [`Self::index_to_sample_number`] 222 | #[inline] 223 | fn timestamp_of_index(&self, index: usize) -> Duration { 224 | let sample_number = self.index_to_sample_number(index); 225 | self.timestamp_of_sample(sample_number) 226 | } 227 | 228 | /*/// Getter for the sampling frequency. 229 | pub fn sampling_frequency(&self) -> f32 { 230 | 1.0 / self.time_per_sample 231 | }*/ 232 | } 233 | 234 | #[cfg(test)] 235 | mod tests { 236 | use super::*; 237 | use std::iter; 238 | 239 | #[test] 240 | fn buffer_len_sane() { 241 | let sampling_rate = 1.0 / DEFAULT_SAMPLES_PER_SECOND as f32; 242 | let duration = Duration::from_secs_f32(sampling_rate * DEFAULT_BUFFER_SIZE as f32); 243 | assert!(duration.as_millis() > 10); 244 | assert!(duration.as_millis() <= 1000); 245 | } 246 | 247 | #[test] 248 | fn audio_duration_is_updated_properly() { 249 | let mut hist = AudioHistory::new(2.0); 250 | assert_eq!(hist.total_consumed_samples, 0); 251 | 252 | hist.update(iter::once(0)); 253 | assert_eq!(hist.total_consumed_samples, 1); 254 | assert_eq!(hist.passed_time(), Duration::from_secs_f32(0.5)); 255 | 256 | hist.update([0, 0].iter().copied()); 257 | assert_eq!(hist.total_consumed_samples, 3); 258 | assert_eq!(hist.passed_time(), Duration::from_secs_f32(1.5)); 259 | } 260 | 261 | #[test] 262 | fn index_to_sample_number_works_across_ringbuffer_overflow() { 263 | let mut hist = AudioHistory::new(2.0); 264 | 265 | let test_data = [0; DEFAULT_BUFFER_SIZE + 10]; 266 | 267 | hist.update(test_data[0..10].iter().copied()); 268 | assert_eq!(hist.index_to_sample_number(0), 0); 269 | assert_eq!(hist.index_to_sample_number(10), 10); 270 | 271 | // now the buffer is full, but no overflow yet 272 | hist.update(test_data[10..DEFAULT_BUFFER_SIZE].iter().copied()); 273 | assert_eq!(hist.index_to_sample_number(0), 0); 274 | assert_eq!(hist.index_to_sample_number(10), 10); 275 | assert_eq!( 276 | hist.index_to_sample_number(DEFAULT_BUFFER_SIZE), 277 | DEFAULT_BUFFER_SIZE 278 | ); 279 | 280 | // now the buffer overflowed 281 | hist.update( 282 | test_data[DEFAULT_BUFFER_SIZE..DEFAULT_BUFFER_SIZE + 10] 283 | .iter() 284 | .copied(), 285 | ); 286 | assert_eq!(hist.index_to_sample_number(0), 10); 287 | assert_eq!(hist.index_to_sample_number(10), 20); 288 | assert_eq!( 289 | hist.index_to_sample_number(DEFAULT_BUFFER_SIZE), 290 | DEFAULT_BUFFER_SIZE + 10 291 | ); 292 | } 293 | 294 | #[test] 295 | // transitively tests timestamp_of_sample() 296 | fn timestamp_of_index_properly_calculated() { 297 | let mut hist = AudioHistory::new(2.0); 298 | 299 | let test_data = [0; DEFAULT_BUFFER_SIZE + 10]; 300 | 301 | hist.update(test_data[0..10].iter().copied()); 302 | assert_eq!(hist.timestamp_of_index(0), Duration::from_secs_f32(0.0)); 303 | assert_eq!(hist.timestamp_of_index(10), Duration::from_secs_f32(5.0)); 304 | 305 | // now the buffer is full, but no overflow yet 306 | hist.update(test_data[10..DEFAULT_BUFFER_SIZE].iter().copied()); 307 | assert_eq!(hist.timestamp_of_index(0), Duration::from_secs_f32(0.0)); 308 | assert_eq!(hist.timestamp_of_index(10), Duration::from_secs_f32(5.0)); 309 | 310 | // now the buffer overflowed 311 | hist.update( 312 | test_data[DEFAULT_BUFFER_SIZE..DEFAULT_BUFFER_SIZE + 10] 313 | .iter() 314 | .copied(), 315 | ); 316 | assert_eq!(hist.timestamp_of_index(0), Duration::from_secs_f32(5.0)); 317 | assert_eq!(hist.timestamp_of_index(10), Duration::from_secs_f32(10.0)); 318 | } 319 | 320 | #[test] 321 | fn audio_history_on_real_data() { 322 | let (samples, header) = crate::test_utils::samples::sample1_long(); 323 | 324 | let mut history = AudioHistory::new(header.sample_rate as f32); 325 | history.update(samples.iter().copied()); 326 | 327 | assert_eq!( 328 | (history.passed_time().as_secs_f32() * 1000.0).round() / 1000.0, 329 | 7.999 330 | ); 331 | 332 | let timestamp_at_end = history 333 | .index_to_sample_info(history.data().capacity() - 1) 334 | .timestamp 335 | .as_secs_f32(); 336 | assert_eq!((timestamp_at_end * 1000.0).round() / 1000.0, 7.999); 337 | } 338 | 339 | #[test] 340 | fn sample_info() { 341 | let mut hist = AudioHistory::new(1.0); 342 | 343 | hist.update(iter::once(0)); 344 | assert_eq!( 345 | hist.index_to_sample_info(0).duration_behind, 346 | Duration::from_secs(0) 347 | ); 348 | hist.update(iter::once(0)); 349 | assert_eq!( 350 | hist.index_to_sample_info(0).duration_behind, 351 | Duration::from_secs(1) 352 | ); 353 | assert_eq!( 354 | hist.index_to_sample_info(1).duration_behind, 355 | Duration::from_secs(0) 356 | ); 357 | 358 | hist.update([0].repeat(hist.data().capacity() * 2).iter().copied()); 359 | 360 | assert_eq!( 361 | hist.index_to_sample_info(0).duration_behind, 362 | Duration::from_secs_f32((DEFAULT_BUFFER_SIZE - 1) as f32) 363 | ); 364 | assert_eq!( 365 | hist.index_to_sample_info(DEFAULT_BUFFER_SIZE - 10) 366 | .duration_behind, 367 | Duration::from_secs_f32(9.0) 368 | ); 369 | assert_eq!( 370 | hist.index_to_sample_info(DEFAULT_BUFFER_SIZE - 1) 371 | .duration_behind, 372 | Duration::from_secs(0) 373 | ); 374 | } 375 | 376 | /// Ensure that [`SampleInfo`] is ordered by `total_index`. 377 | #[test] 378 | fn sample_info_ordering() { 379 | assert_eq!( 380 | SampleInfo { 381 | total_index: 0, 382 | ..Default::default() 383 | }, 384 | SampleInfo { 385 | total_index: 0, 386 | ..Default::default() 387 | } 388 | ); 389 | 390 | assert!( 391 | SampleInfo { 392 | total_index: 0, 393 | ..Default::default() 394 | } < SampleInfo { 395 | total_index: 1, 396 | ..Default::default() 397 | } 398 | ); 399 | 400 | assert!( 401 | SampleInfo { 402 | total_index: 11, 403 | ..Default::default() 404 | } > SampleInfo { 405 | total_index: 10, 406 | ..Default::default() 407 | } 408 | ); 409 | } 410 | 411 | #[test] 412 | fn total_index_to_index_works() { 413 | let mut history = AudioHistory::new(1.0); 414 | for i in 0..history.data().capacity() { 415 | assert_eq!(history.total_index_to_index(i), None); 416 | history.update(iter::once(0)); 417 | assert_eq!(history.total_index_to_index(i), Some(i)); 418 | } 419 | 420 | history.update(iter::once(0)); 421 | // No longer existing. 422 | assert_eq!(history.total_index_to_index(0), None); 423 | assert_eq!(history.total_index_to_index(1), Some(0)); 424 | assert_eq!(history.total_index_to_index(2), Some(1)); 425 | assert_eq!( 426 | history.total_index_to_index(history.total_consumed_samples), 427 | Some(history.data().capacity()) 428 | ); 429 | } 430 | } 431 | -------------------------------------------------------------------------------- /src/beat_detector.rs: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2024 Philipp Schuster 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | //! Module for [`BeatDetector`]. 25 | 26 | use crate::EnvelopeInfo; 27 | use crate::{AudioHistory, EnvelopeIterator}; 28 | use biquad::{Biquad, Coefficients, DirectForm1, ToHertz, Type, Q_BUTTERWORTH_F32}; 29 | use core::fmt::Debug; 30 | 31 | /// Cutoff frequency for the lowpass filter to detect beats. 32 | const CUTOFF_FREQUENCY_HZ: f32 = 95.0; 33 | 34 | /// Information about a beat. 35 | pub type BeatInfo = EnvelopeInfo; 36 | 37 | /// Beat detector following the properties described in the 38 | /// [module description]. 39 | /// 40 | /// ## Example with audio source emitting mono samples 41 | /// ```rust 42 | /// use beat_detector::BeatDetector; 43 | /// let mono_samples = [0, 500, -800, 700 /*, ... */]; 44 | /// let mut detector = BeatDetector::new(44100.0, true); 45 | /// 46 | /// // TODO regularly call this with the latest audio data. 47 | /// let is_beat = detector.update_and_detect_beat( 48 | /// mono_samples.iter().copied() 49 | /// ); 50 | /// ``` 51 | /// 52 | /// ## Example with audio source emitting stereo samples 53 | /// ```rust 54 | /// use beat_detector::BeatDetector; 55 | /// use beat_detector::util::stereo_to_mono; 56 | /// // Let's pretend this is interleaved LRLR stereo data. 57 | /// let stereo_samples = [0, 500, -800, 700 /*, ... */]; 58 | /// let mut detector = BeatDetector::new(44100.0, true); 59 | /// 60 | /// // TODO regularly call this with the latest audio data. 61 | /// let is_beat = detector.update_and_detect_beat( 62 | /// stereo_samples.chunks(2).map(|slice| { 63 | /// let l = slice[0]; 64 | /// let r = slice[1]; 65 | /// stereo_to_mono(l, r) 66 | /// }) 67 | /// ); 68 | /// ``` 69 | /// 70 | /// [module description]: crate 71 | #[derive(Debug)] 72 | pub struct BeatDetector { 73 | lowpass_filter: DirectForm1, 74 | /// Whether the lowpass filter should be applied. Usually you want to 75 | /// set this to true. Set it to false if you know that all your audio 76 | /// input already only contains the interesting frequencies to save some 77 | /// computations. 78 | needs_lowpass_filter: bool, 79 | history: AudioHistory, 80 | /// Holds the previous beat. Once this is initialized, it is never `None`. 81 | previous_beat: Option, 82 | } 83 | 84 | impl BeatDetector { 85 | /// Creates a new beat detector. It is recommended to pass `true` to 86 | /// `needs_lowpass_filter`. If you know that the audio source has already 87 | /// run through a low-pass filter, you can set it to `false` to save 88 | /// a few cycles, with results in a slightly lower latency. 89 | pub fn new(sampling_frequency_hz: f32, needs_lowpass_filter: bool) -> Self { 90 | let lowpass_filter = Self::create_lowpass_filter(sampling_frequency_hz); 91 | Self { 92 | lowpass_filter, 93 | needs_lowpass_filter, 94 | history: AudioHistory::new(sampling_frequency_hz), 95 | previous_beat: None, 96 | } 97 | } 98 | 99 | /// Consumes the latest audio data and returns if the audio history, 100 | /// consisting of previously captured audio and the new data, contains a 101 | /// beat. This function is supposed to be frequently 102 | /// called everytime new audio data from the input source is available so 103 | /// that: 104 | /// - the latency is low 105 | /// - no beats are missed 106 | /// 107 | /// From experience, Linux audio input libraries (using ALSA as backend) 108 | /// give you a 20-40ms audio buffer every 20-40ms with the latest data. 109 | /// That's a good rule of thumb. This corresponds to 1800 mono samples at a 110 | /// sampling rate of 44.1kHz. 111 | /// 112 | /// If new audio data contains two beats, only the first one will be 113 | /// discovered. On the next invocation, the next beat will be discovered, 114 | /// if still present in the internal audio window. 115 | pub fn update_and_detect_beat( 116 | &mut self, 117 | mono_samples_iter: impl Iterator, 118 | ) -> Option { 119 | self.consume_audio(mono_samples_iter); 120 | 121 | let search_begin_index = self 122 | .previous_beat 123 | .and_then(|info| self.history.total_index_to_index(info.to.total_index)); 124 | 125 | // Envelope iterator with respect to previous beats. 126 | let mut envelope_iter = EnvelopeIterator::new(&self.history, search_begin_index); 127 | let beat = envelope_iter.next(); 128 | if let Some(beat) = beat { 129 | self.previous_beat.replace(beat); 130 | } 131 | beat 132 | } 133 | 134 | /// Applies the data from the given audio input to the lowpass filter (if 135 | /// necessary) and adds it to the internal audio window. 136 | fn consume_audio(&mut self, mono_samples_iter: impl Iterator) { 137 | let iter = mono_samples_iter.map(|sample| { 138 | if self.needs_lowpass_filter { 139 | // For the lowpass filter, it is perfectly fine to just 140 | // cast the types. We do not need to limit the i16 value to 141 | // the sample value of typical f32 samples. This is just 142 | // one instruction on x86. On ARM, this is also a 143 | // shortcut. 144 | let sample = self.lowpass_filter.run(sample as f32); 145 | // We know that the number will still be valid and not suddenly 146 | // NAN or Infinite, assuming that lowpass filter performs 147 | // correctly. So we use the fast-path for the conversion. 148 | // This is one instruction on x86 vs six: 149 | // https://rust.godbolt.org/z/5sGToG9rK 150 | debug_assert!(!sample.is_infinite()); 151 | debug_assert!(!sample.is_nan()); 152 | unsafe { sample.to_int_unchecked() } 153 | } else { 154 | sample 155 | } 156 | }); 157 | self.history.update(iter) 158 | } 159 | 160 | fn create_lowpass_filter(sampling_frequency_hz: f32) -> DirectForm1 { 161 | // Cutoff frequency. 162 | let f0 = CUTOFF_FREQUENCY_HZ.hz(); 163 | // Samling frequency. 164 | let fs = sampling_frequency_hz.hz(); 165 | 166 | let coefficients = 167 | Coefficients::::from_params(Type::LowPass, fs, f0, Q_BUTTERWORTH_F32).unwrap(); 168 | DirectForm1::::new(coefficients) 169 | } 170 | } 171 | 172 | #[cfg(test)] 173 | #[allow(clippy::excessive_precision)] 174 | #[allow(clippy::missing_const_for_fn)] 175 | mod tests { 176 | use super::*; 177 | use crate::{test_utils, SampleInfo}; 178 | use std::time::Duration; 179 | use std::vec::Vec; 180 | 181 | #[test] 182 | fn is_send_and_sync() { 183 | fn accept() {} 184 | 185 | accept::(); 186 | } 187 | 188 | /// This test serves as base so that the underlying functionality 189 | /// (forwarding to envelope iterator, do not detect same beat twice) works. 190 | /// It is not feasible to test the complex return type that way in every 191 | /// test. 192 | #[test] 193 | #[allow(non_snake_case)] 194 | fn detect__static__no_lowpass__holiday_single_beat() { 195 | let (samples, header) = test_utils::samples::holiday_single_beat(); 196 | let mut detector = BeatDetector::new(header.sample_rate as f32, false); 197 | assert_eq!( 198 | detector.update_and_detect_beat(samples.iter().copied()), 199 | Some(EnvelopeInfo { 200 | from: SampleInfo { 201 | value: 0, 202 | value_abs: 0, 203 | index: 256, 204 | total_index: 256, 205 | timestamp: Duration::from_secs_f32(0.005804989), 206 | duration_behind: Duration::from_secs_f32(0.401904759) 207 | }, 208 | to: SampleInfo { 209 | value: 0, 210 | value_abs: 0, 211 | index: 1971, 212 | total_index: 1971, 213 | timestamp: Duration::from_secs_f32(0.044693876), 214 | duration_behind: Duration::from_secs_f32(0.363015872), 215 | }, 216 | max: SampleInfo { 217 | value: -0, 218 | value_abs: 0, 219 | index: 830, 220 | total_index: 830, 221 | timestamp: Duration::from_secs_f32(0.018820861), 222 | duration_behind: Duration::from_secs_f32(0.388888887), 223 | } 224 | }) 225 | ); 226 | assert_eq!(detector.update_and_detect_beat(core::iter::empty()), None); 227 | } 228 | 229 | #[test] 230 | #[allow(non_snake_case)] 231 | fn detect__static__lowpass__holiday_single_beat() { 232 | let (samples, header) = test_utils::samples::holiday_single_beat(); 233 | let mut detector = BeatDetector::new(header.sample_rate as f32, true); 234 | assert_eq!( 235 | detector 236 | .update_and_detect_beat(samples.iter().copied()) 237 | .map(|info| info.max.index), 238 | // It seems that the lowpass filter causes a slight delay. This 239 | // is also what my research found [0]. 240 | // 241 | // As long as it is reasonable small, I think this is good, I guess? 242 | // [0]: https://electronics.stackexchange.com/questions/372692/low-pass-filter-delay 243 | Some(939) 244 | ); 245 | assert_eq!(detector.update_and_detect_beat(core::iter::empty()), None); 246 | } 247 | 248 | fn simulate_dynamic_audio_source( 249 | chunk_size: usize, 250 | samples: &[i16], 251 | detector: &mut BeatDetector, 252 | ) -> Vec { 253 | samples 254 | .chunks(chunk_size) 255 | .flat_map(|samples| { 256 | detector 257 | .update_and_detect_beat(samples.iter().copied()) 258 | .map(|info| info.max.total_index) 259 | }) 260 | .collect::>() 261 | } 262 | 263 | #[test] 264 | #[allow(non_snake_case)] 265 | fn detect__dynamic__no_lowpass__holiday_single_beat() { 266 | let (samples, header) = test_utils::samples::holiday_single_beat(); 267 | 268 | let mut detector = BeatDetector::new(header.sample_rate as f32, false); 269 | assert_eq!( 270 | simulate_dynamic_audio_source(256, &samples, &mut detector), 271 | &[829] 272 | ); 273 | 274 | let mut detector = BeatDetector::new(header.sample_rate as f32, false); 275 | assert_eq!( 276 | simulate_dynamic_audio_source(2048, &samples, &mut detector), 277 | &[829] 278 | ); 279 | } 280 | 281 | #[test] 282 | #[allow(non_snake_case)] 283 | fn detect__dynamic__no_lowpass__sample1_double_beat() { 284 | let (samples, header) = test_utils::samples::sample1_double_beat(); 285 | 286 | let mut detector = BeatDetector::new(header.sample_rate as f32, false); 287 | assert_eq!( 288 | simulate_dynamic_audio_source(2048, &samples, &mut detector), 289 | &[1309, 8637] 290 | ); 291 | } 292 | 293 | #[test] 294 | #[allow(non_snake_case)] 295 | fn detect__dynamic__lowpass__sample1_long() { 296 | let (samples, header) = test_utils::samples::sample1_long(); 297 | 298 | let mut detector = BeatDetector::new(header.sample_rate as f32, true); 299 | assert_eq!( 300 | simulate_dynamic_audio_source(2048, &samples, &mut detector), 301 | &[12939, 93789, 101457, 189595, 270785, 278473] 302 | ); 303 | } 304 | 305 | #[test] 306 | #[allow(non_snake_case)] 307 | fn detect__dynamic__no_lowpass__holiday_long() { 308 | let (samples, header) = test_utils::samples::holiday_long(); 309 | 310 | let mut detector = BeatDetector::new(header.sample_rate as f32, false); 311 | assert_eq!( 312 | simulate_dynamic_audio_source(2048, &samples, &mut detector), 313 | &[29077, 31225, 47053, 65811, 83773, 101995, 120137, 138131] 314 | ); 315 | } 316 | 317 | #[test] 318 | #[allow(non_snake_case)] 319 | fn detect__dynamic__lowpass__holiday_long() { 320 | let (samples, header) = test_utils::samples::holiday_long(); 321 | 322 | let mut detector = BeatDetector::new(header.sample_rate as f32, true); 323 | assert_eq!( 324 | simulate_dynamic_audio_source(2048, &samples, &mut detector), 325 | &[31335, 47163, 65921, 84223, 102105, 120247, 138559] 326 | ); 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /src/envelope_iterator.rs: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2024 Philipp Schuster 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | use crate::MaxMinIterator; 25 | use crate::{AudioHistory, SampleInfo}; 26 | use core::cmp::Ordering; 27 | use core::time::Duration; 28 | use ringbuffer::RingBuffer; 29 | 30 | /// Threshold to ignore noise. 31 | const ENVELOPE_MIN_VALUE: i16 = (i16::MAX as f32 * 0.1) as i16; 32 | 33 | /// Ratio between the maximum absolute peak and the absolute average, so that 34 | /// we can be sure there is a clear envelope. 35 | const ENVELOPE_MAX_PEAK_TO_AVG_MIN_RATIO: f32 = 2.0; 36 | 37 | /// Minimum sane duration of an envelope. This value comes from looking at 38 | /// waveforms of songs. I picked a beat that I considered as fast/short. 39 | pub(crate) const ENVELOPE_MIN_DURATION_MS: u64 = 140; 40 | 41 | /// Minimum realistic duration of an envelope. This value is the result of 42 | /// analyzing some waveforms in Audacity. Specifically, this results from an 43 | /// envelope of two beats very close to each other. 44 | const ENVELOPE_MIN_DURATION: Duration = Duration::from_millis(ENVELOPE_MIN_DURATION_MS); 45 | 46 | /// Iterates the envelopes of the provided audio history. An envelope is the set 47 | /// of vibrations(? - german: Schwingungen) that characterize a beat. Its 48 | /// waveform looks somehow like this: 49 | /// ```text 50 | /// x 51 | /// x x x 52 | /// x x x x x 53 | /// -x---x-----x---x---x-x-x----- (and again for next beat) 54 | /// x x x x x 55 | /// x x 56 | /// ``` 57 | /// 58 | /// The properties to detect an envelope are not based on scientific research, 59 | /// but on a best-effort and common sense from my side. 60 | /// 61 | /// This iterator is supposed to be used multiple times on the same audio 62 | /// history object. However, once the audio history was updated, a new iterator 63 | /// must be created. 64 | #[derive(Debug, Clone)] 65 | pub struct EnvelopeIterator<'a> { 66 | index: usize, 67 | buffer: &'a AudioHistory, 68 | } 69 | 70 | impl<'a> EnvelopeIterator<'a> { 71 | pub fn new(buffer: &'a AudioHistory, begin_index: Option) -> Self { 72 | let index = begin_index.unwrap_or(0); 73 | assert!(index < buffer.data().len()); 74 | Self { buffer, index } 75 | } 76 | } 77 | 78 | impl Iterator for EnvelopeIterator<'_> { 79 | type Item = EnvelopeInfo; 80 | 81 | #[inline] 82 | fn next(&mut self) -> Option { 83 | debug_assert!(self.index < self.buffer.data().len()); 84 | if self.index == self.buffer.data().len() - 1 { 85 | return None; 86 | } 87 | 88 | // ##################################################################### 89 | // PREREQUISITES 90 | 91 | // Skip noise. 92 | let envelope_begin = MaxMinIterator::new(self.buffer, Some(self.index)) 93 | // Find the first item that is not noise. 94 | .find(|info| info.value_abs >= ENVELOPE_MIN_VALUE)?; 95 | 96 | // Update index to prevent unnecessary iterations on next 97 | // invocation. 98 | self.index = envelope_begin.index + 1; 99 | 100 | // First check. Is the (possible) envelope begin far enough behind to 101 | // actually point to an 102 | if envelope_begin.duration_behind <= ENVELOPE_MIN_DURATION { 103 | return None; 104 | } 105 | 106 | // ##################################################################### 107 | // FIND ENVELOPE 108 | 109 | // Find average. 110 | let all_peaks_iter = 111 | MaxMinIterator::new(self.buffer, None /* avg calc over whole history */); 112 | let peaks_count = all_peaks_iter.clone().count() as u64; 113 | let peaks_sum = all_peaks_iter 114 | .map(|info| info.value_abs as u64) 115 | .reduce(|a, b| a + b)?; 116 | let peaks_avg = peaks_sum / peaks_count; 117 | 118 | // Sanity checks. 119 | debug_assert!(peaks_avg > 0); 120 | debug_assert!(peaks_avg <= i16::MAX as u64); 121 | 122 | // Find max of envelope. 123 | let envelope_max = MaxMinIterator::new(self.buffer, Some(envelope_begin.index + 1)) 124 | // ignore irrelevant peaks 125 | .skip_while(|info| { 126 | (info.value_abs as f32 / peaks_avg as f32) < ENVELOPE_MAX_PEAK_TO_AVG_MIN_RATIO 127 | }) 128 | // look at interesting peaks 129 | .take_while(|info| { 130 | (info.value_abs as f32 / peaks_avg as f32) >= ENVELOPE_MAX_PEAK_TO_AVG_MIN_RATIO 131 | }) 132 | // get the maximum 133 | .reduce(|a, b| if a.value_abs > b.value_abs { a } else { b })?; 134 | 135 | // Find end of envelope. 136 | let envelope_end = find_descending_peak_trend_end(self.buffer, envelope_max.index)?; 137 | 138 | // ##################################################################### 139 | // FINALIZE 140 | 141 | let envelope = EnvelopeInfo { 142 | from: envelope_begin, 143 | to: envelope_end, 144 | max: envelope_max, 145 | }; 146 | 147 | // TODO do I need this? 148 | /*if envelope.duration() < ENVELOPE_MIN_DURATION { 149 | return None; 150 | }*/ 151 | 152 | // Update index to prevent unnecessary iterations on next 153 | // invocation. 154 | self.index = envelope_end.index + 1; 155 | 156 | Some(envelope) 157 | } 158 | } 159 | 160 | /// Helper to find the end of an envelope. 161 | /// Finds the end of an envelope. This itself turned out as complex enough to 162 | /// justify a dedicated, testable function. An envelope ends when the trend of 163 | /// descending (abs) peaks is over. We must prevent that the envelope end 164 | /// clashes with the beginning of the possibly next envelope. 165 | fn find_descending_peak_trend_end(buffer: &AudioHistory, begin_index: usize) -> Option { 166 | assert!(begin_index < buffer.data().len()); 167 | 168 | // We allow one peak to be out of line within a trend of descending peaks. 169 | // But only within this reasonable limit. 170 | const MAX_NEXT_TO_CURR_OUT_OF_LINE_FACTOR: f32 = 1.05; 171 | 172 | let peak_iter = MaxMinIterator::new(buffer, Some(begin_index)); 173 | peak_iter 174 | .clone() 175 | .zip(peak_iter.clone().skip(1).zip(peak_iter.skip(2))) 176 | .take_while(|(current, (next, nextnext))| { 177 | let val_curr = current.value_abs; 178 | let val_next = next.value_abs; 179 | let val_nextnext = nextnext.value_abs; 180 | 181 | let next_is_descending = val_next <= val_curr; 182 | if next_is_descending { 183 | return true; 184 | } 185 | 186 | let next_to_current_factor = val_next as f32 / val_curr as f32; 187 | debug_assert!(next_to_current_factor > 1.0); 188 | 189 | // nextnext continues descending trend 190 | next_to_current_factor <= MAX_NEXT_TO_CURR_OUT_OF_LINE_FACTOR 191 | && val_nextnext <= val_curr 192 | }) 193 | .last() 194 | .map(|(current, _)| current) 195 | } 196 | 197 | /// Information about an envelope. 198 | #[derive(Clone, Copy, Debug, Default, Eq)] 199 | pub struct EnvelopeInfo { 200 | pub from: SampleInfo, 201 | pub to: SampleInfo, 202 | pub max: SampleInfo, 203 | } 204 | 205 | impl EnvelopeInfo { 206 | /// Returns true if two envelops overlap. This covers the following 207 | /// scenarios: 208 | /// ```text 209 | /// Overlap 1: 210 | /// |___| or |______| 211 | /// |___| |__| 212 | /// 213 | /// Overlap 2: 214 | /// |___| |__| 215 | /// |___| or |______| 216 | /// 217 | /// Overlap 3: 218 | /// |___| 219 | /// |___| 220 | /// 221 | /// No Overlap 1: 222 | /// |___| 223 | /// |___| 224 | /// 225 | /// No overlap 2: 226 | /// |___| 227 | /// |___| 228 | /// ``` 229 | pub const fn overlap(&self, other: &Self) -> bool { 230 | let self_from = self.from.total_index; 231 | let self_to = self.to.total_index; 232 | let other_from = other.from.total_index; 233 | let other_to = other.to.total_index; 234 | 235 | if other_from >= self_from { 236 | other_from < self_to 237 | } else if other_from < self_from { 238 | other_to > self_from 239 | } else { 240 | false 241 | } 242 | } 243 | 244 | /// The duration/length of the envelope. 245 | pub fn duration(&self) -> Duration { 246 | self.to.timestamp - self.from.timestamp 247 | } 248 | 249 | /// The relative timestamp of the beat/the envelope since the beginning of 250 | /// the audio recording. 251 | pub const fn timestamp(&self) -> Duration { 252 | self.max.timestamp 253 | } 254 | } 255 | 256 | impl PartialOrd for EnvelopeInfo { 257 | fn partial_cmp(&self, other: &Self) -> Option { 258 | Some(self.cmp(other)) 259 | } 260 | } 261 | 262 | impl Ord for EnvelopeInfo { 263 | fn cmp(&self, other: &Self) -> Ordering { 264 | self.from 265 | .partial_cmp(&other.from) 266 | .expect("Only valid f32 should be here.") 267 | } 268 | } 269 | 270 | impl PartialEq for EnvelopeInfo { 271 | fn eq(&self, other: &Self) -> bool { 272 | self.overlap(other) 273 | } 274 | } 275 | 276 | #[cfg(test)] 277 | mod tests { 278 | use super::*; 279 | use crate::test_utils; 280 | use std::vec::Vec; 281 | 282 | #[allow(clippy::cognitive_complexity)] 283 | #[test] 284 | fn envelope_info_overlap() { 285 | let mut this = EnvelopeInfo::default(); 286 | let mut that = EnvelopeInfo::default(); 287 | 288 | this.from.total_index = 0; 289 | this.to.total_index = 10; 290 | 291 | that.from.total_index = 11; 292 | that.to.total_index = 20; 293 | 294 | assert!(this.overlap(&this)); 295 | assert!(that.overlap(&that)); 296 | 297 | assert!(!this.overlap(&that)); 298 | assert!(!that.overlap(&this)); 299 | 300 | that.from.total_index = 10; 301 | assert!(!this.overlap(&that)); 302 | assert!(!that.overlap(&this)); 303 | 304 | that.from.total_index = 9; 305 | assert!(this.overlap(&that)); 306 | assert!(that.overlap(&this)); 307 | 308 | this.from.total_index = 10; 309 | this.to.total_index = 20; 310 | that.from.total_index = 10; 311 | that.to.total_index = 20; 312 | assert!(this.overlap(&that)); 313 | assert!(that.overlap(&this)); 314 | 315 | this.to.total_index = 16; 316 | assert!(this.overlap(&that)); 317 | assert!(that.overlap(&this)); 318 | 319 | this.from.total_index = 10; 320 | this.to.total_index = 20; 321 | that.from.total_index = 0; 322 | that.to.total_index = 10; 323 | assert!(!this.overlap(&that)); 324 | assert!(!that.overlap(&this)); 325 | 326 | this.from.total_index = 10; 327 | this.to.total_index = 20; 328 | that.from.total_index = 5; 329 | that.to.total_index = 15; 330 | assert!(this.overlap(&that)); 331 | assert!(that.overlap(&this)); 332 | } 333 | 334 | #[test] 335 | fn find_descending_peak_trend_end_is_correct() { 336 | // sample1: single beat 337 | { 338 | let (samples, header) = test_utils::samples::sample1_single_beat(); 339 | let mut history = AudioHistory::new(header.sample_rate as f32); 340 | history.update(samples.iter().copied()); 341 | 342 | // Taken from waveform in Audacity. 343 | let peak_sample_index = 1430; 344 | assert_eq!( 345 | find_descending_peak_trend_end(&history, peak_sample_index).map(|info| info.index), 346 | Some(7099) 347 | ) 348 | } 349 | // sample1: double beat 350 | { 351 | let (samples, header) = test_utils::samples::sample1_double_beat(); 352 | let mut history = AudioHistory::new(header.sample_rate as f32); 353 | history.update(samples.iter().copied()); 354 | 355 | // Taken from waveform in Audacity. 356 | let peak_sample_index = 1634; 357 | assert_eq!( 358 | find_descending_peak_trend_end(&history, peak_sample_index).map(|info| info.index), 359 | Some(6983) 360 | ); 361 | 362 | let peak_sample_index = 8961; 363 | assert_eq!( 364 | find_descending_peak_trend_end(&history, peak_sample_index).map(|info| info.index), 365 | Some(16140) 366 | ); 367 | } 368 | // holiday: single beat 369 | // TODO: Here I discovered that it is not enough to just look at the 370 | // current, next, and nextnext peak to detect a clear trend. Real music 371 | // is more complex. But for now, I stick to this approach. I think it is 372 | // good enough. 373 | { 374 | let (samples, header) = test_utils::samples::holiday_single_beat(); 375 | let mut history = AudioHistory::new(header.sample_rate as f32); 376 | history.update(samples.iter().copied()); 377 | 378 | // Taken from waveform in Audacity. 379 | let peak_sample_index = 820; 380 | assert_eq!( 381 | find_descending_peak_trend_end(&history, peak_sample_index).map(|info| info.index), 382 | Some(1969) 383 | ) 384 | } 385 | } 386 | 387 | #[test] 388 | fn find_envelopes_sample1_single_beat() { 389 | let (samples, header) = test_utils::samples::sample1_single_beat(); 390 | let mut history = AudioHistory::new(header.sample_rate as f32); 391 | history.update(samples.iter().copied()); 392 | 393 | let envelopes = EnvelopeIterator::new(&history, None) 394 | .take(1) 395 | .map(|info| (info.from.index, info.to.index)) 396 | .collect::>(); 397 | assert_eq!(&envelopes, &[(409, 7098)]) 398 | } 399 | 400 | #[test] 401 | fn find_envelopes_sample1_double_beat() { 402 | let (samples, header) = test_utils::samples::sample1_double_beat(); 403 | let mut history = AudioHistory::new(header.sample_rate as f32); 404 | history.update(samples.iter().copied()); 405 | 406 | let envelopes = EnvelopeIterator::new(&history, None) 407 | .map(|info| (info.from.index, info.to.index)) 408 | .collect::>(); 409 | #[rustfmt::skip] 410 | assert_eq!( 411 | &envelopes, 412 | &[ 413 | (449, 6978), 414 | (7328, 16147) 415 | ] 416 | ); 417 | } 418 | 419 | #[test] 420 | fn find_envelopes_holiday_single_beat() { 421 | let (samples, header) = test_utils::samples::holiday_single_beat(); 422 | let mut history = AudioHistory::new(header.sample_rate as f32); 423 | history.update(samples.iter().copied()); 424 | 425 | let envelopes = EnvelopeIterator::new(&history, None) 426 | .map(|info| (info.from.index, info.to.index)) 427 | .collect::>(); 428 | assert_eq!(&envelopes, &[(259, 1968)]); 429 | } 430 | } 431 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2024 Philipp Schuster 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | //! beat-detector detects beats in live audio, but can also be used for post 25 | //! analysis of audio data. It is a library written in Rust that is 26 | //! `no_std`-compatible and doesn't need `alloc`. 27 | //! 28 | //! beat-detector was developed with typical sampling rates and bit depths in 29 | //! mind, namely 44.1 kHz, 48.0 kHz, and 16 bit. Other input sources might work 30 | //! as well. 31 | //! 32 | //! 33 | //! ## TL;DR 34 | //! 35 | //! Use [`BeatDetector`]. 36 | //! 37 | //! ## Audio Source 38 | //! 39 | //! The library operates on `i16` mono-channel samples. There are public helpers 40 | //! that might assist you preparing the audio material for the crate: 41 | //! 42 | //! - [`util::f32_sample_to_i16`] 43 | //! - [`util::stereo_to_mono`] 44 | //! 45 | //! ## Example 46 | //! 47 | //! ```rust 48 | //! use beat_detector::BeatDetector; 49 | //! let mono_samples = [0, 500, -800, 700 /*, ... */]; 50 | //! let mut detector = BeatDetector::new(44100.0, false); 51 | //! 52 | //! let is_beat = detector.update_and_detect_beat( 53 | //! mono_samples.iter().copied() 54 | //! ); 55 | //! ``` 56 | //! 57 | //! ## Detection and Usage 58 | //! 59 | //! The beat detector is supposed to be continuously invoked with the latest 60 | //! audio samples. On each invocation, it checks if the internal audio buffer 61 | //! contains a beat. The same beat won't be reported multiple times. 62 | //! 63 | //! The detector should be regularly fed with samples that are only 64 | //! a fraction of the internal buffer, For live analysis, ~20ms per invocation 65 | //! are fine. For post analysis, this property is not too important. 66 | //! 67 | //! However, the new audio samples should never be more than what the internal 68 | //! buffer can hold, otherwise you might lose beats. 69 | //! 70 | //! ### Audio Source 71 | //! 72 | //! The audio source must have a certain amount of power. Very low values are 73 | //! considered as noise and are not taken into account. But you need also to 74 | //! prevent clipping! Ideally, you check your audio source with the "Record" 75 | //! feature of Audacity or a similar tool visually, so that you can limit 76 | //! potential sources of error. 77 | //! 78 | //! ## Detection Strategy 79 | //! 80 | //! The beat detection strategy is **not** based on state-of-the-art scientific 81 | //! research, but on a best-effort approach and common sense. 82 | //! 83 | //! ## Technical Information 84 | //! 85 | //! beat-detector uses a smart chaining of iterators in different abstraction 86 | //! levels to minimize buffering. In that process, it tries to never iterate 87 | //! data multiple times, if not necessary, to keep the latency low. 88 | 89 | #![no_std] 90 | #![deny( 91 | clippy::all, 92 | clippy::cargo, 93 | clippy::nursery, 94 | // clippy::restriction, 95 | // clippy::pedantic 96 | )] 97 | // now allow a few rules which are denied by the above statement 98 | // --> they are ridiculous and not necessary 99 | #![allow( 100 | clippy::suboptimal_flops, 101 | clippy::redundant_pub_crate, 102 | clippy::fallible_impl_from, 103 | clippy::multiple_crate_versions 104 | )] 105 | #![deny(missing_debug_implementations)] 106 | #![deny(rustdoc::all)] 107 | 108 | extern crate alloc; 109 | #[cfg_attr(any(test, feature = "std"), macro_use)] 110 | #[cfg(any(test, feature = "std"))] 111 | extern crate std; 112 | 113 | // Better drop-in replacement for "assert!" and even better "check!" macro. 114 | #[cfg_attr(test, macro_use)] 115 | #[cfg(test)] 116 | extern crate assert2; 117 | 118 | #[cfg_attr(test, macro_use)] 119 | #[cfg(test)] 120 | extern crate float_cmp; 121 | 122 | mod audio_history; 123 | mod beat_detector; 124 | mod envelope_iterator; 125 | mod max_min_iterator; 126 | mod root_iterator; 127 | #[cfg(feature = "std")] 128 | mod stdlib; 129 | /// PRIVATE. For tests and helper binaries. 130 | #[cfg(test)] 131 | mod test_utils; 132 | pub mod util; 133 | 134 | pub use audio_history::{AudioHistory, SampleInfo}; 135 | pub use beat_detector::{BeatDetector, BeatInfo}; 136 | pub use envelope_iterator::{EnvelopeInfo, EnvelopeIterator}; 137 | #[cfg(feature = "std")] 138 | pub use stdlib::*; 139 | 140 | use max_min_iterator::MaxMinIterator; 141 | use root_iterator::RootIterator; 142 | 143 | #[cfg(test)] 144 | mod tests { 145 | use super::*; 146 | use crate::audio_history::AudioHistory; 147 | use crate::max_min_iterator::MaxMinIterator; 148 | use crate::test_utils; 149 | use std::vec::Vec; 150 | 151 | fn _print_sample_stats((samples, header): (Vec, hound::WavSpec)) { 152 | let mut history = AudioHistory::new(header.sample_rate as f32); 153 | history.update(samples.iter().copied()); 154 | 155 | let all_peaks = MaxMinIterator::new(&history, None).collect::>(); 156 | 157 | let abs_peak_value_iter = all_peaks.iter().map(|info| info.value_abs); 158 | 159 | let max: i16 = abs_peak_value_iter.clone().max().unwrap(); 160 | let min: i16 = abs_peak_value_iter.clone().min().unwrap(); 161 | 162 | let avg: i16 = 163 | (abs_peak_value_iter.map(|v| v as u64).sum::() / all_peaks.len() as u64) as i16; 164 | 165 | let mut all_peaks_sorted = all_peaks.clone(); 166 | all_peaks_sorted.sort_by(|a, b| a.value_abs.partial_cmp(&b.value_abs).unwrap()); 167 | 168 | let median: i16 = all_peaks_sorted[all_peaks_sorted.len() / 2].value_abs; 169 | 170 | eprintln!("max abs peak : {max:.3}"); 171 | eprintln!("min abs peak : {min:.3}"); 172 | eprintln!("average abs peak : {avg:.3}"); 173 | eprintln!("median abs peak : {median:.3}"); 174 | eprintln!("max / avg peak : {:.3}", max / avg); 175 | eprintln!("max / median peak: {:.3}", max / median); 176 | eprintln!( 177 | "peaks abs : {:#.3?}", 178 | all_peaks 179 | .iter() 180 | .map(|info| info.value_abs) 181 | .collect::>() 182 | ); 183 | eprintln!( 184 | "peak next_to_curr ratio: {:#.3?}", 185 | all_peaks 186 | .iter() 187 | .zip(all_peaks.iter().skip(1)) 188 | .map(|(current, next)| { next.value_abs / current.value_abs }) 189 | .collect::>() 190 | ); 191 | } 192 | 193 | /// This just prints a few statistics of the used sample. This helps to 194 | /// understand characteristics of certain properties in a sample, such as 195 | /// the characteristic of an envelope. 196 | #[test] 197 | fn print_holiday_single_beat_stats() { 198 | eprintln!("holiday stats (single beat):"); 199 | _print_sample_stats(test_utils::samples::holiday_single_beat()) 200 | } 201 | 202 | /// This just prints a few statistics of the used sample. This helps to 203 | /// understand characteristics of certain properties in a sample, such as 204 | /// the characteristic of an envelope. 205 | #[test] 206 | fn print_sample1_single_beat_stats() { 207 | eprintln!("sample1 stats (single beat):"); 208 | _print_sample_stats(test_utils::samples::sample1_single_beat()) 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/max_min_iterator.rs: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2024 Philipp Schuster 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | use crate::RootIterator; 26 | use crate::{AudioHistory, SampleInfo}; 27 | use core::cmp::Ordering; 28 | use ringbuffer::RingBuffer; 29 | 30 | // const IGNORE_NOISE_THRESHOLD: f32 = 0.05; 31 | 32 | /// Iterates the minima and maxima of the wave. 33 | /// 34 | /// This iterator is supposed to be used multiple times on the same audio 35 | /// history object. However, once the audio history was updated, a new iterator 36 | /// must be created. 37 | #[derive(Debug, Clone)] 38 | pub struct MaxMinIterator<'a> { 39 | index: usize, 40 | buffer: &'a AudioHistory, 41 | } 42 | 43 | impl<'a> MaxMinIterator<'a> { 44 | /// Creates a new iterator. Immediately moves the index to point to the 45 | /// next root of the wave. This way, we prevent detection of 46 | /// "invalid/false peaks" before the first root has been found. 47 | pub fn new(buffer: &'a AudioHistory, begin_index: Option) -> Self { 48 | let index = begin_index.unwrap_or(0); 49 | assert!(index < buffer.data().len()); 50 | let index = RootIterator::new(buffer, Some(index)) 51 | .next() 52 | .map(|info| info.index) 53 | .unwrap_or_else(|| buffer.data().len() - 1); 54 | Self { buffer, index } 55 | } 56 | } 57 | 58 | impl Iterator for MaxMinIterator<'_> { 59 | type Item = SampleInfo; 60 | 61 | #[inline] 62 | fn next(&mut self) -> Option { 63 | debug_assert!(self.index < self.buffer.data().len()); 64 | if self.index == self.buffer.data().len() - 1 { 65 | return None; 66 | } 67 | 68 | let begin_index = self.index; 69 | let end_index = RootIterator::new(self.buffer, Some(begin_index)) 70 | .next()? 71 | .index; 72 | let sample_count = end_index - begin_index; 73 | 74 | let max_or_min = self 75 | .buffer 76 | .data() 77 | .iter() 78 | .enumerate() 79 | .skip(begin_index) 80 | .take(sample_count) 81 | .step_by(10) 82 | .max_by(|(_x_index, &x_value), (_y_index, &y_value)| { 83 | if x_value.abs() > y_value.abs() { 84 | Ordering::Greater 85 | } else { 86 | Ordering::Less 87 | } 88 | }); 89 | 90 | max_or_min.map(|(index, _max_or_min)| { 91 | // + 1: don't find the same the next time 92 | self.index = end_index + 1; 93 | self.buffer.index_to_sample_info(index) 94 | }) 95 | } 96 | } 97 | 98 | #[cfg(test)] 99 | mod tests { 100 | use super::*; 101 | use crate::test_utils; 102 | use crate::util::i16_sample_to_f32; 103 | use std::vec::Vec; 104 | 105 | #[test] 106 | fn find_maxmin_in_holiday_excerpt() { 107 | let (samples, header) = test_utils::samples::holiday_excerpt(); 108 | let mut history = AudioHistory::new(header.sample_rate as f32); 109 | history.update(samples.iter().copied()); 110 | 111 | let iter = MaxMinIterator::new(&history, None); 112 | #[rustfmt::skip] 113 | assert_eq!( 114 | iter.map(|info| (info.total_index, i16_sample_to_f32(info.value))) 115 | .collect::>(), 116 | // I checked in Audacity whether the values returned by the code 117 | // make sense. Then, they became the reference for the test. 118 | [ 119 | (539, 0.39054537), 120 | (859, -0.0684225), 121 | (1029, 0.24597919), 122 | (1299, -0.30658895), 123 | ] 124 | ); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/root_iterator.rs: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2024 Philipp Schuster 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | use crate::{AudioHistory, SampleInfo}; 25 | use ringbuffer::RingBuffer; 26 | 27 | const IGNORE_NOISE_THRESHOLD: i16 = (i16::MAX as f32 * 0.05) as i16; 28 | 29 | /// The state a sample. Either above x-axis or below. 30 | #[derive(Copy, Clone, PartialEq, Eq)] 31 | enum State { 32 | /// Above x-axis. 33 | Above, 34 | /// Below x-axis. 35 | Below, 36 | } 37 | 38 | impl From for State { 39 | #[inline(always)] 40 | fn from(sample: i16) -> Self { 41 | if sample.is_positive() { 42 | Self::Above 43 | } else { 44 | Self::Below 45 | } 46 | } 47 | } 48 | 49 | /// Iterates the roots/zeroes of the wave. 50 | /// 51 | /// This iterator is supposed to be used multiple times on the same audio 52 | /// history object. However, once the audio history was updated, a new iterator 53 | /// must be created. 54 | #[derive(Debug, Clone)] 55 | pub struct RootIterator<'a> { 56 | index: usize, 57 | buffer: &'a AudioHistory, 58 | } 59 | 60 | impl<'a> RootIterator<'a> { 61 | pub fn new(buffer: &'a AudioHistory, begin_index: Option) -> Self { 62 | let index = begin_index.unwrap_or(0); 63 | assert!(index < buffer.data().len()); 64 | Self { buffer, index } 65 | } 66 | } 67 | 68 | impl Iterator for RootIterator<'_> { 69 | type Item = SampleInfo; 70 | 71 | #[inline] 72 | fn next(&mut self) -> Option { 73 | debug_assert!(self.index < self.buffer.data().len()); 74 | if self.index == self.buffer.data().len() - 1 { 75 | return None; 76 | } 77 | 78 | let mut iter = self 79 | .buffer 80 | .data() 81 | .iter() 82 | .enumerate() 83 | .skip(self.index) 84 | // Given the very high sampling rate, we can sacrifice a negligible 85 | // impact on precision for better performance / fewer iterations. 86 | .step_by(10) 87 | .skip_while(|(_, &sample)| sample.abs() < IGNORE_NOISE_THRESHOLD); 88 | 89 | let initial_state = State::from(iter.next().map(|(_, &sample)| sample)?); 90 | 91 | let next_root = iter 92 | // Skip while we didn't cross the x axis. 93 | .find(|(_, &sample)| State::from(sample) != initial_state) 94 | // We are looking for the index right before the zero. 95 | .map(|(index, _)| index - 1); 96 | 97 | if let Some(index) = next_root { 98 | // + 1: don't find the same the next time 99 | self.index = index + 1; 100 | Some(self.buffer.index_to_sample_info(index)) 101 | } else { 102 | None 103 | } 104 | } 105 | } 106 | 107 | #[cfg(test)] 108 | mod tests { 109 | use super::*; 110 | use crate::test_utils; 111 | use crate::util::i16_sample_to_f32; 112 | use std::vec::Vec; 113 | 114 | #[test] 115 | fn find_roots_in_holiday_excerpt() { 116 | let (samples, header) = test_utils::samples::holiday_excerpt(); 117 | let mut history = AudioHistory::new(header.sample_rate as f32); 118 | history.update(samples.iter().copied()); 119 | 120 | let iter = RootIterator::new(&history, None); 121 | #[rustfmt::skip] 122 | assert_eq!( 123 | iter.map(|info| (info.total_index, i16_sample_to_f32(info.value))).collect::>(), 124 | // I checked in Audacity whether the values returned by the code 125 | // make sense. Then, they became the reference for the test. 126 | [ 127 | (369, 0.030854214), 128 | (689, -0.013336589), 129 | (929, 0.013275552), 130 | (1129, -0.030640583), 131 | (1449, 0.033509325) 132 | ] 133 | ); 134 | } 135 | 136 | #[test] 137 | fn find_roots_in_holiday_excerpt_but_begin_at_specific_index() { 138 | let (samples, header) = test_utils::samples::holiday_excerpt(); 139 | let mut history = AudioHistory::new(header.sample_rate as f32); 140 | history.update(samples.iter().copied()); 141 | 142 | let iter = RootIterator::new(&history, Some(929 /* index taken from test above */ + 1)); 143 | #[rustfmt::skip] 144 | assert_eq!( 145 | iter.map(|info| (info.total_index, i16_sample_to_f32(info.value))).collect::>(), 146 | // I checked in Audacity whether the values returned by the code 147 | // make sense. Then, they became the reference for the test. 148 | [ 149 | (1129, -0.030640583), 150 | (1449, 0.033509325) 151 | ] 152 | ); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/stdlib/mod.rs: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2024 Philipp Schuster 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | //! All modules that require `std` functionality. 25 | 26 | pub mod recording; 27 | -------------------------------------------------------------------------------- /src/stdlib/recording.rs: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2024 Philipp Schuster 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | //! Module for audio recording from an audio input device. 26 | 27 | use crate::{BeatDetector, BeatInfo}; 28 | use core::fmt::{Display, Formatter}; 29 | use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; 30 | use cpal::{BufferSize, StreamConfig}; 31 | use std::error::Error; 32 | use std::string::ToString; 33 | use std::time::{Duration, Instant}; 34 | 35 | #[derive(Debug)] 36 | // #[derive(Debug, Clone)] 37 | pub enum StartDetectorThreadError { 38 | /// There was no audio device provided and no default device can be found. 39 | NoDefaultAudioDevice, 40 | /// There was a problem detecting the input stream config. 41 | InputConfigError(cpal::DefaultStreamConfigError), 42 | /// Failed to build an input stream. 43 | FailedBuildingInputStream(cpal::BuildStreamError), 44 | /// There was a problem 45 | InputError(cpal::PlayStreamError), 46 | } 47 | 48 | impl Display for StartDetectorThreadError { 49 | fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { 50 | f.write_fmt(format_args!("{:?}", self)) 51 | } 52 | } 53 | 54 | impl std::error::Error for StartDetectorThreadError { 55 | fn source(&self) -> Option<&(dyn Error + 'static)> { 56 | match self { 57 | Self::InputConfigError(err) => Some(err), 58 | Self::FailedBuildingInputStream(err) => Some(err), 59 | Self::InputError(err) => Some(err), 60 | _ => None, 61 | } 62 | } 63 | } 64 | 65 | /// Starts a stream (a thread) that combines the audio input with the provided 66 | /// callback. The stream lives as long as the provided callback 67 | pub fn start_detector_thread( 68 | on_beat_cb: impl Fn(BeatInfo) + Send + 'static, 69 | preferred_input_dev: Option, 70 | ) -> Result { 71 | let input_dev = preferred_input_dev.map(Ok).unwrap_or_else(|| { 72 | let host = cpal::default_host(); 73 | log::debug!("Using '{:?}' as input framework", host.id()); 74 | host.default_input_device() 75 | .ok_or(StartDetectorThreadError::NoDefaultAudioDevice) 76 | })?; 77 | 78 | log::debug!( 79 | "Using '{}' as input device", 80 | input_dev.name().unwrap_or_else(|_| "".to_string()) 81 | ); 82 | 83 | let supported_input_config = input_dev 84 | .default_input_config() 85 | .map_err(StartDetectorThreadError::InputConfigError)?; 86 | 87 | log::trace!( 88 | "Supported input configurations: {:#?}", 89 | supported_input_config 90 | ); 91 | 92 | let input_config = StreamConfig { 93 | channels: 1, 94 | sample_rate: supported_input_config.sample_rate(), 95 | //buffer_size: get_desired_frame_count_if_possible(), 96 | buffer_size: BufferSize::Default, 97 | }; 98 | 99 | log::debug!("Input configuration: {:#?}", input_config); 100 | 101 | let sampling_rate = input_config.sample_rate.0 as f32; 102 | let mut detector = BeatDetector::new(sampling_rate, true); 103 | 104 | // Under the hood, this spawns a thread. 105 | let stream = input_dev 106 | .build_input_stream( 107 | &input_config, 108 | move |data: &[i16], _info| { 109 | log::trace!( 110 | "audio input callback: {} samples ({} ms, sampling rate = {sampling_rate})", 111 | data.len(), 112 | Duration::from_secs_f32(data.len() as f32 / sampling_rate).as_millis() 113 | ); 114 | 115 | let now = Instant::now(); 116 | let beat = detector.update_and_detect_beat(data.iter().copied()); 117 | let duration = now.elapsed(); 118 | log::trace!("Beat detection took {:?}", duration); 119 | 120 | if let Some(beat) = beat { 121 | log::debug!("Beat detection took {:?}", duration); 122 | on_beat_cb(beat); 123 | } 124 | }, 125 | |e| { 126 | log::error!("Input error: {e:#?}"); 127 | }, 128 | // Timeout: worst case max blocking time 129 | // Don't see too short, as otherwise, the error callback will be 130 | // invoked frequently. 131 | // https://github.com/RustAudio/cpal/pull/696 132 | Some(Duration::from_secs(1)), 133 | ) 134 | .map_err(StartDetectorThreadError::FailedBuildingInputStream)?; 135 | 136 | stream 137 | .play() 138 | .map_err(StartDetectorThreadError::InputError)?; 139 | 140 | Ok(stream) 141 | } 142 | -------------------------------------------------------------------------------- /src/test_utils.rs: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2024 Philipp Schuster 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | use crate::util::stereo_to_mono; 25 | use itertools::Itertools; 26 | use std::path::Path; 27 | use std::vec::Vec; 28 | 29 | /// Reads a WAV file to mono audio. Returns the samples as mono audio. 30 | /// Additionally, it returns the sampling rate of the file. 31 | fn read_wav_to_mono>(file: T) -> (Vec, hound::WavSpec) { 32 | let mut reader = hound::WavReader::open(file).unwrap(); 33 | let header = reader.spec(); 34 | 35 | // owning vector with original data in i16 format 36 | let data = reader 37 | .samples::() 38 | .map(|s| s.unwrap()) 39 | .collect::>(); 40 | 41 | if header.channels == 1 { 42 | (data, header) 43 | } else if header.channels == 2 { 44 | let data = data 45 | .into_iter() 46 | .chunks(2) 47 | .into_iter() 48 | .map(|mut lr| { 49 | let l = lr.next().unwrap(); 50 | let r = lr 51 | .next() 52 | .expect("should have an even number of LRLR samples"); 53 | stereo_to_mono(l, r) 54 | }) 55 | .collect::>(); 56 | (data, header) 57 | } else { 58 | panic!("unsupported format!"); 59 | } 60 | } 61 | 62 | /// Accessor to various samples. One sample here refers to what a sample is in 63 | /// the music industry: A small excerpt of audio. "Samples" however refer to the 64 | /// individual data points. 65 | pub mod samples { 66 | use super::*; 67 | use crate::audio_history::DEFAULT_AUDIO_HISTORY_WINDOW_MS; 68 | 69 | /// Returns the mono samples of the holiday sample (long version) 70 | /// together with the sampling rate. 71 | pub fn holiday_long() -> (Vec, hound::WavSpec) { 72 | read_wav_to_mono("res/holiday_lowpassed--long.wav") 73 | } 74 | 75 | /// Returns the mono samples of the holiday sample (excerpt version) 76 | /// together with the sampling rate. 77 | pub fn holiday_excerpt() -> (Vec, hound::WavSpec) { 78 | read_wav_to_mono("res/holiday_lowpassed--excerpt.wav") 79 | } 80 | 81 | /// Returns the mono samples of the holiday sample (single-beat version) 82 | /// together with the sampling rate. 83 | pub fn holiday_single_beat() -> (Vec, hound::WavSpec) { 84 | read_wav_to_mono("res/holiday_lowpassed--single-beat.wav") 85 | } 86 | 87 | /// Returns the mono samples of the "sample1" sample (long version) 88 | /// together with the sampling rate. 89 | pub fn sample1_long() -> (Vec, hound::WavSpec) { 90 | read_wav_to_mono("res/sample1_lowpassed--long.wav") 91 | } 92 | 93 | /// Returns the mono samples of the "sample1" sample (single-beat version) 94 | /// together with the sampling rate. 95 | pub fn sample1_single_beat() -> (Vec, hound::WavSpec) { 96 | read_wav_to_mono("res/sample1_lowpassed--single-beat.wav") 97 | } 98 | 99 | /// Returns the mono samples of the "sample1" sample (double-beat version) 100 | /// together with the sampling rate. 101 | pub fn sample1_double_beat() -> (Vec, hound::WavSpec) { 102 | read_wav_to_mono("res/sample1_lowpassed--double-beat.wav") 103 | } 104 | 105 | #[test] 106 | fn test_samples_are_as_long_as_expected() { 107 | fn to_duration_in_seconds((samples, header): (Vec, hound::WavSpec)) -> f32 { 108 | // Although my code is generic regarding the sampling rate, in my 109 | // demo samples, I only use this sampling rate. So let's do a 110 | // sanity check. 111 | assert_eq!(header.sample_rate, 44100); 112 | 113 | samples.len() as f32 / header.sample_rate as f32 114 | } 115 | 116 | let duration = to_duration_in_seconds(holiday_excerpt()); 117 | assert_eq!(duration, 0.035804987 /* seconds */); 118 | assert!( 119 | duration * 1000.0 <= DEFAULT_AUDIO_HISTORY_WINDOW_MS as f32, 120 | "All test code relies on that this sample fully fits into the audio window!" 121 | ); 122 | 123 | let duration = to_duration_in_seconds(holiday_long()); 124 | assert_eq!(duration, 3.1764627 /* seconds */); 125 | 126 | let duration = to_duration_in_seconds(holiday_single_beat()); 127 | assert_eq!(duration, 0.40773243 /* seconds */); 128 | assert!( 129 | duration * 1000.0 <= DEFAULT_AUDIO_HISTORY_WINDOW_MS as f32, 130 | "All test code relies on that this sample fully fits into the audio window!" 131 | ); 132 | 133 | let duration = to_duration_in_seconds(sample1_long()); 134 | assert_eq!(duration, 7.998526 /* seconds */); 135 | 136 | let duration = to_duration_in_seconds(sample1_single_beat()); 137 | assert_eq!(duration, 0.18380952 /* seconds */); 138 | assert!( 139 | duration * 1000.0 <= DEFAULT_AUDIO_HISTORY_WINDOW_MS as f32, 140 | "All test code relies on that this sample fully fits into the audio window!" 141 | ); 142 | 143 | let duration = to_duration_in_seconds(sample1_double_beat()); 144 | assert_eq!(duration, 0.41687074 /* seconds */); 145 | assert!( 146 | duration * 1000.0 <= DEFAULT_AUDIO_HISTORY_WINDOW_MS as f32, 147 | "All test code relies on that this sample fully fits into the audio window!" 148 | ); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | //! Some common utilities required internally but also useful for external 2 | //! users, when working with this library. 3 | 4 | /// Transforms an audio sample in range `i16::MIN..=i16::MAX` to a `f32` in 5 | /// range `-1.0..1.0`. 6 | #[inline] 7 | pub fn i16_sample_to_f32(val: i16) -> f32 { 8 | // If to prevent division result >1.0. 9 | if val == i16::MIN { 10 | -1.0 11 | } else { 12 | val as f32 / i16::MAX as f32 13 | } 14 | } 15 | 16 | /// The sample is out of range `-1.0..1.0`. 17 | #[derive(Copy, Clone, Debug, PartialEq)] 18 | pub struct OutOfRangeError(f32); 19 | 20 | /// Transforms an audio sample of type `f32` in range `-1.0..1.0` to a `i16` in 21 | /// range `-i16::MAX..=i16::MAX`. 22 | #[inline] 23 | pub fn f32_sample_to_i16(val: f32) -> Result { 24 | if val.is_finite() && libm::fabsf(val) <= 1.0 { 25 | Ok((val * i16::MAX as f32) as i16) 26 | } else { 27 | Err(OutOfRangeError(val)) 28 | } 29 | } 30 | 31 | /// Transforms two stereo samples (that reflect the same point in time on 32 | /// different channels) into one mono sample. 33 | #[inline] 34 | pub const fn stereo_to_mono(l: i16, r: i16) -> i16 { 35 | let l = l as i32; 36 | let r = r as i32; 37 | let avg = (l + r) / 2; 38 | avg as i16 39 | } 40 | 41 | #[cfg(test)] 42 | mod tests { 43 | use super::*; 44 | 45 | #[test] 46 | fn test_i16_sample_to_f32() { 47 | check!(i16_sample_to_f32(0) == 0.0); 48 | check!(approx_eq!( 49 | f32, 50 | i16_sample_to_f32(i16::MAX / 2), 51 | 0.5, 52 | epsilon = 0.01 53 | )); 54 | check!(i16_sample_to_f32(i16::MAX) == 1.0); 55 | check!(i16_sample_to_f32(-i16::MAX) == -1.0); 56 | check!(i16_sample_to_f32(i16::MIN) == -1.0); 57 | } 58 | 59 | #[test] 60 | fn test_f32_sample_to_i16() { 61 | check!(f32_sample_to_i16(0.0) == Ok(0)); 62 | check!(f32_sample_to_i16(-0.5) == Ok(-i16::MAX / 2)); 63 | check!(f32_sample_to_i16(0.5) == Ok(i16::MAX / 2)); 64 | check!(f32_sample_to_i16(-1.0) == Ok(-i16::MAX)); 65 | check!(f32_sample_to_i16(1.0) == Ok(i16::MAX)); 66 | check!(f32_sample_to_i16(1.1) == Err(OutOfRangeError(1.1))); 67 | check!(matches!( 68 | f32_sample_to_i16(f32::NAN), 69 | Err(OutOfRangeError(_)) 70 | )); 71 | } 72 | } 73 | --------------------------------------------------------------------------------