Skip to main content

sound_to_patch/estimate/
envelope.rs

1//! The envelope layer: the amplitude envelope of the notes and the
2//! sidechain pump.
3//!
4//! The pump comes first: a dip that locks to the beat is stripped from the
5//! level tracks before the notes are read. Each note gets an attack, a
6//! hold, a decay, a sustain level, a note-off knee, a peak level and a fit
7//! of its release and tail. The notes of similar length and register form a
8//! pool, and each value of the layer is the median over the pool with its
9//! interquartile range. Five votes over the pool read whether the tail is
10//! reverb.
11
12mod 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
54/// A value over the pool needs this many notes; with fewer it reads low.
55pub const POOL_NOTES_MIN: usize = 3;
56/// Above this interquartile range over the median, a value reads low.
57pub const RELATIVE_SPREAD_MAX: f64 = 0.5;
58/// The pool holds notes whose length lies within this factor of the
59/// median length.
60pub const POOL_LENGTH_FACTOR: f64 = 2.0;
61/// The pool holds notes within this many semitones of the median pitch.
62pub const POOL_PITCH_RANGE: f64 = 12.0;
63/// The pool leaves out a note whose peak lies this far or more below the
64/// peak of a note it overlaps: a quiet second voice, such as another
65/// part's bleed or a ghost note, with an envelope of its own.
66pub const POOL_LEVEL_GATE: Decibels = Decibels::from_const(12.0);
67/// The level spread is the gap between these quantiles of the full-band
68/// levels of the pooled notes.
69pub const SPREAD_QUANTILES: (f64, f64) = (0.1, 0.9);
70
71/// The convention of the envelope stages in `layers.json`.
72pub 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";
105/// The convention of the note-off knee and its offset.
106pub 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";
125/// The convention of the tail.
126pub 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";
140/// The convention of the level spread.
141pub 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";
147/// The convention of the pump.
148pub 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/// What the envelope layer reads.
178#[derive(Clone, Copy, Debug)]
179pub struct EnvelopeInput<'a> {
180    /// The mono mix at the native rate.
181    pub mono: &'a [f64],
182    /// The native rate.
183    pub rate: SampleRate,
184    /// The note list, transcribed or given.
185    pub notes: &'a NoteList,
186    /// The beat grid of the used tempo, for the pump.
187    pub grid: BeatGrid,
188    /// The two channels of the stem, for the side vote of the tail.
189    pub stereo: Option<(&'a [f64], &'a [f64])>,
190}
191
192/// Flags of the envelope layer.
193#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
194pub struct EnvelopeFlags {
195    /// The stem gave no note list, so no note was read.
196    pub no_notes: bool,
197    /// Most pooled attacks are upper bounds, see
198    /// [`NoteEnvelope::attack_upper_bound`], so a preset should not take
199    /// the attack.
200    pub attack_is_upper_bound: bool,
201    /// The pump locks to the beat over [`PUMP_STRIP_BEATS`] counted beats
202    /// or more, so its curve was stripped from the level tracks before the
203    /// notes were read.
204    pub pump_stripped: bool,
205}
206
207/// How many notes each step read or skipped.
208#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
209pub struct EnvelopeCounts {
210    /// Notes with an envelope.
211    pub read: usize,
212    /// Notes that overlap another with no free harmonic.
213    pub no_free_harmonic: usize,
214    /// Notes too short for a track.
215    pub too_short: usize,
216    /// Notes whose amp proxy peaks at the silence floor.
217    pub silent: usize,
218    /// Read notes with a note-off knee.
219    pub knees: usize,
220}
221
222/// The envelope layer of a stem.
223#[derive(Clone, Debug, PartialEq)]
224pub struct EnvelopeLayer {
225    /// The envelope of each read note, in note order.
226    pub notes: Vec<NoteEnvelope>,
227    /// Which of `notes` form the pool.
228    pub pooled: Vec<bool>,
229    /// The attack over the pool: 0 to 100 % for a linear ramp.
230    pub attack: Estimate<Stats<Seconds>, Seconds>,
231    /// The hold over the pool.
232    pub hold: Estimate<Stats<Seconds>, Seconds>,
233    /// The decay over the pool.
234    pub decay: Estimate<Stats<Seconds>, Seconds>,
235    /// The sustain level from the peak over the pool.
236    pub sustain: Estimate<Stats<Decibels>, Decibels>,
237    /// The note's end in the list minus its knee, over the pooled notes
238    /// with a knee.
239    pub note_off_offset: Estimate<Stats<Seconds>, Seconds>,
240    /// The linear release over the pool; no estimate when the tail hides it.
241    pub release: Estimate<Stats<Seconds>, Seconds>,
242    /// The RT60 of the tail over the pooled notes that have one.
243    pub tail_rt60: Estimate<Stats<Seconds>, Seconds>,
244    /// The weight of the tail over the pooled notes with a fit.
245    pub tail_weight: Estimate<Stats<Ratio>, Ratio>,
246    /// How far to trust the release: low when no release is written; `None`
247    /// when no pooled note has a tail fit.
248    pub release_confidence: Option<Level>,
249    /// The votes that the tail is reverb.
250    pub tail_votes: TailVotes,
251    /// The 90th minus the 10th percentile of the full-band levels of the
252    /// pooled notes. [`read_levels`](Self::read_levels) sets it; before,
253    /// it holds no estimate.
254    pub level_spread: Estimate<Decibels, NoSpread>,
255    /// The full-band level of each note of the list, in list order; `None`
256    /// when its span holds no frame. Unlike `peak_db`, it compares notes
257    /// that read different harmonics. [`read_levels`](Self::read_levels)
258    /// sets it; before, it is empty.
259    pub levels: Vec<Option<Decibels>>,
260    /// The median ripple over the pool.
261    pub ripple: Option<Decibels>,
262    /// The median fit residual over the pool.
263    pub residual: Option<Decibels>,
264    /// The pump read; `None` with fewer than [`PUMP_BEATS_MIN`] sustained
265    /// beats.
266    pub pump: Option<Pump>,
267    /// The share of the notes in the list that overlap another.
268    pub overlap_fraction: f64,
269    /// The flags.
270    pub flags: EnvelopeFlags,
271    /// The confidence of the layer as a whole; no estimate without notes.
272    pub confidence: Confidence<NoSpread>,
273    /// The counts behind the summary.
274    pub counts: EnvelopeCounts,
275    /// The onset and pitch of each note whose amp proxy peaks at the silence
276    /// floor.
277    pub silent: Vec<(Seconds, MidiPitch)>,
278}
279
280impl EnvelopeLayer {
281    /// Reads the envelope layer of a stem.
282    ///
283    /// A 220 Hz sine held from 0.1 s, released linearly from 0.8 to 0.9 s,
284    /// reads one note at a sustain near its peak, with its note-off knee
285    /// where the release starts:
286    ///
287    /// ```
288    /// use sound_to_patch::audio::{BeatGrid, SampleRate};
289    /// use sound_to_patch::estimate::envelope::{EnvelopeInput, EnvelopeLayer};
290    /// use sound_to_patch::notes::{Note, NoteList};
291    /// use sound_to_patch::units::{Bpm, MidiPitch, Seconds};
292    ///
293    /// let rate = SampleRate::new(22_050)?;
294    /// let mono: Vec<f64> = (0..22_050)
295    ///     .map(|n| {
296    ///         let t = f64::from(n) / 22_050.0;
297    ///         let gain = if t < 0.1 { 0.0 } else { ((0.9 - t) / 0.1).clamp(0.0, 1.0) };
298    ///         0.5 * gain * (std::f64::consts::TAU * 220.0 * t).sin()
299    ///     })
300    ///     .collect();
301    /// let note = Note::new(Seconds::new(0.1)?, Seconds::new(0.9)?, MidiPitch::new(57.0)?)?;
302    /// let notes = NoteList::new(vec![note]);
303    /// let grid = BeatGrid::new(Bpm::new(120.0)?, Seconds::new(0.0)?);
304    /// let layer = EnvelopeLayer::of(&EnvelopeInput { stereo: None, mono: &mono, rate, notes: &notes, grid })?;
305    /// assert_eq!(layer.counts.read, 1);
306    /// let sustain = layer.sustain.value().map(|s| s.median().get());
307    /// assert!(sustain.is_some_and(|s| s > -1.0));
308    /// let knee = layer.notes[0].knee.map(Seconds::get);
309    /// assert!(knee.is_some_and(|k| (k - 0.8).abs() < 0.01));
310    /// # Ok::<(), Box<dyn std::error::Error>>(())
311    /// ```
312    ///
313    /// # Errors
314    ///
315    /// Returns an [`EnvelopeError`] when a harmonic band reaches the
316    /// Nyquist frequency, or a confidence cannot be built.
317    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    /// Reads the full-band level of each note and their spread over the
417    /// pool, as [`LEVEL_SPREAD_CONVENTION`] defines them. The levels wait
418    /// for the voices layer, which reads this layer first: `mono_line` is
419    /// the one voicing the final notes use, and on a mono line a level's
420    /// span ends at the next later onset.
421    ///
422    /// # Errors
423    ///
424    /// Returns an [`EnvelopeError`] when the spread's confidence cannot be
425    /// built.
426    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    /// Returns the lines that tell what the layer found.
445    #[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                // Adding 0 turns a level that rounds to -0.0 into 0.0.
480                |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
509/// Reads the notes and the pump. The pump counts beats inside the held
510/// parts, which the knees of a first read bound. When those give no pump,
511/// or a beat lock over fewer than [`PUMP_STRIP_BEATS`] beats, the beats
512/// are counted again over the notes to 25 ms before their ends, and that
513/// count is kept when it strips. When the pump strips, the notes are read
514/// again with it stripped.
515fn 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    // A duck's dip can read as a note-off knee in the first read and cut
542    // the held parts short of the beats that strip.
543    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
556/// Reads the envelope of every note against the notes that sound with it,
557/// with the index of its note, and counts the skipped ones.
558fn 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
588/// The median of a dB field over the pooled notes that have it.
589fn 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
601/// The RT60 over the fits that show one; no estimate when the median tail is too
602/// light to have one, fits like the note's own release, or fades faster than its
603/// band can show.
604fn 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
634/// A statistic in seconds.
635type SecondsStat = Estimate<Stats<Seconds>, Seconds>;
636
637/// The release over the fits, by the write rule on the tail weight and the
638/// residual, never on the release itself, which is what fails to fit.
639fn 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
679/// The full-band RMS of the stem over two periods of its lowest note,
680/// which the pump reads; `None` without notes.
681fn 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
699/// Which notes form the pool: of the notes that lie less than
700/// [`POOL_LEVEL_GATE`] below every note they overlap, those whose length
701/// lies within [`POOL_LENGTH_FACTOR`] of their median length and whose
702/// pitch lies within [`POOL_PITCH_RANGE`] of their median pitch. The
703/// length runs to the knee, or to the note's end without one.
704fn 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
734/// The median and interquartile range of values in a unit, at low
735/// confidence with fewer than [`POOL_NOTES_MIN`] values, a relative IQR
736/// above [`RELATIVE_SPREAD_MAX`] or a median of 0 under an IQR above 0;
737/// no estimate without values.
738fn 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
746/// A statistic as [`statistic`] reads it, low whatever its spread when `low` is true.
747fn 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
790/// The level spread over the pool, always at low confidence: the rule is a
791/// proposal no stem has tested.
792/// The spread of the full-band levels of the pooled notes.
793fn 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
805/// The full-band levels of the pooled notes that have one.
806fn 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
817/// The full-band level of each note of the list, as
818/// [`LEVEL_SPREAD_CONVENTION`] defines it. The span ends where
819/// `notes.mid` ends a note before the rules of a mono line: at the knee,
820/// or 50 ms before the list end without one. On a mono line it also ends
821/// at the next later onset; a chord's tones read their own spans, however
822/// their onsets stagger.
823fn 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/// The full-band level over a span: the peak of [`full_band_track`];
860/// `None` for a span that holds no frame.
861#[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/// The full-band level track of a span.
880///
881/// It is the centred RMS of the mono mix in dB over one period of `f0`, or
882/// the fewest whole periods that span 2 ms, on a 1 ms hop from `from` to
883/// `to`. It reads only the samples of the span and half a window either side,
884/// so a note costs its own length, not the stem's.
885#[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/// The name of a lock in the report.
937#[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/// The envelope layer failed.
948#[derive(Debug)]
949pub enum EnvelopeError {
950    /// A harmonic band reaches 0 Hz or the Nyquist frequency.
951    Filter(FilterError),
952    /// A confidence could not be built.
953    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    /// The votes of a note with its knee at 1 s and a 50 ms release, on a tone with
1027    /// a side 30 dB under its mid, whose level past the knee falls by `tail_db` and
1028    /// whose side grows by `side_db`, up to the next note at 1.5 s.
1029    fn hand_votes(tail_db: f64, side_db: f64) -> TailVotes {
1030        hand_votes_with(tail_db, side_db, &[])
1031    }
1032
1033    /// [`hand_votes`] with more notes listed after the fitted one.
1034    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, &notes, &[&envelope])
1069    }
1070
1071    /// The votes of a note with its knee at 1 s and a 50 ms release, up to the next
1072    /// note at `next` s, on the mid and side that `signal` gives from the time and
1073    /// a noise sample.
1074    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, &notes, &[&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        // Rises of about 0.08 and 0.13.
1116        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        // The tail runs from 1.05 to 1.5 s, so its halves meet at 1.275 s.
1123        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        // The knee at 1 s and the release of 50 ms leave 15 or 30 ms before the next note.
1137        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        // The fitted note ends at 1.1 s; the note listed before it rings to the next.
1168        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, &notes, &[&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        // One note past the floor does not read for a pool under it.
1232        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        // 11 semitones up and 0.6 times as long: pooled.
1301        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        // Two octaves up, or three times as long: not pooled.
1304        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(&notes),
1308            [true, true, true, true, true, true, true, false, false]
1309        );
1310        // The length runs to the knee.
1311        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        // A median of 0 with no spread is no spread; with one, it is wide.
1330        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        // An IQR of exactly half the median still reads medium.
1336        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        // A loud note before and after, touching and overlapping.
1347        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        // 8 dB down lies inside the gate.
1363        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        // Five holds of 0 ms and three of 20 ms: the IQR is 20 ms, and a
1383        // ratio to the median would be infinite.
1384        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        // A deterministic noise-like signal with a level that moves.
1400        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(&notes);
1471            pooled.rotate_left(shift);
1472            prop_assert_eq!(pool(&rotated), pooled);
1473        }
1474    }
1475}