Skip to main content

sound_to_patch/estimate/envelope/
knee.rs

1//! The note-off knee: where the level of a held note breaks into its
2//! release.
3//!
4//! The search reads the linear amplitude, not dB. A linear release is a
5//! straight line from the held level there, and an exponential one is
6//! steepest at its start, so the note-off is the sharpest corner either
7//! way. In dB a linear release starts gently and plunges at its end: on
8//! generated notes a dB search put the knee of a 200 ms release 168 ms
9//! late.
10
11use crate::cast;
12use crate::units::Decibels;
13
14use super::track::HOP;
15
16/// Each side of a knee spans at least this many frames, 10 ms.
17pub const KNEE_SIDE_MIN: usize = 10;
18/// The held side of a knee spans up to this many frames, 100 ms.
19pub const KNEE_LEFT: usize = 100;
20/// The release side of a knee spans up to this many frames, 200 ms.
21pub const KNEE_RIGHT: usize = 200;
22/// At a knee the slope steepens by at least this share of the held level
23/// per second. With [`KNEE_FALL`] over [`KNEE_RIGHT`], a linear release shows a
24/// knee up to about 400 ms.
25pub const KNEE_STEEPEN: f64 = 0.5;
26/// A two-line fit with a knee removes at least this share of the squared
27/// error that one line leaves.
28pub const KNEE_GAIN: f64 = 0.5;
29/// The release side of a knee falls by at least this share of the held
30/// level, 6 dB.
31pub const KNEE_FALL: f64 = 0.5;
32/// The release side of a knee ends where the level falls under this share
33/// of the held level, -34 dB.
34///
35/// A line over the silence after a release bends and hides the corner: a
36/// 200 ms release from -24 dB, read 15 ms past its end, put the best split
37/// 21 to 98 ms after the note-off.
38pub const KNEE_FLOOR: f64 = 0.02;
39
40/// A later corner replaces an earlier one only when the level between them
41/// bends by at most this much.
42///
43/// The bend is the median size of the second difference in dB over
44/// [`KNEE_BEND_LAG`] frames each way. A decay, a sustain and a slow tremolo read
45/// near 0, and the fades of a reverb read more.
46pub const KNEE_BEND: Decibels = Decibels::from_const(0.4);
47/// The frames each way of the second difference of [`KNEE_BEND`], 10 ms.
48pub const KNEE_BEND_LAG: usize = 10;
49/// A later corner replaces an earlier one only when the level after it rises
50/// by at most this share of the level before it. A note-off falls for good,
51/// and a fade of a reverb comes back.
52pub const KNEE_RISE: f64 = 0.05;
53/// The frames of the mean that the rise reads past its start, so that a noise
54/// floor's wobble after the note-off does not count as a level that comes back.
55pub const KNEE_RISE_FRAMES: usize = 20;
56/// The least ratio of a next note's held level to the level at the last clear frames.
57///
58/// A later corner past the frames that show a note alone belongs to the next note
59/// when the level there, the mean over their last [`KNEE_SIDE_MIN`], has fallen under
60/// [`KNEE_FLOOR`] of the earlier corner's level and has come back: the mean over the
61/// [`KNEE_LEFT`] frames before the corner is at least 1.25 times the stop level, and
62/// the left line's end lies above it too.
63///
64/// A held sustain reads near 1: a ripple of 1 % or a slow rise must not decide it. A
65/// transcribed note whose level rises over 10 frames into its note-off reads 1.08. A
66/// next note after a silent gap reads mostly 1 to 3 on generated bass lines, so a
67/// higher value loses most of their knees.
68pub const KNEE_BACK: f64 = 1.25;
69
70/// A level that stays under this share of the held level has left it for good.
71const KNEE_LEAVE: f64 = 0.85;
72/// The lowest level the bend reads, -80 dB.
73const KNEE_DB_FLOOR: f64 = 1e-4;
74
75/// The knee of a level track: the split where two lines fit best against
76/// one, at the crossing of the left line with the release's first frames.
77///
78/// The left line spans up to [`KNEE_LEFT`] frames before the split, the
79/// right one up to [`KNEE_RIGHT`] after it, and the crossing takes the line
80/// over the first [`KNEE_SIDE_MIN`] frames after the split.
81///
82/// `amp` holds the linear amplitude, one value per [`HOP`] from `start`. Only its
83/// first `clear` frames show this note alone, so the rise and the read of the fall
84/// stop there. A later corner past them can still replace an earlier one, since a
85/// note can be held past the onset of the next, unless the level has fallen under
86/// [`KNEE_FLOOR`] of the earlier corner's level over their last frames and comes back
87/// before the later corner, by [`KNEE_BACK`]: that note has ended, and the later
88/// corner belongs to the next note.
89/// Every split leaves at least [`KNEE_SIDE_MIN`] frames on each side. A
90/// split counts when the right line is steeper by [`KNEE_STEEPEN`] times
91/// the left side's mean per second, falls by [`KNEE_FALL`] times that
92/// mean over its side, and the two lines leave at most `1 - KNEE_GAIN` of
93/// the squared error of one line. Splits that count in a row form a
94/// corner, and within a corner the split with the largest gain wins. A later
95/// corner replaces an earlier one, by [`KNEE_BEND`] and [`KNEE_RISE`].
96/// The lines are local, so a decay earlier in the note does not hide its
97/// note-off. `None` when no split counts: a pluck that decays
98/// at one rate has no note-off to see.
99///
100/// A level held at 1 for 0.5 s that then falls linearly to 0 over 0.2 s
101/// has its knee where the fall starts:
102///
103/// ```
104/// use sound_to_patch::estimate::envelope::find_knee;
105///
106/// let amp: Vec<f64> = (0..800)
107///     .map(|i| (1.0 - (f64::from(i) - 500.0).max(0.0) / 200.0).max(0.0))
108///     .collect();
109/// let knee = find_knee(0.0, &amp, usize::MAX);
110/// assert!(knee.is_some_and(|t| (t - 0.5).abs() < 0.002));
111/// # Ok::<(), Box<dyn std::error::Error>>(())
112/// ```
113#[must_use]
114pub fn find_knee(start: f64, amp: &[f64], clear: usize) -> Option<f64> {
115    find_knee_against(start, amp, amp, clear)
116}
117
118/// [`find_knee`] whose later corner past the clear frames also replaces nothing when
119/// `level`, a track of the same frames, shows the note ended and the level come back.
120/// A note read with the pump stripped passes the unstripped track here. The strip
121/// lifts the frames in a beat's dip, and the last clear frames before a next note can
122/// lie there while its band already rings ahead of its attack; the unstripped track
123/// reads that ring in the dip, so either track alone can miss the next note.
124pub(super) fn find_knee_against(
125    start: f64,
126    amp: &[f64],
127    level: &[f64],
128    clear: usize,
129) -> Option<f64> {
130    debug_assert_eq!(amp.len(), level.len());
131    let n = amp.len();
132    if n < 2 * KNEE_SIDE_MIN {
133        return None;
134    }
135    let sums = Sums::of(amp);
136    let mut corners: Vec<Corner> = Vec::new();
137    let mut last: Option<usize> = None;
138    for k in KNEE_SIDE_MIN..=n - KNEE_SIDE_MIN {
139        let lo = k.saturating_sub(KNEE_LEFT);
140        let level = sums.mean(lo, k);
141        // The release side stops where the release has ended: a line over
142        // the silence after it bends and hides the corner.
143        let hi = (k..(k + KNEE_RIGHT).min(n))
144            .find(|&j| amp[j] <= KNEE_FLOOR * level)
145            .unwrap_or((k + KNEE_RIGHT).min(n))
146            .max(k + KNEE_SIDE_MIN)
147            .min(n);
148        let (Some(left), Some(right), Some(one)) =
149            (sums.fit(lo, k), sums.fit(k, hi), sums.fit(lo, hi))
150        else {
151            continue;
152        };
153        if one.error <= 0.0 {
154            continue;
155        }
156        let gain = 1.0 - (left.error + right.error) / one.error;
157        let fall = right.at(k) - right.at(hi - 1);
158        if right.slope <= left.slope - KNEE_STEEPEN * level * HOP.get()
159            && fall >= KNEE_FALL * level
160            && gain >= KNEE_GAIN
161        {
162            let found = Corner {
163                first: k,
164                k,
165                gain,
166                left,
167                level,
168            };
169            // Splits that count in a row form one corner.
170            match corners.last_mut() {
171                Some(corner) if last == Some(k - 1) => {
172                    if gain > corner.gain {
173                        *corner = Corner {
174                            first: corner.first,
175                            ..found
176                        };
177                    }
178                }
179                _ => corners.push(found),
180            }
181            last = Some(k);
182        }
183    }
184    let (first, later) = corners.split_first()?;
185    // A hold that breaks into a decay is a corner too and the note-off comes
186    // last, so a later corner replaces an earlier one, unless it is a fade of
187    // a reverb: the level before it wobbles, or the level after it comes back.
188    let db: Vec<f64> = amp
189        .iter()
190        .map(|v| 20.0 * v.max(KNEE_DB_FLOOR).log10())
191        .collect();
192    let mut best = first;
193    let alone = clear.min(n);
194    let mix = Sums::of(level);
195    for corner in later {
196        // Past the clear frames a corner reads no rise, and the bend over a silent
197        // gap reads near 0, so only the level the note left there tells them apart.
198        if alone > 0
199            && corner.k >= alone
200            && (sums.came_back(alone, best.k, corner.k) || mix.came_back(alone, best.k, corner.k))
201        {
202            continue;
203        }
204        if bend(&db, best.k, corner.first, KNEE_BEND_LAG) <= KNEE_BEND.get()
205            && rise(&amp[corner.k..alone.max(corner.k)]) <= KNEE_RISE * corner.level
206        {
207            best = corner;
208        }
209    }
210    let Corner { k, left, .. } = *best;
211    // A level that wobbles over its hold, as under a reverb of the note, can win
212    // a split well before the level leaves for good: read the fall itself there.
213    let held = left.at(k);
214    let reach = (k + KNEE_RIGHT).min(alone.max(k));
215    let leaves = (k..reach).rev().find(|&j| amp[j] >= KNEE_LEAVE * held);
216    if let Some(from) = leaves.filter(|&j| j > k + KNEE_SIDE_MIN) {
217        let to = (from..reach)
218            .find(|&j| amp[j] <= KNEE_FALL * held)
219            .unwrap_or(reach)
220            .max(from + KNEE_SIDE_MIN)
221            .min(n);
222        if let Some(line) = sums.fit(from, to).filter(|l| l.slope < 0.0) {
223            let back = (held - line.intercept) / line.slope;
224            if back > cast::usize_to_f64(k + KNEE_SIDE_MIN) {
225                return Some(start + back.min(cast::usize_to_f64(from)) * HOP.get());
226            }
227        }
228    }
229    // The release side is read again over its first frames only: a line
230    // over 200 ms of an exponential release is a secant that crosses the
231    // held level late, and the line over the first 10 ms is near its
232    // tangent at the split.
233    let tangent = sums.fit(k, (k + KNEE_SIDE_MIN).min(n))?;
234    let crossing = (tangent.intercept - left.intercept) / (left.slope - tangent.slope);
235    // The crossing counts only over the frames of the two lines, so the
236    // knee never lies before the search starts.
237    let first = cast::usize_to_f64(k.saturating_sub(KNEE_LEFT));
238    let k = cast::usize_to_f64(k);
239    let x = if (first..=k + cast::usize_to_f64(KNEE_SIDE_MIN)).contains(&crossing) {
240        crossing
241    } else {
242        k
243    };
244    Some(start + x * HOP.get())
245}
246
247/// The splits that count in a row, with the values of the one that wins.
248#[derive(Clone, Copy, Debug)]
249struct Corner {
250    first: usize,
251    k: usize,
252    gain: f64,
253    left: Line,
254    level: f64,
255}
256
257/// The median size of the second difference of `db` over the frames `from..to`,
258/// in dB; 0 when the span holds no second difference.
259pub(super) fn bend(db: &[f64], from: usize, to: usize, lag: usize) -> f64 {
260    let (lo, hi) = (from + lag, to.saturating_sub(lag));
261    if hi <= lo {
262        return 0.0;
263    }
264    let mut sizes: Vec<f64> = (lo..hi)
265        .map(|i| (db[i - lag] - 2.0 * db[i] + db[i + lag]).abs())
266        .collect();
267    sizes.sort_by(f64::total_cmp);
268    sizes[(sizes.len() - 1) / 2]
269}
270
271/// The largest rise of `amp` over its first [`KNEE_RISE_FRAMES`], and of its mean
272/// over that many frames after them.
273fn rise(amp: &[f64]) -> f64 {
274    let head = amp.iter().take(KNEE_RISE_FRAMES).copied();
275    let means = amp
276        .windows(KNEE_RISE_FRAMES)
277        .map(|w| w.iter().sum::<f64>() / cast::usize_to_f64(KNEE_RISE_FRAMES));
278    climb(head).max(climb(means))
279}
280
281/// The largest rise of a sequence from any lower value before it.
282fn climb(values: impl Iterator<Item = f64>) -> f64 {
283    let mut low = f64::INFINITY;
284    let mut climb = 0.0_f64;
285    for v in values {
286        low = low.min(v);
287        climb = climb.max(v - low);
288    }
289    climb
290}
291
292/// A least-squares line over frame indices, with its squared error.
293#[derive(Clone, Copy, Debug)]
294struct Line {
295    slope: f64,
296    intercept: f64,
297    error: f64,
298}
299
300impl Line {
301    fn at(self, i: usize) -> f64 {
302        self.intercept + self.slope * cast::usize_to_f64(i)
303    }
304}
305
306/// Prefix sums of `x`, `y`, `x^2`, `x y` and `y^2` over frame indices, so a
307/// line over any range fits in constant time.
308struct Sums {
309    x: Vec<f64>,
310    y: Vec<f64>,
311    xx: Vec<f64>,
312    xy: Vec<f64>,
313    yy: Vec<f64>,
314}
315
316impl Sums {
317    fn of(values: &[f64]) -> Self {
318        let mut sums = Self {
319            x: vec![0.0],
320            y: vec![0.0],
321            xx: vec![0.0],
322            xy: vec![0.0],
323            yy: vec![0.0],
324        };
325        for (i, &y) in values.iter().enumerate() {
326            let x = cast::usize_to_f64(i);
327            for (v, add) in [
328                (&mut sums.x, x),
329                (&mut sums.y, y),
330                (&mut sums.xx, x * x),
331                (&mut sums.xy, x * y),
332                (&mut sums.yy, y * y),
333            ] {
334                let last = v.last().copied().unwrap_or(0.0);
335                v.push(last + add);
336            }
337        }
338        sums
339    }
340
341    /// Whether the level has fallen under [`KNEE_FLOOR`] of the held level before
342    /// `from` by the stop level, the mean over the last [`KNEE_SIDE_MIN`] of the
343    /// `alone` frames, and has come back before `k` by [`KNEE_BACK`]: the mean over
344    /// the [`KNEE_LEFT`] frames before `k`, and the end of their line.
345    fn came_back(&self, alone: usize, from: usize, k: usize) -> bool {
346        let stop = self.mean(alone.saturating_sub(KNEE_SIDE_MIN), alone);
347        let held = |k: usize| self.mean(k.saturating_sub(KNEE_LEFT), k);
348        stop < KNEE_FLOOR * held(from)
349            && held(k) >= KNEE_BACK * stop
350            && self
351                .fit(k.saturating_sub(KNEE_LEFT), k)
352                .is_some_and(|left| left.at(k) >= stop)
353    }
354
355    /// The mean over frames `lo..hi`; 0 for none.
356    fn mean(&self, lo: usize, hi: usize) -> f64 {
357        if hi > lo {
358            (self.y[hi] - self.y[lo]) / cast::usize_to_f64(hi - lo)
359        } else {
360            0.0
361        }
362    }
363
364    /// The line over frames `lo..hi`; `None` for fewer than two frames.
365    fn fit(&self, lo: usize, hi: usize) -> Option<Line> {
366        if hi < lo + 2 {
367            return None;
368        }
369        let n = cast::usize_to_f64(hi - lo);
370        let d = |v: &[f64]| v[hi] - v[lo];
371        let (sx, sy, sxx, sxy, syy) = (
372            d(&self.x),
373            d(&self.y),
374            d(&self.xx),
375            d(&self.xy),
376            d(&self.yy),
377        );
378        let var = n * sxx - sx * sx;
379        if var <= 0.0 {
380            return None;
381        }
382        let slope = (n * sxy - sx * sy) / var;
383        let intercept = (sy - slope * sx) / n;
384        // The squared error of the line: sum (y - a - b x)^2 expanded.
385        let error = (syy - 2.0 * intercept * sy - 2.0 * slope * sxy
386            + n * intercept * intercept
387            + 2.0 * intercept * slope * sx
388            + slope * slope * sxx)
389            .max(0.0);
390        Some(Line {
391            slope,
392            intercept,
393            error,
394        })
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    /// The linear amplitude of a dB curve, one value per hop.
403    fn track(f: impl Fn(f64) -> f64, seconds: f64) -> Vec<f64> {
404        (0..cast::round_to_usize(seconds / HOP.get()).unwrap())
405            .map(|i| 10f64.powf(f(cast::usize_to_f64(i) * HOP.get()) / 20.0))
406            .collect()
407    }
408
409    /// A hold that breaks into a dB-linear decay to `sustain`, a note-off, and
410    /// a linear release, with `wobble` in dB over the level.
411    fn adsr(sustain: f64, decay: f64, gap: f64, wobble: impl Fn(f64) -> f64) -> Vec<f64> {
412        let off = 0.1 + decay + gap;
413        track(
414            |t| {
415                let held = sustain * ((t - 0.1) / decay).clamp(0.0, 1.0);
416                let fall = 1.0 - ((t - off) / 0.05).clamp(0.0, 1.0);
417                held + 20.0 * fall.max(1e-4).log10() + wobble(t)
418            },
419            off + 0.1,
420        )
421    }
422
423    #[test]
424    fn a_sustain_shorter_than_its_decay_still_has_its_knee_at_the_note_off() {
425        // The 0.2 s decay would fill any look-back over the 50 ms sustain.
426        let amp = adsr(-12.0, 0.2, 0.05, |_| 0.0);
427        let knee = find_knee(0.0, &amp, usize::MAX).unwrap();
428        assert!((knee - 0.355).abs() < 0.002, "{knee}");
429    }
430
431    #[test]
432    fn a_bend_over_the_limit_keeps_the_earlier_corner() {
433        let off = 0.1 + 0.1 + 0.3;
434        let bend_of = |size: f64, hz: f64| {
435            adsr(-18.0, 0.1, 0.3, move |t| {
436                if t > 0.2 && t < off {
437                    size * (std::f64::consts::TAU * hz * t).sin()
438                } else {
439                    0.0
440                }
441            })
442        };
443        let smooth = find_knee(0.0, &bend_of(0.3, 25.0), usize::MAX).unwrap();
444        assert!((smooth - off).abs() < 0.002, "{smooth}");
445        let wobbling = find_knee(0.0, &bend_of(3.0, 25.0), usize::MAX).unwrap();
446        assert!(wobbling < 0.2, "{wobbling}");
447    }
448
449    #[test]
450    fn the_rise_leaves_out_the_frames_that_read_past_the_track() {
451        // A decay into a shelf, a release, and a next note's attack in the last 10
452        // frames. A rise over them would keep the corner of the decay.
453        let amp: Vec<f64> = (0..800_u32)
454            .map(|i| match i {
455                0..200 => 1.0,
456                200..300 => 1.0 - 0.75 * f64::from(i - 200) / 100.0,
457                300..500 => 0.25,
458                500..550 => 0.25 * (1.0 - f64::from(i - 500) / 50.0),
459                550..790 => 0.0,
460                _ => f64::from(i - 790) / 10.0,
461            })
462            .collect();
463        let at = |clear| find_knee(0.0, &amp, clear).unwrap();
464        assert!((at(790) - 0.5).abs() < 0.002, "{}", at(790));
465        assert!((at(800) - 0.2).abs() < 0.002, "{}", at(800));
466    }
467
468    /// A note-off at 300 ms into `gap` for 250 ms, then a next note from 600 ms at
469    /// `next` with its own note-off at 800 ms.
470    fn gap_then_next(gap: f64, next: f64) -> Vec<f64> {
471        (0..900_u32)
472            .map(|i| match i {
473                0..300 => 1.0,
474                300..350 => gap + (1.0 - gap) * (1.0 - f64::from(i - 300) / 50.0),
475                350..600 => gap,
476                600..610 => gap + (next - gap) * f64::from(i - 600) / 10.0,
477                610..800 => next,
478                800..850 => next * (1.0 - f64::from(i - 800) / 50.0),
479                _ => 0.0,
480            })
481            .collect()
482    }
483
484    #[test]
485    fn a_note_that_ends_before_the_next_onset_keeps_its_note_off() {
486        // The next note's corner lies past the clear frames, so it reads no rise, and
487        // the bend over the silent gap reads 0.
488        let knee = find_knee(0.0, &gap_then_next(0.0, 1.0), 600).unwrap();
489        assert!((knee - 0.3).abs() < 0.002, "{knee}");
490    }
491
492    #[test]
493    fn a_later_corner_past_the_clear_frames_needs_a_level_of_a_fiftieth_at_their_end() {
494        let at = |gap: f64| find_knee(0.0, &gap_then_next(gap, 1.0), 600).unwrap();
495        assert!((at(0.019) - 0.3).abs() < 0.002, "{}", at(0.019));
496        assert!((at(0.021) - 0.8).abs() < 0.002, "{}", at(0.021));
497    }
498
499    #[test]
500    fn a_later_corner_past_the_clear_frames_needs_the_level_to_come_back_by_a_quarter() {
501        // A gap at 0.01 under a next note at 1.2 and 1.3 times that level.
502        let at = |next: f64| find_knee(0.0, &gap_then_next(0.01, next), 600).unwrap();
503        assert!((at(0.012) - 0.8).abs() < 0.002, "{}", at(0.012));
504        assert!((at(0.013) - 0.3).abs() < 0.002, "{}", at(0.013));
505    }
506
507    #[test]
508    fn the_test_past_the_clear_frames_reads_the_given_level() {
509        // A stripped track whose gap reads 0.021 of the hold and an unstripped one at
510        // 0.019: the later corner past the clear frames is read on the second.
511        let stripped = gap_then_next(0.021, 1.0);
512        let knee = find_knee_against(0.0, &stripped, &gap_then_next(0.019, 1.0), 600).unwrap();
513        assert!((knee - 0.3).abs() < 0.002, "{knee}");
514        let knee = find_knee(0.0, &stripped, 600).unwrap();
515        assert!((knee - 0.8).abs() < 0.002, "{knee}");
516    }
517
518    /// A hold to 100 ms that decays to `level` by 200 ms, then `sustain(i)` from there
519    /// to a 50 ms fall at `off`.
520    fn low_sustain(off: u32, level: f64, sustain: impl Fn(f64) -> f64) -> Vec<f64> {
521        (0..off + 200)
522            .map(|i| {
523                let x = f64::from(i);
524                if i < 100 {
525                    1.0
526                } else if i < 200 {
527                    level.powf((x - 100.0) / 100.0)
528                } else if i < off {
529                    sustain(x)
530                } else {
531                    (sustain(f64::from(off)) * (1.0 - (x - f64::from(off)) / 50.0)).max(0.0)
532                }
533            })
534            .collect()
535    }
536
537    #[test]
538    fn a_rippled_sustain_under_the_floor_keeps_its_note_off_past_the_clear_frames() {
539        // A ripple of 1 % over 37 frames, and one of 25 % over 10 frames up to 600 ms,
540        // as a low note's level can ripple at twice its pitch, at every phase. A trough
541        // frame of the second would read the sustain as a third more than the stop.
542        for (depth, period, until) in [(0.01, 37.0, 700.0), (0.25, 10.0, 600.0)] {
543            for p in 0..40 {
544                let phase = std::f64::consts::TAU * f64::from(p) / 40.0;
545                let ripple = |x: f64| {
546                    let swing = if x < until { depth } else { 0.0 };
547                    0.01 * (1.0 + swing * (std::f64::consts::TAU * x / period + phase).sin())
548                };
549                let knee = find_knee(0.0, &low_sustain(700, 0.01, ripple), 500).unwrap();
550                assert!((knee - 0.7).abs() <= 0.015, "{depth} {p} {knee}");
551            }
552        }
553    }
554
555    #[test]
556    fn a_slowly_rising_sustain_under_the_floor_keeps_its_note_off_past_the_clear_frames() {
557        for level in [0.005, 0.008, 0.01, 0.013, 0.016, 0.019] {
558            let rising = move |x: f64| level + 1e-6 * (x - 200.0);
559            let knee = find_knee(0.0, &low_sustain(500, level, rising), 430).unwrap();
560            assert!((knee - 0.5).abs() < 0.005, "{level} {knee}");
561        }
562    }
563
564    #[test]
565    fn a_short_rise_into_a_note_off_past_the_clear_frames_is_no_next_note() {
566        // A sustain that rises over the 10 frames into its note-off, as a transcribed
567        // note reads: the line ends above the level at the stop, while the mean over
568        // 100 frames stays under a quarter more.
569        let amp: Vec<f64> = (0..520_u32)
570            .map(|i| {
571                let x = f64::from(i);
572                match i {
573                    0..100 => 1.0,
574                    100..300 => 0.01_f64.powf((x - 100.0) / 200.0),
575                    300..455 => 0.01,
576                    455..465 => 0.01 + 0.07 * (x - 455.0) / 10.0,
577                    465..475 => 0.08 * (1.0 - (x - 465.0) / 10.0),
578                    _ => 0.0,
579                }
580            })
581            .collect();
582        let knee = find_knee(0.0, &amp, 427).unwrap();
583        assert!(knee > 0.45, "{knee}");
584    }
585
586    #[test]
587    fn a_note_that_falls_steadily_into_a_note_off_past_the_clear_frames_keeps_it() {
588        // Down 33 dB by 340 ms, then a straight fall to the note-off at 440 ms: the
589        // mean over 100 frames lies over a quarter above the clear frames' end, and the
590        // left line ends under it.
591        let amp: Vec<f64> = (0..477_u32)
592            .map(|i| {
593                let x = f64::from(i);
594                match i {
595                    0..100 => 1.0,
596                    100..340 => 0.022_f64.powf((x - 100.0) / 240.0),
597                    340..440 => 0.022 - 0.014 * (x - 340.0) / 100.0,
598                    440..450 => 0.008 * (1.0 - (x - 440.0) / 10.0),
599                    _ => 0.0,
600                }
601            })
602            .collect();
603        let knee = find_knee(0.0, &amp, 427).unwrap();
604        assert!((knee - 0.44).abs() < 0.003, "{knee}");
605    }
606
607    #[test]
608    fn a_corner_at_the_last_clear_frame_counts_as_past_them() {
609        // A release into silence at 300 ms, and a lone fall from 0.2 that starts at the
610        // stop itself.
611        let amp: Vec<f64> = (0..600_u32)
612            .map(|i| match i {
613                0..300 => 1.0,
614                300..350 => 1.0 - f64::from(i - 300) / 50.0,
615                450..500 => 0.2 * (1.0 - f64::from(i - 450) / 50.0),
616                _ => 0.0,
617            })
618            .collect();
619        let knee = find_knee(0.0, &amp, 450).unwrap();
620        assert!((knee - 0.3).abs() < 0.002, "{knee}");
621    }
622
623    #[test]
624    fn the_stop_level_is_the_mean_over_the_last_10_clear_frames() {
625        // A gap at 0.002 with 0.014 over frames 590 to 595, before a next note at
626        // 0.0095 from 600, held over 200 frames before its note-off: over 10 frames the
627        // stop reads 0.008, so the next note lies under a quarter above it; over 5 or 20
628        // frames it reads 0.002 or 0.005.
629        let amp: Vec<f64> = (0..1000_u32)
630            .map(|i| {
631                let x = f64::from(i);
632                match i {
633                    0..300 => 1.0,
634                    300..350 => 0.002 + 0.998 * (1.0 - (x - 300.0) / 50.0),
635                    590..595 => 0.014,
636                    350..600 => 0.002,
637                    600..610 => 0.002 + 0.0075 * (x - 600.0) / 10.0,
638                    610..900 => 0.0095,
639                    900..950 => 0.0095 * (1.0 - (x - 900.0) / 50.0),
640                    _ => 0.0,
641                }
642            })
643            .collect();
644        let knee = find_knee(0.0, &amp, 600).unwrap();
645        assert!((knee - 0.9).abs() < 0.002, "{knee}");
646    }
647
648    #[test]
649    fn no_clear_frames_keep_the_rule_of_the_rise() {
650        // With nothing read as alone, the rise reads no frames and the later corner wins.
651        let knee = find_knee(0.0, &two_corners(|_| 0.25, |_| 0.0), 0).unwrap();
652        assert!((knee - 0.5).abs() < 0.002, "{knee}");
653    }
654
655    #[test]
656    fn the_floor_is_read_against_the_corner_that_the_later_one_would_replace() {
657        // A decay at 200 ms into a shelf at 0.25 and a release at 500 ms into `gap`,
658        // then a next note: the floor reads 2 % of the shelf, not of the hold.
659        let read = |gap: f64| {
660            let amp: Vec<f64> = (0..950_u32)
661                .map(|i| {
662                    let x = f64::from(i);
663                    match i {
664                        0..200 | 710..850 => 1.0,
665                        200..300 => 1.0 - 0.75 * (x - 200.0) / 100.0,
666                        300..500 => 0.25,
667                        500..550 => gap + (0.25 - gap) * (1.0 - (x - 500.0) / 50.0),
668                        550..700 => gap,
669                        700..710 => gap + (1.0 - gap) * (x - 700.0) / 10.0,
670                        850..900 => 1.0 - (x - 850.0) / 50.0,
671                        _ => 0.0,
672                    }
673                })
674                .collect();
675            find_knee(0.0, &amp, 700).unwrap()
676        };
677        assert!((read(0.004) - 0.5).abs() < 0.002, "{}", read(0.004));
678        assert!((read(0.01) - 0.85).abs() < 0.002, "{}", read(0.01));
679    }
680
681    #[test]
682    fn the_rise_means_span_20_frames() {
683        // A square wobble after the release, high first. Over 20 frames its means
684        // climb by its full height; with 21 frames by 19/21 of a period of 40, and with
685        // 20 frames by 18/20 of a period of 38.
686        let read = |period: u32, height: f64| {
687            let after = move |i: u32| {
688                if (i - 550) % period < period / 2 {
689                    height
690                } else {
691                    0.0
692                }
693            };
694            find_knee(0.0, &two_corners(|_| 0.25, after), usize::MAX).unwrap()
695        };
696        // The rise limit is 0.05 of the shelf at 0.25.
697        assert!(
698            (read(40, 0.0135) - 0.2).abs() < 0.002,
699            "{}",
700            read(40, 0.0135)
701        );
702        assert!(
703            (read(38, 0.013) - 0.49).abs() < 0.002,
704            "{}",
705            read(38, 0.013)
706        );
707    }
708
709    /// A decay at 200 ms into a shelf, and a release at 500 ms into silence.
710    fn two_corners(shelf: impl Fn(u32) -> f64, after: impl Fn(u32) -> f64) -> Vec<f64> {
711        (0..800_u32)
712            .map(|i| match i {
713                0..200 => 1.0,
714                200..300 => 1.0 - 0.75 * f64::from(i - 200) / 100.0,
715                300..500 => shelf(i),
716                500..550 => 0.25 * (1.0 - f64::from(i - 500) / 50.0),
717                _ => after(i),
718            })
719            .collect()
720    }
721
722    #[test]
723    fn a_later_corner_needs_a_bend_of_at_most_0_4_db() {
724        // A decay straight in dB at 200 ms into a shelf and a release at 500 ms, under
725        // a triangle 20 frames long whose bend reads 2.4 times its peak: 0.384 and 0.408.
726        let read = |peak_db: f64| {
727            let amp: Vec<f64> = (0..800_u32)
728                .map(|i| {
729                    let phase = f64::from(i % 20) / 5.0;
730                    let shape = if phase < 2.0 {
731                        phase - 1.0
732                    } else {
733                        3.0 - phase
734                    };
735                    let body = -12.0 * (f64::from(i.max(200) - 200) / 100.0).min(1.0);
736                    let level = 10f64.powf((body + peak_db * shape) / 20.0);
737                    match i {
738                        0..500 => level,
739                        500..550 => 0.25 * (1.0 - f64::from(i - 500) / 50.0),
740                        _ => 0.0,
741                    }
742                })
743                .collect();
744            find_knee(0.0, &amp, usize::MAX).unwrap()
745        };
746        assert!((read(0.16) - 0.5).abs() < 0.01, "{}", read(0.16));
747        assert!((read(0.17) - 0.19).abs() < 0.01, "{}", read(0.17));
748    }
749
750    #[test]
751    fn a_later_corner_needs_a_rise_of_at_most_a_twentieth_of_the_level_before_it() {
752        let read = |share: f64| {
753            let back = |i: u32| 0.25 * share * (f64::from(i.saturating_sub(600)) / 50.0).min(1.0);
754            find_knee(0.0, &two_corners(|_| 0.25, back), usize::MAX).unwrap()
755        };
756        assert!((read(0.045) - 0.5).abs() < 0.002, "{}", read(0.045));
757        assert!((read(0.055) - 0.2).abs() < 0.002, "{}", read(0.055));
758    }
759
760    #[test]
761    fn the_fall_is_read_from_a_level_of_85_percent_of_the_held_one() {
762        // At 0.82 of the held level a step reads no fall, where 80 % would.
763        let step: Vec<f64> = (0..600_u32)
764            .map(|i| match i {
765                0..300 => 1.0,
766                300..330 => 0.82,
767                330..430 => 0.82 * (1.0 - f64::from(i - 330) / 100.0),
768                _ => 0.0,
769            })
770            .collect();
771        let knee = find_knee(0.0, &step, usize::MAX).unwrap();
772        assert!((knee - 0.285).abs() < 0.003, "{knee}");
773        // A slope down to 0.89 before a steeper fall reads the fall from it, where 95 %
774        // would read it from the slope's start.
775        let points = [
776            (0.0, 1.0),
777            (312.0, 1.0),
778            (366.0, 0.89),
779            (397.0, 0.37),
780            (447.0, 0.0),
781            (647.0, 0.0),
782        ];
783        let amp: Vec<f64> = (0..647_u32)
784            .map(|i| {
785                let x = f64::from(i);
786                let w = points.windows(2).find(|w| x < w[1].0).unwrap();
787                w[0].1 + (w[1].1 - w[0].1) * (x - w[0].0) / (w[1].0 - w[0].0)
788            })
789            .collect();
790        let knee = find_knee(0.0, &amp, usize::MAX).unwrap();
791        assert!((knee - 0.362).abs() < 0.002, "{knee}");
792    }
793
794    #[test]
795    fn the_fall_read_stops_where_the_note_stops_being_alone() {
796        // The step of the test above, then a next note's level at 400 ms.
797        let amp: Vec<f64> = (0..600_u32)
798            .map(|i| match i {
799                0..300 | 400..450 => 1.0,
800                300..330 => 0.82,
801                330..400 => 0.82 * (1.0 - f64::from(i - 330) / 70.0),
802                450..500 => 1.0 - f64::from(i - 450) / 50.0,
803                _ => 0.0,
804            })
805            .collect();
806        let at = |clear| find_knee(0.0, &amp, clear).unwrap();
807        assert!((at(380) - 0.289).abs() < 0.003, "{}", at(380));
808        assert!(at(usize::MAX) > 0.3, "{}", at(usize::MAX));
809    }
810
811    #[test]
812    fn a_fall_read_stops_at_the_frame_it_starts_from() {
813        // A swell after a fall: the line over the last fall starts above the held level,
814        // so it crosses that level past the frame it starts from.
815        let points = [
816            (0.0, 1.0),
817            (99.0, 0.91),
818            (129.0, 0.91),
819            (173.0, 0.49),
820            (201.0, 0.49),
821            (239.0, 0.35),
822            (268.0, 0.004),
823            (283.0, 3.85),
824            (298.0, 3.85),
825            (328.0, 0.0),
826            (498.0, 0.0),
827        ];
828        let amp: Vec<f64> = (0..498_u32)
829            .map(|i| {
830                let x = f64::from(i);
831                let w = points.windows(2).find(|w| x < w[1].0).unwrap();
832                w[0].1 + (w[1].1 - w[0].1) * (x - w[0].0) / (w[1].0 - w[0].0)
833            })
834            .collect();
835        let knee = find_knee(0.0, &amp, usize::MAX).unwrap();
836        assert!((knee - 0.299).abs() < 1e-3, "{knee}");
837    }
838
839    #[test]
840    fn a_noise_floor_under_the_tail_does_not_hide_the_note_off() {
841        // A floor that wobbles by a tenth of the shelf after the release, as noise
842        // at -40 dBFS does under a quiet sustain.
843        let amp: Vec<f64> = (0..800_u32)
844            .map(|i| match i {
845                0..200 => 1.0,
846                200..300 => 1.0 - 0.75 * f64::from(i - 200) / 100.0,
847                300..500 => 0.25,
848                500..550 => 0.25 - 0.235 * f64::from(i - 500) / 50.0,
849                _ => 0.015 + if i % 20 < 10 { 0.0 } else { 0.025 },
850            })
851            .collect();
852        let knee = find_knee(0.0, &amp, usize::MAX).unwrap();
853        assert!((knee - 0.5).abs() < 0.002, "{knee}");
854        // A wobble twice as long shows through a mean of 20 frames.
855        let slow = |i: u32| if i % 40 < 20 { 0.0 } else { 0.05 };
856        let knee = find_knee(0.0, &two_corners(|_| 0.25, slow), usize::MAX).unwrap();
857        assert!((knee - 0.2).abs() < 0.01, "{knee}");
858    }
859
860    #[test]
861    fn a_later_corner_with_a_rise_after_it_keeps_the_earlier_one() {
862        // A level that returns to a tenth of the shelf after its fall.
863        let amp: Vec<f64> = (0..800_u32)
864            .map(|i| match i {
865                0..200 => 1.0,
866                200..300 => 1.0 - 0.75 * f64::from(i - 200) / 100.0,
867                300..500 => 0.25,
868                500..550 => 0.25 * (1.0 - f64::from(i - 500) / 50.0),
869                550..600 => 0.25 * 0.3 * f64::from(i - 550) / 50.0,
870                _ => 0.25 * 0.3,
871            })
872            .collect();
873        let knee = find_knee(0.0, &amp, usize::MAX).unwrap();
874        assert!((knee - 0.2).abs() < 0.002, "{knee}");
875    }
876
877    #[test]
878    fn the_fall_read_starts_from_the_level_at_the_split() {
879        // A slow rise to 1, under which the 100 ms mean lies: the fall from 1
880        // reads from the level at the split, not from the mean.
881        let amp: Vec<f64> = (0..600_u32)
882            .map(|i| {
883                let x = f64::from(i);
884                match i {
885                    0..300 => 0.6 + 0.4 * x / 300.0,
886                    300..315 => 1.0 - 0.45 * (x - 300.0) / 15.0,
887                    315..340 => 0.55 + 0.45 * (x - 315.0) / 25.0,
888                    340..440 => 1.0 - (x - 340.0) / 100.0,
889                    _ => 0.0,
890                }
891            })
892            .collect();
893        let knee = find_knee(0.0, &amp, usize::MAX).unwrap();
894        assert!((knee - 0.3445).abs() < 0.002, "{knee}");
895    }
896
897    #[test]
898    fn a_linear_release_has_its_knee_at_the_note_off() {
899        // Held at -6 dB, then a linear 200 ms fade, read up to 190 ms in.
900        let amp: Vec<f64> = (0..590)
901            .map(|i| {
902                let t = cast::usize_to_f64(i) * HOP.get();
903                0.5 * (1.0 - (t - 0.4).max(0.0) / 0.2)
904            })
905            .collect();
906        let knee = find_knee(0.0, &amp, usize::MAX).unwrap();
907        assert!((knee - 0.4).abs() < 0.002, "{knee}");
908    }
909
910    #[test]
911    fn a_held_level_that_breaks_into_a_fall_has_its_knee_at_the_break() {
912        let db = track(
913            |t| {
914                if t < 0.4 {
915                    -6.0
916                } else {
917                    -6.0 - 200.0 * (t - 0.4)
918                }
919            },
920            0.5,
921        );
922        let knee = find_knee(1.0, &db, 0).unwrap();
923        assert!((knee - 1.4).abs() < 0.002, "{knee}");
924    }
925
926    #[test]
927    fn a_hold_that_breaks_into_a_linear_decay_is_not_the_note_off() {
928        // Held at 1 for 30 ms, a linear decay to 0.063 over 300 ms, held,
929        // then a linear 20 ms release at 0.8 s, read 15 ms past its end.
930        let amp: Vec<f64> = (0..835)
931            .map(|i| {
932                let t = cast::usize_to_f64(i) * HOP.get();
933                let held = if t < 0.03 {
934                    1.0
935                } else {
936                    (1.0 - 0.937 * (t - 0.03) / 0.3).max(0.063)
937                };
938                held * (1.0 - (t - 0.8).max(0.0) / 0.02).max(0.0)
939            })
940            .collect();
941        let knee = find_knee(0.0, &amp, usize::MAX).unwrap();
942        assert!((knee - 0.8).abs() < 0.002, "{knee}");
943    }
944
945    #[test]
946    fn a_hold_that_breaks_into_a_curved_fall_has_its_knee_at_the_break() {
947        // Held at 1 for 500 frames, then `1 - (t / 150)^1.1` over 150
948        // frames: the fall steepens from the break on.
949        let amp: Vec<f64> = (0..650)
950            .map(|i| {
951                let t = cast::usize_to_f64(i.max(500) - 500);
952                1.0 - (t / 150.0).powf(1.1)
953            })
954            .collect();
955        let knee = find_knee(0.0, &amp, usize::MAX).unwrap();
956        assert!((knee - 0.5011).abs() < 5e-4, "{knee}");
957    }
958
959    #[test]
960    fn a_two_stage_release_reads_the_later_corner() {
961        // A shelf in the release looks like a decay into a sustain, so the
962        // knee reads the second stage. Held at 1 to
963        // 0.4 s, down to 0.5 by 0.45 s, flat to 0.7 s, then to 0 by 0.8 s.
964        let amp: Vec<f64> = (0..820)
965            .map(|i| {
966                let t = cast::usize_to_f64(i) * HOP.get();
967                let first = 1.0 - 0.5 * ((t - 0.4) / 0.05).clamp(0.0, 1.0);
968                first * (1.0 - ((t - 0.7) / 0.1).clamp(0.0, 1.0))
969            })
970            .collect();
971        let knee = find_knee(0.0, &amp, usize::MAX).unwrap();
972        assert!((knee - 0.7).abs() < 0.002, "{knee}");
973    }
974
975    #[test]
976    fn a_fall_at_one_rate_has_no_knee() {
977        let db = track(|t| -40.0 * t, 1.0);
978        assert_eq!(find_knee(0.0, &db, usize::MAX), None);
979    }
980
981    #[test]
982    fn a_slope_that_flattens_is_no_knee() {
983        // A decay into its sustain level steepens nowhere.
984        let db = track(|t| if t < 0.3 { -20.0 * t / 0.3 } else { -20.0 }, 0.8);
985        assert_eq!(find_knee(0.0, &db, usize::MAX), None);
986    }
987
988    #[test]
989    fn the_knee_of_a_decay_sustain_release_is_the_release() {
990        let db = track(
991            |t| {
992                if t < 0.2 {
993                    -30.0 * t
994                } else if t < 0.7 {
995                    -6.0
996                } else {
997                    -6.0 - 300.0 * (t - 0.7)
998                }
999            },
1000            0.8,
1001        );
1002        let knee = find_knee(0.0, &db, usize::MAX).unwrap();
1003        assert!((knee - 0.7).abs() < 0.002, "{knee}");
1004    }
1005
1006    #[test]
1007    fn a_crossing_before_the_search_starts_falls_back_to_the_split() {
1008        // 20 frames at 1, a step to 0.6, then a slow fall to 0 over 60
1009        // frames: the tangent after the step crosses the held level 20
1010        // frames before the first frame, so the knee is the split itself.
1011        let amp: Vec<f64> = (0..100)
1012            .map(|i| match i {
1013                0..20 => 1.0,
1014                20..80 => 0.6 * (1.0 - cast::usize_to_f64(i - 20) / 60.0),
1015                _ => 0.0,
1016            })
1017            .collect();
1018        let knee = find_knee(1.0, &amp, 0).unwrap();
1019        assert!(
1020            (knee - (1.0 + 20.0 * HOP.get())).abs() < 1.5 * HOP.get(),
1021            "{knee}"
1022        );
1023    }
1024
1025    proptest::proptest! {
1026        #[test]
1027        fn a_knee_lies_inside_the_searched_frames(
1028            amp in proptest::collection::vec(0.0..1.0f64, 0..400),
1029            start in 0.0..10.0f64,
1030        ) {
1031            if let Some(knee) = find_knee(start, &amp, 0) {
1032                let end = start + cast::usize_to_f64(amp.len()) * HOP.get();
1033                proptest::prop_assert!(knee >= start && knee <= end, "{knee}");
1034            }
1035        }
1036    }
1037}