Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
226 changes: 121 additions & 105 deletions src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f32>,
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<AudioData> {
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<f32>, 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<f32>,
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<AudioData> {
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<f32>, 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<f32> = Vec::new();
let mut scratch: Vec<f32> = 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<f32> = Vec::new();
let mut scratch: Vec<f32> = 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<f32> {
if channels == 1 {
return samples.to_vec();
}
samples
.chunks(channels)
.map(|frame| frame.iter().sum::<f32>() / 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<f32> {
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::<f32>() / 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<f32>, source_sr: u32, target_sr: u32) -> Result<Vec<f32>> {
Expand Down Expand Up @@ -151,21 +182,6 @@ pub fn resample(samples: Vec<f32>, source_sr: u32, target_sr: u32) -> Result<Vec
mod tests {
use super::*;

#[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]);
}

#[test]
fn test_resample_identity() {
// Same rate should return unchanged
Expand Down
4 changes: 2 additions & 2 deletions src/inference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use std::time::Duration;

use anyhow::Result;

#[cfg(feature = "decode")]
pub use audio::{load_audio, AudioData};
pub use output::{beat_counts, calculate_bpm};
#[cfg(feature = "ort")]
Expand Down Expand Up @@ -156,6 +157,7 @@ impl<M: Model> BeatThis<M> {
///
/// 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<BeatAnalysis> {
let audio = load_audio(path, TARGET_SAMPLE_RATE)?;
self.analyze_audio(&audio.samples, audio.sample_rate)
Expand Down
3 changes: 2 additions & 1 deletion src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>,
pub data: Vec<f32>,
Expand Down
2 changes: 2 additions & 0 deletions tests/audio_integration.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#![cfg(feature = "decode")]

use beat_this::load_audio;
use std::path::Path;

Expand Down
1 change: 1 addition & 0 deletions tests/cross_runtime.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#![cfg(feature = "ort")]
#![cfg(feature = "decode")]
//! Requires the `ort` feature (and libonnxruntime at runtime).

use std::panic::AssertUnwindSafe;
Expand Down
2 changes: 2 additions & 0 deletions tests/inference_integration.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#![cfg(feature = "decode")]

use std::path::Path;

use beat_this::{BeatThis, RtenRuntime};
Expand Down
2 changes: 2 additions & 0 deletions tests/mel_integration.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#![cfg(feature = "decode")]

use std::path::Path;

use beat_this::{BeatThis, RtenRuntime};
Expand Down
2 changes: 2 additions & 0 deletions tests/output_integration.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#![cfg(feature = "decode")]

use std::path::Path;

use beat_this::{beat_counts, calculate_bpm, BeatThis, RtenRuntime};
Expand Down
2 changes: 2 additions & 0 deletions tests/postprocessing_integration.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#![cfg(feature = "decode")]

use std::path::Path;

use beat_this::{BeatThis, RtenRuntime};
Expand Down
2 changes: 2 additions & 0 deletions tests/public_api_integration.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#![cfg(feature = "decode")]

use std::path::Path;

use beat_this::{BeatThis, RtenRuntime};
Expand Down
Loading