| 1 | //! Diatonic intervals: a letter distance plus a semitone count. |
| 2 | |
| 3 | /// An interval measured as `steps` letters (0 = unison, 2 = third, 8 = ninth) and `semis`. |
| 4 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 5 | pub struct Interval { |
| 6 | pub steps: usize, |
| 7 | pub semis: i32, |
| 8 | } |
| 9 | |
| 10 | /// Semitones of the major-scale degree for a letter distance (compound steps fold down). |
| 11 | fn major_semis(steps: usize) -> i32 { |
| 12 | const MAJOR: [i32; 7] = [0, 2, 4, 5, 7, 9, 11]; |
| 13 | MAJOR[steps % 7] + 12 * (steps / 7) as i32 |
| 14 | } |
| 15 | |
| 16 | impl Interval { |
| 17 | pub fn new(steps: usize, semis: i32) -> Self { |
| 18 | Self { steps, semis } |
| 19 | } |
| 20 | |
| 21 | /// Parse a degree token relative to the major scale: `1`, `b3`, `#4`, `bb7`, `b9`, `13`. |
| 22 | pub fn from_token(token: &str) -> Option<Self> { |
| 23 | let number = token.trim_start_matches(['#', 'b']); |
| 24 | let acc = token[..token.len() - number.len()] |
| 25 | .chars() |
| 26 | .map(|c| if c == '#' { 1 } else { -1 }) |
| 27 | .sum::<i32>(); |
| 28 | let n: usize = number.parse().ok().filter(|n| *n >= 1)?; |
| 29 | Some(Self::new(n - 1, major_semis(n - 1) + acc)) |
| 30 | } |
| 31 | |
| 32 | /// Short name such as `P5`, `M3`, `m7`, `A4`, `d5`, `M9`, `b13` style qualities included. |
| 33 | pub fn name(self) -> String { |
| 34 | let number = self.steps + 1; |
| 35 | let perfect_type = matches!(self.steps % 7, 0 | 3 | 4); |
| 36 | let diff = self.semis - major_semis(self.steps); |
| 37 | let quality = if perfect_type { |
| 38 | match diff { |
| 39 | 0 => "P".to_string(), |
| 40 | d if d > 0 => "A".repeat(d as usize), |
| 41 | d => "d".repeat((-d) as usize), |
| 42 | } |
| 43 | } else { |
| 44 | match diff { |
| 45 | 0 => "M".to_string(), |
| 46 | -1 => "m".to_string(), |
| 47 | d if d > 0 => "A".repeat(d as usize), |
| 48 | d => "d".repeat((-d - 1) as usize), |
| 49 | } |
| 50 | }; |
| 51 | format!("{quality}{number}") |
| 52 | } |
| 53 | |
| 54 | /// Scale-degree formula token relative to the major scale: `1`, `b3`, `#4`, `b9`. |
| 55 | pub fn degree_token(self) -> String { |
| 56 | let diff = self.semis - major_semis(self.steps); |
| 57 | let acc = match diff { |
| 58 | 0 => String::new(), |
| 59 | d if d > 0 => "#".repeat(d as usize), |
| 60 | d => "b".repeat((-d) as usize), |
| 61 | }; |
| 62 | format!("{acc}{}", self.steps + 1) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | #[cfg(test)] |
| 67 | mod tests { |
| 68 | use super::*; |
| 69 | |
| 70 | #[test] |
| 71 | fn names() { |
| 72 | assert_eq!(Interval::new(4, 7).name(), "P5"); |
| 73 | assert_eq!(Interval::new(2, 4).name(), "M3"); |
| 74 | assert_eq!(Interval::new(2, 3).name(), "m3"); |
| 75 | assert_eq!(Interval::new(6, 10).name(), "m7"); |
| 76 | assert_eq!(Interval::new(3, 6).name(), "A4"); |
| 77 | assert_eq!(Interval::new(4, 6).name(), "d5"); |
| 78 | assert_eq!(Interval::new(6, 9).name(), "d7"); |
| 79 | assert_eq!(Interval::new(8, 14).name(), "M9"); |
| 80 | assert_eq!(Interval::new(8, 13).name(), "m9"); |
| 81 | assert_eq!(Interval::new(0, 0).name(), "P1"); |
| 82 | } |
| 83 | |
| 84 | #[test] |
| 85 | fn parse_tokens_round_trip() { |
| 86 | for t in ["1", "b3", "#4", "b9", "b13", "13", "bb7", "#11"] { |
| 87 | assert_eq!(Interval::from_token(t).unwrap().degree_token(), t); |
| 88 | } |
| 89 | assert_eq!(Interval::from_token("bb7"), Some(Interval::new(6, 9))); |
| 90 | assert!(Interval::from_token("x").is_none()); |
| 91 | assert!(Interval::from_token("0").is_none()); |
| 92 | } |
| 93 | |
| 94 | #[test] |
| 95 | fn tokens() { |
| 96 | assert_eq!(Interval::new(2, 3).degree_token(), "b3"); |
| 97 | assert_eq!(Interval::new(3, 6).degree_token(), "#4"); |
| 98 | assert_eq!(Interval::new(8, 13).degree_token(), "b9"); |
| 99 | assert_eq!(Interval::new(12, 20).degree_token(), "b13"); |
| 100 | } |
| 101 | } |