1mod 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 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 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 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
159type Settled = (f64, Option<f64>);
161
162const STRIKE_SPAN_SECS: f64 = 0.012;
163const PLACE_REACH_SECS: f64 = 0.0107;
164
165struct 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 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
204fn 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}