1extern crate alloc;
8
9use alloc::vec;
10use alloc::vec::Vec;
11
12use resonant_core::signal::Signal;
13use resonant_core::window;
14use resonant_fft::stft::Stft;
15use resonant_fft::SignalFreqExt;
16
17use crate::error::AnalysisError;
18
19#[derive(Debug, Clone, Copy, PartialEq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub struct Onset {
32 pub sample_index: usize,
34 pub time_secs: f32,
36 pub strength: f32,
38}
39
40#[derive(Debug, Clone)]
53pub struct OnsetDetector {
54 sample_rate: f32,
55 window_size: usize,
56 hop_size: usize,
57 threshold_multiplier: f32,
58 median_window: usize,
59}
60
61impl OnsetDetector {
62 #[must_use]
66 pub fn new(sample_rate: f32) -> Self {
67 Self {
68 sample_rate,
69 window_size: 1024,
70 hop_size: 512,
71 threshold_multiplier: 1.5,
72 median_window: 10,
73 }
74 }
75
76 #[must_use]
78 pub fn with_window_size(mut self, size: usize) -> Self {
79 self.window_size = size;
80 self
81 }
82
83 #[must_use]
85 #[inline]
86 pub fn hop_size(&self) -> usize {
87 self.hop_size
88 }
89
90 #[must_use]
92 pub fn with_hop_size(mut self, hop: usize) -> Self {
93 self.hop_size = hop;
94 self
95 }
96
97 #[must_use]
101 pub fn with_threshold_multiplier(mut self, m: f32) -> Self {
102 self.threshold_multiplier = m;
103 self
104 }
105
106 #[must_use]
108 pub fn with_median_window(mut self, w: usize) -> Self {
109 self.median_window = w;
110 self
111 }
112
113 pub fn detect(&self, samples: &[f32]) -> Result<Vec<Onset>, AnalysisError> {
122 let envelope = self.onset_strength(samples)?;
123 let threshold =
124 adaptive_threshold(&envelope, self.median_window, self.threshold_multiplier);
125 let peaks = pick_peaks(&envelope, &threshold);
126
127 let onsets = peaks
128 .into_iter()
129 .map(|(frame_idx, strength)| {
130 let sample_index = frame_idx * self.hop_size;
131 Onset {
132 sample_index,
133 time_secs: sample_index as f32 / self.sample_rate,
134 strength,
135 }
136 })
137 .collect();
138
139 Ok(onsets)
140 }
141
142 pub fn onset_strength(&self, samples: &[f32]) -> Result<Vec<f32>, AnalysisError> {
150 if samples.is_empty() {
151 return Err(AnalysisError::EmptyInput);
152 }
153
154 if samples.len() < self.window_size {
156 return Ok(vec![0.0]);
157 }
158
159 let signal = Signal::from_samples(samples.to_vec());
160 let stft = Stft::builder(self.window_size, self.hop_size)
161 .window_fn(window::hann)
162 .build();
163
164 let frames = stft.analyze(&signal)?;
165 if frames.is_empty() {
166 return Ok(vec![0.0]);
167 }
168
169 let magnitudes: Vec<Vec<f32>> = frames.iter().map(|f| f.magnitude()).collect();
170
171 let mut envelope = Vec::with_capacity(magnitudes.len());
172 envelope.push(0.0); for i in 1..magnitudes.len() {
175 envelope.push(spectral_flux(&magnitudes[i - 1], &magnitudes[i]));
176 }
177
178 Ok(envelope)
179 }
180}
181
182fn spectral_flux(prev: &[f32], curr: &[f32]) -> f32 {
184 prev.iter()
185 .zip(curr)
186 .map(|(prev_mag, curr_mag)| (curr_mag - prev_mag).max(0.0))
187 .sum()
188}
189
190fn adaptive_threshold(envelope: &[f32], window: usize, multiplier: f32) -> Vec<f32> {
192 let envelope_len = envelope.len();
193 let mut thresholds = Vec::with_capacity(envelope_len);
194 let half_window = window / 2;
195
196 for i in 0..envelope_len {
197 let start = i.saturating_sub(half_window);
198 let end = (i + half_window + 1).min(envelope_len);
199 let local = &envelope[start..end];
200
201 let median = local_median(local);
202 let mad = local_mad(local, median);
203 thresholds.push(median + multiplier * mad);
204 }
205
206 thresholds
207}
208
209fn local_median(values: &[f32]) -> f32 {
211 if values.is_empty() {
212 return 0.0;
213 }
214 let mut sorted: Vec<f32> = values.to_vec();
215 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
216 let median_idx = sorted.len() / 2;
217 if sorted.len() % 2 == 0 {
218 (sorted[median_idx - 1] + sorted[median_idx]) / 2.0
219 } else {
220 sorted[median_idx]
221 }
222}
223
224fn local_mad(values: &[f32], median: f32) -> f32 {
226 if values.is_empty() {
227 return 0.0;
228 }
229 let sum: f32 = values.iter().map(|sample| (sample - median).abs()).sum();
230 sum / values.len() as f32
231}
232
233fn pick_peaks(envelope: &[f32], threshold: &[f32]) -> Vec<(usize, f32)> {
235 let mut peaks = Vec::new();
236 for i in 1..envelope.len().saturating_sub(1) {
237 let flux_value = envelope[i];
238 if flux_value > threshold[i]
239 && flux_value >= envelope[i - 1]
240 && flux_value >= envelope[i + 1]
241 {
242 peaks.push((i, flux_value));
243 }
244 }
245 peaks
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use core::f32::consts::PI;
252
253 const SR: f32 = 44100.0;
254
255 #[test]
256 fn silence_no_onsets() {
257 let detector = OnsetDetector::new(SR);
258 let samples = vec![0.0_f32; 8192];
259 let onsets = detector.detect(&samples).ok();
260 assert_eq!(onsets.as_ref().map(|o| o.len()), Some(0));
261 }
262
263 #[test]
264 fn empty_input_returns_error() {
265 let detector = OnsetDetector::new(SR);
266 let result = detector.detect(&[]);
267 assert_eq!(result, Err(AnalysisError::EmptyInput));
268 }
269
270 #[test]
271 fn onset_strength_empty_error() {
272 let detector = OnsetDetector::new(SR);
273 let result = detector.onset_strength(&[]);
274 assert_eq!(result, Err(AnalysisError::EmptyInput));
275 }
276
277 #[test]
278 fn onset_strength_nonnegative() {
279 let detector = OnsetDetector::new(SR);
280 let samples: Vec<f32> = (0..8192)
281 .map(|i| (2.0 * PI * 440.0 * i as f32 / SR).sin())
282 .collect();
283 let envelope = detector.onset_strength(&samples).ok();
284 if let Some(env) = &envelope {
285 for &v in env {
286 assert!(v >= 0.0, "negative onset strength: {v}");
287 }
288 }
289 }
290
291 #[test]
292 fn onset_strength_first_frame_zero() {
293 let detector = OnsetDetector::new(SR);
294 let samples = vec![1.0_f32; 4096];
295 let envelope = detector.onset_strength(&samples).ok();
296 assert!(envelope.as_ref().is_some_and(|e| e[0] == 0.0));
297 }
298
299 #[test]
300 fn click_track_detects_onsets() {
301 let mut samples = vec![0.0_f32; 44100];
304 let click_positions = [8820_usize, 22050, 35280];
305 for &pos in &click_positions {
306 for j in 0..64 {
308 if pos + j < samples.len() {
309 samples[pos + j] = (2.0 * PI * 1000.0 * j as f32 / SR).sin();
310 }
311 }
312 }
313
314 let detector = OnsetDetector::new(SR)
315 .with_window_size(1024)
316 .with_hop_size(512)
317 .with_threshold_multiplier(1.0);
318
319 let onsets = detector.detect(&samples).ok();
320 let onsets = onsets.as_ref();
321
322 assert!(
325 onsets.is_some_and(|o| o.len() >= 2),
326 "expected >=2 onsets, got {:?}",
327 onsets.map(|o| o.len())
328 );
329
330 if let Some(onsets) = onsets {
332 for onset in onsets {
333 let near_click = click_positions.iter().any(|&pos| {
334 let diff = (onset.sample_index as i64 - pos as i64).unsigned_abs() as usize;
335 diff < 2048 });
337 assert!(
338 near_click,
339 "onset at sample {} not near any click",
340 onset.sample_index
341 );
342 }
343 }
344 }
345
346 #[test]
347 fn sine_onset_detected() {
348 let mut samples = vec![0.0_f32; 8192];
350 let onset_pos = 4096;
351 for i in onset_pos..8192 {
352 samples[i] = (2.0 * PI * 440.0 * (i - onset_pos) as f32 / SR).sin();
353 }
354
355 let detector = OnsetDetector::new(SR)
356 .with_window_size(1024)
357 .with_hop_size(512)
358 .with_threshold_multiplier(1.0);
359
360 let onsets = detector.detect(&samples).ok();
361 assert!(
362 onsets.as_ref().is_some_and(|o| !o.is_empty()),
363 "should detect onset at silence→sine transition"
364 );
365
366 if let Some(onsets) = &onsets {
368 let nearest = onsets
369 .iter()
370 .min_by_key(|o| (o.sample_index as i64 - onset_pos as i64).unsigned_abs())
371 .map(|o| o.sample_index);
372 assert!(
373 nearest.is_some_and(|n| (n as i64 - onset_pos as i64).unsigned_abs() < 2048),
374 "onset too far from transition: {nearest:?}"
375 );
376 }
377 }
378
379 #[test]
380 fn short_signal_returns_single_zero() {
381 let detector = OnsetDetector::new(SR).with_window_size(1024);
382 let samples = vec![1.0_f32; 512]; let env = detector.onset_strength(&samples).ok();
384 assert_eq!(env.as_deref(), Some([0.0].as_slice()));
385 }
386
387 #[test]
388 fn builder_methods() {
389 let d = OnsetDetector::new(SR)
390 .with_window_size(2048)
391 .with_hop_size(1024)
392 .with_threshold_multiplier(2.0)
393 .with_median_window(20);
394 assert_eq!(d.window_size, 2048);
395 assert_eq!(d.hop_size, 1024);
396 assert_eq!(d.threshold_multiplier, 2.0);
397 assert_eq!(d.median_window, 20);
398 }
399
400 #[test]
401 fn onset_time_matches_sample_index() {
402 let mut samples = vec![0.0_f32; 8192];
404 for i in 4096..8192 {
405 samples[i] = (2.0 * PI * 1000.0 * i as f32 / SR).sin();
406 }
407
408 let detector = OnsetDetector::new(SR)
409 .with_window_size(1024)
410 .with_hop_size(512)
411 .with_threshold_multiplier(1.0);
412
413 let onsets = detector.detect(&samples).ok();
414 if let Some(onsets) = &onsets {
415 for o in onsets {
416 let expected_time = o.sample_index as f32 / SR;
417 assert!(
418 (o.time_secs - expected_time).abs() < 1e-6,
419 "time mismatch: {} vs {expected_time}",
420 o.time_secs
421 );
422 }
423 }
424 }
425
426 #[test]
429 fn spectral_flux_identical_is_zero() {
430 let a = [1.0, 2.0, 3.0];
431 assert_eq!(spectral_flux(&a, &a), 0.0);
432 }
433
434 #[test]
435 fn spectral_flux_increase_only() {
436 let prev = [0.0, 0.0, 0.0];
437 let curr = [1.0, 2.0, 3.0];
438 assert_eq!(spectral_flux(&prev, &curr), 6.0);
439 }
440
441 #[test]
442 fn spectral_flux_decrease_ignored() {
443 let prev = [3.0, 2.0, 1.0];
444 let curr = [0.0, 0.0, 0.0];
445 assert_eq!(spectral_flux(&prev, &curr), 0.0);
446 }
447
448 #[test]
449 fn local_median_odd() {
450 assert_eq!(local_median(&[3.0, 1.0, 2.0]), 2.0);
451 }
452
453 #[test]
454 fn local_median_even() {
455 assert_eq!(local_median(&[1.0, 3.0, 2.0, 4.0]), 2.5);
456 }
457
458 #[test]
459 fn local_median_empty() {
460 assert_eq!(local_median(&[]), 0.0);
461 }
462
463 #[test]
464 fn local_mad_uniform() {
465 assert_eq!(local_mad(&[5.0, 5.0, 5.0], 5.0), 0.0);
466 }
467
468 #[cfg(feature = "serde")]
469 #[test]
470 fn onset_serde_roundtrip() {
471 let o = Onset {
472 sample_index: 22050,
473 time_secs: 0.5,
474 strength: 1.2,
475 };
476 let json = serde_json::to_string(&o).unwrap_or_else(|e| panic!("serialize Onset: {e}"));
477 let back: Onset =
478 serde_json::from_str(&json).unwrap_or_else(|e| panic!("deserialize Onset: {e}"));
479 assert_eq!(o, back);
480 }
481}