Skip to main content

sva_samples/measure/score/
strikes.rs

1// Concern: where a clip's notes strike: candidate attacks, the ones after which something new sounds, a re-strike grid | Non-concern: what sounds after one (voices.rs) | IO: (Clip) -> strikes
2
3use crate::profile::PSYCHOACOUSTIC_V1;
4
5use super::spectra::{Clip, cents, db_of_power, hann, median, power, sized};
6use super::voices::voices;
7
8const NOVELTY_SAMPLES_48K: f64 = 1024.0;
9const PARTIAL_SAMPLES_48K: f64 = 4096.0;
10pub const HOP_SECS: f64 = 0.005;
11/// Superflux: each bin's rise over the widest of its neighbours this many hops before.
12const FLUX_LAG: usize = 2;
13const PARTIAL_LAG: usize = 6;
14const PARTIAL_RANGE_DB: f64 = 45.0;
15const RISE_CEILING_DB: f64 = 30.0;
16const BAND_EDGES_HZ: (f64, f64) = (200.0, 2000.0);
17const PICK_WAIT_SECS: f64 = 0.05;
18const PICK_MEDIAN_SECS: f64 = 0.15;
19const ATTACK_DELTA: f64 = 0.06;
20const PARTIAL_DELTA: f64 = 0.12;
21const LAST_SECS: f64 = 0.05;
22const NEW_SHARE: f64 = 0.45;
23const NEW_RISE_DB: f64 = 3.0;
24const RESTRIKE_DB: f64 = 3.0;
25const FAST_WOBBLE: f64 = 0.5;
26/// A dip this deep falls near silence between strikes: a fast train of them, not a beat.
27const SILENT_DIP_DB: f64 = 20.0;
28const AUDIBLE_DB: f64 = 40.0;
29const GLIDE_RISE_DB: f64 = 1.5;
30const GLIDE_CENTS: f64 = 300.0;
31const GLIDE_SHARE: f64 = 0.8;
32const REPEAT_CENTS: f64 = 50.0;
33const ONE_PER_SECS: f64 = 0.06;
34const CROP_SECS: f64 = 0.03;
35const CROP_DB: f64 = 30.0;
36const GRID_LEAST: usize = 8;
37const GRID_SLACK_SECS: f64 = 0.012;
38const GRID_DIP_DB: f64 = 1.5;
39/// The periods tried: from, step, and how many, 80 ms to 1 s.
40const GRID_PERIOD_SECS: (f64, f64) = (0.08, 0.0025);
41const GRID_PERIODS: usize = 368;
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum Kind {
45    New,
46    Restrike,
47    Held,
48}
49
50impl Kind {
51    pub fn key(self) -> &'static str {
52        match self {
53            Kind::New => "new",
54            Kind::Restrike => "restrike",
55            Kind::Held => "held",
56        }
57    }
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum Cue {
62    Attack,
63    Partial,
64    Grid,
65    Crop,
66}
67
68impl Cue {
69    pub fn key(self) -> &'static str {
70        match self {
71            Cue::Attack => "attack",
72            Cue::Partial => "partial",
73            Cue::Grid => "grid",
74            Cue::Crop => "crop",
75        }
76    }
77}
78
79#[derive(Clone, Copy, Debug, PartialEq)]
80pub struct Strike {
81    pub at: f64,
82    pub cue: Cue,
83    pub kind: Kind,
84    /// The share of the power after it that was not there before.
85    pub new: f64,
86    pub rise_db: f64,
87}
88
89/// Each frame of a Hann `n` long every `hop`, centred on `k hop`, the clip zero past its ends.
90fn frames(x: &[f64], n: usize, hop: usize) -> Vec<Vec<f64>> {
91    let w = hann(n);
92    (0..=x.len() / hop)
93        .map(|k| {
94            let framed: Vec<f64> = (0..n)
95                .map(|i| {
96                    let at = (k * hop + i) as i64 - (n / 2) as i64;
97                    match at >= 0 && (at as usize) < x.len() {
98                        true => x[at as usize] * w[i],
99                        false => 0.0,
100                    }
101                })
102                .collect();
103            power(&framed, n).into_iter().map(f64::sqrt).collect()
104        })
105        .collect()
106}
107
108/// Two novelty curves on one hop: a log superflux per band, low, mid and high averaged,
109/// for attacks; and the rise of each bin that is a peak now, weighted by its share of the
110/// frame, for a partial appearing or rising, a soft or legato attack.
111fn novelty(clip: &Clip) -> (usize, Vec<f64>, Vec<f64>) {
112    let hop = ((HOP_SECS * clip.sr).round() as usize).max(1);
113    let n = sized(NOVELTY_SAMPLES_48K, clip.sr);
114    let m = frames(&clip.x, n, hop);
115    let most = m.iter().flatten().copied().fold(0.0, f64::max) + 1e-20;
116    let l: Vec<Vec<f64>> = m
117        .iter()
118        .map(|f| {
119            f.iter()
120                .map(|v| libm::log10(1.0 + 1000.0 * v / most))
121                .collect()
122        })
123        .collect();
124    let bins = n / 2 + 1;
125    let widest: Vec<Vec<f64>> = l
126        .iter()
127        .map(|f| {
128            (0..bins)
129                .map(|j| f[(j + bins - 1) % bins].max(f[j]).max(f[(j + 1) % bins]))
130                .collect()
131        })
132        .collect();
133    let hz = |j: usize| j as f64 * clip.sr / n as f64;
134    let bands = [
135        (0.0, BAND_EDGES_HZ.0),
136        BAND_EDGES_HZ,
137        (BAND_EDGES_HZ.1, f64::INFINITY),
138    ];
139    let mut attack = vec![0.0; l.len()];
140    for (lo, hi) in bands {
141        let per: Vec<f64> = (0..l.len())
142            .map(|i| match i >= FLUX_LAG {
143                true => (0..bins)
144                    .filter(|&j| hz(j) >= lo && hz(j) < hi)
145                    .map(|j| (l[i][j] - widest[i - FLUX_LAG][j]).max(0.0))
146                    .sum(),
147                false => 0.0,
148            })
149            .collect();
150        let top = per.iter().copied().fold(0.0, f64::max) + 1e-20;
151        attack
152            .iter_mut()
153            .zip(&per)
154            .for_each(|(a, p)| *a += p / top / 3.0);
155    }
156
157    let n2 = sized(PARTIAL_SAMPLES_48K, clip.sr);
158    let m2 = frames(&clip.x, n2, hop);
159    let l2: Vec<Vec<f64>> = m2
160        .iter()
161        .map(|f| f.iter().map(|v| 20.0 * libm::log10(v + 1e-12)).collect())
162        .collect();
163    let bins2 = n2 / 2 + 1;
164    let mut partial: Vec<f64> = (0..l2.len())
165        .map(|i| {
166            let f = &l2[i];
167            let top = f.iter().copied().fold(f64::NEG_INFINITY, f64::max);
168            let total: f64 = m2[i].iter().map(|v| v * v).sum::<f64>() + 1e-20;
169            (0..bins2)
170                .filter(|&j| {
171                    f[j] >= f[(j + bins2 - 1) % bins2]
172                        && f[j] >= f[(j + 1) % bins2]
173                        && f[j] > top - PARTIAL_RANGE_DB
174                })
175                .map(|j| {
176                    let rise = match i >= PARTIAL_LAG {
177                        true => {
178                            let before = (1..=PARTIAL_LAG)
179                                .map(|d| l2[i - d][j])
180                                .fold(f64::INFINITY, f64::min);
181                            (f[j] - before).clamp(0.0, RISE_CEILING_DB)
182                        }
183                        false => 0.0,
184                    };
185                    rise * (m2[i][j] * m2[i][j] / total).sqrt()
186                })
187                .sum()
188        })
189        .collect();
190    let top = partial.iter().copied().fold(0.0, f64::max) + 1e-20;
191    partial.iter_mut().for_each(|v| *v /= top);
192    (hop, attack, partial)
193}
194
195/// Each local maximum, the largest within 4 hops, standing `delta` over the median within
196/// 150 ms; one within 50 ms of the last replaces it where larger.
197fn picked(c: &[f64], (hop, sr): (usize, f64), delta: f64) -> Vec<(f64, f64)> {
198    let k = ((PICK_MEDIAN_SECS / (hop as f64 / sr)).round() as usize).max(1);
199    let mut out: Vec<(f64, f64)> = Vec::new();
200    for i in 1..c.len().saturating_sub(1) {
201        let around = c[i.saturating_sub(4)..(i + 5).min(c.len())]
202            .iter()
203            .copied()
204            .fold(f64::NEG_INFINITY, f64::max);
205        let local = median(&c[i.saturating_sub(k)..(i + k + 1).min(c.len())]);
206        if !(c[i] >= c[i - 1] && c[i] > c[i + 1] && c[i] == around && c[i] > local + delta) {
207            continue;
208        }
209        let t = (i * hop) as f64 / sr;
210        if let Some(last) = out.last_mut()
211            && t - last.0 < PICK_WAIT_SECS
212        {
213            if c[i] > last.1 {
214                *last = (t, c[i]);
215            }
216            continue;
217        }
218        out.push((t, c[i]));
219    }
220    out
221}
222
223/// The autocorrelation of the 5 ms level about its mean over `[a - half, a + half)`, its
224/// highest between lags `lo` and `hi` frames: a regular wobble.
225fn wobble(clip: &Clip, a: f64, half: f64, (lo, hi): (usize, usize)) -> f64 {
226    let held: Vec<f64> = clip
227        .level_t
228        .iter()
229        .zip(&clip.level_db)
230        .filter(|(t, _)| **t >= a - half && **t < a + half)
231        .map(|(_, v)| *v)
232        .collect();
233    if held.len() < 2 * hi {
234        return 0.0;
235    }
236    let mean = held.iter().sum::<f64>() / held.len() as f64;
237    let v: Vec<f64> = held.iter().map(|x| x - mean).collect();
238    let r = |lag: usize| (0..v.len() - lag).map(|i| v[i] * v[i + lag]).sum::<f64>();
239    let zero = r(0) + 1e-20;
240    (lo..hi)
241        .map(|lag| r(lag) / zero)
242        .fold(f64::NEG_INFINITY, f64::max)
243}
244
245/// The first difference's level about `a` over its median before: the air a strike carries.
246fn tick_db(clip: &Clip, a: f64) -> f64 {
247    if a <= 0.1 {
248        return 0.0;
249    }
250    let pick = |lo: f64, hi: f64| -> Vec<f64> {
251        clip.level_t
252            .iter()
253            .zip(&clip.tick)
254            .filter(|(t, _)| **t >= lo && **t < hi)
255            .map(|(_, v)| *v)
256            .collect()
257    };
258    let now = pick(a - 0.005, a + 0.015)
259        .into_iter()
260        .fold(f64::NEG_INFINITY, f64::max);
261    let before = median(&pick(a - 0.1, a - 0.01));
262    20.0 * libm::log10((now + 1e-20) / (before + 1e-20))
263}
264
265/// A strike landing on what already sounded: the new power after it within 300 cents of the
266/// harmonics of a voice before, or of a peak before, a glide settling or a swell beating.
267fn lands(clip: &Clip, pre: &[f64], post: &[f64]) -> bool {
268    let before: Vec<f64> = voices(&clip.peaks(pre, -45.0, 10.0))
269        .iter()
270        .map(|v| v.hz)
271        .collect();
272    let fresh: Vec<f64> = post
273        .iter()
274        .zip(pre)
275        .map(|(p, q)| (p - 2.0 * q).max(0.0))
276        .collect();
277    let fresh = clip.peaks(&fresh, -45.0, 10.0);
278    let quiet = clip.peaks(pre, -60.0, 6.0);
279    if before.is_empty() || fresh.is_empty() {
280        return false;
281    }
282    let (mut near, mut all) = (0.0, 0.0);
283    for &(hz, db) in &fresh {
284        let w = libm::pow(10.0, db / 10.0);
285        let harmonic = before.iter().any(|&q| {
286            let k = (hz / q).round_ties_even().max(1.0);
287            cents(hz, q * k).abs() < GLIDE_CENTS
288        });
289        let peak = quiet.iter().any(|&(q, _)| cents(hz, q).abs() < GLIDE_CENTS);
290        all += w;
291        if harmonic || peak {
292            near += w;
293        }
294    }
295    near / all >= GLIDE_SHARE
296}
297
298/// Every voice of the power new after it already sounded before, or a harmonic of it did:
299/// a note beating or swelling again, no new one.
300fn repeats(clip: &Clip, pre: &[f64], post: &[f64]) -> bool {
301    let before: Vec<f64> = voices(&clip.peaks(pre, -45.0, 10.0))
302        .iter()
303        .map(|v| v.hz)
304        .collect();
305    let fresh: Vec<f64> = post
306        .iter()
307        .zip(pre)
308        .map(|(p, q)| (p - 2.0 * q).max(0.0))
309        .collect();
310    let after = voices(&clip.peaks(&fresh, -45.0, 10.0));
311    !before.is_empty()
312        && !after.is_empty()
313        && after
314            .iter()
315            .all(|v| before.iter().any(|&q| related(v.hz, q)))
316}
317
318/// One a harmonic of the other, up to the fourth, within 50 cents.
319fn related(a: f64, b: f64) -> bool {
320    let (lo, hi) = (a.min(b), a.max(b));
321    let k = (hi / lo).round_ties_even();
322    (1.0..=4.0).contains(&k) && cents(hi, k * lo).abs() < REPEAT_CENTS
323}
324
325fn judged(clip: &Clip, a: f64, cue: Cue) -> Option<Strike> {
326    let dur = clip.secs();
327    let post = clip.spectrum(a + 0.015, dur.min(a + 0.085))?;
328    let pre = clip.spectrum(a - 0.07, a - 0.01);
329    let post_sum: f64 = post.iter().sum();
330    let (new, rise_db) = match &pre {
331        Some(pre) if pre.iter().sum::<f64>() >= post_sum * 1e-3 => {
332            let fresh: f64 = post
333                .iter()
334                .zip(pre)
335                .map(|(p, q)| (p - 2.0 * q).max(0.0))
336                .sum();
337            (
338                fresh / post_sum,
339                db_of_power(post_sum / pre.iter().sum::<f64>()),
340            )
341        }
342        _ => (1.0, 60.0),
343    };
344    let dip = clip.loudest(a, a + 0.04) - clip.quietest(a - 0.03, a + 0.005);
345    let tick = tick_db(clip, a);
346    let fast = wobble(clip, a, 0.15, (4, 17));
347    let mut kind = match (
348        new >= NEW_SHARE || rise_db >= NEW_RISE_DB,
349        dip >= RESTRIKE_DB,
350    ) {
351        (true, _) => Some(Kind::New),
352        (false, true) if tick >= RESTRIKE_DB && (fast < FAST_WOBBLE || dip >= SILENT_DIP_DB) => {
353            Some(Kind::Restrike)
354        }
355        _ => None,
356    };
357    if let (Some(Kind::New), Some(pre)) = (kind, &pre) {
358        let quiet = rise_db < GLIDE_RISE_DB;
359        let unstruck = tick < 2.0 && rise_db < 10.0;
360        let swell = unstruck && wobble(clip, a, 0.4, (20, 61)) > FAST_WOBBLE;
361        let beat = unstruck && fast > FAST_WOBBLE && repeats(clip, pre, &post);
362        if ((quiet || swell) && lands(clip, pre, &post)) || beat {
363            kind = None;
364        }
365    }
366    let audible = clip.loudest(a, a + 0.05) > clip.top_db - AUDIBLE_DB;
367    kind.filter(|_| audible).map(|kind| Strike {
368        at: a,
369        cue,
370        kind,
371        new,
372        rise_db,
373    })
374}
375
376/// Where most strikes sit on one period's grid, an audible grid point with a weak cue, a
377/// candidate within 20 ms or a 1.5 dB dip and rise, is a re-strike too, however soft.
378fn gridded(clip: &Clip, strikes: Vec<Strike>, candidates: &[(f64, Cue)]) -> Vec<Strike> {
379    let ts: Vec<f64> = strikes
380        .iter()
381        .filter(|s| s.cue != Cue::Crop)
382        .map(|s| s.at)
383        .collect();
384    if ts.len() < GRID_LEAST {
385        return strikes;
386    }
387    let mut best: Option<(usize, f64)> = None;
388    for step in 0..GRID_PERIODS {
389        let p = GRID_PERIOD_SECS.0 + step as f64 * GRID_PERIOD_SECS.1;
390        let mut score = 0usize;
391        for i in 0..ts.len() {
392            for j in i + 1..ts.len() {
393                let d = ts[j] - ts[i];
394                let m = (d / p).round_ties_even();
395                if (1.0..=4.0).contains(&m) && (d - m * p).abs() < GRID_SLACK_SECS {
396                    score += 1;
397                }
398            }
399        }
400        let fits = ts
401            .windows(2)
402            .filter(|w| {
403                let d = w[1] - w[0];
404                (d - (d / p).round_ties_even() * p).abs() < GRID_SLACK_SECS
405            })
406            .count();
407        if fits as f64 / (ts.len() - 1) as f64 >= 0.6 && best.is_none_or(|(s, _)| score > s) {
408            best = Some((score, p));
409        }
410    }
411    let Some((_, p)) = best.filter(|(s, _)| *s >= GRID_LEAST) else {
412        return strikes;
413    };
414    let phase = median(
415        &ts.iter()
416            .map(|t| (t + p / 2.0).rem_euclid(p) - p / 2.0)
417            .collect::<Vec<f64>>(),
418    );
419    let mut out = strikes;
420    let first = phase.rem_euclid(p);
421    let points = ((clip.secs() - LAST_SECS - first) / p).ceil().max(0.0) as usize;
422    for g in (0..points).map(|i| first + i as f64 * p) {
423        if !out.iter().any(|s| (s.at - g).abs() < 0.025) {
424            let near = candidates.iter().find(|(t, _)| (t - g).abs() < 0.02);
425            let dip = clip.loudest(g - 0.005, g + 0.035) - clip.quietest(g - 0.03, g + 0.01);
426            let audible = clip.loudest(g, g + 0.05) > clip.top_db - AUDIBLE_DB;
427            if audible && (near.is_some() || dip >= GRID_DIP_DB) {
428                out.push(Strike {
429                    at: near.map_or(g, |c| c.0),
430                    cue: Cue::Grid,
431                    kind: Kind::Restrike,
432                    new: 0.0,
433                    rise_db: 0.0,
434                });
435            }
436        }
437    }
438    out.sort_by(|a, b| a.at.total_cmp(&b.at));
439    out
440}
441
442/// None in a clip under the profile's silence threshold; else candidates from both novelty
443/// curves, kept where something new sounds after them, one per 60 ms (the most new power and
444/// rise), completed on a re-strike grid, and the crop's start where the clip opens on sound.
445pub fn strikes(clip: &Clip) -> (Vec<Strike>, f64) {
446    let (hop, attack, partial) = novelty(clip);
447    if clip.top_db <= PSYCHOACOUSTIC_V1.silence_threshold_dbfs {
448        return (Vec::new(), hop as f64 / clip.sr);
449    }
450    let mut candidates: Vec<(f64, Cue)> = picked(&attack, (hop, clip.sr), ATTACK_DELTA)
451        .into_iter()
452        .map(|(t, _)| (t, Cue::Attack))
453        .chain(
454            picked(&partial, (hop, clip.sr), PARTIAL_DELTA)
455                .into_iter()
456                .map(|(t, _)| (t, Cue::Partial)),
457        )
458        .collect();
459    candidates.sort_by(|a, b| {
460        a.0.total_cmp(&b.0)
461            .then((a.1 == Cue::Partial).cmp(&(b.1 == Cue::Partial)))
462    });
463    let kept: Vec<Strike> = candidates
464        .iter()
465        .filter(|(t, _)| *t <= clip.secs() - LAST_SECS)
466        .filter_map(|&(t, cue)| judged(clip, t, cue))
467        .collect();
468    let weight = |s: &Strike| s.new + s.rise_db / 20.0;
469    let mut one: Vec<Strike> = Vec::new();
470    for s in kept {
471        match one.last_mut() {
472            Some(last) if s.at - last.at < ONE_PER_SECS => {
473                if weight(&s) > weight(last) + 0.05 {
474                    *last = s;
475                }
476            }
477            _ => one.push(s),
478        }
479    }
480    let mut out = gridded(clip, one, &candidates);
481    if out.first().is_none_or(|s| s.at > CROP_SECS)
482        && clip.loudest(0.0, CROP_SECS) > clip.top_db - CROP_DB
483    {
484        out.insert(
485            0,
486            Strike {
487                at: 0.0,
488                cue: Cue::Crop,
489                kind: Kind::Held,
490                new: 0.0,
491                rise_db: 0.0,
492            },
493        );
494    }
495    (out, hop as f64 / clip.sr)
496}