1use 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;
11const 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;
26const 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;
39const 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 pub new: f64,
86 pub rise_db: f64,
87}
88
89fn 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
108fn 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
195fn 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
223fn 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
245fn 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
265fn 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
298fn 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
318fn 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
376fn 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
442pub 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}