Skip to main content

sva_samples/measure/score/
mod.rs

1// Concern: reads a clip as a score: events, each one's sound, hit and voices, new ones flagged | Non-concern: where strikes fall (strikes.rs), the sieve (voices.rs) | IO: (planes, rate, start) -> Score
2
3mod spectra;
4mod strikes;
5mod voices;
6
7pub use strikes::{Cue, Kind};
8pub use voices::{Hit, Partial};
9
10use spectra::{Clip, cents};
11use voices::{Voice, coherence, hit, period, voices};
12
13pub const MODEL: &str = "events off spectra averaged over Hann frames: an onset is a peak \
14                         of a band-wise log superflux (an attack) or of a partial flux (a \
15                         partial appearing or rising: a soft or legato attack), kept only where \
16                         something new sounds after it: power new against the 60 ms before (a \
17                         new note), or a level rise out of a dip with an air tick (a \
18                         re-strike); a regular level wobble under 80 ms (beats, tremolo) or a \
19                         glide landing within 300 cents of what sounded is none; one per 60 ms; \
20                         a period most onsets sit on completes them with soft re-strikes. Each \
21                         event's voices by a harmonic sieve on the power that is new (a voice \
22                         new at it) and on all that sounds, a fundamental where its own peak \
23                         is heard, each read again off the event's later part (a bend glides \
24                         in), tonal or noisy by its loudest partials' coherence over 10 ms, a \
25                         noisy voice taking the event's periodicity (McLeod's NSDF) where that \
26                         lies within 60 cents of an octave or two of it; the event tonal, \
27                         noisy-pitched or noisy by its share of power in prominent peaks and its \
28                         periodicity, an unpitched or short hit a kick, snare or hat by band.";
29
30const VOICE_SPAN_SECS: f64 = 0.3;
31const SETTLE_SECS: f64 = 0.015;
32const PRE_SECS: (f64, f64) = (0.07, 0.01);
33const NEW_OVER: f64 = 1.5;
34const SHORT_SECS: f64 = 0.03;
35const TONAL_SHARE: (f64, f64) = (0.4, 0.15);
36const TONAL_PERIOD: (f64, f64) = (0.7, 0.4);
37const END_DB: f64 = 30.0;
38const HIT_DECAY_DB: f64 = 20.0;
39const HIT_SECS: f64 = 0.12;
40const LATE_SHARE: f64 = 0.4;
41const LATE_LEAST_SECS: f64 = 0.05;
42const BEND_CENTS: f64 = 250.0;
43const TONE_COHERENCE: f64 = 0.8;
44const PERIOD_CENTS: f64 = 60.0;
45const SAME_VOICE_CENTS: f64 = 50.0;
46const PERIOD_LEAST_48K: f64 = 1024.0;
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum Sound {
50    Tonal,
51    NoisyPitched,
52    Noisy,
53}
54
55impl Sound {
56    pub fn key(self) -> &'static str {
57        match self {
58            Sound::Tonal => "tonal",
59            Sound::NoisyPitched => "noisy_pitched",
60            Sound::Noisy => "noisy",
61        }
62    }
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum Tone {
67    Tonal,
68    Noisy,
69}
70
71impl Tone {
72    pub fn key(self) -> &'static str {
73        match self {
74            Tone::Tonal => "tonal",
75            Tone::Noisy => "noisy",
76        }
77    }
78}
79
80#[derive(Clone, Debug, PartialEq)]
81pub struct Voiced {
82    pub hz: f64,
83    pub tone: Tone,
84    /// Sounding newly at the event; every voice of a re-strike or a held start.
85    pub new: bool,
86    pub share: f64,
87    pub db: f64,
88    pub bend_cents: Option<f64>,
89    pub coherence: Option<f64>,
90    pub partials: Vec<Partial>,
91}
92
93#[derive(Clone, Debug, PartialEq)]
94pub struct Event {
95    pub at_secs: f64,
96    /// Where it falls 30 dB under its peak, or the next event.
97    pub end_secs: f64,
98    pub next_secs: f64,
99    pub kind: Kind,
100    pub cue: Cue,
101    pub sound: Sound,
102    pub hit: Option<Hit>,
103    pub attack_secs: f64,
104    pub level_db: f64,
105    pub period: Option<(f64, f64)>,
106    pub voices: Vec<Voiced>,
107    /// The loudest new voice, else the loudest.
108    pub main: Option<usize>,
109}
110
111#[derive(Clone, Debug, PartialEq)]
112pub struct Score {
113    pub resolution_secs: f64,
114    pub events: Vec<Event>,
115}
116
117fn mono(planes: &[&[f64]]) -> Vec<f64> {
118    let len = super::held(planes);
119    let count = planes.len() as f64;
120    (0..len)
121        .map(|n| planes.iter().map(|p| p[n]).sum::<f64>() / count)
122        .collect()
123}
124
125pub fn measure(planes: &[&[f64]], sr: f64, start: f64) -> Score {
126    let clip = Clip::new(&mono(planes), sr);
127    let (strikes, resolution_secs) = strikes::strikes(&clip);
128    let energy = Energy::of(&clip.x);
129    let placed: Vec<f64> = strikes
130        .iter()
131        .map(|s| match s.cue {
132            Cue::Crop => s.at,
133            _ => energy.placed(&clip, s.at),
134        })
135        .chain([clip.secs()])
136        .collect();
137    let events = strikes
138        .iter()
139        .enumerate()
140        .filter_map(|(i, s)| {
141            let next = strikes.get(i + 1).map_or(clip.secs(), |n| n.at);
142            let e = event(&clip, s, next)?;
143            let (at, next) = (placed[i], placed[i + 1]);
144            Some(Event {
145                at_secs: at + start,
146                end_secs: e.end_secs.min(next) + start,
147                next_secs: next + start,
148                attack_secs: (e.attack_secs + e.at_secs - at).max(0.0),
149                ..e
150            })
151        })
152        .collect();
153    Score {
154        resolution_secs,
155        events,
156    }
157}
158
159/// A voice's frequency where it lands, and the bend it glided in from, in cents.
160type Settled = (f64, Option<f64>);
161
162const STRIKE_SPAN_SECS: f64 = 0.012;
163const PLACE_REACH_SECS: f64 = 0.0107;
164
165/// Running energy, for where within a hop a strike rises.
166struct Energy {
167    running: Vec<f64>,
168}
169
170impl Energy {
171    fn of(x: &[f64]) -> Energy {
172        let mut running = Vec::with_capacity(x.len() + 1);
173        running.push(0.0);
174        for v in x {
175            running.push(running[running.len() - 1] + v * v);
176        }
177        Energy { running }
178    }
179
180    fn over(&self, from: usize, to: usize) -> f64 {
181        let last = self.running.len() - 1;
182        self.running[to.min(last)] - self.running[from.min(last)]
183    }
184
185    /// The sample within the novelty frame about `t` whose 12 ms after most outweighs the
186    /// 12 ms before, the clip's mean power over that span under both: where the strike rises.
187    fn placed(&self, clip: &Clip, t: f64) -> f64 {
188        let reach = (STRIKE_SPAN_SECS * clip.sr).round().max(1.0) as usize;
189        let len = self.running.len() - 1;
190        let floor = self.over(0, len) / len.max(1) as f64 * reach as f64 * 1e-6;
191        let around = (PLACE_REACH_SECS * clip.sr) as usize;
192        let at = clip.at(t);
193        let mut best = (f64::NEG_INFINITY, at);
194        for n in at.saturating_sub(around)..(at + around).min(len) {
195            let ratio = self.over(n, n + reach) / (self.over(n.saturating_sub(reach), n) + floor);
196            if ratio > best.0 {
197                best = (ratio, n);
198            }
199        }
200        best.1 as f64 / clip.sr
201    }
202}
203
204/// The 5 ms frames in `[a, b)` as (time, dB).
205fn framed(clip: &Clip, a: f64, b: f64) -> Vec<(f64, f64)> {
206    clip.level_t
207        .iter()
208        .zip(&clip.level_db)
209        .filter(|(t, _)| **t >= a && **t < b)
210        .map(|(t, v)| (*t, *v))
211        .collect()
212}
213
214fn event(clip: &Clip, s: &strikes::Strike, b: f64) -> Option<Event> {
215    let a = s.at;
216    let span_end = b.min(a + VOICE_SPAN_SECS);
217    let post = match b - a > SHORT_SECS {
218        true => clip.spectrum(a + SETTLE_SECS, span_end)?,
219        false => clip.spectrum(a, b)?,
220    };
221    let pre = clip.spectrum(a - PRE_SECS.0, a - PRE_SECS.1);
222    let diff: Vec<f64> = match (&pre, s.kind) {
223        (Some(pre), k) if k != Kind::Held => post
224            .iter()
225            .zip(pre)
226            .map(|(p, q)| (p - NEW_OVER * q).max(0.0))
227            .collect(),
228        _ => post.clone(),
229    };
230    let all = voices(&clip.peaks(&post, spectra::PEAK_FLOOR_DB, spectra::PEAK_PROMINENCE_DB));
231    let fresh = match (s.kind, pre.is_some() && diff.iter().sum::<f64>() > 0.0) {
232        (Kind::New, true) => {
233            voices(&clip.peaks(&diff, spectra::PEAK_FLOOR_DB, spectra::PEAK_PROMINENCE_DB))
234        }
235        (Kind::New, false) => all.clone(),
236        _ => Vec::new(),
237    };
238    let seg = &clip.x[clip.at(a + SETTLE_SECS)..clip.at(span_end).max(clip.at(a + SETTLE_SECS))];
239    let periodic = match seg.len() as f64 > PERIOD_LEAST_48K * clip.sr / 48_000.0 {
240        true => period(seg, clip.sr),
241        false => None,
242    };
243    let clarity = periodic.map_or(0.0, |p| p.1);
244    let prominent = clip.peaks(&post, -50.0, spectra::PEAK_PROMINENCE_DB);
245    let tonal_share = prominent
246        .iter()
247        .map(|&(hz, _)| {
248            let k = (hz / clip.bin_hz) as usize;
249            post[k.saturating_sub(3)..(k + 4).min(post.len())]
250                .iter()
251                .sum::<f64>()
252        })
253        .sum::<f64>()
254        / post.iter().sum::<f64>();
255    let sound = if tonal_share > TONAL_SHARE.0 || clarity > TONAL_PERIOD.0 {
256        Sound::Tonal
257    } else if tonal_share > TONAL_SHARE.1 || clarity > TONAL_PERIOD.1 {
258        Sound::NoisyPitched
259    } else {
260        Sound::Noisy
261    };
262
263    let frames = framed(clip, a, b);
264    let peak = clip.loudest(a, b);
265    let crest = match frames.len() > 1 {
266        true => {
267            frames
268                .iter()
269                .fold(
270                    (a, f64::NEG_INFINITY),
271                    |m, &(t, v)| if v > m.1 { (t, v) } else { m },
272                )
273                .0
274        }
275        false => a,
276    };
277    let under = |db: f64| {
278        frames
279            .iter()
280            .find(|(t, v)| *t > crest && *v < peak - db)
281            .map(|(t, _)| *t)
282    };
283    let end = under(END_DB).unwrap_or(b);
284    let short = under(HIT_DECAY_DB).is_some_and(|t| t - crest < HIT_SECS);
285    let hit = match sound != Sound::Tonal || (short && s.kind == Kind::New) {
286        true => hit(clip, (clip.at(a), clip.at(b.min(a + HIT_SECS)))),
287        false => None,
288    };
289
290    let late = match b - a > HIT_SECS {
291        true => clip
292            .spectrum(a + LATE_LEAST_SECS.max(LATE_SHARE * (b - a)), b - 0.01)
293            .map(|p| voices(&clip.peaks(&p, spectra::PEAK_FLOOR_DB, spectra::PEAK_PROMINENCE_DB))),
294        false => None,
295    };
296    let late_from = a + LATE_LEAST_SECS.max(LATE_SHARE * (b - a));
297    let late_seg = &clip.x[clip.at(late_from).min(clip.at(b - 0.01))..clip.at(b - 0.01)];
298    let landed =
299        match b - a > HIT_SECS && late_seg.len() as f64 > PERIOD_LEAST_48K * clip.sr / 48_000.0 {
300            true => period(late_seg, clip.sr),
301            false => None,
302        }
303        .or(periodic);
304    let settle = |v: &Voice| -> (f64, Option<f64>) {
305        let landing = late.as_ref().and_then(|late| {
306            late.iter()
307                .filter(|w| cents(w.hz, v.hz).abs() < BEND_CENTS)
308                .min_by(|x, y| {
309                    libm::fabs(libm::log2(x.hz / v.hz))
310                        .total_cmp(&libm::fabs(libm::log2(y.hz / v.hz)))
311                })
312        });
313        match landing {
314            Some(w) => (w.hz, Some(cents(v.hz, w.hz).round_ties_even())),
315            None => (v.hz, None),
316        }
317    };
318    let fresh: Vec<(&Voice, (f64, Option<f64>))> = fresh.iter().map(|v| (v, settle(v))).collect();
319    let others = all.iter().map(|v| (v, settle(v))).filter(|(_, (hz, _))| {
320        fresh
321            .iter()
322            .all(|(_, (n, _))| cents(*hz, *n).abs() > SAME_VOICE_CENTS)
323    });
324    let read: Vec<(&Voice, Settled, bool)> = fresh
325        .iter()
326        .map(|&(v, settled)| (v, settled, true))
327        .chain(others.map(|(v, settled)| (v, settled, s.kind != Kind::New)))
328        .collect();
329    let voiced: Vec<Voiced> = read
330        .into_iter()
331        .map(|(v, (hz, bend_cents), new)| {
332            let mut loudest: Vec<&Partial> = v.partials.iter().collect();
333            loudest.sort_by(|x, y| y.db.total_cmp(&x.db));
334            loudest.truncate(3);
335            let coherence = coherence(clip, (a + SETTLE_SECS, span_end), &loudest);
336            let tone = match coherence.is_some_and(|c| c >= TONE_COHERENCE) {
337                true => Tone::Tonal,
338                false => Tone::Noisy,
339            };
340            let hz = match (tone, landed) {
341                (Tone::Noisy, Some((per, _))) => {
342                    let k = libm::log2(hz / per).round_ties_even();
343                    let octave = per * libm::pow(2.0, k);
344                    match k.abs() <= 2.0 && cents(hz, octave).abs() < PERIOD_CENTS {
345                        true => per,
346                        false => hz,
347                    }
348                }
349                _ => hz,
350            };
351            Voiced {
352                hz,
353                tone,
354                new,
355                share: v.share,
356                db: v.db,
357                bend_cents,
358                coherence,
359                partials: v.partials.clone(),
360            }
361        })
362        .collect();
363    let pool: Vec<usize> = match voiced.iter().any(|v| v.new) {
364        true => (0..voiced.len()).filter(|&i| voiced[i].new).collect(),
365        false => (0..voiced.len()).collect(),
366    };
367    let main = pool.into_iter().fold(None::<usize>, |best, i| match best {
368        Some(j) if voiced[i].share <= voiced[j].share => Some(j),
369        _ => Some(i),
370    });
371    Some(Event {
372        at_secs: a,
373        end_secs: end.min(b),
374        next_secs: b,
375        kind: s.kind,
376        cue: s.cue,
377        sound,
378        hit,
379        attack_secs: crest - a,
380        level_db: peak,
381        period: periodic,
382        voices: voiced,
383        main,
384    })
385}