development
rs 101 lines 3.52 KB
Raw
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 }