Skip to main content

sound_to_patch/audio/
stem.rs

1//! The stem that every estimator reads: samples, mid and side, the beat
2//! grid, the band edge, the flat-top flag and the stereo screen.
3
4use std::fmt;
5
6use crate::cast;
7use crate::confidence::{ConfidenceError, Estimate, Level, Reason, ReasonCode, Signal};
8use crate::dsp::descriptors::too_few_frames;
9use crate::dsp::{ActiveFrames, MIN_ACTIVE_FRAMES, Stft, StftParams, Summary, Window};
10use crate::notes::NoteList;
11use crate::units::{Bpm, Decibels, Hz, Normalized, Seconds};
12
13use super::stereo::{StereoScreen, stereo_screen};
14use super::{Audio, SampleRate};
15
16/// The median f0 below which harmonic analysis uses its long frame.
17///
18/// At 44.1 kHz the frame is 8192 samples below it, else 4096: a Hann main
19/// lobe is about 4 bins wide, so harmonics `f0` apart need
20/// `N >= 4 fs / f0`. At another rate the length scales with the rate, see
21/// [`Stem::frame_len`].
22pub const LONG_FRAME_BELOW_HZ: Hz = Hz::from_const(100.0);
23/// A fall of at least this much over one semitone marks a cutoff, and the
24/// band edge sits at its half-depth point. Synthetic cuts fall 57 to 91 dB
25/// there, a 2-pole roll-off about 1.3 dB.
26pub const BAND_EDGE_STEEP_DROP_DB: Decibels = Decibels::from_const(25.0);
27/// The plateau allowance of a steep fall in the band-edge rule.
28///
29/// A steep fall marks a cut only when the median level over the octave
30/// below it lies within this much of the level where it starts: a cut ends
31/// a band of content, while the fall above a lone tone does not.
32pub const BAND_EDGE_PLATEAU_DB: Decibels = Decibels::from_const(20.0);
33/// The margin above the noise floor that the fallback band-edge rule needs.
34///
35/// The fallback serves a spectrum without a steep fall. A cut can leave a
36/// shelf of noise above it, which a small margin reads as content; with a
37/// shelf 60 dB down above a 16 kHz cut this margin still misses the cut by
38/// 3.2 semitones, which is why the steep-fall rule comes first.
39pub const BAND_EDGE_MARGIN_DB: Decibels = Decibels::from_const(30.0);
40/// Samples within this share of the peak count as at the ceiling.
41pub const CEILING_TOLERANCE: Normalized = Normalized::from_const(1e-5);
42/// The shortest run at the ceiling that counts as a flat top.
43pub const FLAT_TOP_MIN_RUN_S: Seconds = Seconds::from_const(0.001);
44/// The approach share of a flat-top run.
45///
46/// A run counts only when the samples on both sides of it lie at least this
47/// share of the ceiling on the same side: clipping leaves a wave that runs
48/// into the ceiling, while a square or pulse wave jumps to it.
49pub const CLIP_APPROACH: Normalized = Normalized::from_const(0.5);
50/// The share of samples in flat-top runs that marks a stem hard-limited.
51pub const FLAT_TOP_MIN_SHARE: Normalized = Normalized::from_const(0.001);
52
53/// The convention of the band edge.
54pub const BAND_EDGE_CONVENTION: &str = "long-term power spectrum of the mid over the active \
55frames that lie wholly inside the stem, median-smoothed over one semitone on each side; where \
56the smoothed level falls at least 25 dB over one semitone above 2 kHz and the median level of \
57the octave below lies within 20 dB of the level where the fall starts, the edge is the \
58half-depth point of the steepest such fall; otherwise the noise floor is the lowest level above \
591 kHz and the edge is the highest bin at least 30 dB above it, at low confidence, or the Nyquist \
60frequency when no bin above 1 kHz is";
61/// The convention of the flat-top flag.
62pub const HARD_LIMITED_CONVENTION: &str = "true when runs of at least 1 ms of samples \
63within 1e-5 of the channel peak, each with its neighbours on both sides at least half the \
64peak on the same side, cover at least 0.1 % of one of the first two channels";
65
66/// The length of a note division in beats, before its feel.
67#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
68pub enum NoteValue {
69    /// Four bars of 4/4, 16 beats.
70    FourBars,
71    /// Two bars of 4/4, 8 beats.
72    TwoBars,
73    /// A whole note, one bar of 4/4, 4 beats.
74    Whole,
75    /// A half note, 2 beats.
76    Half,
77    /// A quarter note, 1 beat.
78    Quarter,
79    /// An eighth note, half a beat.
80    Eighth,
81    /// A sixteenth note, a quarter beat.
82    Sixteenth,
83    /// A thirty-second note, an eighth of a beat.
84    ThirtySecond,
85}
86
87impl NoteValue {
88    fn beats(self) -> f64 {
89        match self {
90            Self::FourBars => 16.0,
91            Self::TwoBars => 8.0,
92            Self::Whole => 4.0,
93            Self::Half => 2.0,
94            Self::Quarter => 1.0,
95            Self::Eighth => 0.5,
96            Self::Sixteenth => 0.25,
97            Self::ThirtySecond => 0.125,
98        }
99    }
100
101    fn label(self) -> &'static str {
102        match self {
103            Self::FourBars => "4 bars",
104            Self::TwoBars => "2 bars",
105            Self::Whole => "1/1",
106            Self::Half => "1/2",
107            Self::Quarter => "1/4",
108            Self::Eighth => "1/8",
109            Self::Sixteenth => "1/16",
110            Self::ThirtySecond => "1/32",
111        }
112    }
113}
114
115/// The feel of a note division.
116#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
117pub enum Feel {
118    /// The plain value.
119    Straight,
120    /// Three in the time of two: the rate times 1.5.
121    Triplet,
122    /// One and a half times as long: the rate times 2/3.
123    Dotted,
124}
125
126impl Feel {
127    /// The length of a cycle against the plain value, as `(num, den)`.
128    /// Both [`Division::beats_per_cycle`] and [`Division::rate`] read it,
129    /// so a new feel changes one place.
130    fn stretch(self) -> (f64, f64) {
131        match self {
132            Self::Straight => (1.0, 1.0),
133            Self::Triplet => (2.0, 3.0),
134            Self::Dotted => (3.0, 2.0),
135        }
136    }
137}
138
139/// A note division, such as a dotted eighth, that an LFO rate can lock to.
140#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
141pub struct Division {
142    /// The note value.
143    pub value: NoteValue,
144    /// The feel.
145    pub feel: Feel,
146}
147
148impl Division {
149    /// The supported divisions: 1/1 to 1/32, each straight, triplet and
150    /// dotted, plus 2 and 4 bars, slowest first.
151    pub const ALL: [Self; 20] = {
152        use Feel::{Dotted, Straight, Triplet};
153        use NoteValue::{Eighth, FourBars, Half, Quarter, Sixteenth, ThirtySecond, TwoBars, Whole};
154        [
155            Self::of(FourBars, Straight),
156            Self::of(TwoBars, Straight),
157            Self::of(Whole, Dotted),
158            Self::of(Whole, Straight),
159            Self::of(Half, Dotted),
160            Self::of(Whole, Triplet),
161            Self::of(Half, Straight),
162            Self::of(Quarter, Dotted),
163            Self::of(Half, Triplet),
164            Self::of(Quarter, Straight),
165            Self::of(Eighth, Dotted),
166            Self::of(Quarter, Triplet),
167            Self::of(Eighth, Straight),
168            Self::of(Sixteenth, Dotted),
169            Self::of(Eighth, Triplet),
170            Self::of(Sixteenth, Straight),
171            Self::of(ThirtySecond, Dotted),
172            Self::of(Sixteenth, Triplet),
173            Self::of(ThirtySecond, Straight),
174            Self::of(ThirtySecond, Triplet),
175        ]
176    };
177
178    const fn of(value: NoteValue, feel: Feel) -> Self {
179        Self { value, feel }
180    }
181
182    /// Returns the beats one cycle lasts: the note value's beats, times 2/3
183    /// for a triplet and 3/2 for a dotted note.
184    #[must_use]
185    pub fn beats_per_cycle(self) -> f64 {
186        let (num, den) = self.feel.stretch();
187        self.value.beats() * num / den
188    }
189
190    /// Returns the rate of one cycle per division at a tempo,
191    /// `(BPM / 60) / beats_per_cycle`.
192    #[must_use]
193    pub fn rate(self, bpm: Bpm) -> Hz {
194        // Dividing by `beats_per_cycle` would round once more and move some
195        // rates by one ulp; this order keeps the earlier values bit for bit.
196        let (num, den) = self.feel.stretch();
197        Hz::clamped(bpm.get() / 60.0 / self.value.beats() * den / num)
198    }
199}
200
201impl fmt::Display for Division {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        let suffix = match self.feel {
204            Feel::Straight => "",
205            Feel::Triplet => "T",
206            Feel::Dotted => "D",
207        };
208        write!(f, "{}{suffix}", self.value.label())
209    }
210}
211
212/// The beat grid from the BPM. The BPM gives the beat period, not where the
213/// first beat falls, so the offset is given, never estimated; it defaults
214/// to 0.
215#[derive(Clone, Copy, Debug, PartialEq)]
216pub struct BeatGrid {
217    bpm: Bpm,
218    offset: Seconds,
219}
220
221impl BeatGrid {
222    /// A grid at `bpm` whose first beat falls at `offset`.
223    #[must_use]
224    pub fn new(bpm: Bpm, offset: Seconds) -> Self {
225        Self { bpm, offset }
226    }
227
228    /// Returns the tempo.
229    #[must_use]
230    pub fn bpm(self) -> Bpm {
231        self.bpm
232    }
233
234    /// Returns the time of the first beat.
235    #[must_use]
236    pub fn offset(self) -> Seconds {
237        self.offset
238    }
239
240    /// Returns the beat period, `60 / BPM` seconds.
241    #[must_use]
242    pub fn beat_period(self) -> Seconds {
243        Seconds::clamped(60.0 / self.bpm.get())
244    }
245
246    /// Returns the time of beat `n`, counted from 0 at the offset.
247    #[must_use]
248    pub fn beat_time(self, beat: usize) -> Seconds {
249        Seconds::clamped(self.offset.get() + cast::usize_to_f64(beat) * 60.0 / self.bpm.get())
250    }
251
252    /// Returns the rate of a division at the grid's tempo.
253    #[must_use]
254    pub fn rate(self, division: Division) -> Hz {
255        division.rate(self.bpm)
256    }
257}
258
259/// The stem: native-rate samples with mid and side, the beat grid, the
260/// band edge, the flat-top flag and the stereo screen.
261#[derive(Clone, Debug, PartialEq)]
262pub struct Stem {
263    audio: Audio,
264    mid: Vec<f64>,
265    grid: BeatGrid,
266    median_f0: Option<Hz>,
267    frame_len: usize,
268    band_edge: Estimate<Hz, Hz>,
269    hard_limited: bool,
270    stereo: StereoScreen,
271}
272
273impl Stem {
274    /// Builds the stem. The median f0 of `notes` picks the frame length and
275    /// feeds the stereo phase; without notes the frame is 4096 samples and
276    /// every phase is `None`.
277    ///
278    /// # Errors
279    ///
280    /// Never in practice; the reasons are built from counts and flags.
281    pub fn new(
282        audio: Audio,
283        grid: BeatGrid,
284        notes: Option<&NoteList>,
285    ) -> Result<Self, ConfidenceError> {
286        let mid = audio.mono();
287        let median_f0 = notes.and_then(median_f0);
288        let rate = audio.sample_rate();
289        let frame_len = frame_len_for(median_f0, rate);
290        let band_edge = band_edge(&mid, rate, StftParams::fixed(frame_len))?;
291        // The first two channels, the pair the mid and the stereo screen read.
292        let hard_limited = audio
293            .channels()
294            .iter()
295            .take(2)
296            .any(|channel| is_flat_topped(channel, rate));
297        let stereo = match audio.channels() {
298            [left, right, ..] => stereo_screen(left, right, rate, median_f0)?,
299            _ => StereoScreen::one_channel()?,
300        };
301        Ok(Self {
302            audio,
303            mid,
304            grid,
305            median_f0,
306            frame_len,
307            band_edge,
308            hard_limited,
309            stereo,
310        })
311    }
312
313    /// Returns the decoded audio with every channel.
314    #[must_use]
315    pub fn audio(&self) -> &Audio {
316        &self.audio
317    }
318
319    /// Returns the sample rate.
320    #[must_use]
321    pub fn sample_rate(&self) -> SampleRate {
322        self.audio.sample_rate()
323    }
324
325    /// Returns the length.
326    #[must_use]
327    pub fn duration(&self) -> Seconds {
328        self.audio.duration()
329    }
330
331    /// Returns the mid, `M = (L + R) / 2`: the mono signal of every
332    /// non-stereo estimate. A one-channel file's mid is its channel; a file
333    /// of three or more channels mixes only the first two, as the side and
334    /// the stereo screen read them.
335    #[must_use]
336    pub fn mid(&self) -> &[f64] {
337        &self.mid
338    }
339
340    /// Returns the side, `S = (L - R) / 2`, computed on demand to spare
341    /// memory, or `None` for a one-channel file.
342    #[must_use]
343    pub fn side(&self) -> Option<Vec<f64>> {
344        match self.audio.channels() {
345            [left, right, ..] => Some(left.iter().zip(right).map(|(l, r)| (l - r) / 2.0).collect()),
346            _ => None,
347        }
348    }
349
350    /// Returns the beat grid.
351    #[must_use]
352    pub fn grid(&self) -> BeatGrid {
353        self.grid
354    }
355
356    /// Returns the median f0 of the note list, if one was given.
357    #[must_use]
358    pub fn median_f0(&self) -> Option<Hz> {
359        self.median_f0
360    }
361
362    /// Returns the frame length of harmonic analysis: at 44.1 kHz 8192
363    /// below a median f0 of 100 Hz, else 4096, and 4096 without a note
364    /// list. At another rate the length is the power of two nearest, in log,
365    /// to that length times `fs / 44100`, so the frame keeps its duration
366    /// within a factor of the square root of 2: 32 and 48 kHz keep 4096 and
367    /// 8192, 96 kHz takes 8192 and 16384.
368    #[must_use]
369    pub fn frame_len(&self) -> usize {
370        self.frame_len
371    }
372
373    /// Returns the band edge: the highest frequency that stays above the
374    /// noise floor. A filter cutoff within one octave of it is never
375    /// reported.
376    #[must_use]
377    pub fn band_edge(&self) -> &Estimate<Hz, Hz> {
378        &self.band_edge
379    }
380
381    /// Returns whether the stem is flat-topped at its peak, as a limiter or
382    /// clipping leaves it, in either of its first two channels.
383    #[must_use]
384    pub fn hard_limited(&self) -> bool {
385        self.hard_limited
386    }
387
388    /// Returns the stereo screen of the whole stem.
389    #[must_use]
390    pub fn stereo(&self) -> &StereoScreen {
391        &self.stereo
392    }
393}
394
395fn median_f0(notes: &NoteList) -> Option<Hz> {
396    let frequencies: Vec<f64> = notes
397        .notes()
398        .iter()
399        .map(|note| Hz::from(note.pitch()).get())
400        .collect();
401    Summary::of(&frequencies).map(|summary| Hz::clamped(summary.median()))
402}
403
404/// The rate whose frame lengths are 4096 and 8192 samples.
405const FRAME_BASE_RATE_HZ: f64 = 44_100.0;
406
407fn frame_len_for(median_f0: Option<Hz>, rate: SampleRate) -> usize {
408    let base = match median_f0 {
409        Some(f0) if f0 < LONG_FRAME_BELOW_HZ => 8192.0,
410        _ => 4096.0,
411    };
412    let wanted = base * rate.hz().get() / FRAME_BASE_RATE_HZ;
413    let up = cast::ceil_to_usize(wanted)
414        .unwrap_or(1)
415        .max(1)
416        .next_power_of_two();
417    // Nearest in log: halve when the power above lies more than a factor of
418    // the square root of 2 above the wanted length.
419    if cast::usize_to_f64(up) > wanted * std::f64::consts::SQRT_2 {
420        (up / 2).max(1)
421    } else {
422        up
423    }
424}
425
426/// The band edge of a mono signal: see [`BAND_EDGE_CONVENTION`].
427///
428/// # Errors
429///
430/// Never in practice; the reasons are built from counts and flags.
431pub fn band_edge(
432    samples: &[f64],
433    rate: SampleRate,
434    params: StftParams,
435) -> Result<Estimate<Hz, Hz>, ConfidenceError> {
436    let params = params.with_window(Window::Hann);
437    let active = ActiveFrames::of(samples, params);
438    let frames = Reason::new(
439        ReasonCode::ActiveFrames,
440        Signal::Count(u64::try_from(active.count()).unwrap_or(u64::MAX)),
441    )?;
442    if active.count() == 0 {
443        return Estimate::none(vec![
444            Reason::new(ReasonCode::SilentStem, Signal::Flag(true))?,
445            frames,
446        ]);
447    }
448    if active.count() < MIN_ACTIVE_FRAMES {
449        return Estimate::none(vec![frames, too_few_frames()?]);
450    }
451    // A frame that reaches into the padding sees the signal start or stop as
452    // a step, whose broadband energy would hide a low-pass. Such frames count
453    // only when no frame lies wholly inside.
454    let inside: Vec<usize> = active
455        .indices()
456        .filter(|&frame| {
457            let start = params.frame_start(frame);
458            start >= 0
459                && usize::try_from(start)
460                    .is_ok_and(|start| start + params.frame_len() <= samples.len())
461        })
462        .collect();
463    let frames_used = if inside.is_empty() {
464        active.indices().collect()
465    } else {
466        inside
467    };
468    let (smoothed, bin_hz) = smoothed_spectrum(samples, rate, params, &frames_used);
469    let steepest = steepest_edge(&smoothed, bin_hz);
470    let depth = steepest.map_or(0.0, |(_, depth)| depth);
471    let mut reasons = vec![
472        frames,
473        Reason::new(ReasonCode::BandEdgeDrop, Signal::Number(depth))?,
474    ];
475    // Without a steep fall the margin rule only bounds where the content
476    // ends, so its edge is Low. A spectrum that never rises the margin above
477    // its floor has no edge below the Nyquist frequency.
478    let (edge, level) = match steepest {
479        Some((edge, depth)) if depth >= BAND_EDGE_STEEP_DROP_DB.get() => (edge, Level::High),
480        _ => {
481            if let Some(edge) = margin_edge(&smoothed, bin_hz) {
482                (edge, Level::Low)
483            } else {
484                reasons.push(Reason::new(ReasonCode::NoBandEdge, Signal::Flag(true))?);
485                (smoothed.len().saturating_sub(1), Level::Low)
486            }
487        }
488    };
489    Estimate::new(
490        Hz::clamped(cast::usize_to_f64(edge) * bin_hz),
491        level,
492        Some(Hz::clamped(bin_hz)),
493        reasons,
494    )
495}
496
497/// The long-term power spectrum in dB over `frames`, median-smoothed over
498/// one semitone, and the bin width.
499fn smoothed_spectrum(
500    samples: &[f64],
501    rate: SampleRate,
502    params: StftParams,
503    frames: &[usize],
504) -> (Vec<f64>, f64) {
505    let mut stft = Stft::new(params);
506    let mut power = vec![0.0; params.bins()];
507    for &frame in frames {
508        for (sum, value) in power.iter_mut().zip(stft.frame(samples, frame)) {
509            *sum += value.norm_sqr();
510        }
511    }
512    let peak = power.iter().copied().fold(0.0, f64::max);
513    // A floor far below any real noise keeps the logarithm finite.
514    let levels: Vec<f64> = power
515        .iter()
516        .map(|p| 10.0 * (p + peak * 1e-30).log10())
517        .collect();
518    (semitone_median(&levels), params.bin_width(rate).get())
519}
520
521/// The fallback rule: the highest bin at least [`BAND_EDGE_MARGIN_DB`]
522/// above the floor, the lowest smoothed level above 1 kHz. `None` when no
523/// bin above 1 kHz rises that far.
524fn margin_edge(smoothed: &[f64], bin_hz: f64) -> Option<usize> {
525    let first_above_1k = cast::ceil_to_usize(1000.0 / bin_hz).unwrap_or(1).max(1);
526    let floor = smoothed
527        .get(first_above_1k..)
528        .and_then(|band| band.iter().copied().reduce(f64::min))
529        .unwrap_or(f64::NEG_INFINITY);
530    smoothed
531        .iter()
532        .rposition(|&level| level >= floor + BAND_EDGE_MARGIN_DB.get())
533        .filter(|&edge| edge >= first_above_1k)
534}
535
536/// The steepest fall of the smoothed level over one semitone above 2 kHz:
537/// the bin at the half-depth point of that fall, and its depth in dB.
538fn steepest_edge(smoothed: &[f64], bin_hz: f64) -> Option<(usize, f64)> {
539    let semitone = 2.0_f64.powf(1.0 / 12.0);
540    let first = cast::ceil_to_usize(2000.0 / bin_hz).unwrap_or(1).max(1);
541    let (depth, start, stop) = (first..smoothed.len())
542        .filter_map(|bin| {
543            let stop = cast::round_to_usize(cast::usize_to_f64(bin) * semitone)?;
544            let below = *smoothed.get(stop)?;
545            Some((smoothed[bin] - below, bin, stop))
546        })
547        .fold(None, |best: Option<(f64, usize, usize)>, item| match best {
548            Some(b) if b.0 >= item.0 => Some(b),
549            _ => Some(item),
550        })?;
551    let half = smoothed[start] - depth / 2.0;
552    let edge = (start..=stop).rev().find(|&bin| smoothed[bin] >= half)?;
553    // A cut ends a band of content; the edge of a lone tone does not. The
554    // octave below the fall must sit within the plateau allowance of its top.
555    let octave = Summary::of(&smoothed[start / 2..start])?.median();
556    let supported = octave >= smoothed[start] - BAND_EDGE_PLATEAU_DB.get();
557    Some((edge, if supported { depth } else { 0.0 }))
558}
559
560/// A running median of the levels over one semitone on each side of each
561/// bin, at least two bins. A median keeps a step edge where it is.
562fn semitone_median(levels: &[f64]) -> Vec<f64> {
563    let semitone = 2.0_f64.powf(1.0 / 12.0) - 1.0;
564    (0..levels.len())
565        .map(|bin| {
566            let half = cast::round_to_usize(cast::usize_to_f64(bin) * semitone)
567                .unwrap_or(2)
568                .max(2);
569            let lo = bin.saturating_sub(half);
570            let hi = (bin + half + 1).min(levels.len());
571            Summary::of(&levels[lo..hi]).map_or(levels[bin], Summary::median)
572        })
573        .collect()
574}
575
576/// Whether a channel is flat-topped: see [`HARD_LIMITED_CONVENTION`].
577#[must_use]
578pub fn is_flat_topped(channel: &[f64], rate: SampleRate) -> bool {
579    let peak = channel.iter().fold(0.0_f64, |max, x| max.max(x.abs()));
580    if peak <= 0.0 || channel.is_empty() {
581        return false;
582    }
583    let ceiling = peak * (1.0 - CEILING_TOLERANCE.get());
584    let min_run = cast::ceil_to_usize(FLAT_TOP_MIN_RUN_S.get() * rate.hz().get())
585        .unwrap_or(1)
586        .max(2);
587    // A clipped wave runs into the ceiling and out of it on the same side; a
588    // square or pulse wave jumps to it from the other side.
589    let approaches = |index: Option<usize>, sign: f64| {
590        index
591            .and_then(|i| channel.get(i))
592            .is_some_and(|&x| x * sign >= CLIP_APPROACH.get() * ceiling)
593    };
594    let mut flat = 0_usize;
595    let mut start = 0;
596    while start < channel.len() {
597        let sign = channel[start].signum();
598        if channel[start].abs() < ceiling {
599            start += 1;
600            continue;
601        }
602        let end = channel[start..]
603            .iter()
604            .position(|&x| x.abs() < ceiling || x.is_sign_positive() != sign.is_sign_positive())
605            .map_or(channel.len(), |offset| start + offset);
606        let run = end - start;
607        if run >= min_run && approaches(start.checked_sub(1), sign) && approaches(Some(end), sign) {
608            flat += run;
609        }
610        start = end;
611    }
612    cast::usize_to_f64(flat) >= FLAT_TOP_MIN_SHARE.get() * cast::usize_to_f64(channel.len())
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    #[test]
620    fn the_constants_pass_the_unit_check() {
621        assert_eq!(LONG_FRAME_BELOW_HZ, Hz::new(100.0).unwrap());
622        assert_eq!(BAND_EDGE_MARGIN_DB, Decibels::new(30.0).unwrap());
623        assert_eq!(BAND_EDGE_STEEP_DROP_DB, Decibels::new(25.0).unwrap());
624        assert_eq!(BAND_EDGE_PLATEAU_DB, Decibels::new(20.0).unwrap());
625        assert_eq!(CEILING_TOLERANCE, Normalized::new(1e-5).unwrap());
626        assert_eq!(FLAT_TOP_MIN_RUN_S, Seconds::new(0.001).unwrap());
627        assert_eq!(FLAT_TOP_MIN_SHARE, Normalized::new(0.001).unwrap());
628        assert_eq!(CLIP_APPROACH, Normalized::new(0.5).unwrap());
629    }
630
631    fn bpm(value: f64) -> Bpm {
632        Bpm::new(value).unwrap()
633    }
634
635    fn rate() -> SampleRate {
636        SampleRate::new(44_100).unwrap()
637    }
638
639    #[test]
640    fn at_120_bpm_beats_fall_every_half_second_and_divisions_have_their_rates() {
641        let grid = BeatGrid::new(bpm(120.0), Seconds::new(0.0).unwrap());
642        assert_eq!(grid.beat_period().get(), 0.5);
643        assert_eq!(grid.beat_time(3).get(), 1.5);
644        let straight = |value| Division {
645            value,
646            feel: Feel::Straight,
647        };
648        assert_eq!(grid.rate(straight(NoteValue::Half)).get(), 1.0);
649        assert_eq!(grid.rate(straight(NoteValue::Quarter)).get(), 2.0);
650        assert_eq!(grid.rate(straight(NoteValue::Eighth)).get(), 4.0);
651        assert_eq!(grid.rate(straight(NoteValue::Sixteenth)).get(), 8.0);
652        assert_eq!(grid.rate(straight(NoteValue::Whole)).get(), 0.5);
653        let quarter = |feel| Division {
654            value: NoteValue::Quarter,
655            feel,
656        };
657        assert_eq!(grid.rate(quarter(Feel::Triplet)).get(), 3.0);
658        assert!((grid.rate(quarter(Feel::Dotted)).get() - 4.0 / 3.0).abs() < 1e-15);
659    }
660
661    #[test]
662    fn rate_and_beats_per_cycle_read_one_ratio() {
663        for tempo in [60.0, 97.3, 120.0, 128.0, 174.25] {
664            let beat_hz = tempo / 60.0;
665            for division in Division::ALL {
666                let rate = division.rate(bpm(tempo)).get();
667                let product = rate * division.beats_per_cycle();
668                assert!(
669                    (product / beat_hz - 1.0).abs() < 1e-12,
670                    "{division} at {tempo}"
671                );
672                // The rates of 2a219a0, bit for bit.
673                let base = beat_hz / division.value.beats();
674                let earlier = match division.feel {
675                    Feel::Straight => base,
676                    Feel::Triplet => base * 1.5,
677                    Feel::Dotted => base * 2.0 / 3.0,
678                };
679                assert_eq!(rate.to_bits(), earlier.to_bits(), "{division} at {tempo}");
680            }
681        }
682    }
683
684    #[test]
685    fn divisions_are_sorted_slowest_first_and_named() {
686        let rates: Vec<f64> = Division::ALL
687            .iter()
688            .map(|d| d.rate(bpm(120.0)).get())
689            .collect();
690        assert!(rates.windows(2).all(|pair| pair[0] < pair[1]), "{rates:?}");
691        assert_eq!(Division::ALL[10].to_string(), "1/8D");
692        assert_eq!(Division::ALL[0].to_string(), "4 bars");
693    }
694
695    #[test]
696    fn a_one_channel_stem_has_no_side_and_no_stereo_values() {
697        let samples: Vec<f64> = (0..44_100)
698            .map(|n| (cast::usize_to_f64(n) * 0.05).sin())
699            .collect();
700        let audio = Audio::new(rate(), vec![samples.clone()]).unwrap();
701        let stem = Stem::new(
702            audio,
703            BeatGrid::new(bpm(120.0), Seconds::new(0.0).unwrap()),
704            None,
705        )
706        .unwrap();
707        assert_eq!(stem.mid(), samples.as_slice());
708        assert_eq!(stem.side(), None);
709        assert!(!stem.stereo().confidence().is_estimate());
710        assert_eq!(stem.frame_len(), 4096);
711    }
712
713    #[test]
714    fn equal_channels_have_zero_side_and_opposite_ones_zero_mid() {
715        let x: Vec<f64> = (0..8820)
716            .map(|n| (cast::usize_to_f64(n) * 0.05).sin())
717            .collect();
718        let negated: Vec<f64> = x.iter().map(|v| -v).collect();
719        let grid = BeatGrid::new(bpm(120.0), Seconds::new(0.0).unwrap());
720        let same = Stem::new(
721            Audio::new(rate(), vec![x.clone(), x.clone()]).unwrap(),
722            grid,
723            None,
724        )
725        .unwrap();
726        assert!(same.side().unwrap().iter().all(|&s| s == 0.0));
727        let opposite =
728            Stem::new(Audio::new(rate(), vec![x, negated]).unwrap(), grid, None).unwrap();
729        assert!(opposite.mid().iter().all(|&m| m == 0.0));
730    }
731
732    #[test]
733    fn the_harmonic_frame_keeps_its_duration_at_every_rate() {
734        let low = Some(Hz::new(55.0).unwrap());
735        let high = Some(Hz::new(220.0).unwrap());
736        let lens = |hz| {
737            let rate = SampleRate::new(hz).unwrap();
738            (
739                frame_len_for(high, rate),
740                frame_len_for(low, rate),
741                frame_len_for(None, rate),
742            )
743        };
744        assert_eq!(lens(44_100), (4096, 8192, 4096));
745        assert_eq!(lens(48_000), (4096, 8192, 4096));
746        assert_eq!(lens(32_000), (4096, 8192, 4096));
747        assert_eq!(lens(88_200), (8192, 16_384, 8192));
748        assert_eq!(lens(96_000), (8192, 16_384, 8192));
749        assert_eq!(lens(192_000), (16_384, 32_768, 16_384));
750        assert_eq!(lens(22_050), (2048, 4096, 2048));
751        assert_eq!(lens(8_000), (1024, 2048, 1024));
752        // The square root of 2 bounds the duration's ratio to 44.1 kHz's,
753        // so the long frame meets N >= 4 fs / f0 down to 30.5 Hz.
754        for hz in (8_000..=192_000).step_by(50) {
755            let rate = SampleRate::new(hz).unwrap();
756            let seconds = |len| cast::usize_to_f64(len) / f64::from(hz);
757            let ratio = seconds(frame_len_for(low, rate)) / (8192.0 / 44_100.0);
758            assert!(
759                (1.0 / std::f64::consts::SQRT_2..=std::f64::consts::SQRT_2).contains(&ratio),
760                "{hz}: {ratio}"
761            );
762        }
763    }
764
765    #[test]
766    fn a_stem_at_96_khz_keeps_the_frame_duration_of_44_1_khz() {
767        let at = |hz: u32| {
768            let rate = SampleRate::new(hz).unwrap();
769            let samples: Vec<f64> = (0..usize::try_from(hz).unwrap())
770                .map(|n| (cast::usize_to_f64(n) * 0.05).sin())
771                .collect();
772            let stem = Stem::new(
773                Audio::new(rate, vec![samples]).unwrap(),
774                BeatGrid::new(bpm(120.0), Seconds::new(0.0).unwrap()),
775                None,
776            )
777            .unwrap();
778            rate.seconds(stem.frame_len()).get()
779        };
780        // 8192 samples at 96 kHz last 85.3 ms, 4096 at 44.1 kHz 92.9 ms.
781        assert!((at(96_000) - 8192.0 / 96_000.0).abs() < 1e-12);
782        assert!((at(44_100) - 4096.0 / 44_100.0).abs() < 1e-12);
783    }
784
785    #[test]
786    fn a_clipped_sine_is_flat_topped_and_clean_or_saturated_ones_are_not() {
787        let sine: Vec<f64> = (0..44_100)
788            .map(|n| (std::f64::consts::TAU * 110.0 * cast::usize_to_f64(n) / 44_100.0).sin())
789            .collect();
790        let clipped: Vec<f64> = sine.iter().map(|v| v.clamp(-0.5, 0.5)).collect();
791        let saturated: Vec<f64> = sine.iter().map(|v| (4.0 * v).tanh()).collect();
792        assert!(is_flat_topped(&clipped, rate()));
793        assert!(!is_flat_topped(&sine, rate()));
794        assert!(!is_flat_topped(&saturated, rate()));
795        assert!(!is_flat_topped(&[0.0; 100], rate()));
796    }
797}