1mod adsr;
13mod knee;
14mod pump;
15mod tail;
16mod track;
17
18pub use adsr::{
19 ATTACK_PERIODS_MIN, ATTACK_TOP, BAND_STEP_RISE, BAND_STEPS_MIN, DECAY_FIRST_DB, DECAY_SHARES,
20 FIT_FLOOR, FREE_GAP, HOLD_MIN, KNEE_END_GAP, NoteEnvelope, NoteSkip, OBSERVABLE_DECAYS,
21 PROXY_HARMONICS, RAMP_FACTOR, SILENCE_DB, SUSTAIN_SHARE, TRACK_LEAD, amp_proxy, read_note,
22};
23pub use knee::{
24 KNEE_BACK, KNEE_BEND, KNEE_BEND_LAG, KNEE_FALL, KNEE_FLOOR, KNEE_GAIN, KNEE_LEFT, KNEE_RIGHT,
25 KNEE_RISE, KNEE_RISE_FRAMES, KNEE_SIDE_MIN, KNEE_STEEPEN, find_knee,
26};
27pub use pump::{
28 DUCK_TAIL_BINS, DUCK_TAIL_WINDOWS, FALL_MAX, FOLD_ERROR_GAIN, FOLD_ERROR_SHARE, FREE_RATE_MIN,
29 FREE_REACH, FREE_RESIDUAL, FREE_SWING, PERIOD_RANGE, PERIOD_RUN_BEATS, PHASE_DEPTH,
30 PHASE_SHARE, PHASE_SLACK, PUMP_BACK, PUMP_BEATS_MIN, PUMP_BINS, PUMP_MIN, PUMP_REFERENCE,
31 PUMP_STRIP_BEATS, Pump, PumpLock, SUSTAIN_LEAD,
32};
33use tail::read_votes;
34pub use tail::{
35 FLATNESS_RISE, GAP_LEVEL, HIGH_BAND_FALL, SAME_RATE_SPREAD, SIDE_GROWTH, TAIL_BAND_PERIODS,
36 TAIL_FLOOR, TAIL_HOLD_BEND, TAIL_HOLD_FRAMES, TAIL_HOLD_LAG, TAIL_RELEASE_RANGE,
37 TAIL_RESIDUAL_MAX, TAIL_RT60_RANGE, TAIL_SMOOTH, TAIL_WEIGHT_MAX, TAIL_WEIGHT_MIN,
38 TAIL_WEIGHT_RANGE, TAIL_WOBBLE_RESIDUAL, TailFit, TailVotes, fit_tail,
39};
40pub use track::{
41 HARMONIC_CUTOFF_MAX, HARMONIC_CUTOFF_MIN, HOP, LevelTrack, PUMP_RMS_PERIODS, RMS_PERIODS,
42 RMS_WINDOW_MIN, rms_window,
43};
44
45use crate::audio::{BeatGrid, SampleRate};
46use crate::cast;
47use crate::confidence::{
48 Confidence, ConfidenceError, Estimate, Level, NoSpread, Reason, ReasonCode, Signal,
49};
50use crate::dsp::{FilterError, Stats, Summary, quantile};
51use crate::notes::{NO_KNEE_CUT, Note, NoteList};
52use crate::units::{Decibels, Hz, MidiPitch, Ratio, Seconds};
53
54pub const POOL_NOTES_MIN: usize = 3;
56pub const RELATIVE_SPREAD_MAX: f64 = 0.5;
58pub const POOL_LENGTH_FACTOR: f64 = 2.0;
61pub const POOL_PITCH_RANGE: f64 = 12.0;
63pub const POOL_LEVEL_GATE: Decibels = Decibels::from_const(12.0);
67pub const SPREAD_QUANTILES: (f64, f64) = (0.1, 0.9);
70
71pub const ENVELOPE_CONVENTION: &str = "each note is read on a 1 ms hop from 30 ms before its onset \
73in the note list to its end; the amp proxy is the note's lowest harmonic among the first three \
74that lies 30 Hz or more from every harmonic of the notes sounding with it: for a note that \
75overlaps no other, a sliding DFT over one period of the fundamental, else the zero-phase band \
76magnitude, low-passed at half the distance to the nearest foreign or own neighbouring harmonic, 12 \
77to 40 Hz; levels are dB from the note's peak of the amp proxy, read no lower than -60 dB; attack_s \
78is 1.25 times the 10 to 90 % rise of the linear amplitude to the attack's top, the full 0 to 100 % \
79time of a linear ramp, read on the centred RMS over one period of the fundamental, or the fewest \
80whole periods that span 2 ms, for a note that overlaps no other, else on the amp proxy; the top is \
81the first frame from the onset that the level exceeds by less than 1 dB over as long again as it \
82took to rise to it from under 10 % of it, and at least over the shortest rise the track can tell, \
83the upper bound's figure below, and the shares are of the highest frame over that span; \
84the attack is an upper bound \
85when the rise is shorter than 1.5 RMS windows, or on the amp proxy shorter than 0.98 over its \
86low-pass cutoff, twice its rise for an instant step; hold_s is the time from the first frame \
87within 1 dB of the peak to the start of the decay, 0 when under 10 ms; decay_s is the time between \
88the first frames after the peak, linear between frames, at which the level falls through 10 % and \
8990 % of the sustain level in dB, the first at least 0.5 dB down, divided by the gap between the \
90two shares, and the decay starts the first share of it before the first; sustain_db is the median \
91of the last 30 % of the held part, from the first frame within 1 dB of the peak to the note-off \
92knee, or to 25 ms before the note's end without one; decay and sustain are null when a note with a \
93knee holds for less than the attack, the hold and three decay times, and a note without one \
94sustains at its end level; fit_residual_db is the median over the pool of the RMS in dB of each \
95held part against 0 dB over the hold, a fall linear in dB to the sustain level over the decay, \
96then the sustain level; ripple_db is the median, over the pooled notes whose sustain is \
97observable, of the 90th minus the 10th percentile of the sustain window after its line; each \
98statistic is the median and interquartile range over the pooled notes: of the notes whose peak lies \
99less than 12 dB below the peak of every note they overlap, those whose length to the knee, or to \
100the end without one, lies within a factor of 2 of their median length and whose pitch lies within \
10112 semitones of their median pitch; a value over fewer than 3 notes, with an interquartile range \
102above half its median, or with a median of 0 under an interquartile range above 0, has low \
103confidence; attack_is_upper_bound is set when more than half the pooled attacks are upper bounds; \
104a note whose amp proxy peaks at -120 dB or lower is skipped as silent";
105pub const KNEE_CONVENTION: &str = "the note-off knee is read on the linear amplitude of the amp \
107proxy, from the first frame within 1 dB of the peak to 25 ms before the note's end; at each split \
108a line over up to 100 ms before it and one over up to 200 ms after it, stopping where the level \
109falls under 2 % of the mean before it, fit against one line over both; a split counts when the \
110right line is steeper by half the mean before it per second, falls by half that mean over its \
111side, and the two lines leave at most half the squared error of one; splits that count in a row \
112form a corner, and the split with the largest gain wins it; a later corner replaces an earlier one \
113when the median size of the second difference of the level in dB between them, 10 ms each way, is \
114at most 0.4 dB and the level after it rises by at most a twentieth of the mean before it, read as \
115is over its first 20 ms and as the mean over every 20 ms span, up to what follows the note; a later \
116corner past what follows the note replaces none when the mean over the last 10 ms before that point \
117lies under 2 % of the earlier corner's mean before it, the mean before the later corner lies at \
118least 1.25 times that level and the left line's end lies above it, on the amp proxy or, when the \
119note is stripped, on the proxy before the pump's strip; the knee is where the left line crosses a line over the first 10 ms after the split, or the split itself \
120when that crossing lies outside the two lines; when the level stays at 85 % or more of the left \
121line's end for over 10 ms more, the knee is where a line over its fall from there to half that \
122level crosses that level; null when no split counts, as for a pluck that decays at one rate; \
123note_off_offset_s is the note's end in the note list, or the stem's end when that comes first, minus its \
124knee, over the pooled notes with a knee";
125pub const TAIL_CONVENTION: &str = "after the note-off knee, relative to the median level over the \
12750 ms before it, or since the note's onset when that is nearer, the level follows max(1 - t / release_s, 0) + tail_weight * 10^(-3 t / \
128tail_rt60_s), fitted in dB by least squares to -55 dB under that level, from 4 x 3 starts, up to \
129the note's end or the next note that overlaps it, on the one-period RMS, or on the amp proxy's \
130band for a note that overlaps another; each is a statistic over the pooled notes with a fit, \
131tail_rt60_s over those of a tail_weight of 0.05 or more read over a fall of 15 dB of it, leaving \
132out a note heavier than 0.5 at a residual of at most 1.1 dB or under 6 periods of the narrowest \
133band's cutoff on a note that overlaps another, and null when the pool's medians do the same or its \
134median tail_weight is under 0.05; release_s is null, with \
135release_confidence low, when the median tail_weight is over 0.5 or the median residual is over \
1363 dB; release_confidence reads low when the median residual is over 2 dB and the median second \
137difference of the level in dB over the 300 ms before the knee, 25 ms each way, is over 0.3 dB; \
138release_confidence is null when no pooled note has a fit; tail_votes hold null where \
139nothing could be read, and tail_is_reverb stays false until a space layer exists";
140pub const LEVEL_SPREAD_CONVENTION: &str = "the 90th minus the 10th percentile of the full-band \
142level of the pooled notes, quantiles by linear interpolation; a note's full-band level is the peak \
143of the centred RMS of the mono mix in dB of full-scale power over one period of the fundamental, \
144or the fewest whole periods that span 2 ms, on a 1 ms hop from its onset to its note-off knee, or \
145to 50 ms before its end in the note list without one, and on a mono line, one whose voices layer \
146reads at most one voice at a time, never past the next later onset; always low confidence, a rule no stem has tested; null under 3 pooled notes with a level";
147pub const PUMP_CONVENTION: &str = "the full-band centred RMS in dB over two periods of the lowest \
149note on a 1 ms hop, cut into beats of the used tempo from 0 s; a beat counts when the held part of \
150one note covers it, from 50 ms or more before it, the held part running from the note's onset to \
151its knee, or to 25 ms before its end without one; each counted beat is binned into 64 phase bins \
152and taken relative to the 90th percentile of its bins, and the curve is the median over the beats; \
153pump_depth_db is minus the curve's minimum; pump_recovery_s is the time from the minimum until the \
154curve comes back within 1.5 dB, going on over the beat's wrap, null when it does not within one \
155beat; pump_tau_ms is pump_recovery_s over ln(pump_depth_db / 1.5), in milliseconds, null unless \
156the pump locks to the beat; pump_locked_to is note when the beats with a note onset inside, from \
15750 ms before the beat, dip 3 dB or more and the others under 1.5 dB, each group of 2 beats or more; \
158else free when the level swings 1 dB or more at a rate of 0.5 to 20 Hz that lies 50 cents or more \
159from every note division; else beat when the curve dips 3 dB or more in one run of bins half its \
160depth down, falls into its dip within 8 bins, reading on into the beat before, the odd and the even \
161beats' own median curves do the same to half its depth at its dip, and half the beats fall by half \
162its depth from their highest bin over the 8 bins before the 3 bins either side of the dip to their \
163lowest within those; else none; the read at the period up to 2.81 % from the tempo's, \
164by the longest run of counted beats, whose counted beats fold onto the deepest mean curve replaces \
165this read if that reads beat and this one reads note, or reads beat and, when both strip \
166or neither does, at a fold error, the standard deviation of the level minus the curve over the \
167counted beats, each beat about its own mean, over 1 / 0.9 of that read's and 0.15 dB higher, or, \
168when only one of the reads strips, that read strips and has the lower fold error, or reads free on a line within 60 cents of a division \
169under a curve that falls faster than it recovers and takes 3 bins and half the level window from \
170half its depth back to a quarter, or reads none under a curve that falls faster than it recovers \
171and takes 3 bins from half its depth back to a quarter; \
172the curve is stripped from the tracks of a note only when it locks to the beat over 8 counted beats \
173or more and the median fall of the beats over the note reaches half the curve's depth, the beats \
174counted again over each note to 25 ms before its end when the held parts give fewer \
175or no pump; null with fewer than 4 counted beats";
176
177#[derive(Clone, Copy, Debug)]
179pub struct EnvelopeInput<'a> {
180 pub mono: &'a [f64],
182 pub rate: SampleRate,
184 pub notes: &'a NoteList,
186 pub grid: BeatGrid,
188 pub stereo: Option<(&'a [f64], &'a [f64])>,
190}
191
192#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
194pub struct EnvelopeFlags {
195 pub no_notes: bool,
197 pub attack_is_upper_bound: bool,
201 pub pump_stripped: bool,
205}
206
207#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
209pub struct EnvelopeCounts {
210 pub read: usize,
212 pub no_free_harmonic: usize,
214 pub too_short: usize,
216 pub silent: usize,
218 pub knees: usize,
220}
221
222#[derive(Clone, Debug, PartialEq)]
224pub struct EnvelopeLayer {
225 pub notes: Vec<NoteEnvelope>,
227 pub pooled: Vec<bool>,
229 pub attack: Estimate<Stats<Seconds>, Seconds>,
231 pub hold: Estimate<Stats<Seconds>, Seconds>,
233 pub decay: Estimate<Stats<Seconds>, Seconds>,
235 pub sustain: Estimate<Stats<Decibels>, Decibels>,
237 pub note_off_offset: Estimate<Stats<Seconds>, Seconds>,
240 pub release: Estimate<Stats<Seconds>, Seconds>,
242 pub tail_rt60: Estimate<Stats<Seconds>, Seconds>,
244 pub tail_weight: Estimate<Stats<Ratio>, Ratio>,
246 pub release_confidence: Option<Level>,
249 pub tail_votes: TailVotes,
251 pub level_spread: Estimate<Decibels, NoSpread>,
255 pub levels: Vec<Option<Decibels>>,
260 pub ripple: Option<Decibels>,
262 pub residual: Option<Decibels>,
264 pub pump: Option<Pump>,
267 pub overlap_fraction: f64,
269 pub flags: EnvelopeFlags,
271 pub confidence: Confidence<NoSpread>,
273 pub counts: EnvelopeCounts,
275 pub silent: Vec<(Seconds, MidiPitch)>,
278}
279
280impl EnvelopeLayer {
281 pub fn of(input: &EnvelopeInput<'_>) -> Result<Self, EnvelopeError> {
318 let list = input.notes.clone().with_overlaps();
319 let notes = list.notes();
320 let mut counts = EnvelopeCounts::default();
321 let mut flags = EnvelopeFlags {
322 no_notes: notes.is_empty(),
323 ..EnvelopeFlags::default()
324 };
325 let mut silent = Vec::new();
326 let (read, pump) = read_with_pump(input, notes, &mut counts, &mut silent)?;
327 flags.pump_stripped = pump.as_ref().is_some_and(Pump::strips);
328 counts.read = read.len();
329 counts.knees = read.iter().filter(|n| n.knee.is_some()).count();
330 let pooled = pool(&read);
331 let pool: Vec<&NoteEnvelope> = read
332 .iter()
333 .zip(&pooled)
334 .filter(|&(_, &p)| p)
335 .map(|(n, _)| n)
336 .collect();
337 let bounds = pool.iter().filter(|n| n.attack_upper_bound).count();
338 let bound_share = share(bounds, pool.iter().filter(|n| n.attack.is_some()).count());
339 flags.attack_is_upper_bound = bound_share > 0.5;
340 let overlap_fraction = share(
341 list.notes()
342 .iter()
343 .filter(|n| n.overlap() == Some(true))
344 .count(),
345 notes.len(),
346 );
347 let fits: Vec<TailFit> = pool.iter().filter_map(|n| n.tail).collect();
348 let (release, release_confidence) = release_of(&fits)?;
349 let tail_votes = read_votes(input.mono, input.stereo, input.rate, notes, &pool);
350 let confidence = if read.is_empty() {
351 Confidence::no_estimate(vec![if notes.is_empty() {
352 Reason::new(ReasonCode::NoNoteList, Signal::Flag(true))?
353 } else {
354 Reason::new(ReasonCode::TooFewNotes, Signal::Count(0))?
355 }])?
356 } else {
357 Confidence::estimate(
358 if pool.len() < POOL_NOTES_MIN {
359 Level::Low
360 } else {
361 Level::Medium
362 },
363 None,
364 vec![Reason::new(
365 ReasonCode::TooFewNotes,
366 Signal::Count(count(pool.len())),
367 )?],
368 )?
369 };
370 Ok(Self {
371 attack: statistic(
372 pool.iter().filter_map(|n| n.attack).map(Seconds::get),
373 Seconds::clamped,
374 Some(Reason::new(
375 ReasonCode::UpperBoundShare,
376 Signal::Number(bound_share),
377 )?),
378 )?,
379 hold: statistic(pool.iter().map(|n| n.hold.get()), Seconds::clamped, None)?,
380 decay: statistic(
381 pool.iter().filter_map(|n| n.decay).map(Seconds::get),
382 Seconds::clamped,
383 None,
384 )?,
385 sustain: statistic(
386 pool.iter().filter_map(|n| n.sustain).map(Decibels::get),
387 Decibels::clamped,
388 None,
389 )?,
390 note_off_offset: statistic(
391 pool.iter()
392 .filter_map(|n| n.knee.map(|k| n.end.get() - k.get())),
393 Seconds::clamped,
394 None,
395 )?,
396 release,
397 tail_rt60: tail_rt60_of(&fits)?,
398 tail_weight: statistic(fits.iter().map(|f| f.weight), Ratio::clamped, None)?,
399 release_confidence,
400 tail_votes,
401 level_spread: level_spread(&[])?,
402 levels: Vec::new(),
403 ripple: pool_median(&pool, |n| n.ripple),
404 residual: pool_median(&pool, |n| n.residual),
405 pump,
406 overlap_fraction,
407 flags,
408 confidence,
409 counts,
410 pooled,
411 notes: read,
412 silent,
413 })
414 }
415
416 pub fn read_levels(
427 &mut self,
428 input: &EnvelopeInput<'_>,
429 mono_line: bool,
430 ) -> Result<(), EnvelopeError> {
431 let notes = input.notes.notes();
432 self.levels = full_band_levels(input, notes, &self.notes, mono_line);
433 let pool: Vec<&NoteEnvelope> = self
434 .notes
435 .iter()
436 .zip(&self.pooled)
437 .filter(|&(_, &p)| p)
438 .map(|(n, _)| n)
439 .collect();
440 self.level_spread = level_spread(&pooled_levels(notes, &self.levels, &pool))?;
441 Ok(())
442 }
443
444 #[must_use]
446 pub fn summary(&self) -> Vec<String> {
447 if self.flags.no_notes {
448 return vec!["envelope: no notes, so no envelope".to_owned()];
449 }
450 let stat = |name: &str, e: &Estimate<Stats<Seconds>, Seconds>| {
451 e.value().map_or_else(
452 || format!("{name} none"),
453 |s| format!("{name} {:.0} ms", 1000.0 * s.median().get()),
454 )
455 };
456 let total = self.counts.read
457 + self.counts.no_free_harmonic
458 + self.counts.too_short
459 + self.counts.silent;
460 let mut lines = vec![format!(
461 "envelope: {} of {total} notes read, {} pooled, {} with a note-off knee{}",
462 self.counts.read,
463 self.pooled.iter().filter(|&&p| p).count(),
464 self.counts.knees,
465 skipped(self.counts),
466 )];
467 lines.push(format!(
468 "envelope: {}{}, {}, {}, sustain {}",
469 stat("attack", &self.attack),
470 if self.flags.attack_is_upper_bound {
471 " (upper bound)"
472 } else {
473 ""
474 },
475 stat("hold", &self.hold),
476 stat("decay", &self.decay),
477 self.sustain.value().map_or_else(
478 || "none".to_owned(),
479 |s| format!("{:.1} dB", (10.0 * s.median().get()).round() / 10.0 + 0.0)
481 ),
482 ));
483 lines.push(format!(
484 "tail: {}, {}, weight {}, {} of 5 reverb votes",
485 stat("release", &self.release),
486 self.tail_rt60.value().map_or_else(
487 || "rt60 none".to_owned(),
488 |s| format!("rt60 {:.2} s", s.median().get())
489 ),
490 self.tail_weight
491 .value()
492 .map_or_else(|| "none".to_owned(), |s| format!("{:.2}", s.median().get())),
493 self.tail_votes.count(),
494 ));
495 lines.push(match &self.pump {
496 Some(p) => format!(
497 "pump: {:.1} dB over {} beats, {}{}",
498 p.depth.get(),
499 p.beats,
500 lock_name(p.lock),
501 if p.strips() { ", stripped" } else { "" }
502 ),
503 None => "pump: too few sustained beats".to_owned(),
504 });
505 lines
506 }
507}
508
509fn read_with_pump(
516 input: &EnvelopeInput<'_>,
517 notes: &[Note],
518 counts: &mut EnvelopeCounts,
519 silent: &mut Vec<(Seconds, MidiPitch)>,
520) -> Result<(Vec<NoteEnvelope>, Option<Pump>), FilterError> {
521 let first = read_notes(input, notes, None, (&mut *counts, &mut *silent))?;
522 let mut knees = vec![None; notes.len()];
523 for (i, e) in &first {
524 knees[*i] = e.knee;
525 }
526 let spans: Vec<(f64, f64)> = notes
527 .iter()
528 .map(|n| (n.onset().get(), n.offset().get() - KNEE_END_GAP.get()))
529 .collect();
530 let held: Vec<(f64, f64)> = spans
531 .iter()
532 .zip(&knees)
533 .map(|(&(on, end), knee)| (on, knee.map_or(end, Seconds::get)))
534 .collect();
535 let track = pump_track(input);
536 let find = |parts: &[(f64, f64)]| {
537 track
538 .as_ref()
539 .and_then(|t| Pump::find(t, input.grid, input.notes, parts))
540 };
541 let pump = match find(&held) {
544 Some(p) if p.lock != PumpLock::Beat || p.strips() => Some(p),
545 short => find(&spans).filter(Pump::strips).or(short),
546 };
547 let read = if pump.as_ref().is_some_and(Pump::strips) {
548 *counts = EnvelopeCounts::default();
549 read_notes(input, notes, pump.as_ref(), (counts, silent))?
550 } else {
551 first
552 };
553 Ok((read.into_iter().map(|(_, e)| e).collect(), pump))
554}
555
556fn read_notes(
559 input: &EnvelopeInput<'_>,
560 notes: &[Note],
561 pump: Option<&Pump>,
562 (counts, silent): (&mut EnvelopeCounts, &mut Vec<(Seconds, MidiPitch)>),
563) -> Result<Vec<(usize, NoteEnvelope)>, FilterError> {
564 let mut read = Vec::new();
565 silent.clear();
566 for (i, note) in notes.iter().enumerate() {
567 let others: Vec<&Note> = notes
568 .iter()
569 .enumerate()
570 .filter(|&(j, other)| {
571 j != i && other.onset() < note.offset() && note.onset() < other.offset()
572 })
573 .map(|(_, other)| other)
574 .collect();
575 match read_note(input.mono, input.rate, note, &others, pump)? {
576 Ok(envelope) => read.push((i, envelope)),
577 Err(NoteSkip::NoFreeHarmonic) => counts.no_free_harmonic += 1,
578 Err(NoteSkip::TooShort) => counts.too_short += 1,
579 Err(NoteSkip::Silent) => {
580 counts.silent += 1;
581 silent.push((note.onset(), note.pitch()));
582 }
583 }
584 }
585 Ok(read)
586}
587
588fn pool_median(
590 pool: &[&NoteEnvelope],
591 field: impl Fn(&NoteEnvelope) -> Option<Decibels>,
592) -> Option<Decibels> {
593 let values: Vec<f64> = pool
594 .iter()
595 .filter_map(|n| field(n))
596 .map(Decibels::get)
597 .collect();
598 median(&values).map(Decibels::clamped)
599}
600
601fn tail_rt60_of(fits: &[TailFit]) -> Result<Estimate<Stats<Seconds>, Seconds>, ConfidenceError> {
605 let of = |value: fn(&TailFit) -> f64| median(&fits.iter().map(value).collect::<Vec<f64>>());
606 let weight = of(|f| f.weight);
607 let residual = of(|f| f.residual.get());
608 let hidden = if let Some(weight) = weight.filter(|w| *w < TAIL_WEIGHT_MIN) {
609 Some((ReasonCode::TailLight, weight))
610 } else if let Some(residual) =
611 residual.filter(|r| *r <= TAIL_SMOOTH.get() && weight.is_some_and(|w| w > TAIL_WEIGHT_MAX))
612 {
613 Some((ReasonCode::TailSmooth, residual))
614 } else {
615 let showing: Vec<f64> = fits
616 .iter()
617 .filter(|f| f.weight >= TAIL_WEIGHT_MIN && f.rt60_observed)
618 .map(|f| f.rt60.get() - f.rt60_floor.get())
619 .collect();
620 median(&showing)
621 .filter(|margin| *margin < 0.0)
622 .map(|margin| (ReasonCode::TailBand, margin))
623 };
624 match hidden {
625 Some((code, value)) => Estimate::none(vec![Reason::new(code, Signal::Number(value))?]),
626 None => statistic(
627 fits.iter().filter(|f| f.reads_rt60()).map(|f| f.rt60.get()),
628 Seconds::clamped,
629 None,
630 ),
631 }
632}
633
634type SecondsStat = Estimate<Stats<Seconds>, Seconds>;
636
637fn release_of(fits: &[TailFit]) -> Result<(SecondsStat, Option<Level>), ConfidenceError> {
640 let weights: Vec<f64> = fits.iter().map(|f| f.weight).collect();
641 let residuals: Vec<f64> = fits.iter().map(|f| f.residual.get()).collect();
642 let (Some(weight), Some(residual)) = (median(&weights), median(&residuals)) else {
643 return Ok((
644 Estimate::none(vec![Reason::new(
645 ReasonCode::TooFewNotes,
646 Signal::Count(0),
647 )?])?,
648 None,
649 ));
650 };
651 let hidden = if weight > TAIL_WEIGHT_MAX {
652 Some(Reason::new(ReasonCode::TailWeight, Signal::Number(weight))?)
653 } else if residual > TAIL_RESIDUAL_MAX.get() {
654 Some(Reason::new(
655 ReasonCode::TailResidual,
656 Signal::Number(residual),
657 )?)
658 } else {
659 None
660 };
661 if let Some(reason) = hidden {
662 return Ok((Estimate::none(vec![reason])?, Some(Level::Low)));
663 }
664 let bends: Vec<f64> = fits.iter().map(|f| f.hold_bend.get()).collect();
665 let wobble = median(&bends)
666 .filter(|b| *b > TAIL_HOLD_BEND.get() && residual > TAIL_WOBBLE_RESIDUAL.get())
667 .map(|b| Reason::new(ReasonCode::TailWobble, Signal::Number(b)))
668 .transpose()?;
669 let release = statistic_at(
670 wobble.is_some(),
671 fits.iter().map(|f| f.release.get()),
672 Seconds::clamped,
673 wobble,
674 )?;
675 let level = release.confidence().level();
676 Ok((release, level))
677}
678
679fn pump_track(input: &EnvelopeInput<'_>) -> Option<LevelTrack> {
682 let lowest = input
683 .notes
684 .notes()
685 .iter()
686 .map(Note::pitch)
687 .min_by(|a, b| a.get().total_cmp(&b.get()))?;
688 let end = cast::usize_to_f64(input.mono.len()) / input.rate.hz().get();
689 Some(LevelTrack::rms(
690 input.mono,
691 0,
692 input.rate,
693 (Seconds::clamped(0.0), Seconds::clamped(end)),
694 Hz::from(lowest),
695 PUMP_RMS_PERIODS,
696 ))
697}
698
699fn pool(notes: &[NoteEnvelope]) -> Vec<bool> {
705 let length = |n: &NoteEnvelope| n.knee.unwrap_or(n.end).get() - n.onset.get();
706 let pitch = |n: &NoteEnvelope| n.pitch.get();
707 let loud: Vec<bool> = notes
708 .iter()
709 .map(|n| {
710 !notes.iter().any(|o| {
711 o.onset < n.end
712 && n.onset < o.end
713 && o.peak_db.get() - n.peak_db.get() >= POOL_LEVEL_GATE.get()
714 })
715 })
716 .collect();
717 let kept = || notes.iter().zip(&loud).filter(|&(_, &l)| l).map(|(n, _)| n);
718 let lengths: Vec<f64> = kept().map(length).collect();
719 let pitches: Vec<f64> = kept().map(pitch).collect();
720 let (Some(l), Some(p)) = (median(&lengths), median(&pitches)) else {
721 return vec![false; notes.len()];
722 };
723 notes
724 .iter()
725 .zip(loud)
726 .map(|(n, loud)| {
727 let ratio = length(n) / l;
728 loud && (1.0 / POOL_LENGTH_FACTOR..=POOL_LENGTH_FACTOR).contains(&ratio)
729 && (pitch(n) - p).abs() <= POOL_PITCH_RANGE
730 })
731 .collect()
732}
733
734fn statistic<T: crate::confidence::Spread>(
739 values: impl Iterator<Item = f64>,
740 unit: impl Fn(f64) -> T + Copy,
741 extra: Option<Reason>,
742) -> Result<Estimate<Stats<T>, T>, ConfidenceError> {
743 statistic_at(false, values, unit, extra)
744}
745
746fn statistic_at<T: crate::confidence::Spread>(
748 low: bool,
749 values: impl Iterator<Item = f64>,
750 unit: impl Fn(f64) -> T + Copy,
751 extra: Option<Reason>,
752) -> Result<Estimate<Stats<T>, T>, ConfidenceError> {
753 let values: Vec<f64> = values.collect();
754 let n = Reason::new(ReasonCode::TooFewNotes, Signal::Count(count(values.len())))?;
755 let Some(summary) = Summary::of(&values) else {
756 return Estimate::none(vec![n]);
757 };
758 let (spread, wide) = if summary.median().abs() > 0.0 {
759 let relative = summary.iqr() / summary.median().abs();
760 (
761 Reason::new(ReasonCode::RelativeSpread, Signal::Number(relative))?,
762 relative > RELATIVE_SPREAD_MAX,
763 )
764 } else if summary.iqr() > 0.0 {
765 (
766 Reason::new(ReasonCode::ZeroMedian, Signal::Number(summary.iqr()))?,
767 true,
768 )
769 } else {
770 (
771 Reason::new(ReasonCode::RelativeSpread, Signal::Number(0.0))?,
772 false,
773 )
774 };
775 let level = if low || values.len() < POOL_NOTES_MIN || wide {
776 Level::Low
777 } else {
778 Level::Medium
779 };
780 let mut reasons = vec![n, spread];
781 reasons.extend(extra);
782 Estimate::new(
783 summary.to_stats(unit),
784 level,
785 Some(unit(summary.iqr())),
786 reasons,
787 )
788}
789
790fn level_spread(levels: &[f64]) -> Result<Estimate<Decibels, NoSpread>, ConfidenceError> {
794 let n = Reason::new(ReasonCode::TooFewNotes, Signal::Count(count(levels.len())))?;
795 if levels.len() < POOL_NOTES_MIN {
796 return Estimate::none(vec![n]);
797 }
798 let mut sorted = levels.to_vec();
799 sorted.sort_by(f64::total_cmp);
800 let (lo, hi) = SPREAD_QUANTILES;
801 let spread = quantile(&sorted, hi).unwrap_or(0.0) - quantile(&sorted, lo).unwrap_or(0.0);
802 Estimate::new(Decibels::clamped(spread), Level::Low, None, vec![n])
803}
804
805fn pooled_levels(notes: &[Note], levels: &[Option<Decibels>], pool: &[&NoteEnvelope]) -> Vec<f64> {
807 pool.iter()
808 .filter_map(|n| {
809 let i = notes
810 .iter()
811 .position(|l| l.onset() == n.onset && l.pitch() == n.pitch)?;
812 levels[i].map(Decibels::get)
813 })
814 .collect()
815}
816
817fn full_band_levels(
824 input: &EnvelopeInput<'_>,
825 notes: &[Note],
826 read: &[NoteEnvelope],
827 mono: bool,
828) -> Vec<Option<Decibels>> {
829 notes
830 .iter()
831 .map(|note| {
832 let knee = read
833 .iter()
834 .find(|n| n.onset == note.onset() && n.pitch == note.pitch())
835 .and_then(|n| n.knee);
836 let next = if mono {
837 notes
838 .iter()
839 .map(Note::onset)
840 .find(|&onset| onset > note.onset())
841 .map_or(f64::INFINITY, Seconds::get)
842 } else {
843 f64::INFINITY
844 };
845 let end = knee
846 .map_or(note.offset().get() - NO_KNEE_CUT.get(), Seconds::get)
847 .min(note.offset().get())
848 .min(next);
849 full_band_level(
850 input.mono,
851 input.rate,
852 (note.onset(), Seconds::clamped(end)),
853 Hz::from(note.pitch()),
854 )
855 })
856 .collect()
857}
858
859#[must_use]
862pub fn full_band_level(
863 mono: &[f64],
864 rate: SampleRate,
865 span: (Seconds, Seconds),
866 f0: Hz,
867) -> Option<Decibels> {
868 if span.1 <= span.0 {
869 return None;
870 }
871 full_band_track(mono, rate, span, f0)
872 .db()
873 .iter()
874 .copied()
875 .reduce(f64::max)
876 .map(Decibels::clamped)
877}
878
879#[must_use]
886pub fn full_band_track(
887 mono: &[f64],
888 rate: SampleRate,
889 (from, to): (Seconds, Seconds),
890 f0: Hz,
891) -> LevelTrack {
892 let fs = rate.hz().get();
893 let margin = rms_window(f0, RMS_PERIODS).get() * fs + 2.0;
894 let lo = cast::floor_to_usize((from.get() * fs - margin).max(0.0))
895 .unwrap_or(0)
896 .min(mono.len());
897 let hi = cast::ceil_to_usize(to.get() * fs + margin)
898 .unwrap_or(mono.len())
899 .clamp(lo, mono.len());
900 LevelTrack::rms(&mono[lo..hi], lo, rate, (from, to), f0, RMS_PERIODS)
901}
902
903fn median(values: &[f64]) -> Option<f64> {
904 Summary::of(values).map(Summary::median)
905}
906
907fn share(part: usize, whole: usize) -> f64 {
908 if whole == 0 {
909 0.0
910 } else {
911 cast::usize_to_f64(part) / cast::usize_to_f64(whole)
912 }
913}
914
915fn count(n: usize) -> u64 {
916 u64::try_from(n).unwrap_or(u64::MAX)
917}
918
919fn skipped(counts: EnvelopeCounts) -> String {
920 let items: Vec<String> = [
921 (counts.no_free_harmonic, "no free harmonic"),
922 (counts.too_short, "too short"),
923 (counts.silent, "silent"),
924 ]
925 .into_iter()
926 .filter(|(n, _)| *n > 0)
927 .map(|(n, why)| format!("{n} {why}"))
928 .collect();
929 if items.is_empty() {
930 String::new()
931 } else {
932 format!("; skipped {}", items.join(", "))
933 }
934}
935
936#[must_use]
938pub fn lock_name(lock: PumpLock) -> &'static str {
939 match lock {
940 PumpLock::Beat => "beat",
941 PumpLock::Note => "note",
942 PumpLock::Free => "free",
943 PumpLock::None => "none",
944 }
945}
946
947#[derive(Debug)]
949pub enum EnvelopeError {
950 Filter(FilterError),
952 Confidence(ConfidenceError),
954}
955
956impl std::fmt::Display for EnvelopeError {
957 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
958 match self {
959 Self::Filter(e) => write!(f, "cannot read the envelope layer: {e}"),
960 Self::Confidence(e) => write!(f, "cannot rate the envelope layer: {e}"),
961 }
962 }
963}
964
965impl std::error::Error for EnvelopeError {
966 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
967 match self {
968 Self::Filter(e) => Some(e),
969 Self::Confidence(e) => Some(e),
970 }
971 }
972}
973
974impl From<FilterError> for EnvelopeError {
975 fn from(e: FilterError) -> Self {
976 Self::Filter(e)
977 }
978}
979
980impl From<ConfidenceError> for EnvelopeError {
981 fn from(e: ConfidenceError) -> Self {
982 Self::Confidence(e)
983 }
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989
990 fn fit(release: f64, weight: f64, rt60: f64, residual: f64) -> TailFit {
991 TailFit {
992 release: Seconds::clamped(release),
993 weight,
994 rt60: Seconds::clamped(rt60),
995 rt60_observed: true,
996 residual: Decibels::clamped(residual),
997 rt60_floor: Seconds::clamped(0.0),
998 hold_bend: Decibels::clamped(0.0),
999 }
1000 }
1001
1002 #[test]
1003 fn a_release_is_written_up_to_half_a_weight_and_3_db() {
1004 for (weight, residual, written) in
1005 [(0.5, 3.0, true), (0.51, 3.0, false), (0.5, 3.01, false)]
1006 {
1007 let fits = [fit(0.05, weight, 1.0, residual); 3];
1008 let (release, level) = release_of(&fits).unwrap();
1009 assert_eq!(release.value().is_some(), written, "{weight} {residual}");
1010 let code = release.confidence().reasons()[0].code();
1011 if weight > 0.5 {
1012 assert_eq!(code, ReasonCode::TailWeight);
1013 } else if residual > 3.0 {
1014 assert_eq!(code, ReasonCode::TailResidual);
1015 }
1016 assert_eq!(
1017 level,
1018 Some(if written { Level::Medium } else { Level::Low })
1019 );
1020 }
1021 let (release, level) = release_of(&[]).unwrap();
1022 assert!(release.value().is_none());
1023 assert_eq!(level, None);
1024 }
1025
1026 fn hand_votes(tail_db: f64, side_db: f64) -> TailVotes {
1030 hand_votes_with(tail_db, side_db, &[])
1031 }
1032
1033 fn hand_votes_with(tail_db: f64, side_db: f64, more: &[(f64, f64)]) -> TailVotes {
1035 let rate = SampleRate::new(22_050).unwrap();
1036 let fs = rate.hz().get();
1037 let mut seed = 1u32;
1038 let (mut left, mut right) = (Vec::new(), Vec::new());
1039 for i in 0..33_075_u32 {
1040 let t = f64::from(i) / fs;
1041 seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
1042 let noise = f64::from(seed) / f64::from(u32::MAX) - 0.5;
1043 let (gain, side) = if t < 1.0 {
1044 (0.0, 0.0)
1045 } else {
1046 (tail_db, side_db)
1047 };
1048 let mid = 10f64.powf(gain / 20.0) * (std::f64::consts::TAU * 220.0 * t).sin();
1049 let side = 10f64.powf((gain + side - 30.0) / 20.0) * noise * 3.46;
1050 left.push(f64::midpoint(mid, side));
1051 right.push(f64::midpoint(mid, -side));
1052 }
1053 let mono: Vec<f64> = left
1054 .iter()
1055 .zip(&right)
1056 .map(|(a, b)| 0.5 * (a + b))
1057 .collect();
1058 let mut envelope = note(0.0, 1.5, 220.0, Some(1.0 / 1.5));
1059 envelope.tail = Some(fit(0.05, 0.2, 1.0, 1.0));
1060 let notes: Vec<Note> = [(0.0, 1.5), (1.5, 1.6)]
1061 .iter()
1062 .chain(more)
1063 .map(|&(on, off)| {
1064 let pitch = crate::units::MidiPitch::new(57.0).unwrap();
1065 Note::new(Seconds::clamped(on), Seconds::clamped(off), pitch).unwrap()
1066 })
1067 .collect();
1068 read_votes(&mono, Some((&left, &right)), rate, ¬es, &[&envelope])
1069 }
1070
1071 fn votes_on(next: f64, signal: impl Fn(f64, f64) -> (f64, f64)) -> TailVotes {
1075 let rate = SampleRate::new(22_050).unwrap();
1076 let fs = rate.hz().get();
1077 let mut seed = 1u32;
1078 let (mut left, mut right) = (Vec::new(), Vec::new());
1079 for i in 0..44_100_u32 {
1080 seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
1081 let noise = f64::from(seed) / f64::from(u32::MAX) - 0.5;
1082 let (mid, side) = signal(f64::from(i) / fs, noise * 3.46);
1083 left.push(f64::midpoint(mid, side));
1084 right.push(f64::midpoint(mid, -side));
1085 }
1086 let mono: Vec<f64> = left
1087 .iter()
1088 .zip(&right)
1089 .map(|(a, b)| 0.5 * (a + b))
1090 .collect();
1091 let mut envelope = note(0.0, next, 220.0, Some(1.0 / next));
1092 envelope.tail = Some(fit(0.05, 0.2, 1.0, 1.0));
1093 let pitch = crate::units::MidiPitch::new(57.0).unwrap();
1094 let notes: Vec<Note> = [(0.0, next), (next, next + 0.1)]
1095 .iter()
1096 .map(|&(on, off)| {
1097 Note::new(Seconds::clamped(on), Seconds::clamped(off), pitch).unwrap()
1098 })
1099 .collect();
1100 read_votes(&mono, Some((&left, &right)), rate, ¬es, &[&envelope])
1101 }
1102
1103 fn tone(hz: f64, t: f64) -> f64 {
1104 (std::f64::consts::TAU * hz * t).sin()
1105 }
1106
1107 #[test]
1108 fn the_flatness_vote_turns_on_at_a_rise_of_a_tenth() {
1109 let read = |noise: f64| {
1110 votes_on(1.5, |t, n| {
1111 (tone(220.0, t) + if t < 1.0 { 0.0 } else { noise * n }, 0.0)
1112 })
1113 .flatness_rises
1114 };
1115 assert_eq!(read(0.007), Some(false));
1117 assert_eq!(read(0.012), Some(true));
1118 }
1119
1120 #[test]
1121 fn the_high_band_vote_turns_on_at_a_fall_of_3_db() {
1122 let read = |fall_db: f64| {
1124 votes_on(1.5, |t, _| {
1125 let high = 0.1 * 10f64.powf(if t < 1.275 { 0.0 } else { -fall_db } / 20.0);
1126 (tone(220.0, t) + high * tone(2000.0, t), 0.0)
1127 })
1128 .high_band_faster
1129 };
1130 assert_eq!(read(2.5), Some(false));
1131 assert_eq!(read(3.5), Some(true));
1132 }
1133
1134 #[test]
1135 fn the_gap_needs_20_ms_past_the_release() {
1136 let read = |next| votes_on(next, |t, _| (tone(220.0, t), 0.0)).outlasts_gap;
1138 assert_eq!(read(1.065), None);
1139 assert!(read(1.08).is_some());
1140 }
1141
1142 #[test]
1143 fn the_same_rate_vote_turns_on_at_a_spread_of_a_quarter() {
1144 let read = |spread: f64| {
1145 let rate = SampleRate::new(22_050).unwrap();
1146 let notes: Vec<NoteEnvelope> = [1.0 - spread, 1.0, 1.0 + spread]
1147 .iter()
1148 .map(|&rt60| {
1149 let mut envelope = note(0.0, 1.0, 220.0, Some(0.5));
1150 envelope.tail = Some(fit(0.05, 0.2, rt60, 2.0));
1151 envelope
1152 })
1153 .collect();
1154 let pool: Vec<&NoteEnvelope> = notes.iter().collect();
1155 read_votes(&vec![0.0; 22_050], None, rate, &[], &pool).same_rate
1156 };
1157 assert_eq!(read(0.2), Some(true));
1158 assert_eq!(read(0.3), Some(false));
1159 }
1160
1161 #[test]
1162 fn a_note_struck_with_the_fitted_one_at_another_pitch_is_another_note() {
1163 let rate = SampleRate::new(22_050).unwrap();
1164 let mono: Vec<f64> = (0..33_075_u32)
1165 .map(|i| tone(220.0, f64::from(i) / rate.hz().get()))
1166 .collect();
1167 let mut fitted = note(0.0, 1.1, 220.0, Some(1.0 / 1.1));
1169 fitted.tail = Some(fit(0.05, 0.2, 1.0, 1.0));
1170 let list = [(0.0, 1.5, 60.0), (0.0, 1.1, 57.0), (1.5, 1.6, 57.0)];
1171 let with_rest = |listed: &[(f64, f64, f64)]| {
1172 let notes: Vec<Note> = listed
1173 .iter()
1174 .map(|&(on, off, pitch)| {
1175 let pitch = MidiPitch::new(pitch).unwrap();
1176 Note::new(Seconds::clamped(on), Seconds::clamped(off), pitch).unwrap()
1177 })
1178 .collect();
1179 read_votes(&mono, None, rate, ¬es, &[&fitted]).outlasts_gap
1180 };
1181 assert_eq!(with_rest(&list), None);
1182 assert!(with_rest(&[list[1], list[2]]).is_some());
1183 }
1184
1185 #[test]
1186 fn the_gap_vote_reads_from_40_db_under_the_body() {
1187 assert_eq!(hand_votes(-37.0, 0.0).outlasts_gap, Some(true));
1188 assert_eq!(hand_votes(-43.0, 0.0).outlasts_gap, Some(false));
1189 }
1190
1191 #[test]
1192 fn a_longer_note_of_the_same_onset_and_pitch_casts_no_gap_vote() {
1193 let votes = hand_votes_with(-30.0, 0.0, &[(0.0, 1.6)]);
1194 assert_eq!(votes.outlasts_gap, None);
1195 }
1196
1197 #[test]
1198 fn the_side_vote_reads_from_a_growth_of_3_db() {
1199 assert_eq!(hand_votes(-6.0, 3.5).side_grows, Some(true));
1200 assert_eq!(hand_votes(-6.0, 2.5).side_grows, Some(false));
1201 }
1202
1203 #[test]
1204 fn a_light_note_gives_no_rt60_to_the_pool() {
1205 let fits = [
1206 fit(0.05, 0.01, 9.0, 0.0),
1207 fit(0.05, 0.06, 1.0, 0.0),
1208 fit(0.05, 0.06, 2.0, 0.0),
1209 ];
1210 let rt60 = tail_rt60_of(&fits).unwrap();
1211 let median = rt60.value().map(|s| s.median().get());
1212 assert_eq!(median, Some(1.5));
1213 }
1214
1215 #[test]
1216 fn a_heavy_smooth_tail_reads_as_the_notes_own_release() {
1217 let read = |weight, residual| tail_rt60_of(&[fit(0.05, weight, 1.0, residual); 3]).unwrap();
1218 assert!(read(1.0, 1.1).value().is_none());
1219 assert!(read(1.0, 1.11).value().is_some());
1220 assert!(read(0.5, 0.5).value().is_some());
1221 }
1222
1223 #[test]
1224 fn a_tail_under_what_its_band_shows_has_no_rt60() {
1225 let banded = |rt60| TailFit {
1226 rt60_floor: Seconds::clamped(0.3),
1227 ..fit(0.05, 0.2, rt60, 2.0)
1228 };
1229 assert!(tail_rt60_of(&[banded(0.29); 3]).unwrap().value().is_none());
1230 assert!(tail_rt60_of(&[banded(0.3); 3]).unwrap().value().is_some());
1231 let mixed = [banded(0.2), banded(0.25), banded(0.9)];
1233 assert!(tail_rt60_of(&mixed).unwrap().value().is_none());
1234 }
1235
1236 #[test]
1237 fn a_wobble_under_a_poor_fit_lowers_a_written_release() {
1238 let read = |bend: f64, residual: f64| {
1239 let fits = [TailFit {
1240 hold_bend: Decibels::clamped(bend),
1241 ..fit(0.05, 0.2, 1.0, residual)
1242 }; 3];
1243 release_of(&fits).unwrap()
1244 };
1245 for (bend, residual, level) in [
1246 (0.31, 2.01, Level::Low),
1247 (0.29, 2.01, Level::Medium),
1248 (0.31, 1.99, Level::Medium),
1249 ] {
1250 let (release, read_level) = read(bend, residual);
1251 assert!(release.value().is_some());
1252 assert_eq!(read_level, Some(level), "{bend} {residual}");
1253 let wobble = release
1254 .confidence()
1255 .reasons()
1256 .iter()
1257 .any(|r| r.code() == ReasonCode::TailWobble);
1258 assert_eq!(wobble, level == Level::Low);
1259 }
1260 }
1261
1262 #[test]
1263 fn a_tail_rt60_reads_from_a_median_weight_of_a_twentieth() {
1264 let read = |weight| tail_rt60_of(&[fit(0.05, weight, 1.0, 0.0); 3]).unwrap();
1265 assert!(read(0.05).value().is_some());
1266 let light = read(0.049);
1267 assert!(light.value().is_none());
1268 assert_eq!(
1269 light.confidence().reasons()[0].code(),
1270 ReasonCode::TailLight
1271 );
1272 }
1273 use crate::units::MidiPitch;
1274 use proptest::prelude::*;
1275
1276 fn note(onset: f64, length: f64, f0: f64, knee: Option<f64>) -> NoteEnvelope {
1277 NoteEnvelope {
1278 onset: Seconds::clamped(onset),
1279 end: Seconds::clamped(onset + length),
1280 pitch: MidiPitch::try_from(Hz::clamped(f0)).unwrap(),
1281 peak_db: Decibels::clamped(-6.0),
1282 attack: None,
1283 attack_upper_bound: false,
1284 hold: Seconds::clamped(0.0),
1285 decay: None,
1286 sustain: None,
1287 knee: knee.map(|k| Seconds::clamped(onset + k * length)),
1288 ripple: None,
1289 residual: None,
1290 overlap: false,
1291 tail: None,
1292 }
1293 }
1294
1295 #[test]
1296 fn the_pool_keeps_notes_near_the_median_length_and_pitch() {
1297 let mut notes: Vec<NoteEnvelope> = (0..5)
1298 .map(|i| note(f64::from(i), 1.0, 110.0, None))
1299 .collect();
1300 notes.push(note(6.0, 1.0, 110.0 * 2f64.powf(11.0 / 12.0), None));
1302 notes.push(note(7.0, 0.6, 110.0, None));
1303 notes.push(note(8.0, 1.0, 440.0, None));
1305 notes.push(note(9.0, 3.0, 110.0, None));
1306 assert_eq!(
1307 pool(¬es),
1308 [true, true, true, true, true, true, true, false, false]
1309 );
1310 let mut late = notes[..5].to_vec();
1312 late.push(note(10.0, 3.0, 110.0, Some(0.33)));
1313 assert!(pool(&late)[5]);
1314 }
1315
1316 fn level_of(values: &[f64]) -> Option<Level> {
1317 let stat = statistic(values.iter().copied(), Seconds::clamped, None).unwrap();
1318 assert_eq!(
1319 stat.confidence().reasons()[0].value(),
1320 Signal::Count(u64::try_from(values.len()).unwrap())
1321 );
1322 stat.confidence().level()
1323 }
1324
1325 #[test]
1326 fn a_statistic_reads_low_on_few_notes_or_a_wide_spread() {
1327 assert_eq!(level_of(&[1.0, 1.0]), Some(Level::Low));
1328 assert_eq!(level_of(&[1.0, 1.0, 1.0]), Some(Level::Medium));
1329 assert_eq!(level_of(&[0.0; 8]), Some(Level::Medium));
1331 assert_eq!(
1332 level_of(&[0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0]),
1333 Some(Level::Low)
1334 );
1335 assert_eq!(level_of(&[3.0, 3.0, 4.0, 5.0, 5.0]), Some(Level::Medium));
1337 assert_eq!(level_of(&[3.0, 3.0, 4.0, 5.1, 5.1]), Some(Level::Low));
1338 }
1339
1340 #[test]
1341 fn the_level_gate_needs_a_louder_note_that_overlaps() {
1342 let peak = |mut n: NoteEnvelope, db: f64| {
1343 n.peak_db = Decibels::clamped(db);
1344 n
1345 };
1346 let touching: Vec<NoteEnvelope> = (0..3)
1348 .map(|i| {
1349 peak(
1350 note(f64::from(i), 1.0, 110.0, None),
1351 -6.0 - 20.0 * f64::from(i % 2),
1352 )
1353 })
1354 .collect();
1355 assert_eq!(pool(&touching), [true, true, true]);
1356 let overlapping = vec![
1357 peak(note(0.0, 1.5, 110.0, None), -6.0),
1358 peak(note(1.0, 1.5, 110.0, None), -26.0),
1359 peak(note(3.0, 1.5, 110.0, None), -6.0),
1360 ];
1361 assert_eq!(pool(&overlapping), [true, false, true]);
1362 let inside = vec![
1364 peak(note(0.0, 1.5, 110.0, None), -6.0),
1365 peak(note(1.0, 1.5, 110.0, None), -14.0),
1366 peak(note(3.0, 1.5, 110.0, None), -6.0),
1367 ];
1368 assert_eq!(pool(&inside), [true, true, true]);
1369 let quiet = |db: f64| {
1370 pool(&[
1371 peak(note(0.0, 1.5, 110.0, None), -6.0),
1372 peak(note(1.0, 1.5, 110.0, None), db),
1373 peak(note(3.0, 1.5, 110.0, None), -6.0),
1374 ])
1375 };
1376 assert_eq!(quiet(-18.0), [true, false, true], "exactly 12 dB down");
1377 assert_eq!(quiet(-17.9), [true, true, true]);
1378 }
1379
1380 #[test]
1381 fn a_median_of_0_under_a_spread_writes_the_spread_itself() {
1382 let values = [0.0, 0.0, 0.0, 0.0, 0.0, 0.02, 0.02, 0.02];
1385 let read = statistic(values.into_iter(), Seconds::clamped, None).unwrap();
1386 let reasons = read.confidence().reasons();
1387 assert_eq!(reasons[1].code(), ReasonCode::ZeroMedian);
1388 assert_eq!(reasons[1].value(), Signal::Number(0.02));
1389 assert!(
1390 reasons
1391 .iter()
1392 .all(|r| r.code() != ReasonCode::RelativeSpread)
1393 );
1394 }
1395
1396 #[test]
1397 fn the_full_band_track_reads_only_its_span_and_matches_the_whole_stem() {
1398 let rate = SampleRate::new(22_050).unwrap();
1399 let mono: Vec<f64> = (0..44_100_u32)
1401 .map(|n| {
1402 let x = f64::from(n);
1403 (x * 0.731).sin() * (x * 0.000_37).cos() + 0.3 * (x * 2.17).sin()
1404 })
1405 .collect();
1406 for (from, to, f0) in [(0.0, 0.3, 55.0), (0.5, 1.2, 220.0), (1.7, 2.0, 880.0)] {
1407 let span = (Seconds::clamped(from), Seconds::clamped(to));
1408 let whole = LevelTrack::rms(&mono, 0, rate, span, Hz::clamped(f0), RMS_PERIODS);
1409 let part = full_band_track(&mono, rate, span, Hz::clamped(f0));
1410 assert_eq!(whole.db().len(), part.db().len());
1411 for (a, b) in whole.db().iter().zip(part.db()) {
1412 assert!((a - b).abs() < 1e-9, "{from} s: {a} against {b}");
1413 }
1414 }
1415 assert_eq!(
1416 full_band_level(
1417 &mono,
1418 rate,
1419 (Seconds::clamped(1.0), Seconds::clamped(1.0)),
1420 Hz::clamped(110.0)
1421 ),
1422 None
1423 );
1424 }
1425
1426 #[test]
1427 fn the_level_spread_needs_3_pooled_notes() {
1428 let levels = [-6.0, -3.0, 0.0];
1429 assert!(level_spread(&levels).unwrap().value().is_some());
1430 assert!(level_spread(&levels[..2]).unwrap().value().is_none());
1431 }
1432
1433 #[test]
1434 fn each_lock_has_its_report_name() {
1435 assert_eq!(lock_name(PumpLock::Beat), "beat");
1436 assert_eq!(lock_name(PumpLock::Note), "note");
1437 assert_eq!(lock_name(PumpLock::Free), "free");
1438 assert_eq!(lock_name(PumpLock::None), "none");
1439 }
1440
1441 #[test]
1442 fn a_filter_error_names_the_layer_and_keeps_its_source() {
1443 use std::error::Error as _;
1444 let rate = SampleRate::new(22_050).unwrap();
1445 let filter =
1446 crate::dsp::band_magnitude(&[0.0; 64], rate, Hz::clamped(100.0), Hz::clamped(20_000.0))
1447 .unwrap_err();
1448 let error = EnvelopeError::from(filter);
1449 assert_eq!(
1450 error.to_string(),
1451 format!("cannot read the envelope layer: {filter}")
1452 );
1453 assert!(error.source().is_some());
1454 }
1455
1456 proptest! {
1457 #[test]
1458 fn the_pool_does_not_depend_on_note_order(
1459 notes in proptest::collection::vec(
1460 (0.0..10.0f64, 0.05..4.0f64, 30.0..2000.0f64, proptest::option::of(0.2..1.0f64)),
1461 1..12,
1462 ),
1463 shift in 0usize..12,
1464 ) {
1465 let notes: Vec<NoteEnvelope> =
1466 notes.into_iter().map(|(o, l, f, k)| note(o, l, f, k)).collect();
1467 let mut rotated = notes.clone();
1468 let shift = shift % notes.len();
1469 rotated.rotate_left(shift);
1470 let mut pooled = pool(¬es);
1471 pooled.rotate_left(shift);
1472 prop_assert_eq!(pool(&rotated), pooled);
1473 }
1474 }
1475}