1use super::FAST_TAU;
5use super::fit::golden_section;
6use super::line::SubBandLine;
7use crate::audio::SampleRate;
8use crate::cast;
9use crate::units::{Hz, Seconds, Semitones};
10
11pub const DROP_FIT_START: Seconds = Seconds::from_const(0.007);
15pub(crate) const DROP_FIT_END: Seconds = Seconds::from_const(0.4);
17pub const DROP_MIN: Semitones = Semitones::from_const(2.0);
19pub const DROP_MAX: Semitones = Semitones::from_const(36.0);
23pub const DROP_TAU_MIN: Seconds = Seconds::from_const(0.007 / std::f64::consts::LN_10);
30pub const DROP_MAX_RESIDUAL: f64 = 0.15;
33pub const DROP_ONSET_SHARE: f64 = 0.5;
36pub(crate) const ATTACK_FROM: f64 = 0.1;
39pub(crate) const ATTACK_TO: f64 = 0.9;
41pub const ONSET_SMOOTHING: Seconds = Seconds::from_const(0.01);
43pub const RISE_PERIODS: f64 = 2.0;
55const TAU_MAX: f64 = 1.0;
57const TAU_EDGE: f64 = 1.01;
60const FULL_PERIODS: f64 = 1.5;
63const TAU_GRID: usize = 240;
65
66pub const DROP_CONVENTION: &str = "f(t) = f_end + df exp(-t / tau) fitted by least squares to \
68the sub-band line from 7 ms to 400 ms after the onset, or to the end of the note; tau by a log \
69grid and golden-section search from 3.04 ms, where exp(-7 ms / tau) = 0.1, to 1 s, f_end and \
70df linear for each tau; a fit with tau under 10 ms, or after a strike whose full-band RMS rises \
7120 dB, is fitted again on the line with its high-pass at 10 Hz, and the refit counts; \
72df_st = 12 log2((f_end + df) / f_end); onset_s is the first sample after the lowest point of \
73the onset search where the 20 to 200 Hz envelope, averaged over a centred 10 ms, reaches half \
74the peak that follows; a strike whose full-band RMS over the 20 ms after the onset lies more \
75than 40 dB below the loudest 25 ms frame of the stem, or whose 20 to 200 Hz RMS over those \
7620 ms lies more than 18 dB below the loudest 25 ms frame of that band, is no candidate; a drop \
77with tau under 10 ms needs an envelope that takes 30 ms or less from 10 to 90 % of its peak; a \
78drop with tau under 10 ms, one that starts at 200 Hz or above, or one whose envelope takes over \
7930 ms from 10 to 90 % of its peak needs the full-band RMS over the 20 ms after the onset 6 dB or \
80more above the 20 ms that end 25 ms before it; residual is the RMS of the fit's error, averaged \
81over half a period of f_end when tau is 10 ms or more and over a full period when tau spans 1.5 \
82periods or more, over the fitted fall from 7 ms on, df exp(-7 ms / tau); a drop counts when \
83df_st is above 2 and at most 36, that fall is above 2 semitones too, tau is above the bound of \
84the search, residual is below 0.15, and f_end lies within 100 cents of a note that starts 150 \
85ms or less before the onset and sounds at some point from 5 tau after the onset, or from the end \
86of the fit span if that comes first, to the end of the fit span; a fit after a 6 dB strike that is no \
87drop is fitted again on the note line, whose low-pass sits at 1.5 times the note's pitch, when \
88that lies under 200 Hz, with its span ending one period of the note before the note line's \
89frequency first falls to 0 Hz, and the refit counts with tau of 10 ms or more and an RMS \
90envelope over its fall, from 7 ms to 5 tau, at most 6 dB below the line the first fit read; a \
91search that starts where the previous note's search ended, before the drop read last has \
92settled, 5 tau after its onset, finds that strike again when its 20 to 200 Hz envelope, \
93averaged over a centred 2 periods of the note, is lowest at its first sample";
94
95#[derive(Clone, Copy, Debug, PartialEq)]
97pub struct DropFit {
98 pub onset: Seconds,
100 pub f_end: Hz,
102 pub df_hz: f64,
105 pub tau: Seconds,
107 pub residual: f64,
112 pub points: usize,
114}
115
116impl DropFit {
117 #[must_use]
120 pub fn settled(&self) -> Seconds {
121 Seconds::clamped(self.onset.get() + 5.0 * self.tau.get())
122 }
123
124 #[must_use]
127 pub fn df(&self) -> Option<Semitones> {
128 self.above_end(self.f_end.get() + self.df_hz)
129 }
130
131 #[must_use]
136 pub fn visible(&self) -> Option<Semitones> {
137 self.above_end(
138 self.f_end.get() + self.df_hz * (-DROP_FIT_START.get() / self.tau.get()).exp(),
139 )
140 }
141
142 fn above_end(&self, start: f64) -> Option<Semitones> {
145 (start > 0.0 && self.f_end.get() > 0.0)
146 .then(|| Semitones::clamped(12.0 * (start / self.f_end.get()).log2()))
147 }
148
149 #[must_use]
154 pub fn accepted(&self) -> bool {
155 self.miss().is_none()
156 }
157
158 #[must_use]
161 pub fn miss(&self) -> Option<DropMiss> {
162 if !(self.df().is_some_and(|df| df > DROP_MIN && df <= DROP_MAX)
163 && self.visible().is_some_and(|visible| visible > DROP_MIN))
164 {
165 Some(DropMiss::Size)
166 } else if self.tau.get() <= TAU_EDGE * DROP_TAU_MIN.get() {
167 Some(DropMiss::TauBound)
168 } else if self.residual >= DROP_MAX_RESIDUAL {
169 Some(DropMiss::Residual)
170 } else {
171 None
172 }
173 }
174}
175
176#[derive(Clone, Copy, Debug, PartialEq, Eq)]
178pub enum DropMiss {
179 Size,
182 TauBound,
184 Residual,
186}
187
188#[must_use]
200pub fn band_onset(line: &SubBandLine, from: Seconds, to: Seconds) -> Option<Seconds> {
201 let (start, smooth) = smoothed_envelope(line, from, to, ONSET_SMOOTHING.get())?;
202 let (low, peak) = low_and_peak(&smooth)?;
203 let first = smooth[low..]
204 .iter()
205 .position(|&e| e >= DROP_ONSET_SHARE * peak)?;
206 Some(line.sample_time(start + low + first))
207}
208
209#[must_use]
213pub(crate) fn rises_into(line: &SubBandLine, from: Seconds, to: Seconds, pitch: Hz) -> bool {
214 smoothed_envelope(line, from, to, RISE_PERIODS / pitch.get()).is_some_and(|(_, smooth)| {
215 (0..smooth.len()).min_by(|&a, &b| smooth[a].total_cmp(&smooth[b])) == Some(0)
216 })
217}
218
219#[must_use]
225pub(crate) fn band_attack(line: &SubBandLine, from: Seconds, to: Seconds) -> Option<Seconds> {
226 let (_, smooth) = smoothed_envelope(line, from, to, ONSET_SMOOTHING.get())?;
227 let (low, peak) = low_and_peak(&smooth)?;
228 let after = &smooth[low..];
229 let first = after.iter().position(|&e| e >= ATTACK_FROM * peak)?;
230 let last = after.iter().position(|&e| e >= ATTACK_TO * peak)?;
231 Some(Seconds::clamped(
232 cast::usize_to_f64(last.saturating_sub(first)) / line.sample_rate().hz().get(),
233 ))
234}
235
236fn smoothed_envelope(
239 line: &SubBandLine,
240 from: Seconds,
241 to: Seconds,
242 span: f64,
243) -> Option<(usize, Vec<f64>)> {
244 let rate = line.sample_rate().hz().get();
245 let half = cast::round_to_usize(0.5 * span * rate).unwrap_or(0);
246 let envelope = line.envelope();
247 let (start, end) = (line.index_at(from), line.index_at(to).min(envelope.len()));
248 if start >= end {
249 return None;
250 }
251 let mut sums = Vec::with_capacity(envelope.len() + 1);
252 sums.push(0.0);
253 for e in envelope {
254 sums.push(sums[sums.len() - 1] + e);
255 }
256 let smooth = (start..end)
257 .map(|n| {
258 let (a, b) = (n.saturating_sub(half), (n + half + 1).min(envelope.len()));
259 (sums[b] - sums[a]) / cast::usize_to_f64(b - a)
260 })
261 .collect();
262 Some((start, smooth))
263}
264
265fn low_and_peak(smooth: &[f64]) -> Option<(usize, f64)> {
269 let low = (0..smooth.len()).min_by(|&a, &b| smooth[a].total_cmp(&smooth[b]))?;
270 let peak = smooth[low..].iter().copied().fold(0.0, f64::max);
271 (peak > 0.0 && smooth[low] <= DROP_ONSET_SHARE * peak).then_some((low, peak))
272}
273
274#[must_use]
277pub fn fit_drop(line: &SubBandLine, onset: Seconds, end: Seconds) -> Option<DropFit> {
278 let last = (onset.get() + DROP_FIT_END.get()).min(end.get());
279 let from = line.index_at(Seconds::clamped(onset.get() + DROP_FIT_START.get()));
280 let to = line.index_at(Seconds::clamped(last)).min(line.hz().len());
281 let points: Vec<(f64, f64)> = (from..to)
282 .map(|n| (line.time_of(n).get() - onset.get(), line.hz()[n]))
283 .collect();
284 if points.len() < 3 {
285 return None;
286 }
287 let (lo, hi) = (DROP_TAU_MIN.get().ln(), TAU_MAX.ln());
288 let step = (hi - lo) / cast::usize_to_f64(TAU_GRID - 1);
289 let sse = |log_tau: f64| solve(&points, log_tau.exp()).2;
290 let (best, _) = (0..TAU_GRID)
291 .map(|k| lo + step * cast::usize_to_f64(k))
292 .map(|log_tau| (log_tau, sse(log_tau)))
293 .min_by(|a, b| a.1.total_cmp(&b.1))?;
294 let (log_tau, _) = golden_section(sse, (best - step).max(lo), (best + step).min(hi), 1e-9);
295 let tau = log_tau.exp();
296 let (f_end, df, sse) = solve(&points, tau);
297 let rms = if tau >= FAST_TAU.get() {
300 period_rms(&points, f_end, df, tau, line.sample_rate())
301 } else {
302 (sse / cast::usize_to_f64(points.len())).sqrt()
303 };
304 Some(DropFit {
305 onset,
306 f_end: Hz::new(f_end).ok()?,
307 df_hz: df,
308 tau: Seconds::new(tau).ok()?,
309 residual: {
310 let seen = df.abs() * (-DROP_FIT_START.get() / tau).exp();
311 if seen > 0.0 {
312 rms / seen
313 } else {
314 f64::INFINITY
315 }
316 },
317 points: points.len(),
318 })
319}
320
321fn period_rms(points: &[(f64, f64)], f_end: f64, df: f64, tau: f64, rate: SampleRate) -> f64 {
334 let errors: Vec<f64> = points
335 .iter()
336 .map(|&(t, y)| y - f_end - df * (-t / tau).exp())
337 .collect();
338 let period = if f_end > 0.0 {
339 cast::round_to_usize(rate.hz().get() / f_end).unwrap_or(1)
340 } else {
341 1
342 };
343 let half = if tau * f_end >= FULL_PERIODS {
346 period.max(1) / 2
347 } else {
348 period.max(1) / 4
349 };
350 let mut sums = vec![0.0; errors.len() + 1];
351 for (k, e) in errors.iter().enumerate() {
352 sums[k + 1] = sums[k] + e;
353 }
354 let squares: f64 = (0..errors.len())
355 .map(|k| {
356 let (a, b) = (k.saturating_sub(half), (k + half + 1).min(errors.len()));
357 ((sums[b] - sums[a]) / cast::usize_to_f64(b - a)).powi(2)
358 })
359 .sum();
360 (squares / cast::usize_to_f64(errors.len())).sqrt()
361}
362
363fn solve(points: &[(f64, f64)], tau: f64) -> (f64, f64, f64) {
366 let n = cast::usize_to_f64(points.len());
367 let g: Vec<f64> = points.iter().map(|&(t, _)| (-t / tau).exp()).collect();
368 let (mut sg, mut sgg, mut sy, mut sgy) = (0.0, 0.0, 0.0, 0.0);
369 for (&(_, y), &g) in points.iter().zip(&g) {
370 sg += g;
371 sgg += g * g;
372 sy += y;
373 sgy += g * y;
374 }
375 let det = n * sgg - sg * sg;
376 let (a, b) = if det.abs() <= f64::EPSILON * n * sgg {
377 (sy / n, 0.0)
378 } else {
379 ((sgg * sy - sg * sgy) / det, (n * sgy - sg * sy) / det)
380 };
381 let sse = points
382 .iter()
383 .zip(&g)
384 .map(|(&(_, y), &g)| (y - a - b * g).powi(2))
385 .sum();
386 (a, b, sse)
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use crate::audio::SampleRate;
393
394 #[test]
395 fn the_convention_states_the_constants() {
396 use super::super::line::{LINE_HI, LINE_LO, LINE_LO_FAST};
397 use super::super::{
398 DROP_BAND_GATE, DROP_NOTE_LEAD, DROP_NOTE_MATCH, RUN_START_RISE, STRIKE_ATTACK,
399 STRIKE_RISE, STRIKE_SPANS, num, ratio_db, says,
400 };
401 use crate::dsp::ACTIVE_GATE_DB;
402 use crate::notes::QUIET_FRAME;
403 let ms = |s: f64| num(1000.0 * s);
404 let [before, after] = STRIKE_SPANS;
405 assert!((DROP_ONSET_SHARE - 0.5).abs() < 1e-12, "half the peak");
406 says(
407 DROP_CONVENTION,
408 &[
409 format!(
410 "from {} ms to {} ms after the onset",
411 ms(DROP_FIT_START.get()),
412 ms(DROP_FIT_END.get())
413 ),
414 format!(
415 "search from {} ms, where exp(-{} ms / tau) = 0.1, to {} s",
416 ms(DROP_TAU_MIN.get()),
417 ms(DROP_FIT_START.get()),
418 num(TAU_MAX)
419 ),
420 format!(
421 "a fit with tau under {} ms, or after a strike whose full-band RMS rises {}, \
422 is fitted again on the line with its high-pass at {} Hz",
423 ms(FAST_TAU.get()),
424 ratio_db(RUN_START_RISE),
425 num(LINE_LO_FAST.get())
426 ),
427 format!(
428 "where the {} to {} Hz envelope, averaged over a centred {} ms, reaches half",
429 num(LINE_LO.get()),
430 num(LINE_HI.get()),
431 ms(ONSET_SMOOTHING.get())
432 ),
433 format!(
434 "RMS over the {} ms after the onset lies more than {} dB below the loudest \
435 {} ms frame of the stem",
436 ms(after.1 - after.0),
437 num(ACTIVE_GATE_DB.get()),
438 ms(QUIET_FRAME.get())
439 ),
440 format!(
441 "lies more than {} dB below the loudest {} ms frame of that band",
442 num(DROP_BAND_GATE.get()),
443 ms(QUIET_FRAME.get())
444 ),
445 format!(
446 "a drop with tau under {} ms needs an envelope that takes {} ms or less from \
447 {} to {} % of its peak",
448 ms(FAST_TAU.get()),
449 ms(STRIKE_ATTACK.get()),
450 num(100.0 * ATTACK_FROM),
451 num(100.0 * ATTACK_TO)
452 ),
453 format!("one that starts at {} Hz or above", num(LINE_HI.get())),
454 format!(
455 "the {} ms after the onset {} or more above the {} ms that end {} ms before it",
456 ms(after.1 - after.0),
457 ratio_db(STRIKE_RISE),
458 ms(before.1 - before.0),
459 ms(-before.1)
460 ),
461 format!(
462 "half a period of f_end when tau is {} ms or more and over a full period \
463 when tau spans {} periods or more",
464 ms(FAST_TAU.get()),
465 num(FULL_PERIODS)
466 ),
467 format!(
468 "df_st is above {} and at most {}, that fall is above {} semitones too",
469 num(DROP_MIN.get()),
470 num(DROP_MAX.get()),
471 num(DROP_MIN.get())
472 ),
473 format!("residual is below {}", num(DROP_MAX_RESIDUAL)),
474 format!(
475 "within {} cents of a note that starts {} ms or less before the onset",
476 num(DROP_NOTE_MATCH.get()),
477 num(1000.0 * DROP_NOTE_LEAD.get())
478 ),
479 ],
480 );
481 }
482
483 #[test]
484 fn the_convention_states_the_note_line_constants() {
485 use super::super::line::LINE_HI;
486 use super::super::{FAST_TAU, NOTE_LINE_EDGE, NOTE_LINE_LOSS, STRIKE_RISE};
487 use super::super::{num, ratio_db, says};
488 let ms = |s: f64| num(1000.0 * s);
489 says(
490 DROP_CONVENTION,
491 &[
492 format!(
493 "a fit after a {} strike that is no drop is fitted again on the note line, \
494 whose low-pass sits at {} times the note's pitch, when that lies under {} Hz",
495 ratio_db(STRIKE_RISE),
496 num(NOTE_LINE_EDGE),
497 num(LINE_HI.get())
498 ),
499 format!(
500 "the refit counts with tau of {} ms or more and an RMS envelope over its \
501 fall, from {} ms to 5 tau, at most {} dB below",
502 ms(FAST_TAU.get()),
503 ms(DROP_FIT_START.get()),
504 num(NOTE_LINE_LOSS.get())
505 ),
506 format!(
507 "averaged over a centred {} periods of the note, is lowest at its first \
508 sample",
509 num(RISE_PERIODS)
510 ),
511 ],
512 );
513 }
514
515 #[test]
516 fn the_fit_recovers_an_exact_exponential() {
517 let points: Vec<(f64, f64)> = (0..400)
518 .map(|k| {
519 let t = 0.007 + cast::usize_to_f64(k) * 0.001;
520 (t, 41.2 + 41.2 * (-t / 0.02).exp())
521 })
522 .collect();
523 let (a, b, sse) = solve(&points, 0.02);
524 assert!((a - 41.2).abs() < 1e-9 && (b - 41.2).abs() < 1e-9 && sse < 1e-18);
525 }
526
527 #[test]
528 fn a_constant_line_has_no_drop() {
529 let rate = SampleRate::new(22_050).unwrap();
530 let x: Vec<f64> = (0..22_050)
531 .map(|n| (std::f64::consts::TAU * 55.0 * cast::usize_to_f64(n) / 22_050.0).sin())
532 .collect();
533 let line = SubBandLine::of(&x, rate, Seconds::new(0.0).unwrap()).unwrap();
534 let fit = fit_drop(
535 &line,
536 Seconds::new(0.3).unwrap(),
537 Seconds::new(0.9).unwrap(),
538 )
539 .unwrap();
540 assert!((fit.f_end.get() - 55.0).abs() < 0.05, "{fit:?}");
541 assert!(!fit.accepted(), "{fit:?}");
542 }
543
544 #[test]
545 fn the_drop_in_semitones_follows_its_definition() {
546 let fit = DropFit {
547 onset: Seconds::new(0.0).unwrap(),
548 f_end: Hz::new(41.2).unwrap(),
549 df_hz: 41.2,
550 tau: Seconds::new(0.02).unwrap(),
551 residual: 0.05,
552 points: 100,
553 };
554 assert!((fit.df().unwrap().get() - 12.0).abs() < 1e-12);
555 assert!(fit.accepted());
556 let fast = DropFit {
558 tau: Seconds::new(0.002).unwrap(),
559 ..fit
560 };
561 assert!(fast.df().unwrap() > DROP_MIN && fast.visible().unwrap() < DROP_MIN);
562 assert!(!fast.accepted());
563 let bound = DropFit {
565 tau: DROP_TAU_MIN,
566 df_hz: 41.2 * 3.0,
567 ..fit
568 };
569 assert!(bound.visible().unwrap() > DROP_MIN && !bound.accepted());
570 assert!(
571 DropFit {
572 tau: Seconds::new(1.02 * DROP_TAU_MIN.get()).unwrap(),
573 ..bound
574 }
575 .accepted()
576 );
577 assert!(((-DROP_FIT_START.get() / DROP_TAU_MIN.get()).exp() - 0.1).abs() < 1e-12);
578 let octaves = |n: f64| DropFit {
580 df_hz: 41.2 * (2f64.powf(n) - 1.0),
581 ..fit
582 };
583 assert!(octaves(3.0).accepted() && !octaves(3.01).accepted());
584 assert!(
585 !DropFit {
586 residual: 0.15,
587 ..fit
588 }
589 .accepted()
590 );
591 assert!(
592 !DropFit {
593 df_hz: 41.2 * (2f64.powf(1.99 / 12.0) - 1.0),
594 ..fit
595 }
596 .accepted()
597 );
598 assert_eq!(
599 DropFit {
600 df_hz: -50.0,
601 ..fit
602 }
603 .df(),
604 None
605 );
606 }
607}