Skip to main content

sound_to_patch/notes/
profile.rs

1//! Decode settings: the pitch range, the minimum note length and the
2//! decoder switches, and the three profiles that fix them per stem type.
3
4use std::error::Error;
5use std::fmt;
6
7use basic_pitch::NoteParams;
8use basic_pitch::constants::{AUDIO_SAMPLE_RATE, FFT_HOP};
9
10use super::Voicing;
11use crate::cast;
12use crate::units::{Bpm, Hz, MidiPitch, Seconds};
13
14/// The lowest pitch the model can give, A0.
15pub const LOWEST_MODEL_PITCH: u8 = 21;
16/// The highest pitch the model can give, C8.
17pub const HIGHEST_MODEL_PITCH: u8 = 108;
18
19/// The decoder's default minimum length in frames, 127.7 ms. A kept note
20/// lasts at least one frame more, 139.3 ms.
21pub const DEFAULT_MIN_NOTE_FRAMES: usize = 11;
22
23/// The shortest setting the BPM rule gives, in milliseconds.
24const RULE_FLOOR_MS: f64 = 58.0;
25/// The longest setting the BPM rule gives: the decoder default.
26const RULE_CEILING_MS: f64 = 127.7;
27/// The fraction of a sixteenth note that the BPM rule takes.
28const SIXTEENTH_FRACTION: f64 = 0.5;
29
30/// The length of one model frame in milliseconds, 256 / 22,050 s.
31pub(super) fn frame_ms() -> f64 {
32    cast::usize_to_f64(FFT_HOP) / f64::from(AUDIO_SAMPLE_RATE) * 1000.0
33}
34
35/// The minimum note length for a BPM, in model frames.
36///
37/// The rule takes half a sixteenth note, `0.5 * 15000 / BPM` ms, within
38/// 58 to 127.7 ms, and rounds it to whole frames, half to even as the
39/// decoder does. The decoder drops a note that lasts this many frames or
40/// fewer, so a kept note lasts at least one frame more. 60 BPM gives 11
41/// frames, 90 BPM 7 and 120 BPM 5.
42#[must_use]
43pub fn min_note_frames(bpm: Bpm) -> usize {
44    let ms = (SIXTEENTH_FRACTION * 15_000.0 / bpm.get()).clamp(RULE_FLOOR_MS, RULE_CEILING_MS);
45    cast::floor_to_usize((ms / frame_ms()).round_ties_even()).unwrap_or(DEFAULT_MIN_NOTE_FRAMES)
46}
47
48/// The minimum note length for a BPM: [`min_note_frames`] model frames,
49/// the length the decoder drops a note at.
50#[must_use]
51pub fn min_note_length(bpm: Bpm) -> Seconds {
52    Seconds::clamped(cast::usize_to_f64(min_note_frames(bpm)) * frame_ms() / 1000.0)
53}
54
55/// A range of whole MIDI pitches the decoder may create, both ends kept,
56/// within the model's range A0 to C8.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub struct PitchRange {
59    lowest: u8,
60    highest: u8,
61}
62
63impl PitchRange {
64    /// Checks that both ends are whole pitches within 21 to 108 and that
65    /// the lowest is not above the highest.
66    ///
67    /// # Errors
68    ///
69    /// Returns a [`RangeError`] that names the bad end.
70    pub fn new(lowest: MidiPitch, highest: MidiPitch) -> Result<Self, RangeError> {
71        let whole = |pitch: MidiPitch| {
72            let value = pitch.get();
73            (value.fract() == 0.0
74                && (f64::from(LOWEST_MODEL_PITCH)..=f64::from(HIGHEST_MODEL_PITCH))
75                    .contains(&value))
76            .then(|| cast::floor_to_usize(value).and_then(|n| u8::try_from(n).ok()))
77            .flatten()
78            .ok_or(RangeError::Pitch(pitch))
79        };
80        let (lowest, highest) = (whole(lowest)?, whole(highest)?);
81        if lowest > highest {
82            return Err(RangeError::Order { lowest, highest });
83        }
84        Ok(Self { lowest, highest })
85    }
86
87    /// Builds a range from two literals that a test also builds through
88    /// [`Self::new`].
89    const fn from_const(lowest: u8, highest: u8) -> Self {
90        Self { lowest, highest }
91    }
92
93    /// Returns the lowest kept pitch.
94    #[must_use]
95    pub fn lowest(self) -> MidiPitch {
96        MidiPitch::clamped(f64::from(self.lowest))
97    }
98
99    /// Returns the highest kept pitch.
100    #[must_use]
101    pub fn highest(self) -> MidiPitch {
102        MidiPitch::clamped(f64::from(self.highest))
103    }
104
105    /// The decoder's lower bound: the frequency of the lowest pitch. The
106    /// decoder rounds a bound to the nearest semitone and keeps it.
107    #[must_use]
108    pub fn minimum_frequency(self) -> Hz {
109        Hz::from(self.lowest())
110    }
111
112    /// The decoder's upper bound: the frequency one semitone above the
113    /// highest pitch, because the decoder excludes its upper bound.
114    #[must_use]
115    pub fn maximum_frequency(self) -> Hz {
116        Hz::from(MidiPitch::clamped(f64::from(self.highest) + 1.0))
117    }
118}
119
120/// Everything the decoder needs besides its thresholds, which keep their
121/// defaults: the pitch range, the minimum note length and the melodia pass.
122/// Pitch bends stay off, because the notes file writes none.
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub struct DecodeSettings {
125    /// The pitches the decoder may create.
126    pub range: PitchRange,
127    /// The decoder drops a note of this many frames or fewer.
128    pub min_note_frames: usize,
129    /// Whether the decoder also builds notes without a clear onset from
130    /// the energy left over, the melodia pass.
131    pub melodia: bool,
132}
133
134impl DecodeSettings {
135    /// The decoder parameters. The minimum length goes in as the middle of
136    /// its frame's rounding interval, so it never sits on a tie.
137    #[must_use]
138    pub fn note_params(&self) -> NoteParams {
139        NoteParams {
140            minimum_note_length_ms: cast::usize_to_f64(self.min_note_frames) * frame_ms(),
141            minimum_frequency: Some(self.range.minimum_frequency().get()),
142            maximum_frequency: Some(self.range.maximum_frequency().get()),
143            melodia_trick: self.melodia,
144            include_pitch_bends: false,
145            ..NoteParams::default()
146        }
147    }
148}
149
150/// A fixed set of decode settings for one kind of stem.
151#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, clap::ValueEnum)]
152pub enum Profile {
153    /// B0 to F#4, with the minimum length from the BPM.
154    Bass,
155    /// C2 to B6, with the minimum length from the BPM. Plucks use it too.
156    Lead,
157    /// C2 to B6, with the decoder's default minimum length. Chords use it
158    /// too.
159    Pad,
160}
161
162impl Profile {
163    /// Every profile.
164    pub const ALL: [Self; 3] = [Self::Bass, Self::Lead, Self::Pad];
165
166    /// Returns the name the command line uses.
167    #[must_use]
168    pub fn name(self) -> &'static str {
169        match self {
170            Self::Bass => "bass",
171            Self::Lead => "lead",
172            Self::Pad => "pad",
173        }
174    }
175
176    /// Returns the pitch range.
177    #[must_use]
178    pub fn range(self) -> PitchRange {
179        match self {
180            // B0 clears the A0 bin edge with margin and covers a
181            // five-string bass. Above F#4 a bass has no fundamental.
182            Self::Bass => PitchRange::from_const(23, 66),
183            Self::Lead | Self::Pad => PitchRange::from_const(36, 95),
184        }
185    }
186
187    /// Returns the voicing the cleanup assumes: chords for `pad`, one note
188    /// at a time otherwise.
189    #[must_use]
190    pub fn voicing(self) -> Voicing {
191        match self {
192            Self::Bass | Self::Lead => Voicing::Monophonic,
193            Self::Pad => Voicing::Polyphonic,
194        }
195    }
196
197    /// Returns the decode settings for a stem at a BPM.
198    #[must_use]
199    pub fn settings(self, bpm: Bpm) -> DecodeSettings {
200        DecodeSettings {
201            range: self.range(),
202            min_note_frames: match self {
203                Self::Bass | Self::Lead => min_note_frames(bpm),
204                Self::Pad => DEFAULT_MIN_NOTE_FRAMES,
205            },
206            melodia: true,
207        }
208    }
209}
210
211impl fmt::Display for Profile {
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        f.write_str(self.name())
214    }
215}
216
217/// A pitch range that the decoder cannot use.
218#[derive(Clone, Copy, Debug, PartialEq)]
219pub enum RangeError {
220    /// An end that is not a whole pitch within 21 to 108.
221    Pitch(MidiPitch),
222    /// A lowest pitch above the highest.
223    Order {
224        /// The lowest pitch.
225        lowest: u8,
226        /// The highest pitch.
227        highest: u8,
228    },
229}
230
231impl fmt::Display for RangeError {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        match self {
234            Self::Pitch(pitch) => write!(
235                f,
236                "pitch {} is not a whole MIDI pitch from {LOWEST_MODEL_PITCH} to {HIGHEST_MODEL_PITCH}, the range of the transcription model",
237                pitch.get()
238            ),
239            Self::Order { lowest, highest } => write!(
240                f,
241                "the lowest pitch {lowest} lies above the highest pitch {highest}"
242            ),
243        }
244    }
245}
246
247impl Error for RangeError {}
248
249#[cfg(test)]
250mod tests {
251    use basic_pitch::notes::{constrain_frequency, model_output_to_notes};
252    use basic_pitch::{Posteriorgram, Posteriorgrams};
253
254    use super::*;
255
256    fn pitch(value: f64) -> MidiPitch {
257        MidiPitch::new(value).unwrap()
258    }
259
260    fn bpm(value: f64) -> Bpm {
261        Bpm::new(value).unwrap()
262    }
263
264    /// The pitches whose bins survive the frequency bounds of a range.
265    fn kept(range: PitchRange) -> Vec<u8> {
266        let ones = Posteriorgram::from_vec(vec![1.0; 88], 88).unwrap();
267        let (_, frames) = constrain_frequency(
268            &ones,
269            &ones,
270            Some(range.maximum_frequency().get()),
271            Some(range.minimum_frequency().get()),
272        )
273        .unwrap();
274        (0..88_u8)
275            .filter(|&bin| frames[(0, usize::from(bin))] > 0.0)
276            .map(|bin| bin + LOWEST_MODEL_PITCH)
277            .collect()
278    }
279
280    #[test]
281    fn every_range_keeps_exactly_its_pitches() {
282        for lowest in LOWEST_MODEL_PITCH..=HIGHEST_MODEL_PITCH {
283            for highest in lowest..=HIGHEST_MODEL_PITCH {
284                let range = PitchRange::new(pitch(lowest.into()), pitch(highest.into())).unwrap();
285                assert_eq!(
286                    kept(range),
287                    (lowest..=highest).collect::<Vec<_>>(),
288                    "{lowest} to {highest}"
289                );
290            }
291        }
292    }
293
294    #[test]
295    fn every_profile_keeps_exactly_its_pitches() {
296        for (profile, lowest, highest) in [
297            (Profile::Bass, 23, 66),
298            (Profile::Lead, 36, 95),
299            (Profile::Pad, 36, 95),
300        ] {
301            let range = profile.range();
302            assert_eq!(
303                range,
304                PitchRange::new(pitch(lowest.into()), pitch(highest.into())).unwrap()
305            );
306            assert_eq!(kept(range), (lowest..=highest).collect::<Vec<_>>());
307        }
308    }
309
310    #[test]
311    fn a_400_hz_ceiling_keeps_f_sharp_4_and_drops_g4() {
312        let ones = Posteriorgram::from_vec(vec![1.0; 88], 88).unwrap();
313        let (_, frames) = constrain_frequency(&ones, &ones, Some(400.0), None).unwrap();
314        assert!(frames[(0, 66 - 21)] > 0.0);
315        assert_eq!(frames[(0, 67 - 21)], 0.0);
316        assert_eq!(
317            Profile::Bass.range().highest(),
318            pitch(66.0),
319            "the bass ceiling is the 400 Hz example"
320        );
321    }
322
323    #[test]
324    fn a_range_takes_whole_pitches_of_the_model_only() {
325        assert!(PitchRange::new(pitch(21.0), pitch(108.0)).is_ok());
326        for bad in [20.0, 109.0, 60.5, 0.0, 127.0] {
327            assert_eq!(
328                PitchRange::new(pitch(bad), pitch(108.0)),
329                Err(RangeError::Pitch(pitch(bad)))
330            );
331        }
332        assert_eq!(
333            PitchRange::new(pitch(60.0), pitch(59.0))
334                .unwrap_err()
335                .to_string(),
336            "the lowest pitch 60 lies above the highest pitch 59"
337        );
338        assert_eq!(
339            RangeError::Pitch(pitch(60.5)).to_string(),
340            "pitch 60.5 is not a whole MIDI pitch from 21 to 108, the range of the transcription model"
341        );
342    }
343
344    #[test]
345    fn the_bpm_rule_gives_the_issue_frame_counts() {
346        for (tempo, frames) in [(60.0, 11), (90.0, 7), (120.0, 5), (4.0, 11), (1000.0, 5)] {
347            assert_eq!(min_note_frames(bpm(tempo)), frames, "{tempo} BPM");
348        }
349        // The ceiling holds up to 58.7 BPM and the floor from 129.3 BPM.
350        assert_eq!(min_note_frames(bpm(58.7)), 11);
351        assert_eq!(min_note_frames(bpm(129.4)), 5);
352    }
353
354    proptest::proptest! {
355        #[test]
356        fn the_bpm_rule_stays_in_range_and_never_grows_with_tempo(
357            a in 4.0..=1000.0_f64,
358            b in 4.0..=1000.0_f64,
359        ) {
360            let (slow, fast) = (a.min(b), a.max(b));
361            let frames = min_note_frames(bpm(slow));
362            proptest::prop_assert!((5..=11).contains(&frames));
363            proptest::prop_assert!(min_note_frames(bpm(fast)) <= frames);
364            for profile in Profile::ALL {
365                let settings = profile.settings(bpm(slow));
366                proptest::prop_assert_eq!(
367                    settings.note_params().min_note_len_frames().unwrap(),
368                    settings.min_note_frames
369                );
370            }
371        }
372    }
373
374    #[test]
375    fn profiles_turn_bends_off_and_keep_the_decoder_thresholds() {
376        let defaults = NoteParams::default();
377        for profile in Profile::ALL {
378            let params = profile.settings(bpm(120.0)).note_params();
379            assert!(!params.include_pitch_bends, "{profile}");
380            assert!(params.melodia_trick, "{profile}");
381            assert_eq!(params.onset_threshold, defaults.onset_threshold);
382            assert_eq!(params.frame_threshold, defaults.frame_threshold);
383            assert_eq!(params.energy_tolerance, defaults.energy_tolerance);
384            params.validate().unwrap();
385        }
386        assert_eq!(Profile::Pad.settings(bpm(200.0)).min_note_frames, 11);
387        assert_eq!(Profile::Bass.settings(bpm(200.0)).min_note_frames, 5);
388        assert_eq!(Profile::ALL.map(Profile::name), ["bass", "lead", "pad"]);
389        let plain = DecodeSettings {
390            melodia: false,
391            ..Profile::Lead.settings(bpm(120.0))
392        };
393        assert!(!plain.note_params().melodia_trick);
394    }
395
396    /// Posteriorgrams of 60 frames with one note at A3 from frame 10 for
397    /// `len` frames, with or without an onset at its first frame.
398    fn region(len: usize, onset: bool) -> Posteriorgrams {
399        let (frames, bin) = (60, 57 - 21);
400        let mut note = Posteriorgram::zeros(frames, 88);
401        let mut onsets = Posteriorgram::zeros(frames, 88);
402        for t in 10..10 + len {
403            note.as_mut_slice()[t * 88 + bin] = 0.8;
404        }
405        if onset {
406            onsets.as_mut_slice()[10 * 88 + bin] = 0.9;
407        }
408        Posteriorgrams::new(note, onsets, Posteriorgram::zeros(frames, 264)).unwrap()
409    }
410
411    #[test]
412    fn the_default_length_drops_11_frames_and_keeps_12_with_an_onset() {
413        let params = NoteParams {
414            infer_onsets: false,
415            melodia_trick: false,
416            ..NoteParams::default()
417        };
418        let lengths = |len| {
419            model_output_to_notes(&region(len, true), &params)
420                .unwrap()
421                .iter()
422                .map(|note| note.end_frame - note.start_frame)
423                .collect::<Vec<_>>()
424        };
425        assert_eq!(lengths(11), Vec::<usize>::new());
426        assert_eq!(lengths(12), vec![12]);
427    }
428
429    #[test]
430    fn the_melodia_pass_is_one_frame_stricter() {
431        let params = NoteParams {
432            infer_onsets: false,
433            ..NoteParams::default()
434        };
435        let lengths = |len| {
436            model_output_to_notes(&region(len, false), &params)
437                .unwrap()
438                .iter()
439                .map(|note| note.end_frame - note.start_frame)
440                .collect::<Vec<_>>()
441        };
442        assert_eq!(lengths(12), Vec::<usize>::new());
443        // A 13-frame region becomes a note one frame shorter: the pass
444        // stores an end frame one lower.
445        assert_eq!(lengths(13), vec![12]);
446    }
447}