From 8ccaab2acb801a9036ca2b51ac2f83c406f694c5 Mon Sep 17 00:00:00 2001 From: Michael Kreuzer Date: Sun, 16 Aug 2026 20:49:10 +0200 Subject: [PATCH 1/4] remove cli-only dependencies from library crate --- .github/workflows/ci.yml | 4 ++-- Cargo.toml | 15 ++++++++++----- README.md | 13 +++++++++---- src/runtime.rs | 3 ++- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee6ee70..51fafba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 - run: cargo fmt --check - - run: cargo clippy -- -D warnings + - run: cargo clippy --all-targets --features cli -- -D warnings - run: cargo test # Opt-in ort backend: exercises the feature so it can't bit-rot. @@ -47,5 +47,5 @@ jobs: "https://github.com/microsoft/onnxruntime/releases/download/v${ORT_VERSION}/onnxruntime-linux-x64-${ORT_VERSION}.tgz" tar xzf ort.tgz echo "ORT_DYLIB_PATH=${PWD}/onnxruntime-linux-x64-${ORT_VERSION}/lib/libonnxruntime.so" >> "$GITHUB_ENV" - - run: cargo clippy --features ort -- -D warnings + - run: cargo clippy --all-targets --features cli,ort -- -D warnings - run: cargo test --features ort diff --git a/Cargo.toml b/Cargo.toml index 728fe39..6b39c29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ exclude = [ [[bin]] name = "beat-this" path = "src/main.rs" +required-features = ["cli"] [lib] name = "beat_this" @@ -33,6 +34,8 @@ path = "src/lib.rs" [features] default = [] +cli = ["dep:clap", "dep:glob", "dep:hound", "dep:serde_json", "serde"] +serde = ["dep:serde"] # Opt-in ONNX Runtime backend (`--runtime ort`). Pure-Rust `rten` is the default # and needs no system libraries; `ort` is kept as a maintainer reference oracle # (cross-runtime parity, op-level `--profile`) and requires libonnxruntime at @@ -51,13 +54,15 @@ rten-tensor = { version = "0.24" } anyhow = "1" symphonia = { version = "0.6", features = ["all"] } rubato = "3.0" -hound = "3.5" -clap = { version = "4", features = ["derive"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -glob = "0.3" +hound = { version = "3.5", optional = true } +clap = { version = "4", features = ["derive"], optional = true } +serde = { version = "1", features = ["derive"], optional = true } +serde_json = { version = "1", optional = true } +glob = { version = "0.3", optional = true } [dev-dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" tempfile = "3" [profile.release] diff --git a/README.md b/README.md index 5226d95..53ed7b8 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ repo, so you can run inference immediately after building: ```bash git clone git@github.com:danigb/beat-this-rs.git cd beat-this-rs -cargo build --release +cargo build --release --features cli # Run with the bundled small model (zero setup): ./target/release/beat-this input.mp3 --model models/beat_this_small.onnx @@ -49,7 +49,7 @@ Optionally install the binary you just built onto your `PATH`, so you can call `beat-this` from anywhere instead of `./target/release/beat-this`: ```bash -cargo install --path . # installs to ~/.cargo/bin/beat-this +cargo install --path . --features cli # installs to ~/.cargo/bin/beat-this beat-this input.mp3 --model models/beat_this_small.onnx ``` @@ -62,7 +62,7 @@ checkout, see [Install](#install). **As a CLI tool** (from crates.io): ```bash -cargo install beat-this +cargo install beat-this --features cli ``` The published crate does **not** bundle the model files. The committed mel + small models @@ -118,7 +118,7 @@ also build the ONNX Runtime backend (for `--runtime ort` and `--profile`), enabl `ort` feature: ```bash -cargo build --release --features ort +cargo build --release --features cli,ort ``` It loads `libonnxruntime` at runtime — install it (`brew install onnxruntime` on macOS) @@ -161,6 +161,11 @@ beat-this "music/**/*.mp3" --json ## Library usage +The default feature set is library-only, so depending on `beat-this` does not pull in +the CLI argument parsing, output serialization, globbing, or WAV-writing crates. Enable +the `cli` feature only when building or installing the `beat-this` executable. The +`serde` feature separately enables serialization support for `Tensor`. + ```rust use std::path::Path; use beat_this::{BeatThis, RtenRuntime}; diff --git a/src/runtime.rs b/src/runtime.rs index dc58229..214a0c4 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -8,7 +8,8 @@ use std::collections::HashMap; use std::path::Path; /// Simple f32 tensor with shape (row-major / C-order). -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Tensor { pub shape: Vec, pub data: Vec, From 0591b3d8c9f691e3da2c3c04dbd8597d78541e00 Mon Sep 17 00:00:00 2001 From: Michael Kreuzer Date: Sun, 16 Aug 2026 21:00:24 +0200 Subject: [PATCH 2/4] decode feature for library crate --- Cargo.toml | 7 +- README.md | 12 +- src/audio.rs | 226 +++++++++++++++------------- src/lib.rs | 2 + tests/audio_integration.rs | 2 + tests/cross_runtime.rs | 1 + tests/inference_integration.rs | 2 + tests/mel_integration.rs | 2 + tests/output_integration.rs | 2 + tests/postprocessing_integration.rs | 2 + tests/public_api_integration.rs | 2 + tests/python_parity.rs | 2 + tests/rten_integration.rs | 2 + 13 files changed, 152 insertions(+), 112 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6b39c29..3982fed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,8 +33,9 @@ name = "beat_this" path = "src/lib.rs" [features] -default = [] -cli = ["dep:clap", "dep:glob", "dep:hound", "dep:serde_json", "serde"] +default = ["decode", "serde"] +cli = ["decode", "dep:clap", "dep:glob", "dep:hound", "dep:serde_json", "serde"] +decode = ["dep:symphonia"] serde = ["dep:serde"] # Opt-in ONNX Runtime backend (`--runtime ort`). Pure-Rust `rten` is the default # and needs no system libraries; `ort` is kept as a maintainer reference oracle @@ -52,7 +53,7 @@ ndarray = { version = "0.17", optional = true } rten = { version = "0.24", features = ["fft"] } rten-tensor = { version = "0.24" } anyhow = "1" -symphonia = { version = "0.6", features = ["all"] } +symphonia = { version = "0.6", features = ["all"], optional = true } rubato = "3.0" hound = { version = "3.5", optional = true } clap = { version = "4", features = ["derive"], optional = true } diff --git a/README.md b/README.md index 53ed7b8..5d5b48a 100644 --- a/README.md +++ b/README.md @@ -161,10 +161,14 @@ beat-this "music/**/*.mp3" --json ## Library usage -The default feature set is library-only, so depending on `beat-this` does not pull in -the CLI argument parsing, output serialization, globbing, or WAV-writing crates. Enable -the `cli` feature only when building or installing the `beat-this` executable. The -`serde` feature separately enables serialization support for `Tensor`. +The default feature set includes audio file decoding and `Tensor` serialization for API +compatibility, but does not pull in CLI-only dependencies. Disable default features for +a minimal raw-sample library build; resampling remains available without them. The +`decode` and `serde` features can also be enabled independently, and `cli` enables both. + +```toml +beat-this = { version = "1", default-features = false } +``` ```rust use std::path::Path; diff --git a/src/audio.rs b/src/audio.rs index cd1884a..caa4221 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -4,114 +4,145 @@ use rubato::{ Async, FixedAsync, Resampler, SincInterpolationParameters, SincInterpolationType, WindowFunction, }; -use std::fs::File; -use std::path::Path; -use symphonia::core::codecs::audio::AudioDecoderOptions; -use symphonia::core::errors::Error; -use symphonia::core::formats::probe::Hint; -use symphonia::core::formats::{FormatOptions, TrackType}; -use symphonia::core::io::MediaSourceStream; -use symphonia::core::meta::MetadataOptions; - -/// Mono audio data at a known sample rate. -pub struct AudioData { - pub samples: Vec, - pub sample_rate: u32, -} - -/// Load an audio file, convert to mono, and resample to `target_sr`. -/// -/// Supports MP3, WAV, FLAC, OGG, and other formats via symphonia. -/// Uses high-quality sinc resampling via rubato when the source rate -/// differs from `target_sr`. -pub fn load_audio(path: &Path, target_sr: u32) -> Result { - let (samples, source_sr, channels) = decode(path)?; - let mono = to_mono(&samples, channels); - let resampled = resample(mono, source_sr, target_sr)?; - - Ok(AudioData { - samples: resampled, - sample_rate: target_sr, - }) -} -/// Decode an audio file into interleaved f32 samples. -/// Returns (samples, sample_rate, channel_count). -fn decode(path: &Path) -> Result<(Vec, u32, usize)> { - let src = File::open(path)?; - let mss = MediaSourceStream::new(Box::new(src), Default::default()); +#[cfg(feature = "decode")] +mod decode { + use std::fs::File; + use std::path::Path; + + use anyhow::{anyhow, Result}; + use symphonia::core::codecs::audio::AudioDecoderOptions; + use symphonia::core::errors::Error; + use symphonia::core::formats::probe::Hint; + use symphonia::core::formats::{FormatOptions, TrackType}; + use symphonia::core::io::MediaSourceStream; + use symphonia::core::meta::MetadataOptions; + + use super::resample; + + /// Mono audio data at a known sample rate. + pub struct AudioData { + pub samples: Vec, + pub sample_rate: u32, + } - // Provide file extension hint for better format detection - let mut hint = Hint::new(); - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - hint.with_extension(ext); + /// Load an audio file, convert to mono, and resample to `target_sr`. + /// + /// Supports MP3, WAV, FLAC, OGG, and other formats via symphonia. + /// Uses high-quality sinc resampling via rubato when the source rate + /// differs from `target_sr`. + pub fn load_audio(path: &Path, target_sr: u32) -> Result { + let (samples, source_sr, channels) = decode(path)?; + let mono = to_mono(&samples, channels); + let resampled = resample(mono, source_sr, target_sr)?; + + Ok(AudioData { + samples: resampled, + sample_rate: target_sr, + }) } - let mut format = symphonia::default::get_probe().probe( - &hint, - mss, - FormatOptions::default(), - MetadataOptions::default(), - )?; + /// Decode an audio file into interleaved f32 samples. + /// Returns (samples, sample_rate, channel_count). + fn decode(path: &Path) -> Result<(Vec, u32, usize)> { + let src = File::open(path)?; + let mss = MediaSourceStream::new(Box::new(src), Default::default()); - let track = format - .first_track_known_codec(TrackType::Audio) - .ok_or_else(|| anyhow!("no supported audio tracks"))?; - let track_id = track.id; - let codec_params = track - .codec_params - .as_ref() - .and_then(|p| p.audio()) - .ok_or_else(|| anyhow!("audio track missing codec parameters"))?; - - let mut decoder = symphonia::default::get_codecs() - .make_audio_decoder(codec_params, &AudioDecoderOptions::default())?; - - let mut samples: Vec = Vec::new(); - let mut scratch: Vec = Vec::new(); - let mut source_sr = 0u32; - let mut channels = 0usize; - - // `next_packet` returns `Ok(None)` at end of stream in symphonia 0.6. - while let Some(packet) = format.next_packet()? { - if packet.track_id != track_id { - continue; + // Provide file extension hint for better format detection + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); } - match decoder.decode(&packet) { - Ok(decoded) => { - let spec = decoded.spec(); - source_sr = spec.rate(); - channels = spec.channels().count(); + let mut format = symphonia::default::get_probe().probe( + &hint, + mss, + FormatOptions::default(), + MetadataOptions::default(), + )?; + + let track = format + .first_track_known_codec(TrackType::Audio) + .ok_or_else(|| anyhow!("no supported audio tracks"))?; + let track_id = track.id; + let codec_params = track + .codec_params + .as_ref() + .and_then(|p| p.audio()) + .ok_or_else(|| anyhow!("audio track missing codec parameters"))?; + + let mut decoder = symphonia::default::get_codecs() + .make_audio_decoder(codec_params, &AudioDecoderOptions::default())?; + + let mut samples: Vec = Vec::new(); + let mut scratch: Vec = Vec::new(); + let mut source_sr = 0u32; + let mut channels = 0usize; + + // `next_packet` returns `Ok(None)` at end of stream in symphonia 0.6. + while let Some(packet) = format.next_packet()? { + if packet.track_id != track_id { + continue; + } - // `copy_to_vec_interleaved` resizes `scratch` to exactly this - // packet's sample count, so we append it to the running buffer. - decoded.copy_to_vec_interleaved(&mut scratch); - samples.extend_from_slice(&scratch); + match decoder.decode(&packet) { + Ok(decoded) => { + let spec = decoded.spec(); + source_sr = spec.rate(); + channels = spec.channels().count(); + + // `copy_to_vec_interleaved` resizes `scratch` to exactly this + // packet's sample count, so we append it to the running buffer. + decoded.copy_to_vec_interleaved(&mut scratch); + samples.extend_from_slice(&scratch); + } + Err(Error::DecodeError(_)) => (), // skip corrupted packets + Err(err) => return Err(anyhow!(err)), } - Err(Error::DecodeError(_)) => (), // skip corrupted packets - Err(err) => return Err(anyhow!(err)), } + + if source_sr == 0 { + return Err(anyhow!("failed to decode any audio packets")); + } + + Ok((samples, source_sr, channels)) } - if source_sr == 0 { - return Err(anyhow!("failed to decode any audio packets")); + /// Convert interleaved multi-channel audio to mono by averaging channels. + fn to_mono(samples: &[f32], channels: usize) -> Vec { + if channels == 1 { + return samples.to_vec(); + } + samples + .chunks(channels) + .map(|frame| frame.iter().sum::() / channels as f32) + .collect() } - Ok((samples, source_sr, channels)) -} + #[cfg(test)] + mod tests { + use super::*; -/// Convert interleaved multi-channel audio to mono by averaging channels. -fn to_mono(samples: &[f32], channels: usize) -> Vec { - if channels == 1 { - return samples.to_vec(); + #[test] + fn test_to_mono_passthrough() { + let samples = vec![1.0, 2.0, 3.0]; + let mono = to_mono(&samples, 1); + assert_eq!(mono, vec![1.0, 2.0, 3.0]); + } + + #[test] + fn test_to_mono_stereo() { + // Two frames of stereo: (0.5, 1.5) and (1.0, 3.0) + let samples = vec![0.5, 1.5, 1.0, 3.0]; + let mono = to_mono(&samples, 2); + assert_eq!(mono, vec![1.0, 2.0]); + } } - samples - .chunks(channels) - .map(|frame| frame.iter().sum::() / channels as f32) - .collect() } +#[cfg(feature = "decode")] +pub use decode::{load_audio, AudioData}; + /// Resample mono audio from `source_sr` to `target_sr` using sinc interpolation. /// Returns samples unchanged if rates already match. pub fn resample(samples: Vec, source_sr: u32, target_sr: u32) -> Result> { @@ -151,21 +182,6 @@ pub fn resample(samples: Vec, source_sr: u32, target_sr: u32) -> Result BeatThis { /// /// Loads the file, resamples to 22050 Hz mono, computes mel spectrogram, /// runs beat prediction, and decodes into beat/downbeat timestamps. + #[cfg(feature = "decode")] pub fn analyze_file(&mut self, path: &Path) -> Result { let audio = load_audio(path, TARGET_SAMPLE_RATE)?; self.analyze_audio(&audio.samples, audio.sample_rate) diff --git a/tests/audio_integration.rs b/tests/audio_integration.rs index 4db6d70..9ab7caf 100644 --- a/tests/audio_integration.rs +++ b/tests/audio_integration.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "decode")] + use beat_this::load_audio; use std::path::Path; diff --git a/tests/cross_runtime.rs b/tests/cross_runtime.rs index 104b8f0..c0da48e 100644 --- a/tests/cross_runtime.rs +++ b/tests/cross_runtime.rs @@ -1,4 +1,5 @@ #![cfg(feature = "ort")] +#![cfg(feature = "decode")] //! Requires the `ort` feature (and libonnxruntime at runtime). use std::panic::AssertUnwindSafe; diff --git a/tests/inference_integration.rs b/tests/inference_integration.rs index 1a3a35b..a2658f8 100644 --- a/tests/inference_integration.rs +++ b/tests/inference_integration.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "decode")] + use std::path::Path; use beat_this::{BeatThis, RtenRuntime}; diff --git a/tests/mel_integration.rs b/tests/mel_integration.rs index c6c13d6..d0f828f 100644 --- a/tests/mel_integration.rs +++ b/tests/mel_integration.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "decode")] + use std::path::Path; use beat_this::{BeatThis, RtenRuntime}; diff --git a/tests/output_integration.rs b/tests/output_integration.rs index be555ac..f28496c 100644 --- a/tests/output_integration.rs +++ b/tests/output_integration.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "decode")] + use std::path::Path; use beat_this::{beat_counts, calculate_bpm, BeatThis, RtenRuntime}; diff --git a/tests/postprocessing_integration.rs b/tests/postprocessing_integration.rs index ed5e965..2833e5b 100644 --- a/tests/postprocessing_integration.rs +++ b/tests/postprocessing_integration.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "decode")] + use std::path::Path; use beat_this::{BeatThis, RtenRuntime}; diff --git a/tests/public_api_integration.rs b/tests/public_api_integration.rs index 1e495fb..f491ade 100644 --- a/tests/public_api_integration.rs +++ b/tests/public_api_integration.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "decode")] + use std::path::Path; use beat_this::{BeatThis, RtenRuntime}; diff --git a/tests/python_parity.rs b/tests/python_parity.rs index 6d9f189..0928f60 100644 --- a/tests/python_parity.rs +++ b/tests/python_parity.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "decode")] + //! Golden parity test against the Python reference (`beat_this`). //! //! Runs the full Rust pipeline (audio -> mel -> inference -> postprocessing) on diff --git a/tests/rten_integration.rs b/tests/rten_integration.rs index 95ae6c1..497b09e 100644 --- a/tests/rten_integration.rs +++ b/tests/rten_integration.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "decode")] + use std::path::Path; use beat_this::{Model, RtenRuntime, Runtime, Tensor}; From 2da5957736476631d662ab1d49c4892836669bed Mon Sep 17 00:00:00 2001 From: Michael Kreuzer Date: Sun, 16 Aug 2026 21:02:11 +0200 Subject: [PATCH 3/4] fix clippy lint in inference.rs --- src/inference.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/inference.rs b/src/inference.rs index 27eec39..567568e 100644 --- a/src/inference.rs +++ b/src/inference.rs @@ -223,8 +223,8 @@ mod tests { let write_start = (start + BORDER_SIZE as i32).max(0) as usize; let write_end = ((start as usize).wrapping_add(chunk_time) - BORDER_SIZE).min(full_time); - for i in write_start..write_end { - covered[i] = true; + for frame in &mut covered[write_start..write_end] { + *frame = true; } } assert!( From 0c3dac24eb35a068295072637ddf31106c4edf98 Mon Sep 17 00:00:00 2001 From: Michael Kreuzer Date: Sun, 16 Aug 2026 21:33:54 +0200 Subject: [PATCH 4/4] make cli a default feature for backwards compatibility --- .github/workflows/ci.yml | 4 ++-- Cargo.toml | 2 +- README.md | 20 +++++++++++--------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51fafba..ee6ee70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 - run: cargo fmt --check - - run: cargo clippy --all-targets --features cli -- -D warnings + - run: cargo clippy -- -D warnings - run: cargo test # Opt-in ort backend: exercises the feature so it can't bit-rot. @@ -47,5 +47,5 @@ jobs: "https://github.com/microsoft/onnxruntime/releases/download/v${ORT_VERSION}/onnxruntime-linux-x64-${ORT_VERSION}.tgz" tar xzf ort.tgz echo "ORT_DYLIB_PATH=${PWD}/onnxruntime-linux-x64-${ORT_VERSION}/lib/libonnxruntime.so" >> "$GITHUB_ENV" - - run: cargo clippy --all-targets --features cli,ort -- -D warnings + - run: cargo clippy --features ort -- -D warnings - run: cargo test --features ort diff --git a/Cargo.toml b/Cargo.toml index 3982fed..fa83c30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ name = "beat_this" path = "src/lib.rs" [features] -default = ["decode", "serde"] +default = ["decode", "serde", "cli"] cli = ["decode", "dep:clap", "dep:glob", "dep:hound", "dep:serde_json", "serde"] decode = ["dep:symphonia"] serde = ["dep:serde"] diff --git a/README.md b/README.md index 5d5b48a..f0cf421 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ repo, so you can run inference immediately after building: ```bash git clone git@github.com:danigb/beat-this-rs.git cd beat-this-rs -cargo build --release --features cli +cargo build --release # Run with the bundled small model (zero setup): ./target/release/beat-this input.mp3 --model models/beat_this_small.onnx @@ -49,7 +49,7 @@ Optionally install the binary you just built onto your `PATH`, so you can call `beat-this` from anywhere instead of `./target/release/beat-this`: ```bash -cargo install --path . --features cli # installs to ~/.cargo/bin/beat-this +cargo install --path . # installs to ~/.cargo/bin/beat-this beat-this input.mp3 --model models/beat_this_small.onnx ``` @@ -62,7 +62,7 @@ checkout, see [Install](#install). **As a CLI tool** (from crates.io): ```bash -cargo install beat-this --features cli +cargo install beat-this ``` The published crate does **not** bundle the model files. The committed mel + small models @@ -118,7 +118,7 @@ also build the ONNX Runtime backend (for `--runtime ort` and `--profile`), enabl `ort` feature: ```bash -cargo build --release --features cli,ort +cargo build --release --features ort ``` It loads `libonnxruntime` at runtime — install it (`brew install onnxruntime` on macOS) @@ -161,13 +161,15 @@ beat-this "music/**/*.mp3" --json ## Library usage -The default feature set includes audio file decoding and `Tensor` serialization for API -compatibility, but does not pull in CLI-only dependencies. Disable default features for -a minimal raw-sample library build; resampling remains available without them. The -`decode` and `serde` features can also be enabled independently, and `cli` enables both. +The default feature set pulls on a lot of dependencies required for the CLI. If you intend to use +the crate as a library, you should disable default features and enable only the ones needed: + +- `decode` for audio file decoding using `symphonia` +- `serde` for serialization of the `Tensor` struct +- `ort` for the ONNX Runtime backend. ```toml -beat-this = { version = "1", default-features = false } +beat-this = { version = "1", default-features = false, features = ["decode"] } ``` ```rust