Skip to main content

sound_to_patch/music/
scale.rs

1//! Scale lookup and the snap rule.
2//!
3//! A note is in a scale when its offset above the root, modulo 12, is one
4//! of the scale's offsets. The snap rule keeps a note in the scale and
5//! moves any other to the nearest scale pitch, a tie going to the lower.
6//!
7//! ```
8//! use sound_to_patch::music::scale::{offsets, snap};
9//!
10//! let major = offsets("scale.major")?;
11//! assert_eq!(snap(60, 4, major), 59);
12//! assert_eq!(snap(61, 4, major), 61);
13//! # Ok::<(), sound_to_patch::music::MusicError>(())
14//! ```
15
16use crate::music::entry::{Group, Rule};
17use crate::music::{MusicError, dictionary};
18
19/// The offsets of a scale entry.
20///
21/// # Errors
22///
23/// [`MusicError::UnknownScale`] for an id that is no scale entry, and
24/// [`MusicError::BrokenData`] when the dictionary does not load.
25pub fn offsets(id: &str) -> Result<&'static [u8], MusicError> {
26    let entry = dictionary()?
27        .entry(id)
28        .filter(|entry| entry.group == Group::Scale)
29        .ok_or_else(|| MusicError::UnknownScale(id.to_owned()))?;
30    match &entry.rule {
31        Rule::PitchSet { offsets, .. } => Ok(offsets),
32        _ => Err(MusicError::UnknownScale(id.to_owned())),
33    }
34}
35
36/// Whether a pitch lies in the scale of `offsets` on `root`.
37#[must_use]
38pub fn contains(pitch: u8, root: u8, offsets: &[u8]) -> bool {
39    let offset = (i16::from(pitch) - i16::from(root)).rem_euclid(12);
40    offsets.iter().any(|&scale| i16::from(scale) == offset)
41}
42
43/// The pitch moved into the scale: kept when it is in, else the nearest
44/// scale pitch below or above, a tie going to the lower. A scale with no
45/// offsets keeps every pitch.
46#[must_use]
47pub fn snap(pitch: u8, root: u8, offsets: &[u8]) -> u8 {
48    if offsets.is_empty() || contains(pitch, root, offsets) {
49        return pitch;
50    }
51    let below = (0..=pitch).rev().find(|&p| contains(p, root, offsets));
52    let above = (pitch..=127).find(|&p| contains(p, root, offsets));
53    match (below, above) {
54        (Some(low), Some(high)) if pitch - low <= high - pitch => low,
55        (_, Some(high)) => high,
56        (Some(low), None) => low,
57        (None, None) => pitch,
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    const MAJOR: [u8; 7] = [0, 2, 4, 5, 7, 9, 11];
66    const MINOR_PENTATONIC: [u8; 5] = [0, 3, 5, 7, 10];
67    const NATURAL_MINOR: [u8; 7] = [0, 2, 3, 5, 7, 8, 10];
68
69    #[test]
70    fn snaps_the_published_cases() {
71        assert_eq!(snap(60, 4, &MAJOR), 59);
72        assert_eq!(snap(61, 4, &MAJOR), 61);
73        assert_eq!(snap(62, 0, &MINOR_PENTATONIC), 63);
74        assert_eq!(snap(66, 9, &NATURAL_MINOR), 65);
75    }
76
77    #[test]
78    fn a_tie_goes_to_the_lower_pitch() {
79        assert_eq!(snap(61, 0, &MAJOR), 60);
80        assert_eq!(snap(66, 0, &MAJOR), 65);
81    }
82
83    #[test]
84    fn every_note_in_c_major_moves_by_at_most_one() {
85        for pitch in 0..=127 {
86            let snapped = snap(pitch, 0, &MAJOR);
87            assert!(snapped.abs_diff(pitch) <= 1, "{pitch} -> {snapped}");
88            assert!(contains(snapped, 0, &MAJOR));
89        }
90    }
91
92    #[test]
93    fn the_ends_of_midi_snap_inward() {
94        assert_eq!(snap(127, 0, &[0]), 120);
95        assert_eq!(snap(0, 1, &[0]), 1);
96    }
97}