use std::cmp::Ordering;
use crate::frame::{SpectralFrame, spectral_frames};
use crate::{AnalysisError, TempoGrid};
pub const FRAME_SECS: f64 = 0.023;
pub const HOP_SECS: f64 = FRAME_SECS / 2.0;
pub const MIN_ONSET_GAP_SECS: f64 = 0.05;
pub const ADAPTIVE_WINDOW_FRAMES: usize = 10;
pub const THRESHOLD_MULTIPLIER: f64 = 1.5;
pub const THRESHOLD_DELTA: f64 = 1e-6;
pub const IOI_BUCKET_SECS: f64 = 0.025;
pub const IOI_MAX_SECS: f64 = 2.0;
pub const LEVEL_FRACTION: f64 = 0.03;
pub const STRIKE_SPAN_SECS: f64 = 0.012;
pub const JUMP_ENERGY_RATIO: f64 = 4.0;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Onset {
pub t_secs: f64,
pub strength: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct IoiBucket {
pub lo_secs: f64,
pub hi_secs: f64,
pub count: usize,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Onsets {
pub onsets: Vec<Onset>,
pub ioi_histogram: Vec<IoiBucket>,
pub onsets_per_bar: Option<Vec<usize>>,
pub syncopation_index: Option<f64>,
pub resolution_secs: f64,
}
pub fn detect(
samples: &[f32],
sample_rate: f64,
start_secs: f64,
tempo: Option<TempoGrid>,
) -> Result<Onsets, AnalysisError> {
let duration_secs = samples.len() as f64 / sample_rate;
if let Some(t) = tempo {
if t.seconds_per_bar.partial_cmp(&0.0) != Some(Ordering::Greater) {
return Err(AnalysisError(format!(
"a bar of {} seconds is no tempo grid; `onsets` needs a bar of positive length",
t.seconds_per_bar
)));
}
if duration_secs / t.seconds_per_bar > samples.len() as f64 {
return Err(AnalysisError(format!(
"a bar of {:e} seconds divides {} samples into more bars than there are \
samples; `onsets` needs a bar at least one sample long",
t.seconds_per_bar,
samples.len()
)));
}
}
let frames = spectral_frames(samples, sample_rate, start_secs, FRAME_SECS, HOP_SECS);
let floor = LEVEL_FRACTION * frames.iter().map(|f| total(&f.mags)).fold(0.0, f64::max);
let flux = flux_of(&frames, floor);
let onsets = pick_peaks(
&flux,
&frames,
&Energy::of(samples, sample_rate, start_secs),
floor,
);
Ok(Onsets {
ioi_histogram: ioi_histogram(&onsets),
onsets_per_bar: tempo
.map(|t| per_bar(&onsets, start_secs, duration_secs, t.seconds_per_bar)),
syncopation_index: tempo.map(|t| syncopation(&onsets, t)),
onsets,
resolution_secs: HOP_SECS,
})
}
fn total(mags: &[f64]) -> f64 {
mags.iter().sum()
}
fn flux_of(frames: &[SpectralFrame], floor: f64) -> Vec<f64> {
let opens_on_sound = frames
.first()
.is_some_and(|f| floor > 0.0 && total(&f.mags) > floor);
(0..frames.len())
.map(|i| match i {
0 => match opens_on_sound {
true => total(&frames[0].mags),
false => 0.0,
},
_ => spectral_flux(&frames[i - 1].mags, &frames[i].mags),
})
.collect()
}
fn spectral_flux(prev: &[f64], curr: &[f64]) -> f64 {
prev.iter().zip(curr).map(|(&p, &c)| (c - p).max(0.0)).sum()
}
fn pick_peaks(flux: &[f64], frames: &[SpectralFrame], energy: &Energy, floor: f64) -> Vec<Onset> {
let mut onsets: Vec<Onset> = Vec::new();
for i in 0..flux.len() {
let lo = i.saturating_sub(ADAPTIVE_WINDOW_FRAMES);
let hi = (i + ADAPTIVE_WINDOW_FRAMES + 1).min(flux.len());
let local = &flux[lo..hi];
let mean = local.iter().sum::<f64>() / local.len() as f64;
let threshold = THRESHOLD_DELTA + THRESHOLD_MULTIPLIER * mean;
let reach = (MIN_ONSET_GAP_SECS / HOP_SECS).round().max(1.0) as usize;
let near = &flux[i.saturating_sub(reach)..(i + reach + 1).min(flux.len())];
if flux[i] <= threshold || flux[i] < floor || near.iter().any(|&v| v > flux[i]) {
continue;
}
let Some(t) = energy.strike(frames[i].t_secs, frames[i].span_secs) else {
continue;
};
if onsets
.last()
.is_some_and(|o| t - o.t_secs < MIN_ONSET_GAP_SECS)
{
continue;
}
onsets.push(Onset {
t_secs: t,
strength: flux[i],
});
}
onsets
}
struct Energy {
running: Vec<f64>,
sample_rate: f64,
start_secs: f64,
reach: usize,
floor: f64,
}
impl Energy {
fn of(samples: &[f32], sample_rate: f64, start_secs: f64) -> Energy {
let mut running = Vec::with_capacity(samples.len() + 1);
running.push(0.0);
for s in samples {
let held = running[running.len() - 1];
running.push(held + f64::from(*s) * f64::from(*s));
}
let reach = (STRIKE_SPAN_SECS * sample_rate).round().max(1.0) as usize;
let loudest = (0..samples.len())
.map(|n| running[(n + reach).min(samples.len())] - running[n])
.fold(0.0, f64::max);
Energy {
running,
sample_rate,
start_secs,
reach,
floor: LEVEL_FRACTION * LEVEL_FRACTION * loudest,
}
}
fn over(&self, from: usize, to: usize) -> f64 {
let last = self.running.len() - 1;
self.running[to.min(last)] - self.running[from.min(last)]
}
fn strike(&self, from_secs: f64, span_secs: f64) -> Option<f64> {
let at = |secs: f64| {
((secs - self.start_secs) * self.sample_rate)
.round()
.max(0.0) as usize
};
let (from, to) = (at(from_secs), at(from_secs + span_secs));
let mut best: Option<(f64, usize)> = None;
for n in from..to.min(self.running.len() - 1) {
let before = self.over(n.saturating_sub(self.reach), n) + self.floor;
let after = self.over(n, n + self.reach);
if before == 0.0 {
continue;
}
let ratio = after / before;
if best.is_none_or(|(r, _)| ratio >= r) {
best = Some((ratio, n));
}
}
let (ratio, n) = best?;
(ratio >= JUMP_ENERGY_RATIO).then(|| self.start_secs + n as f64 / self.sample_rate)
}
}
fn ioi_histogram(onsets: &[Onset]) -> Vec<IoiBucket> {
let n = (IOI_MAX_SECS / IOI_BUCKET_SECS).ceil() as usize + 1;
let mut counts = vec![0usize; n];
for w in onsets.windows(2) {
let ioi = w[1].t_secs - w[0].t_secs;
counts[((ioi / IOI_BUCKET_SECS) as usize).min(n - 1)] += 1;
}
counts
.into_iter()
.enumerate()
.map(|(i, count)| IoiBucket {
lo_secs: i as f64 * IOI_BUCKET_SECS,
hi_secs: if i + 1 == n {
f64::INFINITY
} else {
(i + 1) as f64 * IOI_BUCKET_SECS
},
count,
})
.collect()
}
fn per_bar(
onsets: &[Onset],
start_secs: f64,
duration_secs: f64,
seconds_per_bar: f64,
) -> Vec<usize> {
let bars = ((duration_secs / seconds_per_bar).ceil() as usize).max(1);
let mut counts = vec![0usize; bars];
for o in onsets {
let bar = (((o.t_secs - start_secs) / seconds_per_bar) as usize).min(bars - 1);
counts[bar] += 1;
}
counts
}
fn metrical_weight(k: u32, subdivisions: u32, beats_per_bar: u32) -> f64 {
if k == 0 {
return 1.0;
}
if subdivisions.is_power_of_two() {
return (k.trailing_zeros() + 1) as f64 / (subdivisions.trailing_zeros() + 1) as f64;
}
let per_beat = (subdivisions / beats_per_bar).max(1);
if k.is_multiple_of(per_beat) { 0.5 } else { 0.0 }
}
fn syncopation(onsets: &[Onset], tempo: TempoGrid) -> f64 {
if onsets.is_empty() {
return 0.0;
}
let beats_per_bar = tempo.beats_per_bar.round().max(1.0) as u32;
let subdivisions = (beats_per_bar * 4).max(1);
let total: f64 = onsets
.iter()
.map(|o| {
let phase = o.t_secs.rem_euclid(tempo.seconds_per_bar) / tempo.seconds_per_bar;
let k = (phase * subdivisions as f64).round() as u32 % subdivisions;
1.0 - metrical_weight(k, subdivisions, beats_per_bar)
})
.sum();
total / onsets.len() as f64
}
#[cfg(test)]
mod tests {
use super::*;
fn click_track(sr: f64, secs: f64, gap_secs: f64) -> Vec<f32> {
let n = (secs * sr) as usize;
let gap = (gap_secs * sr) as usize;
let mut out = vec![0.0f32; n];
let mut at = 0;
while at + 8 < n {
for (i, s) in out[at..at + 8].iter_mut().enumerate() {
*s = (1.0 - i as f32 / 8.0) * if i % 2 == 0 { 1.0 } else { -1.0 };
}
at += gap;
}
out
}
#[test]
fn evenly_spaced_clicks_are_found_at_roughly_their_own_spacing() {
let sr = 44100.0;
let gap = 0.25;
let samples = click_track(sr, 4.0, gap);
let found = detect(&samples, sr, 0.0, None).expect("no tempo, no grid");
assert!(found.onsets.len() >= 12, "{}", found.onsets.len());
for w in found.onsets.windows(2) {
let ioi = w[1].t_secs - w[0].t_secs;
assert!((ioi - gap).abs() < 0.03, "ioi {ioi} far from {gap}");
}
}
#[test]
fn silence_holds_no_onsets() {
let found = detect(&vec![0.0f32; 44100], 44100.0, 0.0, None).expect("no tempo");
assert!(found.onsets.is_empty());
assert!(found.onsets_per_bar.is_none());
assert!(found.syncopation_index.is_none());
}
#[test]
fn a_close_double_trigger_on_one_transient_is_suppressed() {
let sr = 44100.0;
let mut samples = vec![0.0f32; (sr * 0.2) as usize];
for (i, s) in samples.iter_mut().enumerate().take(200) {
*s = (1.0 - i as f32 / 200.0) * if i % 2 == 0 { 1.0 } else { -1.0 };
}
let found = detect(&samples, sr, 0.0, None).expect("no tempo, no grid");
assert!(found.onsets.len() <= 1, "{:?}", found.onsets);
}
#[test]
fn onsets_squarely_on_the_downbeat_read_as_barely_syncopated() {
let sr = 44100.0;
let tempo = TempoGrid {
seconds_per_bar: 2.0,
beats_per_bar: 4.0,
};
let samples = click_track(sr, 8.0, 2.0);
let found = detect(&samples, sr, 0.0, Some(tempo)).expect("a two-second bar");
assert!(
found.syncopation_index.unwrap() < 0.2,
"{:?}",
found.syncopation_index
);
assert_eq!(found.onsets_per_bar.as_ref().unwrap().len(), 4);
}
#[test]
fn metrical_weight_favours_the_downbeat_over_an_off_sixteenth() {
assert!(metrical_weight(0, 16, 4) > metrical_weight(1, 16, 4));
assert!(metrical_weight(8, 16, 4) > metrical_weight(1, 16, 4));
}
}