use crate::analysis::transient::{detect_transients, TransientMap};
const BEAT_FFT_SIZE: usize = 2048;
const BEAT_HOP_SIZE: usize = 512;
const BEAT_SENSITIVITY: f32 = 0.4;
const MIN_EDM_BPM: f64 = 100.0;
const MAX_EDM_BPM: f64 = 160.0;
const MAX_BPM_NORMALIZATION_STEPS: usize = 32;
const MAX_GRID_POINTS: usize = 1_000_000;
const PLL_ALPHA: f64 = 0.08;
const PLL_BETA: f64 = 0.03;
#[derive(Debug, Clone)]
pub struct BeatGrid {
pub beats: Vec<usize>,
pub beats_fractional: Vec<f64>,
pub bpm: f64,
pub sample_rate: u32,
}
impl BeatGrid {
#[inline]
pub fn beat_interval_samples(&self) -> f64 {
60.0 * self.sample_rate as f64 / self.bpm
}
#[inline]
pub fn snap_to_grid(&self, position: usize) -> usize {
if self.beats.is_empty() {
return position;
}
let mut closest = self.beats[0];
let mut min_dist = (position as i64 - closest as i64).unsigned_abs() as usize;
for &beat in &self.beats[1..] {
let dist = (position as i64 - beat as i64).unsigned_abs() as usize;
if dist < min_dist {
min_dist = dist;
closest = beat;
}
}
closest
}
#[inline]
pub fn snap_to_grid_fractional(&self, position: f64) -> f64 {
if self.beats_fractional.is_empty() {
return position;
}
let mut closest = self.beats_fractional[0];
let mut min_dist = (position - closest).abs();
for &beat in &self.beats_fractional[1..] {
let dist = (position - beat).abs();
if dist < min_dist {
min_dist = dist;
closest = beat;
}
}
closest
}
}
pub fn detect_beats(samples: &[f32], sample_rate: u32) -> BeatGrid {
let transients = detect_transients(
samples,
sample_rate,
BEAT_FFT_SIZE,
BEAT_HOP_SIZE,
BEAT_SENSITIVITY,
);
detect_beats_from_transients(&transients, sample_rate)
}
pub(crate) fn detect_beats_from_transients(
transients: &TransientMap,
sample_rate: u32,
) -> BeatGrid {
if transients.onsets.len() < 2 {
let beats_fractional = transients.onsets_fractional.to_vec();
let beats_int = if beats_fractional.is_empty() {
transients.onsets.clone()
} else {
beats_fractional
.iter()
.map(|&f| f.round() as usize)
.collect()
};
return BeatGrid {
beats: beats_int,
beats_fractional,
bpm: 0.0,
sample_rate,
};
}
let onset_positions_f64: Vec<f64> =
if transients.onsets_fractional.len() == transients.onsets.len() {
transients.onsets_fractional.clone()
} else {
transients.onsets.iter().map(|&o| o as f64).collect()
};
let intervals_f64: Vec<f64> = onset_positions_f64
.windows(2)
.map(|w| w[1] - w[0])
.collect();
let intervals: Vec<usize> = transients.onsets.windows(2).map(|w| w[1] - w[0]).collect();
let bpm = estimate_bpm_from_intervals(&intervals, sample_rate);
let initial_interval = 60.0 * sample_rate as f64 / bpm;
let beats_fractional =
quantize_to_grid_pll(&onset_positions_f64, &intervals_f64, initial_interval);
let beats: Vec<usize> = beats_fractional
.iter()
.map(|&f| f.round() as usize)
.collect();
BeatGrid {
beats,
beats_fractional,
bpm,
sample_rate,
}
}
fn estimate_bpm_from_intervals(intervals: &[usize], sample_rate: u32) -> f64 {
if intervals.is_empty() {
return 0.0;
}
let mut sorted = intervals.to_vec();
sorted.sort();
let median_interval = sorted[sorted.len() / 2];
if median_interval == 0 {
return 0.0;
}
let raw_bpm = 60.0 * sample_rate as f64 / median_interval as f64;
if !raw_bpm.is_finite() || raw_bpm <= 0.0 {
return 0.0;
}
let mut bpm = raw_bpm;
for _ in 0..MAX_BPM_NORMALIZATION_STEPS {
if bpm <= MAX_EDM_BPM {
break;
}
bpm /= 2.0;
}
for _ in 0..MAX_BPM_NORMALIZATION_STEPS {
if !(bpm < MIN_EDM_BPM && bpm > 0.0) {
break;
}
bpm *= 2.0;
}
bpm
}
#[cfg(test)]
fn quantize_to_grid(onsets: &[usize], beat_interval: usize) -> Vec<usize> {
if onsets.is_empty() || beat_interval == 0 {
return onsets.to_vec();
}
let first = onsets[0];
let last = *onsets.last().unwrap_or(&first);
let max_points = last
.saturating_sub(first)
.saturating_div(beat_interval)
.saturating_add(2)
.min(MAX_GRID_POINTS);
let mut grid = Vec::new();
let mut pos = first;
for _ in 0..max_points {
if pos > last + beat_interval / 2 {
break;
}
grid.push(pos);
pos += beat_interval;
}
grid
}
fn quantize_to_grid_pll(onsets: &[f64], _intervals: &[f64], initial_interval: f64) -> Vec<f64> {
if onsets.is_empty() || initial_interval <= 0.0 || !initial_interval.is_finite() {
return onsets.to_vec();
}
let first = onsets[0];
let last = *onsets.last().unwrap_or(&first);
let mut beat_interval = initial_interval;
let mut grid_offset: f64 = 0.0;
for &onset in onsets.iter().skip(1) {
let relative = onset - first - grid_offset;
if beat_interval <= 0.0 || !beat_interval.is_finite() {
break;
}
let nearest_grid_idx = (relative / beat_interval).round();
let nearest_grid_pos = first + grid_offset + nearest_grid_idx * beat_interval;
let phase_error = onset - nearest_grid_pos;
beat_interval += PLL_ALPHA * phase_error / nearest_grid_idx.abs().max(1.0);
grid_offset += PLL_BETA * phase_error;
beat_interval = beat_interval.clamp(initial_interval * 0.5, initial_interval * 1.5);
}
let mut grid = Vec::new();
let mut pos = first + grid_offset;
let max_back_steps = onsets.len().saturating_mul(8).max(64);
for _ in 0..max_back_steps {
if pos <= first + beat_interval * 0.5 {
break;
}
pos -= beat_interval;
}
if pos > first + beat_interval * 0.5 {
pos = first;
}
if pos < 0.0 {
pos += beat_interval * (((-pos) / beat_interval).ceil());
}
let end = last + beat_interval * 0.5;
let max_grid_points = (((end - pos) / beat_interval).max(0.0).ceil() as usize)
.saturating_add(2)
.min(MAX_GRID_POINTS);
for _ in 0..max_grid_points {
if pos > end {
break;
}
grid.push(pos);
pos += beat_interval;
}
grid
}
pub fn generate_subdivision_grid(
bpm: f64,
sample_rate: u32,
total_samples: usize,
subdivision: u32,
) -> Vec<f64> {
if bpm <= 0.0 || subdivision == 0 || total_samples == 0 {
return Vec::new();
}
let beat_interval_samples = 60.0 * sample_rate as f64 / bpm;
let sub_interval = beat_interval_samples / subdivision as f64;
if sub_interval <= 0.0 {
return Vec::new();
}
let estimated_count = (total_samples as f64 / sub_interval).ceil() as usize + 1;
let max_points = estimated_count.min(MAX_GRID_POINTS);
let mut grid = Vec::with_capacity(max_points);
let mut pos = 0.0;
for _ in 0..max_points {
if pos >= total_samples as f64 {
break;
}
grid.push(pos);
pos += sub_interval;
}
grid
}
pub fn snap_to_subdivision(position: f64, grid: &[f64], tolerance_samples: f64) -> Option<f64> {
if grid.is_empty() {
return None;
}
let idx = grid.partition_point(|&g| g < position);
let mut best_dist = f64::MAX;
let mut best_pos = position;
for &check_idx in &[idx.saturating_sub(1), idx] {
if check_idx < grid.len() {
let dist = (grid[check_idx] - position).abs();
if dist < best_dist {
best_dist = dist;
best_pos = grid[check_idx];
}
}
}
if best_dist <= tolerance_samples {
Some(best_pos)
} else {
None }
}
pub fn default_subdivision_for_preset(preset: Option<crate::core::types::EdmPreset>) -> u32 {
match preset {
Some(crate::core::types::EdmPreset::Halftime) => 8,
Some(crate::core::types::EdmPreset::Ambient) => 4,
_ => 16,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_grid(beats: Vec<usize>, bpm: f64, sample_rate: u32) -> BeatGrid {
let beats_fractional = beats.iter().map(|&b| b as f64).collect();
BeatGrid {
beats,
beats_fractional,
bpm,
sample_rate,
}
}
#[test]
fn test_beat_grid_snap() {
let grid = make_grid(vec![0, 22050, 44100, 66150], 120.0, 44100);
assert_eq!(grid.snap_to_grid(100), 0);
assert_eq!(grid.snap_to_grid(22000), 22050);
assert_eq!(grid.snap_to_grid(33000), 22050);
}
#[test]
fn test_estimate_bpm() {
let intervals = vec![22050, 22050, 22050, 22050];
let bpm = estimate_bpm_from_intervals(&intervals, 44100);
assert!((bpm - 120.0).abs() < 1.0, "Expected ~120 BPM, got {}", bpm);
}
#[test]
fn test_estimate_bpm_empty() {
assert_eq!(estimate_bpm_from_intervals(&[], 44100), 0.0);
}
#[test]
fn test_quantize_grid() {
let onsets = vec![0, 22000, 44200];
let grid = quantize_to_grid(&onsets, 22050);
assert_eq!(grid.len(), 3);
assert_eq!(grid[0], 0);
assert_eq!(grid[1], 22050);
assert_eq!(grid[2], 44100);
}
#[test]
fn test_beat_interval_samples_120bpm() {
let grid = make_grid(vec![0], 120.0, 44100);
assert!((grid.beat_interval_samples() - 22050.0).abs() < 1.0);
}
#[test]
fn test_beat_interval_samples_128bpm_48khz() {
let grid = make_grid(vec![0], 128.0, 48000);
assert!((grid.beat_interval_samples() - 22500.0).abs() < 1.0);
}
#[test]
fn test_snap_to_grid_empty_beats() {
let grid = make_grid(vec![], 120.0, 44100);
assert_eq!(grid.snap_to_grid(1000), 1000);
}
#[test]
fn test_snap_to_grid_before_first_beat() {
let grid = make_grid(vec![1000, 2000, 3000], 120.0, 44100);
assert_eq!(grid.snap_to_grid(500), 1000);
}
#[test]
fn test_snap_to_grid_after_last_beat() {
let grid = make_grid(vec![1000, 2000, 3000], 120.0, 44100);
assert_eq!(grid.snap_to_grid(10000), 3000);
}
#[test]
fn test_snap_to_grid_equidistant() {
let grid = make_grid(vec![0, 100, 200], 120.0, 44100);
let result = grid.snap_to_grid(50);
assert!(
result == 0 || result == 100,
"Should snap to 0 or 100, got {}",
result
);
}
#[test]
fn test_snap_to_grid_exact_beat() {
let grid = make_grid(vec![0, 22050, 44100], 120.0, 44100);
assert_eq!(grid.snap_to_grid(22050), 22050);
}
#[test]
fn test_estimate_bpm_halving_high_bpm() {
let intervals = vec![8269, 8269, 8269];
let bpm = estimate_bpm_from_intervals(&intervals, 44100);
assert!(
(bpm - 160.0).abs() < 2.0,
"320 BPM should halve to ~160, got {}",
bpm
);
}
#[test]
fn test_estimate_bpm_doubling_low_bpm() {
let intervals = vec![52920, 52920, 52920];
let bpm = estimate_bpm_from_intervals(&intervals, 44100);
assert!(
(bpm - 100.0).abs() < 2.0,
"50 BPM should double to ~100, got {}",
bpm
);
}
#[test]
fn test_estimate_bpm_already_in_range() {
let interval = (60.0 * 44100.0 / 128.0) as usize;
let intervals = vec![interval, interval, interval];
let bpm = estimate_bpm_from_intervals(&intervals, 44100);
assert!(
(bpm - 128.0).abs() < 2.0,
"128 BPM should stay ~128, got {}",
bpm
);
}
#[test]
fn test_estimate_bpm_outlier_robustness() {
let normal = (60.0 * 44100.0 / 120.0) as usize; let outlier = normal / 3; let intervals = vec![normal, normal, outlier, normal, normal];
let bpm = estimate_bpm_from_intervals(&intervals, 44100);
assert!(
(bpm - 120.0).abs() < 2.0,
"BPM should be robust to outlier, got {}",
bpm
);
}
#[test]
fn test_quantize_to_grid_empty_onsets() {
let result = quantize_to_grid(&[], 22050);
assert!(result.is_empty());
}
#[test]
fn test_quantize_to_grid_zero_interval() {
let onsets = vec![100, 200, 300];
let result = quantize_to_grid(&onsets, 0);
assert_eq!(result, onsets);
}
#[test]
fn test_quantize_to_grid_single_onset() {
let result = quantize_to_grid(&[5000], 22050);
assert_eq!(result, vec![5000]);
}
#[test]
fn test_quantize_to_grid_extension() {
let onsets = vec![0, 44100];
let result = quantize_to_grid(&onsets, 22050);
assert_eq!(result, vec![0, 22050, 44100]);
}
#[test]
fn test_pll_grid_perfect_onsets() {
let interval = 22050.0; let onsets: Vec<f64> = (0..8).map(|i| i as f64 * interval).collect();
let intervals: Vec<f64> = onsets.windows(2).map(|w| w[1] - w[0]).collect();
let grid = quantize_to_grid_pll(&onsets, &intervals, interval);
assert_eq!(grid.len(), 8, "PLL grid should have 8 beats");
for (i, &pos) in grid.iter().enumerate() {
let expected = i as f64 * interval;
assert!(
(pos - expected).abs() < interval * 0.1,
"Beat {} at {:.1} should be near {:.1}",
i,
pos,
expected
);
}
}
#[test]
fn test_pll_grid_offset_first_onset() {
let interval = 22050.0;
let offset = 200.0;
let mut onsets: Vec<f64> = (0..8).map(|i| i as f64 * interval).collect();
onsets[0] += offset;
let intervals: Vec<f64> = onsets.windows(2).map(|w| w[1] - w[0]).collect();
let grid = quantize_to_grid_pll(&onsets, &intervals, interval);
assert!(
grid.len() >= 7,
"PLL grid should produce reasonable number of beats"
);
}
#[test]
fn test_pll_grid_empty() {
let result = quantize_to_grid_pll(&[], &[], 22050.0);
assert!(result.is_empty());
}
#[test]
fn test_pll_grid_zero_interval() {
let onsets = vec![100.0, 200.0, 300.0];
let result = quantize_to_grid_pll(&onsets, &[], 0.0);
assert_eq!(result, onsets);
}
#[test]
fn test_pll_grid_single_onset() {
let result = quantize_to_grid_pll(&[5000.0], &[], 22050.0);
assert_eq!(result.len(), 1);
assert!((result[0] - 5000.0).abs() < 1.0);
}
#[test]
fn test_snap_to_grid_fractional_basic() {
let grid = BeatGrid {
beats: vec![0, 22050, 44100],
beats_fractional: vec![0.0, 22050.5, 44100.25],
bpm: 120.0,
sample_rate: 44100,
};
let snapped = grid.snap_to_grid_fractional(22000.0);
assert!(
(snapped - 22050.5).abs() < 1.0,
"Should snap to 22050.5, got {}",
snapped
);
}
#[test]
fn test_snap_to_grid_fractional_empty() {
let grid = BeatGrid {
beats: vec![],
beats_fractional: vec![],
bpm: 120.0,
sample_rate: 44100,
};
let snapped = grid.snap_to_grid_fractional(1000.0);
assert!((snapped - 1000.0).abs() < 1e-10);
}
#[test]
fn test_detect_beats_has_fractional() {
let sample_rate = 44100u32;
let num_samples = sample_rate as usize * 4;
let mut samples = vec![0.0f32; num_samples];
let click_interval = (60.0 * sample_rate as f64 / 120.0) as usize; for i in (0..num_samples).step_by(click_interval) {
for j in 0..10.min(num_samples - i) {
samples[i + j] = if j < 5 { 1.0 } else { -0.5 };
}
}
let grid = detect_beats(&samples, sample_rate);
assert_eq!(
grid.beats.len(),
grid.beats_fractional.len(),
"beats and beats_fractional should have same length"
);
}
#[test]
fn test_generate_subdivision_grid_120bpm_1sec() {
let grid = generate_subdivision_grid(120.0, 44100, 44100, 16);
assert_eq!(
grid.len(),
32,
"Expected 32 subdivision positions, got {}",
grid.len()
);
assert!((grid[0] - 0.0).abs() < 1e-10, "First position should be 0");
let expected_interval = 60.0 * 44100.0 / 120.0 / 16.0; for i in 1..grid.len() {
let interval = grid[i] - grid[i - 1];
assert!(
(interval - expected_interval).abs() < 1e-6,
"Interval {} at position {} should be {}, got {}",
i,
grid[i],
expected_interval,
interval
);
}
}
#[test]
fn test_generate_subdivision_grid_zero_bpm() {
let grid = generate_subdivision_grid(0.0, 44100, 44100, 16);
assert!(grid.is_empty());
}
#[test]
fn test_generate_subdivision_grid_zero_subdivision() {
let grid = generate_subdivision_grid(120.0, 44100, 44100, 0);
assert!(grid.is_empty());
}
#[test]
fn test_generate_subdivision_grid_zero_samples() {
let grid = generate_subdivision_grid(120.0, 44100, 0, 16);
assert!(grid.is_empty());
}
#[test]
fn test_generate_subdivision_grid_quarter_notes() {
let grid = generate_subdivision_grid(128.0, 48000, 96000, 1);
assert_eq!(
grid.len(),
5,
"Expected 5 beat positions, got {}",
grid.len()
);
}
#[test]
fn test_snap_to_subdivision_exact_on_grid() {
let grid = vec![0.0, 1000.0, 2000.0, 3000.0];
let result = snap_to_subdivision(1000.0, &grid, 220.0);
assert_eq!(result, Some(1000.0));
}
#[test]
fn test_snap_to_subdivision_within_tolerance() {
let grid = vec![0.0, 1000.0, 2000.0, 3000.0];
let tolerance = 44100.0 * 0.005; let result = snap_to_subdivision(1132.0, &grid, tolerance);
assert_eq!(
result,
Some(1000.0),
"Should snap to 1000 (132 samples away, within 220 tolerance)"
);
}
#[test]
fn test_snap_to_subdivision_outside_tolerance() {
let grid = vec![0.0, 1000.0, 2000.0, 3000.0];
let tolerance = 44100.0 * 0.005; let result = snap_to_subdivision(1441.0, &grid, tolerance);
assert_eq!(
result, None,
"Should suppress (441 samples away, outside 220 tolerance)"
);
}
#[test]
fn test_snap_to_subdivision_empty_grid() {
let result = snap_to_subdivision(1000.0, &[], 220.0);
assert_eq!(result, None);
}
#[test]
fn test_snap_to_subdivision_snaps_to_nearest() {
let grid = vec![0.0, 1000.0, 2000.0];
let result = snap_to_subdivision(1800.0, &grid, 250.0);
assert_eq!(
result,
Some(2000.0),
"Should snap to 2000 (200 away), not 1000 (800 away)"
);
}
#[test]
fn test_snap_to_subdivision_first_position() {
let grid = vec![0.0, 1000.0, 2000.0];
let result = snap_to_subdivision(50.0, &grid, 100.0);
assert_eq!(result, Some(0.0));
}
#[test]
fn test_snap_to_subdivision_last_position() {
let grid = vec![0.0, 1000.0, 2000.0];
let result = snap_to_subdivision(1990.0, &grid, 100.0);
assert_eq!(result, Some(2000.0));
}
#[test]
fn test_default_subdivision_for_preset() {
use crate::core::types::EdmPreset;
assert_eq!(default_subdivision_for_preset(None), 16);
assert_eq!(
default_subdivision_for_preset(Some(EdmPreset::DjBeatmatch)),
16
);
assert_eq!(
default_subdivision_for_preset(Some(EdmPreset::HouseLoop)),
16
);
assert_eq!(default_subdivision_for_preset(Some(EdmPreset::Halftime)), 8);
assert_eq!(default_subdivision_for_preset(Some(EdmPreset::Ambient)), 4);
assert_eq!(
default_subdivision_for_preset(Some(EdmPreset::VocalChop)),
16
);
}
#[test]
fn test_snap_transients_to_beat_grid_integration() {
let sample_rate = 44100u32;
let bpm = 128.0;
let num_samples = sample_rate as usize * 2; let beat_interval = (60.0 * sample_rate as f64 / bpm) as usize;
let mut samples = vec![0.0f32; num_samples];
let mut true_beat_positions = Vec::new();
for beat in 0..5 {
let pos = beat * beat_interval;
if pos >= num_samples {
break;
}
true_beat_positions.push(pos);
for j in 0..20.min(num_samples - pos) {
samples[pos + j] = if j < 5 { 1.0 } else { -0.5 };
}
}
let transients =
crate::analysis::transient::detect_transients(&samples, sample_rate, 2048, 512, 0.4);
let grid = generate_subdivision_grid(bpm, sample_rate, num_samples, 16);
let tolerance = sample_rate as f64 * 0.005;
let snapped: Vec<usize> = transients
.onsets
.iter()
.filter_map(|&onset| {
snap_to_subdivision(onset as f64, &grid, tolerance).map(|s| s.round() as usize)
})
.collect();
let tolerance_2ms = (sample_rate as f64 * 0.002) as usize;
for &snapped_pos in &snapped {
let near_beat = true_beat_positions.iter().any(|&beat| {
snapped_pos.abs_diff(beat) <= tolerance_2ms || {
let sub_interval = beat_interval as f64 / 16.0;
let nearest_sub = (snapped_pos as f64 / sub_interval).round() * sub_interval;
(snapped_pos as f64 - nearest_sub).abs() <= tolerance_2ms as f64
}
});
assert!(
near_beat,
"Snapped position {} should be near a beat subdivision",
snapped_pos
);
}
}
#[test]
fn test_snap_preserves_dedup() {
let grid = vec![0.0, 1000.0, 2000.0, 3000.0];
let tolerance = 300.0;
let transients = [990, 1010];
let mut snapped: Vec<usize> = transients
.iter()
.filter_map(|&onset| {
snap_to_subdivision(onset as f64, &grid, tolerance).map(|s| s.round() as usize)
})
.collect();
snapped.dedup();
assert_eq!(
snapped.len(),
1,
"Duplicate snapped positions should be deduplicated"
);
assert_eq!(snapped[0], 1000);
}
}