#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnsetMethod {
EnergyBased,
SpectralFlux,
PhaseDeviation,
ComplexDomain,
}
impl OnsetMethod {
#[must_use]
pub fn typical_latency_ms(&self) -> f32 {
match self {
Self::EnergyBased => 10.0,
Self::SpectralFlux | Self::PhaseDeviation | Self::ComplexDomain => 23.2,
}
}
}
#[derive(Debug, Clone)]
pub struct OnsetEvent {
pub time_ms: u64,
pub strength: f32,
pub method: OnsetMethod,
}
impl OnsetEvent {
#[must_use]
pub fn is_strong(&self, threshold: f32) -> bool {
self.strength >= threshold
}
}
#[must_use]
pub fn spectral_flux(prev_spectrum: &[f32], curr_spectrum: &[f32]) -> f32 {
assert_eq!(
prev_spectrum.len(),
curr_spectrum.len(),
"Spectra must have the same length"
);
curr_spectrum
.iter()
.zip(prev_spectrum.iter())
.map(|(&curr, &prev)| {
let diff = curr - prev;
if diff > 0.0 {
diff
} else {
0.0
}
})
.sum()
}
#[must_use]
pub fn energy_onset(frames: &[f32], hop_size: usize) -> Vec<f32> {
if hop_size == 0 || frames.is_empty() {
return Vec::new();
}
let frame_energies: Vec<f32> = frames
.chunks(hop_size)
.map(|chunk| {
let sum_sq: f32 = chunk.iter().map(|&s| s * s).sum();
(sum_sq / chunk.len() as f32).sqrt()
})
.collect();
if frame_energies.len() < 2 {
return Vec::new();
}
frame_energies
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.collect()
}
fn peak_pick(novelty: &[f32], threshold: f32) -> Vec<usize> {
if novelty.len() < 3 {
return Vec::new();
}
let mut peaks = Vec::new();
for i in 1..novelty.len() - 1 {
if novelty[i] > threshold && novelty[i] >= novelty[i - 1] && novelty[i] >= novelty[i + 1] {
peaks.push(i);
}
}
peaks
}
pub struct OnsetDetector {
pub method: OnsetMethod,
pub threshold: f32,
pub sample_rate: u32,
}
impl OnsetDetector {
#[must_use]
pub fn new(method: OnsetMethod, threshold: f32, sample_rate: u32) -> Self {
Self {
method,
threshold,
sample_rate,
}
}
#[must_use]
pub fn detect(&self, signal: &[f32]) -> Vec<OnsetEvent> {
let hop_size = (self.sample_rate / 100) as usize; let hop_size = hop_size.max(1);
let novelty = match self.method {
OnsetMethod::EnergyBased => energy_onset(signal, hop_size),
OnsetMethod::SpectralFlux
| OnsetMethod::PhaseDeviation
| OnsetMethod::ComplexDomain => {
energy_onset(signal, hop_size)
}
};
if novelty.is_empty() {
return Vec::new();
}
let max_val = novelty.iter().copied().fold(0.0_f32, f32::max);
let normalised: Vec<f32> = if max_val > 0.0 {
novelty.iter().map(|&v| v / max_val).collect()
} else {
novelty.clone()
};
let peaks = peak_pick(&normalised, self.threshold);
peaks
.into_iter()
.map(|frame_idx| {
let time_ms =
(frame_idx as u64 * hop_size as u64 * 1000) / u64::from(self.sample_rate);
OnsetEvent {
time_ms,
strength: normalised[frame_idx],
method: self.method,
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_onset_method_latency_energy() {
assert!((OnsetMethod::EnergyBased.typical_latency_ms() - 10.0).abs() < 0.01);
}
#[test]
fn test_onset_method_latency_flux() {
assert!(OnsetMethod::SpectralFlux.typical_latency_ms() > 0.0);
}
#[test]
fn test_onset_method_latency_phase() {
assert!(OnsetMethod::PhaseDeviation.typical_latency_ms() > 0.0);
}
#[test]
fn test_onset_method_latency_complex() {
assert!(OnsetMethod::ComplexDomain.typical_latency_ms() > 0.0);
}
#[test]
fn test_onset_event_is_strong() {
let ev = OnsetEvent {
time_ms: 100,
strength: 0.8,
method: OnsetMethod::EnergyBased,
};
assert!(ev.is_strong(0.5));
assert!(!ev.is_strong(0.9));
}
#[test]
fn test_onset_event_threshold_boundary() {
let ev = OnsetEvent {
time_ms: 0,
strength: 0.5,
method: OnsetMethod::SpectralFlux,
};
assert!(ev.is_strong(0.5));
}
#[test]
fn test_spectral_flux_positive_only() {
let prev = vec![1.0, 2.0, 3.0];
let curr = vec![2.0, 1.0, 5.0];
let flux = spectral_flux(&prev, &curr);
assert!((flux - 3.0).abs() < 1e-5);
}
#[test]
fn test_spectral_flux_no_increase() {
let prev = vec![5.0, 5.0, 5.0];
let curr = vec![1.0, 2.0, 3.0];
let flux = spectral_flux(&prev, &curr);
assert_eq!(flux, 0.0);
}
#[test]
fn test_spectral_flux_equal_spectra() {
let spec = vec![1.0, 2.0, 3.0];
assert_eq!(spectral_flux(&spec, &spec), 0.0);
}
#[test]
fn test_energy_onset_empty() {
assert!(energy_onset(&[], 512).is_empty());
}
#[test]
fn test_energy_onset_returns_deltas() {
let mut signal = vec![0.0_f32; 512];
signal.extend(vec![1.0_f32; 512]);
let deltas = energy_onset(&signal, 512);
assert!(!deltas.is_empty());
assert!(deltas[0] > 0.0); }
#[test]
fn test_onset_detector_silent_signal() {
let detector = OnsetDetector::new(OnsetMethod::EnergyBased, 0.5, 44100);
let signal = vec![0.0_f32; 44100];
let events = detector.detect(&signal);
assert!(events.is_empty());
}
#[test]
fn test_onset_detector_finds_attack() {
let detector = OnsetDetector::new(OnsetMethod::EnergyBased, 0.5, 44100);
let mut signal = vec![0.0_f32; 44100];
for s in &mut signal[22050..22060] {
*s = 1.0;
}
let events = detector.detect(&signal);
assert!(!events.is_empty());
}
#[test]
fn test_onset_detector_spectral_flux_method() {
let detector = OnsetDetector::new(OnsetMethod::SpectralFlux, 0.3, 22050);
let mut signal = vec![0.0_f32; 22050];
for s in &mut signal[11000..11020] {
*s = 0.9;
}
let events = detector.detect(&signal);
let _ = events; }
}