Skip to main content

sound_to_patch/estimate/envelope/
adsr.rs

1//! The amplitude envelope of one note: attack, hold, decay, sustain, the
2//! note-off knee and the peak level.
3
4use crate::audio::SampleRate;
5use crate::cast;
6use crate::dsp::{FilterError, fit_line, quantile};
7use crate::notes::Note;
8use crate::units::{Decibels, Hz, MidiPitch, Seconds};
9
10use super::knee::{bend, find_knee_against};
11use super::median;
12use super::pump::Pump;
13use super::tail::{TAIL_BAND_PERIODS, TAIL_HOLD_FRAMES, TAIL_HOLD_LAG, TailFit, fit_tail};
14use super::track::{
15    HARMONIC_CUTOFF_MAX, HARMONIC_CUTOFF_MIN, HOP, LevelTrack, RMS_PERIODS, rms_window,
16};
17
18/// How little the level may rise after the attack's top.
19///
20/// The attack ends at its top: the first frame from the onset that the
21/// level exceeds by less than this over as long again as it took to rise to
22/// it from under 10 % of it, and at least over the shortest rise the track
23/// can tell. The hold starts where the level first comes within this much
24/// of the peak.
25///
26/// The peak of the whole note is no top: on a 10 ms attack that swells by
27/// 2 to 3 dB over 600 ms, the rise to 90 % of the peak read 400 to 525 ms.
28pub const ATTACK_TOP: Decibels = Decibels::from_const(1.0);
29/// A linear ramp spends 80 % of its time between 10 % and 90 % of its
30/// peak, so the 10-90 % rise times 1.25 is the full attack.
31pub const RAMP_FACTOR: f64 = 1.25;
32/// A rise shorter than this many RMS windows cannot be told from a
33/// shorter one: the attack is an upper bound.
34///
35/// The window is one period of the fundamental, or whole periods of at
36/// least 2 ms, and a periodic wave gives one amplitude sample per period.
37/// On generated saws a 1 ms attack rose in 0.75 windows at 55 Hz and 1.1
38/// at 220 Hz; a 5 ms attack at 220 Hz, 1.1 periods, rose in 5.0 ms, under
39/// 1.5 windows; a 10 ms attack, 2.2 periods, rose in 7.5 ms.
40pub const ATTACK_PERIODS_MIN: f64 = 1.5;
41/// The 10-90 % rise of a harmonic band track for an instant step, times
42/// its low-pass cutoff: 0.490 to 0.491 at cutoffs of 12 to 40 Hz on a
43/// generated 220 Hz sine.
44pub const BAND_STEP_RISE: f64 = 0.49;
45/// The attack of an overlapping note, read on its harmonic band, is an
46/// upper bound when its rise is shorter than this many times the band's
47/// rise for an instant step.
48pub const BAND_STEPS_MIN: f64 = 2.0;
49/// A plateau within [`ATTACK_TOP`] of the peak counts as hold when it
50/// lasts longer than this.
51pub const HOLD_MIN: Seconds = Seconds::from_const(0.01);
52/// The sustain level is the median of this last share of the held part.
53pub const SUSTAIN_SHARE: f64 = 0.3;
54/// The decay time is read between these shares of the fall in dB from the
55/// peak to the sustain level, as the attack is read between 10 % and 90 %
56/// of its rise.
57///
58/// A decay linear in dB reads its time exactly. On generated notes whose
59/// decay was linear in amplitude, a least-squares line in dB from -1 dB to
60/// 1 dB above the sustain level read 6 to 22 % long; a fall to -3 dB, the
61/// shallowest sustain, read 9 to 19 % long even when linear in dB, since
62/// the line spanned a third of it.
63pub const DECAY_SHARES: (f64, f64) = (0.1, 0.9);
64/// The first share of the decay lies at least this far below the peak.
65///
66/// The band filter of the amp proxy rings after a fast attack by a few
67/// tenths of a dB. A sustain of -3 dB put the 10 % point 0.3 dB down,
68/// where the ringing moved it: decays of 50 ms read up to 1.5 times long.
69pub const DECAY_FIRST_DB: Decibels = Decibels::from_const(0.5);
70/// Decay and sustain are observable when the held part lasts the attack,
71/// the hold and this many decay times, or when the note has no knee.
72pub const OBSERVABLE_DECAYS: f64 = 3.0;
73/// The tracks are read no lower than this far below the note's peak, so
74/// the floor of a silent frame does not steer a fit.
75pub const FIT_FLOOR: Decibels = Decibels::from_const(60.0);
76/// A note whose amp proxy peaks at or below this has nothing to read: a
77/// list of notes over a silent stretch would read 0 dB sustains at the
78/// tracks' floor. 16-bit dither sits near -96 dB.
79pub const SILENCE_DB: Decibels = Decibels::from_const(-120.0);
80/// The tracks start this long before the onset, so the attack's start is
81/// inside them.
82pub const TRACK_LEAD: Seconds = Seconds::from_const(0.03);
83/// Each note's tracks read its span and this much on either side.
84const SPAN_MARGIN: Seconds = Seconds::from_const(0.3);
85/// The knee search stops this long before the note's end.
86///
87/// On dry saws the transcriber's end falls 25 to 60 ms after the release
88/// ends (#70). The low edge keeps the whole release inside the search, so
89/// a 20 ms release still shows; #70's own cap of 50 ms left 10 ms of it.
90pub const KNEE_END_GAP: Seconds = Seconds::from_const(0.025);
91/// A harmonic of an overlapping note is free when it lies at least this
92/// far from every harmonic of the notes it overlaps.
93pub const FREE_GAP: Hz = Hz::from_const(30.0);
94/// The amp proxy is the lowest free harmonic up to this one.
95///
96/// A filter that closes moves the high harmonics, not the fundamental. On
97/// a generated 110 Hz saw falling 20 dB in 1.15 s under a low-pass that
98/// sweeps from 4 kHz down to the fundamental, the fundamental read the
99/// fall as 1.091 s, harmonics 1 to 3 summed 1.056 s and the full band
100/// 1.033 s.
101pub const PROXY_HARMONICS: u32 = 3;
102/// The level before a knee reads this many frames, 50 ms.
103const LEVEL_FRAMES: usize = 50;
104
105/// The envelope of one note.
106#[derive(Clone, Debug, PartialEq)]
107pub struct NoteEnvelope {
108    /// The note's onset.
109    pub onset: Seconds,
110    /// The note's end in the note list, or the stem's end when that comes
111    /// first.
112    pub end: Seconds,
113    /// The note's pitch.
114    pub pitch: MidiPitch,
115    /// The peak of the amp proxy, in dB of the harmonic's power.
116    pub peak_db: Decibels,
117    /// The 10-90 % rise times [`RAMP_FACTOR`]; `None` when the level does
118    /// not start below 10 % of its peak.
119    pub attack: Option<Seconds>,
120    /// The rise is shorter than [`ATTACK_PERIODS_MIN`] RMS windows, or for
121    /// an overlapping note [`BAND_STEPS_MIN`] times its band's rise for an
122    /// instant step, so `attack` is an upper bound.
123    pub attack_upper_bound: bool,
124    /// The plateau after the attack; 0 when none lasts [`HOLD_MIN`].
125    pub hold: Seconds,
126    /// The time from the peak to the sustain level, read between 10 % and
127    /// 90 % of the fall in dB; `None` when not observable or when the level
128    /// holds at the peak.
129    pub decay: Option<Seconds>,
130    /// The sustain level from the peak; `None` when not observable.
131    pub sustain: Option<Decibels>,
132    /// The note-off knee; `None` when no knee shows.
133    pub knee: Option<Seconds>,
134    /// The swing of the level over the sustain window, the 90th minus the
135    /// 10th percentile after its line; `None` when the sustain is not
136    /// observable.
137    pub ripple: Option<Decibels>,
138    /// The RMS of the held part, in dB, against the model of the hold, the
139    /// decay line and the sustain level; `None` when not observable.
140    pub residual: Option<Decibels>,
141    /// Another note sounds during this one, so the attack reads the amp
142    /// proxy, not the full band.
143    pub overlap: bool,
144    /// The tail model after the knee; `None` without a knee or enough frames.
145    pub tail: Option<TailFit>,
146}
147
148/// Why a note has no envelope.
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub enum NoteSkip {
151    /// None of the note's first harmonics can be read: each lies within
152    /// [`FREE_GAP`] of a harmonic of a note it overlaps, as in an octave or
153    /// a unison, or its band reaches 0 Hz or the Nyquist frequency.
154    NoFreeHarmonic,
155    /// The note is too short for a track.
156    TooShort,
157    /// The note's amp proxy peaks at or below [`SILENCE_DB`].
158    Silent,
159}
160
161/// Reads the envelope of `note` from the mono mix. `others` are the notes
162/// that sound during it; `pump` is stripped first when it locks to the
163/// beat.
164///
165/// # Errors
166///
167/// Returns a [`FilterError`] when a harmonic band reaches the Nyquist
168/// frequency.
169pub fn read_note(
170    mono: &[f64],
171    rate: SampleRate,
172    note: &Note,
173    others: &[&Note],
174    pump: Option<&Pump>,
175) -> Result<Result<NoteEnvelope, NoteSkip>, FilterError> {
176    let f0 = Hz::from(note.pitch());
177    let (from, to) = span(mono, rate, note);
178    if to.get() - note.onset().get() < 2.0 * HOLD_MIN.get() {
179        return Ok(Err(NoteSkip::TooShort));
180    }
181    let partials = proxy_partials(f0, others, rate);
182    if partials.is_empty() {
183        return Ok(Err(NoteSkip::NoFreeHarmonic));
184    }
185    let overlap = !others.is_empty();
186    let (proxy, timing, mix) = tracks(mono, rate, (from, to), f0, &partials, overlap, pump)?;
187    let onset = proxy.index(note.onset().get());
188    let peak_db = proxy.db()[onset..]
189        .iter()
190        .copied()
191        .fold(f64::NEG_INFINITY, f64::max);
192    if peak_db <= SILENCE_DB.get() {
193        return Ok(Err(NoteSkip::Silent));
194    }
195    let resolution = if overlap {
196        BAND_STEPS_MIN * BAND_STEP_RISE / partials[0].1.get()
197    } else {
198        ATTACK_PERIODS_MIN * rms_window(f0, RMS_PERIODS).get()
199    };
200    let (attack, attack_upper_bound) = match rise(&timing, onset, resolution) {
201        Some(rise) => (
202            Some(Seconds::clamped(RAMP_FACTOR * rise)),
203            rise < resolution,
204        ),
205        None => (None, false),
206    };
207    let rel: Vec<f64> = proxy
208        .db()
209        .iter()
210        .map(|db| (db - peak_db).max(-FIT_FLOOR.get()))
211        .collect();
212    let top = (onset..rel.len())
213        .find(|&i| rel[i] >= -ATTACK_TOP.get())
214        .unwrap_or(onset);
215    let knee_end = proxy.index(to.get() - KNEE_END_GAP.get());
216    let settle = settle_frames(overlap, f0, &partials);
217    let frames = knee_end.saturating_sub(top);
218    let clear = clear_frames(&proxy, top, frames, others, to, settle);
219    let knee = note_off(&proxy, mix.as_ref(), (onset, top, knee_end), clear);
220    let held_end = knee.map_or(knee_end, |k| proxy.index(k)).max(top + 1);
221    let window = cast::floor_to_usize(SUSTAIN_SHARE * cast::usize_to_f64(held_end - top))
222        .unwrap_or(0)
223        .max(1);
224    let sustain_window = &rel[held_end - window..held_end];
225    let sustain = median(sustain_window).unwrap_or(0.0);
226    // The fall is read from the peak: the frames between the first one
227    // within 1 dB of it and the peak still rise.
228    let peak = (top..held_end)
229        .max_by(|&a, &b| rel[a].total_cmp(&rel[b]))
230        .unwrap_or(top);
231    let fall = (sustain < -ATTACK_TOP.get())
232        .then(|| decay_fall(&rel, peak, held_end, sustain))
233        .flatten();
234    // The decay starts a tenth of its time before its 10 % point; the hold
235    // runs from the attack's end to there.
236    let plateau = fall.map_or(0.0, |(start, _)| {
237        start - cast::usize_to_f64(top) * HOP.get()
238    });
239    let hold = if plateau > HOLD_MIN.get() {
240        plateau
241    } else {
242        0.0
243    };
244    let decay = fall.map(|(_, decay)| decay);
245    let tail = knee
246        .and_then(|k| read_tail(&timing, onset, k, to, others, f0))
247        .map(|fit| TailFit {
248            rt60_floor: rt60_floor(overlap, &partials),
249            ..fit
250        });
251    let held_time = cast::usize_to_f64(held_end - onset) * HOP.get();
252    // A note with no knee is a pluck that decays until it ends: its
253    // sustain is its end level and is always observable (#70).
254    let observable = knee.is_none()
255        || held_time
256            >= attack.map_or(0.0, Seconds::get) + hold + OBSERVABLE_DECAYS * decay.unwrap_or(0.0);
257    Ok(Ok(NoteEnvelope {
258        onset: note.onset(),
259        end: to,
260        pitch: note.pitch(),
261        peak_db: Decibels::clamped(peak_db),
262        attack,
263        attack_upper_bound,
264        hold: Seconds::clamped(hold),
265        decay: decay.filter(|_| observable).map(Seconds::clamped),
266        sustain: observable.then(|| Decibels::clamped(sustain)),
267        knee: knee.map(Seconds::clamped),
268        ripple: observable
269            .then(|| ripple(sustain_window))
270            .flatten()
271            .map(Decibels::clamped),
272        residual: observable
273            .then(|| residual(&rel[top..held_end], hold, decay, sustain))
274            .flatten()
275            .map(Decibels::clamped),
276        overlap,
277        tail,
278    }))
279}
280
281/// The frames from `top` that show this note alone: a next note that overlaps leaks
282/// into the band from its attack on, and the band rings `settle` frames ahead of it.
283fn clear_frames(
284    proxy: &LevelTrack,
285    top: usize,
286    frames: usize,
287    others: &[&Note],
288    to: Seconds,
289    settle: usize,
290) -> usize {
291    let next = others
292        .iter()
293        .map(|n| n.onset().get())
294        .filter(|&t| t > proxy.time(top))
295        .fold(to.get(), f64::min);
296    proxy
297        .index(next)
298        .min(top + frames)
299        .saturating_sub(settle + top)
300}
301
302/// The last frames of a track that can read what follows it: half the window of
303/// the level, or the rise of the band for a note that overlaps another, whose
304/// zero-phase filter rings ahead of a next note's attack.
305fn settle_frames(overlap: bool, f0: Hz, partials: &[(Hz, Hz)]) -> usize {
306    let reach = if overlap {
307        let narrowest = partials
308            .iter()
309            .map(|p| p.1.get())
310            .fold(f64::INFINITY, f64::min);
311        BAND_STEPS_MIN * BAND_STEP_RISE / narrowest
312    } else {
313        0.5 * rms_window(f0, RMS_PERIODS).get()
314    };
315    cast::round_to_usize(reach / HOP.get()).unwrap_or(0)
316}
317
318/// The shortest RT60 the note's track shows: only a note that overlaps another
319/// reads its tail through a band, and the narrowest band rings longest.
320fn rt60_floor(overlap: bool, partials: &[(Hz, Hz)]) -> Seconds {
321    let narrowest = partials
322        .iter()
323        .map(|p| p.1.get())
324        .fold(f64::INFINITY, f64::min);
325    Seconds::clamped(if overlap {
326        TAIL_BAND_PERIODS / narrowest
327    } else {
328        0.0
329    })
330}
331
332/// The tail fit of a note after its knee at `k`, up to its end at `to` or the
333/// next note that overlaps it.
334fn read_tail(
335    timing: &LevelTrack,
336    onset: usize,
337    k: f64,
338    to: Seconds,
339    others: &[&Note],
340    f0: Hz,
341) -> Option<TailFit> {
342    let stop = others
343        .iter()
344        .map(|n| n.onset().get())
345        .filter(|&t| t > k)
346        .fold(to.get(), f64::min);
347    // The level window reaches half its length ahead, into a next note.
348    let stop = stop - 0.5 * rms_window(f0, RMS_PERIODS).get();
349    let (a, b) = (timing.index(k), timing.index(stop));
350    let from = a.saturating_sub(LEVEL_FRAMES).max(onset.min(a));
351    let level = median(&timing.db()[from..a]).unwrap_or(0.0);
352    let db: Vec<f64> = timing.db()[a.min(b)..b].iter().map(|v| v - level).collect();
353    let held = a.saturating_sub(TAIL_HOLD_FRAMES).max(onset.min(a));
354    let hold_bend = Decibels::clamped(bend(timing.db(), held, a, TAIL_HOLD_LAG));
355    fit_tail(&db).map(|fit| TailFit { hold_bend, ..fit })
356}
357
358/// The span of a note's tracks: from [`TRACK_LEAD`] before its onset to its
359/// end, inside the stem.
360fn span(mono: &[f64], rate: SampleRate, note: &Note) -> (Seconds, Seconds) {
361    let stem_end = cast::usize_to_f64(mono.len()) / rate.hz().get();
362    (
363        Seconds::clamped(note.onset().get() - TRACK_LEAD.get()),
364        Seconds::clamped(note.offset().get().min(stem_end)),
365    )
366}
367
368/// The RMS in dB of the held part against its model: 0 dB over the hold,
369/// a fall linear in dB from 0 dB to `sustain` over the decay, then
370/// `sustain`. `None` for an empty part.
371fn residual(part: &[f64], hold: f64, decay: Option<f64>, sustain: f64) -> Option<f64> {
372    if part.is_empty() {
373        return None;
374    }
375    let decay = decay.unwrap_or(0.0);
376    let model = |t: f64| {
377        if t < hold {
378            0.0
379        } else if t < hold + decay {
380            sustain * (t - hold) / decay
381        } else {
382            sustain
383        }
384    };
385    let sum: f64 = part
386        .iter()
387        .enumerate()
388        .map(|(i, db)| (db - model(cast::usize_to_f64(i) * HOP.get())).powi(2))
389        .sum();
390    Some((sum / cast::usize_to_f64(part.len())).sqrt())
391}
392
393/// The amp proxy of `note` against the notes that sound with it, from
394/// [`TRACK_LEAD`] before its onset to its end, in dB of the harmonic's
395/// power; `None` when no harmonic is free.
396///
397/// # Errors
398///
399/// Returns a [`FilterError`] when a harmonic band reaches the Nyquist
400/// frequency.
401pub fn amp_proxy(
402    mono: &[f64],
403    rate: SampleRate,
404    note: &Note,
405    others: &[&Note],
406) -> Result<Option<LevelTrack>, FilterError> {
407    let f0 = Hz::from(note.pitch());
408    let (from, to) = span(mono, rate, note);
409    let partials = proxy_partials(f0, others, rate);
410    if partials.is_empty() {
411        return Ok(None);
412    }
413    let (proxy, _, _) = tracks(
414        mono,
415        rate,
416        (from, to),
417        f0,
418        &partials,
419        !others.is_empty(),
420        None,
421    )?;
422    Ok(Some(proxy))
423}
424
425/// The knee of the amp proxy over the frames from `top` to `end`, of which the first
426/// `clear` show the note alone. `mix` is the proxy before the pump's strip: the strip
427/// lifts the frames in a beat's dip, where the band can already ring ahead of a next
428/// note, so the test of whether the note has ended reads the mix as well.
429fn note_off(
430    proxy: &LevelTrack,
431    mix: Option<&LevelTrack>,
432    (onset, top, end): (usize, usize, usize),
433    clear: usize,
434) -> Option<f64> {
435    let amp = knee_track(proxy, onset, top, end);
436    let level = mix.map(|mix| knee_track(mix, onset, top, end));
437    find_knee_against(
438        proxy.time(top),
439        &amp,
440        level.as_deref().unwrap_or(&amp),
441        clear,
442    )
443}
444
445/// The linear level of `track` over the frames from `top` to `end`, relative to its
446/// peak from `onset` and floored [`FIT_FLOOR`] under it.
447fn knee_track(track: &LevelTrack, onset: usize, top: usize, end: usize) -> Vec<f64> {
448    let db = track.db();
449    let peak = db[onset..]
450        .iter()
451        .copied()
452        .fold(f64::NEG_INFINITY, f64::max);
453    db[top..end.max(top)]
454        .iter()
455        .map(|v| 10f64.powf((v - peak).max(-FIT_FLOOR.get()) / 20.0))
456        .collect()
457}
458
459/// The amp proxy and the attack's track of a note from `from` to `to`, with
460/// the pump stripped when it locks to the beat and its beats carry the duck,
461/// and the proxy as it was before the strip when it was stripped.
462///
463/// A note that overlaps no other reads its harmonic by a sliding DFT over
464/// one period and its attack on the one-period RMS; an overlapping note
465/// reads both on the band of its free harmonic.
466fn tracks(
467    mono: &[f64],
468    rate: SampleRate,
469    (from, to): (Seconds, Seconds),
470    f0: Hz,
471    partials: &[(Hz, Hz)],
472    overlap: bool,
473    pump: Option<&Pump>,
474) -> Result<(LevelTrack, LevelTrack, Option<LevelTrack>), FilterError> {
475    // Every track reads the note's span and a margin, so a note costs its
476    // own length, not the stem's. The band filters reflect three periods
477    // of their cutoff at each end, and 0.3 s keeps the note clear of them;
478    // it also covers the longest RMS window and DFT period.
479    let fs = rate.hz().get();
480    let lo = cast::floor_to_usize((from.get() - SPAN_MARGIN.get()).max(0.0) * fs).unwrap_or(0);
481    let hi = cast::ceil_to_usize((to.get() + SPAN_MARGIN.get()) * fs)
482        .unwrap_or(mono.len())
483        .min(mono.len());
484    let span = &mono[lo.min(hi)..hi];
485    let mut proxy = if overlap {
486        let offset = Seconds::clamped(cast::usize_to_f64(lo) / fs);
487        LevelTrack::partials(span, offset, rate, (from, to), partials)?
488    } else {
489        LevelTrack::harmonic(span, lo, rate, (from, to), partials[0].0, f0)
490    };
491    let mut timing = if overlap {
492        proxy.clone()
493    } else {
494        LevelTrack::rms(span, lo, rate, (from, to), f0, RMS_PERIODS)
495    };
496    let mix = pump
497        .filter(|p| p.strips() && p.carries(from.get(), to.get()))
498        .map(|pump| {
499            let mix = proxy.clone();
500            pump.strip(&mut proxy);
501            pump.strip(&mut timing);
502            mix
503        });
504    Ok((proxy, timing, mix))
505}
506
507/// The harmonic of the amp proxy with its low-pass: the lowest of
508/// harmonics 1 to [`PROXY_HARMONICS`] below the Nyquist frequency that
509/// lies at least [`FREE_GAP`] from every harmonic of `others` and whose
510/// band lies above 0 Hz. The
511/// low-pass is half the distance to the nearest foreign or own
512/// neighbouring harmonic, between [`HARMONIC_CUTOFF_MIN`] and
513/// [`HARMONIC_CUTOFF_MAX`]. Empty when no harmonic is free.
514fn proxy_partials(f0: Hz, others: &[&Note], rate: SampleRate) -> Vec<(Hz, Hz)> {
515    let nyquist = rate.nyquist().get();
516    (1..=PROXY_HARMONICS)
517        .filter_map(|k| {
518            let f = f64::from(k) * f0.get();
519            let foreign = others
520                .iter()
521                .map(|n| {
522                    let g = Hz::from(n.pitch()).get();
523                    let nearest = (f / g).round().max(1.0) * g;
524                    (f - nearest).abs()
525                })
526                .fold(f64::INFINITY, f64::min);
527            if foreign < FREE_GAP.get() {
528                return None;
529            }
530            let cutoff = (0.5 * foreign.min(f0.get()))
531                .clamp(HARMONIC_CUTOFF_MIN.get(), HARMONIC_CUTOFF_MAX.get());
532            (f > cutoff && f + cutoff < nyquist).then(|| (Hz::clamped(f), Hz::clamped(cutoff)))
533        })
534        .take(1)
535        .collect()
536}
537
538/// The 10-90 % rise of a track's linear amplitude from `onset` to the top
539/// of the attack, in seconds: from the last frame under 10 % of the top
540/// level before the first frame at 90 %, with both crossings linear
541/// between frames. `None` when the level never rises from under 10 % of a
542/// frame to a top.
543///
544/// The top is the first frame from `onset` whose level the track exceeds
545/// by less than [`ATTACK_TOP`] over as long again as the level took to
546/// rise to it from under 10 % of it, and at least over `resolution`, the
547/// shortest rise the track can tell; the top level is the highest frame
548/// over that span. A note that swells or ripples after a fast attack rises
549/// to its top first, and a slow attack has no top before its end.
550fn rise(track: &LevelTrack, onset: usize, resolution: f64) -> Option<f64> {
551    let db = track.db();
552    let peak = attack_top(db, onset, resolution)?;
553    let amp: Vec<f64> = db.iter().map(|d| 10f64.powf((d - peak) / 20.0)).collect();
554    let up = (onset.max(1)..amp.len()).find(|&i| amp[i] >= 0.9)?;
555    let t90 = between(&amp, up, 0.9);
556    let low = (0..up).rev().find(|&i| amp[i] < 0.1)?;
557    let t10 = between(&amp, low + 1, 0.1);
558    Some((t90 - t10) * HOP.get())
559}
560
561/// The top level of an attack from `onset`, in dB: see [`rise`].
562fn attack_top(db: &[f64], onset: usize, resolution: f64) -> Option<f64> {
563    // A tenth of the amplitude.
564    let tenth = 20.0;
565    let least = cast::ceil_to_usize(resolution / HOP.get()).unwrap_or(0);
566    let first = onset.max(1);
567    let mut lowest = db[..first.min(db.len())]
568        .iter()
569        .copied()
570        .fold(f64::INFINITY, f64::min);
571    (first..db.len()).find_map(|i| {
572        // The running minimum skips the search on a level that never rose,
573        // which would otherwise read a long note's frames once per frame.
574        let low = lowest;
575        lowest = lowest.min(db[i]);
576        if low >= db[i] - tenth {
577            return None;
578        }
579        let start = (0..i).rev().find(|&j| db[j] < db[i] - tenth)?;
580        let ahead = db[i..=(i + (i - start).max(least)).min(db.len() - 1)]
581            .iter()
582            .copied()
583            .fold(f64::NEG_INFINITY, f64::max);
584        (ahead < db[i] + ATTACK_TOP.get()).then_some(ahead)
585    })
586}
587
588/// The fractional index where `amp` rises through `level` between frames
589/// `i - 1` and `i`.
590fn between(amp: &[f64], i: usize, level: f64) -> f64 {
591    let (a, b) = (amp[i - 1], amp[i]);
592    let w = if b > a { (level - a) / (b - a) } else { 1.0 };
593    cast::usize_to_f64(i - 1) + w.clamp(0.0, 1.0)
594}
595
596/// The decay from the track between `from` and `to`: the times at which
597/// the dB level first falls through [`DECAY_SHARES`] of `sustain`, linear
598/// between frames, the first share at least [`DECAY_FIRST_DB`] down. The
599/// decay time is the time between them over the gap between the shares,
600/// and the decay starts that share of it before the first. Returns the
601/// start, from the track start, and the decay time; `None` when the level
602/// does not reach the second share before `to`.
603fn decay_fall(rel: &[f64], from: usize, to: usize, sustain: f64) -> Option<(f64, f64)> {
604    let (lo, hi) = DECAY_SHARES;
605    let lo = lo.max(DECAY_FIRST_DB.get() / -sustain);
606    let first = falls_through(rel, from, to, lo * sustain)?;
607    let second = falls_through(rel, cast::floor_to_usize(first)?, to, hi * sustain)?;
608    let decay = (second - first) * HOP.get() / (hi - lo);
609    Some((first * HOP.get() - lo * decay, decay))
610}
611
612/// The fractional index where `rel` first falls through `level` at or
613/// after `from` and before `to`.
614fn falls_through(rel: &[f64], from: usize, to: usize, level: f64) -> Option<f64> {
615    let i = (from.max(1)..to.min(rel.len())).find(|&i| rel[i] <= level)?;
616    let (a, b) = (rel[i - 1], rel[i]);
617    let w = if a > b { (a - level) / (a - b) } else { 1.0 };
618    Some(cast::usize_to_f64(i - 1) + w.clamp(0.0, 1.0))
619}
620
621/// The 90th minus the 10th percentile of values after their line; `None`
622/// for fewer than ten values.
623fn ripple(values: &[f64]) -> Option<f64> {
624    if values.len() < 10 {
625        return None;
626    }
627    let points: Vec<(f64, f64)> = values
628        .iter()
629        .enumerate()
630        .map(|(i, &v)| (cast::usize_to_f64(i), v))
631        .collect();
632    let b = fit_line(&points).map_or(0.0, |(slope, _)| slope);
633    let mut rest: Vec<f64> = points.iter().map(|&(x, y)| y - b * x).collect();
634    rest.sort_by(f64::total_cmp);
635    Some(quantile(&rest, 0.9)? - quantile(&rest, 0.1)?)
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    #[test]
643    fn the_tail_level_is_the_median_of_the_50_frames_before_the_knee() {
644        // Over the 100 frames before the knee the level is -6 dB, and over the last
645        // 20 it is 0 dB, or over the last 30 for the window of 25.
646        let read = |last: usize| {
647            let fall: Vec<f64> = (0..300).map(|i| -0.1 * f64::from(i)).collect();
648            let mut db = vec![-6.0; 1000 - last];
649            db.extend(vec![0.0; last]);
650            db.extend(&fall);
651            let track = LevelTrack::new(Seconds::clamped(0.0), db);
652            let f0 = Hz::clamped(440.0);
653            let knee = 1000.0 * HOP.get();
654            read_tail(&track, 0, knee, Seconds::clamped(1.3), &[], f0).unwrap()
655        };
656        let weight_at = |level: f64| {
657            let f0_frames = cast::round_to_usize(
658                0.5 * rms_window(Hz::clamped(440.0), RMS_PERIODS).get() / HOP.get(),
659            )
660            .unwrap();
661            let db: Vec<f64> = (0..300 - f0_frames)
662                .map(|i| -0.1 * cast::usize_to_f64(i) - level)
663                .collect();
664            fit_tail(&db).unwrap().weight
665        };
666        // 20 of 50 frames: the median stays at -6 dB, where a window of 25 reads 0 dB.
667        assert!((read(20).weight - weight_at(-6.0)).abs() < 1e-9);
668        // 30 of 50 frames: the median reads 0 dB, where a window of 100 reads -6 dB.
669        assert!((read(30).weight - weight_at(0.0)).abs() < 1e-9);
670    }
671
672    #[test]
673    fn the_shortest_rt60_is_6_periods_of_the_narrowest_cutoff() {
674        let partials = [
675            (Hz::clamped(220.0), Hz::clamped(40.0)),
676            (Hz::clamped(440.0), Hz::clamped(20.0)),
677        ];
678        assert_eq!(rt60_floor(true, &partials).get(), 6.0 / 20.0);
679        assert_eq!(rt60_floor(false, &partials).get(), 0.0);
680    }
681
682    #[test]
683    fn the_settle_is_half_a_level_window_alone_and_two_band_rises_in_an_overlap() {
684        let partials = [
685            (Hz::clamped(100.0), Hz::clamped(40.0)),
686            (Hz::clamped(200.0), Hz::clamped(20.0)),
687        ];
688        // Half of one 20 ms period at 50 Hz, and 2 rises of 0.49 / 20 Hz.
689        assert_eq!(settle_frames(false, Hz::clamped(50.0), &partials), 10);
690        assert_eq!(settle_frames(true, Hz::clamped(50.0), &partials), 49);
691    }
692
693    #[test]
694    fn the_hold_bend_reads_300_frames_before_the_knee_25_each_way() {
695        // A triangle 50 frames long reads 4 times its median size over the last
696        // 300 frames, and a flat level reads 0 dB before them.
697        let triangle = |i: usize, peak: f64| {
698            let phase = cast::usize_to_f64(i % 50) / 12.5;
699            peak * if phase < 2.0 {
700                phase - 1.0
701            } else {
702                3.0 - phase
703            }
704        };
705        let read = |flat_frames: usize, bent: usize| {
706            let mut db = vec![0.0; flat_frames];
707            db.extend((0..bent).map(|i| triangle(i, 1.0)));
708            let knee = db.len();
709            db.extend((0..300).map(|i| -0.1 * f64::from(i)));
710            let track = LevelTrack::new(Seconds::clamped(0.0), db);
711            let end = cast::usize_to_f64(knee + 300) * HOP.get();
712            read_tail(
713                &track,
714                0,
715                cast::usize_to_f64(knee) * HOP.get(),
716                Seconds::clamped(end),
717                &[],
718                Hz::clamped(440.0),
719            )
720            .unwrap()
721            .hold_bend
722            .get()
723        };
724        assert!((read(1000, 300) - 2.0).abs() < 0.2, "{}", read(1000, 300));
725        // Under half the 300 frames bend, so a window of 300 reads no median bend,
726        // where a window of 200 would.
727        assert!(read(1000, 100) < 0.01, "{}", read(1000, 100));
728    }
729
730    #[test]
731    fn the_residual_is_the_rms_against_hold_decay_and_sustain() {
732        // 10 ms of hold, a 50 ms fall to -6 dB, then the sustain.
733        let model: Vec<f64> = (0..100)
734            .map(|i| {
735                let t = cast::usize_to_f64(i) * HOP.get();
736                if t < 0.01 {
737                    0.0
738                } else if t < 0.06 {
739                    -6.0 * (t - 0.01) / 0.05
740                } else {
741                    -6.0
742                }
743            })
744            .collect();
745        assert!(residual(&model, 0.01, Some(0.05), -6.0).unwrap() < 1e-9);
746        let off: Vec<f64> = model.iter().map(|v| v + 1.0).collect();
747        assert!((residual(&off, 0.01, Some(0.05), -6.0).unwrap() - 1.0).abs() < 1e-9);
748        // Without a decay the model steps to the sustain after the hold.
749        let step: Vec<f64> = (0..20).map(|i| if i < 10 { 0.0 } else { -3.0 }).collect();
750        assert!(residual(&step, 0.0095, None, -3.0).unwrap() < 1e-9);
751        assert!(residual(&[], 0.0, None, 0.0).is_none());
752    }
753
754    #[test]
755    fn a_fall_through_a_level_interpolates_between_frames() {
756        let ramp = [0.0, -1.0, -2.0, -3.0, -4.0];
757        assert!((falls_through(&ramp, 0, 5, -2.5).unwrap() - 2.5).abs() < 1e-12);
758        // The search starts at `from` and stops before `to`.
759        assert!((falls_through(&ramp, 4, 5, -0.5).unwrap() - 3.0).abs() < 1e-12);
760        assert!(falls_through(&ramp, 0, 3, -2.5).is_none());
761        // A level reached without a fall lands on the frame itself.
762        assert!((falls_through(&[-3.0, -3.0], 0, 2, -3.0).unwrap() - 1.0).abs() < 1e-12);
763    }
764
765    #[test]
766    fn the_decay_reads_a_fall_linear_in_db_exactly() {
767        // A fall from 0 dB over 100 frames to the sustain, then the sustain:
768        // a decay of 0.1 s that starts at frame 0, whichever share leads.
769        for sustain in [-10.0, -4.0] {
770            let rel: Vec<f64> = (0..200)
771                .map(|i| sustain * (cast::usize_to_f64(i) / 100.0).min(1.0))
772                .collect();
773            let (start, decay) = decay_fall(&rel, 0, 200, sustain).unwrap();
774            assert!(
775                start.abs() < 1e-9 && (decay - 0.1).abs() < 1e-9,
776                "{sustain}: {start} {decay}"
777            );
778        }
779        // A fall of 2 dB over 20 frames, then 2 dB more over 80, to -4 dB:
780        // the first share is 0.5 dB down, a share of 0.125, at frame 5,
781        // and the 90 % share at frame 84.
782        let rel: Vec<f64> = (0..200)
783            .map(|i| {
784                let i = cast::usize_to_f64(i);
785                if i < 20.0 {
786                    -0.1 * i
787                } else {
788                    (-2.0 - 2.0 * (i - 20.0) / 80.0).max(-4.0)
789                }
790            })
791            .collect();
792        let (start, decay) = decay_fall(&rel, 0, 200, -4.0).unwrap();
793        let want = 0.079 / 0.775;
794        assert!((decay - want).abs() < 1e-9, "{decay}");
795        assert!((start - (0.005 - 0.125 * want)).abs() < 1e-9, "{start}");
796        // The second share must come before `to`.
797        let rel: Vec<f64> = (0..200)
798            .map(|i| -10.0 * (cast::usize_to_f64(i) / 100.0).min(1.0))
799            .collect();
800        assert!(decay_fall(&rel, 0, 80, -10.0).is_none());
801    }
802
803    #[test]
804    fn the_attack_top_looks_ahead_at_least_over_the_resolution() {
805        // A rise from -60 dB that stalls at -20 dB for 3 frames before it
806        // goes on to 0 dB: over 3 frames of look-ahead the stall is a top.
807        let mut db: Vec<f64> = vec![-60.0; 5];
808        db.extend([-40.0, -20.0, -20.0, -20.0, -20.0, -10.0, -5.0, 0.0]);
809        db.extend([0.0; 20]);
810        assert_eq!(attack_top(&db, 0, 0.0), Some(-20.0));
811        assert_eq!(attack_top(&db, 0, 0.01), Some(0.0));
812    }
813
814    #[test]
815    fn the_ripple_is_the_spread_after_the_line() {
816        // A slope of 0.5 dB per frame with a swing of 1 dB either way.
817        let values: Vec<f64> = (0..200)
818            .map(|i| 0.5 * cast::usize_to_f64(i) + if i % 2 == 0 { 1.0 } else { -1.0 })
819            .collect();
820        assert!((ripple(&values).unwrap() - 2.0).abs() < 0.05);
821        let flat: Vec<f64> = (0..20)
822            .map(|i| -3.0 - 0.2 * cast::usize_to_f64(i))
823            .collect();
824        assert!(ripple(&flat).unwrap().abs() < 1e-9);
825        assert!(ripple(&[0.0; 9]).is_none());
826        assert_eq!(ripple(&[0.0; 10]), Some(0.0));
827    }
828
829    #[test]
830    fn the_constants_pass_the_unit_check() {
831        assert_eq!(ATTACK_TOP, Decibels::new(1.0).unwrap());
832        assert_eq!(HOLD_MIN, Seconds::new(0.01).unwrap());
833        assert_eq!(FIT_FLOOR, Decibels::new(60.0).unwrap());
834        assert_eq!(DECAY_FIRST_DB, Decibels::new(0.5).unwrap());
835        assert_eq!(TRACK_LEAD, Seconds::new(0.03).unwrap());
836        assert_eq!(KNEE_END_GAP, Seconds::new(0.025).unwrap());
837        assert_eq!(FREE_GAP, Hz::new(30.0).unwrap());
838    }
839
840    #[test]
841    fn a_linear_ramp_rises_from_10_to_90_percent_in_80_percent_of_its_time() {
842        // 1 ms frames of a 100 ms linear ramp from silence.
843        let db: Vec<f64> = (0..300)
844            .map(|i| {
845                let a = (cast::usize_to_f64(i) - 50.0).clamp(0.0, 100.0) / 100.0;
846                20.0 * a.max(1e-9).log10()
847            })
848            .collect();
849        let track = LevelTrack::new(Seconds::clamped(0.0), db);
850        let rise = rise(&track, 0, 0.0).unwrap();
851        assert!((rise - 0.08).abs() < 1e-3, "{rise}");
852    }
853
854    #[test]
855    fn a_harmonic_near_another_notes_harmonic_is_not_free() {
856        let rate = SampleRate::new(44_100).unwrap();
857        let fifth = Note::new(
858            Seconds::clamped(0.0),
859            Seconds::clamped(1.0),
860            crate::units::MidiPitch::new(52.0).unwrap(),
861        )
862        .unwrap();
863        // A2 at 110 Hz under E3 at 164.8 Hz keeps its fundamental, 54.8 Hz
864        // from E3.
865        let kept: Vec<f64> = proxy_partials(Hz::new(110.0).unwrap(), &[&fifth], rate)
866            .iter()
867            .map(|p| p.0.get())
868            .collect();
869        assert_eq!(kept, vec![110.0]);
870        // Against 120 Hz, 110 and 220 Hz lie 10 and 20 Hz from 120 and 240
871        // Hz; 330 Hz lies 30 Hz from 360 Hz, free, with a 15 Hz low-pass.
872        let near = Note::new(
873            Seconds::clamped(0.0),
874            Seconds::clamped(1.0),
875            crate::units::MidiPitch::try_from(Hz::new(120.0).unwrap()).unwrap(),
876        )
877        .unwrap();
878        let kept = proxy_partials(Hz::new(110.0).unwrap(), &[&near], rate);
879        assert_eq!(kept.len(), 1);
880        assert!((kept[0].0.get() - 330.0).abs() < 1e-9);
881        assert!((kept[0].1.get() - 15.0).abs() < 1e-6, "{:?}", kept[0].1);
882        let octave = Note::new(
883            Seconds::clamped(0.0),
884            Seconds::clamped(1.0),
885            crate::units::MidiPitch::new(57.0).unwrap(),
886        )
887        .unwrap();
888        assert!(
889            proxy_partials(Hz::new(440.0).unwrap(), &[&octave], rate).is_empty(),
890            "an octave above has no free harmonic"
891        );
892    }
893}