diff --git a/Cargo.toml b/Cargo.toml index 728fe39..fa83c30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,13 +26,17 @@ exclude = [ [[bin]] name = "beat-this" path = "src/main.rs" +required-features = ["cli"] [lib] name = "beat_this" path = "src/lib.rs" [features] -default = [] +default = ["decode", "serde", "cli"] +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 # (cross-runtime parity, op-level `--profile`) and requires libonnxruntime at @@ -49,15 +53,17 @@ 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 = "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..f0cf421 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,17 @@ beat-this "music/**/*.mp3" --json ## Library usage +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, features = ["decode"] } +``` + ```rust use std::path::Path; use beat_this::{BeatThis, RtenRuntime}; 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/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, 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};