Skip to main content

sva_samples/measure/
onsets.rs

1// Concern: spectral flux onset detection, each onset placed at the sample its strike rises | Non-concern: a tempo grid, what an onset means musically | IO: (planes, rate, start) -> Onsets
2
3use crate::measure::spectrum::{MAX_PINNED_FRAME, magnitudes, pinned_frame};
4
5/// ~23ms: short enough to localize a transient, long enough for low percussion.
6const FRAME_SECS: f64 = 0.023;
7/// Strikes 25 ms apart peak a few hops from each other.
8const HOP_SECS: f64 = FRAME_SECS / 8.0;
9/// Below this two novelty peaks are one transient's attack and decay.
10const MIN_ONSET_GAP_SECS: f64 = 0.02;
11const ADAPTIVE_WINDOW_SECS: f64 = 0.115;
12/// A roll's flux peaks barely over its mean; the strike tells it from a swell.
13const THRESHOLD_MULTIPLIER: f64 = 1.0;
14const THRESHOLD_DELTA: f64 = 1e-6;
15const IOI_BUCKET_SECS: f64 = 0.025;
16const IOI_MAX_SECS: f64 = 2.0;
17/// Under this share of the loudest frame a rise is leakage.
18const LEVEL_FRACTION: f64 = 0.03;
19const STRIKE_SPAN_SECS: f64 = 0.012;
20/// ~5 dB over the span before, carried on as it moved: a strike jumps off a ring's fall.
21const JUMP_ENERGY_RATIO: f64 = 3.0;
22
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct Onset {
25    pub t_secs: f64,
26    pub strength: f64,
27}
28
29#[derive(Clone, Copy, Debug, PartialEq)]
30pub struct IoiBucket {
31    pub lo_secs: f64,
32    pub hi_secs: f64,
33    pub count: usize,
34}
35
36#[derive(Clone, Debug, PartialEq)]
37pub struct Onsets {
38    pub onsets: Vec<Onset>,
39    pub ioi_histogram: Vec<IoiBucket>,
40    pub resolution_secs: f64,
41}
42
43struct SpectralFrame {
44    t_secs: f64,
45    mags: Vec<Vec<f64>>,
46    span_secs: f64,
47}
48
49fn spectral_frames(planes: &[&[f64]], sample_rate: f64, start_secs: f64) -> Vec<SpectralFrame> {
50    let frame_len = pinned_frame(FRAME_SECS, sample_rate).min(MAX_PINNED_FRAME);
51    let hop = ((HOP_SECS * sample_rate).round() as usize).max(1);
52    let len = planes.iter().map(|p| p.len()).min().unwrap_or(0);
53    (0..len)
54        .step_by(hop)
55        .map(|start| {
56            let end = (start + frame_len).min(len);
57            let mags = planes
58                .iter()
59                .map(|p| {
60                    let mut frame = p[start..end].to_vec();
61                    frame.resize(frame_len, 0.0);
62                    magnitudes(&frame, sample_rate, Some(FRAME_SECS)).0
63                })
64                .collect();
65            SpectralFrame {
66                t_secs: start_secs + start as f64 / sample_rate,
67                mags,
68                span_secs: frame_len as f64 / sample_rate,
69            }
70        })
71        .collect()
72}
73
74/// Every channel, so opposite phases cancel nothing.
75pub fn detect(planes: &[&[f64]], sample_rate: f64, start_secs: f64) -> Onsets {
76    let frames = spectral_frames(planes, sample_rate, start_secs);
77    let floor = LEVEL_FRACTION * frames.iter().map(|f| total(&f.mags)).fold(0.0, f64::max);
78    let flux = flux_of(&frames, floor);
79    let onsets = pick_peaks(
80        &flux,
81        &frames,
82        &Energy::of(planes, sample_rate, start_secs),
83        floor,
84    );
85    Onsets {
86        ioi_histogram: ioi_histogram(&onsets),
87        onsets,
88        resolution_secs: HOP_SECS,
89    }
90}
91
92fn total(mags: &[Vec<f64>]) -> f64 {
93    mags.iter().flatten().sum()
94}
95
96/// The rise into each frame; nothing precedes the buffer, so one opening above the floor
97/// opens on a rise from silence.
98fn flux_of(frames: &[SpectralFrame], floor: f64) -> Vec<f64> {
99    let opens_on_sound = frames
100        .first()
101        .is_some_and(|f| floor > 0.0 && total(&f.mags) > floor);
102    (0..frames.len())
103        .map(|i| match i {
104            0 => match opens_on_sound {
105                true => total(&frames[0].mags),
106                false => 0.0,
107            },
108            _ => frames[i - 1]
109                .mags
110                .iter()
111                .zip(&frames[i].mags)
112                .map(|(prev, curr)| spectral_flux(prev, curr))
113                .sum(),
114        })
115        .collect()
116}
117
118pub(crate) fn spectral_flux(prev: &[f64], curr: &[f64]) -> f64 {
119    prev.iter().zip(curr).map(|(&p, &c)| (c - p).max(0.0)).sum()
120}
121
122/// A candidate clears the threshold and the floor, peaks over the gap, and holds a strike;
123/// a frame past the buffer's end reads silence.
124fn pick_peaks(flux: &[f64], frames: &[SpectralFrame], energy: &Energy, floor: f64) -> Vec<Onset> {
125    let mut onsets: Vec<Onset> = Vec::new();
126    let window = (ADAPTIVE_WINDOW_SECS / HOP_SECS).round() as usize;
127    let reach = (MIN_ONSET_GAP_SECS / 2.0 / HOP_SECS).round().max(1.0) as usize;
128    for i in 0..flux.len() {
129        let lo = i.saturating_sub(window);
130        let hi = (i + window + 1).min(flux.len());
131        let local = &flux[lo..hi];
132        let mean = local.iter().sum::<f64>() / local.len() as f64;
133        let threshold = THRESHOLD_DELTA + THRESHOLD_MULTIPLIER * mean;
134        let near = &flux[i.saturating_sub(reach)..(i + reach + 1).min(flux.len())];
135        if flux[i] <= threshold || flux[i] < floor || near.iter().any(|&v| v > flux[i]) {
136            continue;
137        }
138        let Some(t) = energy.strike(frames[i].t_secs, frames[i].span_secs) else {
139            continue;
140        };
141        if onsets
142            .last()
143            .is_some_and(|o| t - o.t_secs < MIN_ONSET_GAP_SECS)
144        {
145            continue;
146        }
147        onsets.push(Onset {
148            t_secs: t,
149            strength: flux[i],
150        });
151    }
152    onsets
153}
154
155struct Energy {
156    running: Vec<f64>,
157    sample_rate: f64,
158    start_secs: f64,
159    reach: usize,
160    floor: f64,
161}
162
163impl Energy {
164    fn of(planes: &[&[f64]], sample_rate: f64, start_secs: f64) -> Energy {
165        let len = planes.iter().map(|p| p.len()).min().unwrap_or(0);
166        let mut running = Vec::with_capacity(len + 1);
167        running.push(0.0);
168        for n in 0..len {
169            let held = running[running.len() - 1];
170            running.push(held + planes.iter().map(|p| p[n] * p[n]).sum::<f64>());
171        }
172        let reach = (STRIKE_SPAN_SECS * sample_rate).round().max(1.0) as usize;
173        let loudest = (0..len)
174            .map(|n| running[(n + reach).min(len)] - running[n])
175            .fold(0.0, f64::max);
176        Energy {
177            running,
178            sample_rate,
179            start_secs,
180            reach,
181            floor: LEVEL_FRACTION * LEVEL_FRACTION * loudest,
182        }
183    }
184
185    /// The energy of `[from, to)`, silence outside the buffer.
186    fn over(&self, from: usize, to: usize) -> f64 {
187        let last = self.running.len() - 1;
188        self.running[to.min(last)] - self.running[from.min(last)]
189    }
190
191    /// The sample whose energy after most outweighs the energy before it and the floor, if
192    /// by a strike's jump over that energy carried on as it moved.
193    fn strike(&self, from_secs: f64, span_secs: f64) -> Option<f64> {
194        let at = |secs: f64| {
195            ((secs - self.start_secs) * self.sample_rate)
196                .round()
197                .max(0.0) as usize
198        };
199        let (from, to) = (at(from_secs), at(from_secs + span_secs));
200        let before = |n: usize| self.over(n.saturating_sub(self.reach), n) + self.floor;
201        let mut best: Option<(f64, usize)> = None;
202        for n in from..to.min(self.running.len() - 1) {
203            let after = self.over(n, n + self.reach);
204            if before(n) == 0.0 {
205                continue;
206            }
207            let ratio = after / before(n);
208            if best.is_none_or(|(r, _)| ratio >= r) {
209                best = Some((ratio, n));
210            }
211        }
212        let (ratio, n) = best?;
213        let carried = before(n) / before(n.saturating_sub(self.reach));
214        (ratio / carried >= JUMP_ENERGY_RATIO)
215            .then(|| self.start_secs + n as f64 / self.sample_rate)
216    }
217}
218
219fn ioi_histogram(onsets: &[Onset]) -> Vec<IoiBucket> {
220    let n = (IOI_MAX_SECS / IOI_BUCKET_SECS).ceil() as usize + 1;
221    let mut counts = vec![0usize; n];
222    for w in onsets.windows(2) {
223        let ioi = w[1].t_secs - w[0].t_secs;
224        counts[((ioi / IOI_BUCKET_SECS) as usize).min(n - 1)] += 1;
225    }
226    counts
227        .into_iter()
228        .enumerate()
229        .map(|(i, count)| IoiBucket {
230            lo_secs: i as f64 * IOI_BUCKET_SECS,
231            hi_secs: if i + 1 == n {
232                f64::INFINITY
233            } else {
234                (i + 1) as f64 * IOI_BUCKET_SECS
235            },
236            count,
237        })
238        .collect()
239}