| 1 | //! Scales and modes, spelled diatonically from a formula of degree tokens. |
| 2 | |
| 3 | use anyhow::{Result, bail}; |
| 4 | |
| 5 | use super::interval::Interval; |
| 6 | use super::pitch::Note; |
| 7 | |
| 8 | pub struct ScaleDef { |
| 9 | pub name: &'static str, |
| 10 | pub aliases: &'static [&'static str], |
| 11 | /// Degree tokens relative to the major scale. |
| 12 | pub formula: &'static str, |
| 13 | } |
| 14 | |
| 15 | const fn def( |
| 16 | name: &'static str, |
| 17 | aliases: &'static [&'static str], |
| 18 | formula: &'static str, |
| 19 | ) -> ScaleDef { |
| 20 | ScaleDef { |
| 21 | name, |
| 22 | aliases, |
| 23 | formula, |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | pub const SCALES: &[ScaleDef] = &[ |
| 28 | def("major", &["ionian", "maj"], "1 2 3 4 5 6 7"), |
| 29 | def( |
| 30 | "natural minor", |
| 31 | &["minor", "aeolian", "min"], |
| 32 | "1 2 b3 4 5 b6 b7", |
| 33 | ), |
| 34 | def("dorian", &[], "1 2 b3 4 5 6 b7"), |
| 35 | def("phrygian", &[], "1 b2 b3 4 5 b6 b7"), |
| 36 | def("lydian", &[], "1 2 3 #4 5 6 7"), |
| 37 | def("mixolydian", &["dominant"], "1 2 3 4 5 6 b7"), |
| 38 | def("locrian", &[], "1 b2 b3 4 b5 b6 b7"), |
| 39 | def("harmonic minor", &[], "1 2 b3 4 5 b6 7"), |
| 40 | def("melodic minor", &["jazz minor"], "1 2 b3 4 5 6 7"), |
| 41 | def("harmonic major", &[], "1 2 3 4 5 b6 7"), |
| 42 | def("dorian b2", &["phrygian #6"], "1 b2 b3 4 5 6 b7"), |
| 43 | def("lydian augmented", &[], "1 2 3 #4 #5 6 7"), |
| 44 | def( |
| 45 | "lydian dominant", |
| 46 | &["overtone", "acoustic"], |
| 47 | "1 2 3 #4 5 6 b7", |
| 48 | ), |
| 49 | def( |
| 50 | "mixolydian b6", |
| 51 | &["hindu", "melodic major"], |
| 52 | "1 2 3 4 5 b6 b7", |
| 53 | ), |
| 54 | def( |
| 55 | "locrian #2", |
| 56 | &["half-diminished", "aeolian b5"], |
| 57 | "1 2 b3 4 b5 b6 b7", |
| 58 | ), |
| 59 | def( |
| 60 | "altered", |
| 61 | &["super locrian", "diminished whole tone"], |
| 62 | "1 b2 b3 b4 b5 b6 b7", |
| 63 | ), |
| 64 | def( |
| 65 | "phrygian dominant", |
| 66 | &["spanish", "freygish"], |
| 67 | "1 b2 3 4 5 b6 b7", |
| 68 | ), |
| 69 | def("major pentatonic", &["pentatonic major"], "1 2 3 5 6"), |
| 70 | def( |
| 71 | "minor pentatonic", |
| 72 | &["pentatonic minor", "pentatonic"], |
| 73 | "1 b3 4 5 b7", |
| 74 | ), |
| 75 | def("blues", &["minor blues"], "1 b3 4 b5 5 b7"), |
| 76 | def("major blues", &[], "1 2 b3 3 5 6"), |
| 77 | def("whole tone", &[], "1 2 3 #4 #5 b7"), |
| 78 | def( |
| 79 | "diminished whole-half", |
| 80 | &["octatonic whole-half", "diminished"], |
| 81 | "1 2 b3 4 b5 b6 6 7", |
| 82 | ), |
| 83 | def( |
| 84 | "diminished half-whole", |
| 85 | &["octatonic half-whole", "dominant diminished"], |
| 86 | "1 b2 b3 3 #4 5 6 b7", |
| 87 | ), |
| 88 | def("bebop dominant", &[], "1 2 3 4 5 6 b7 7"), |
| 89 | ]; |
| 90 | |
| 91 | fn norm(s: &str) -> String { |
| 92 | s.trim() |
| 93 | .to_lowercase() |
| 94 | .replace(['_', '-'], " ") |
| 95 | .split_whitespace() |
| 96 | .collect::<Vec<_>>() |
| 97 | .join(" ") |
| 98 | } |
| 99 | |
| 100 | pub fn find(name: &str) -> Option<&'static ScaleDef> { |
| 101 | let n = norm(name); |
| 102 | SCALES |
| 103 | .iter() |
| 104 | .find(|d| norm(d.name) == n || d.aliases.iter().any(|a| norm(a) == n)) |
| 105 | } |
| 106 | |
| 107 | pub fn names() -> Vec<&'static str> { |
| 108 | SCALES.iter().map(|d| d.name).collect() |
| 109 | } |
| 110 | |
| 111 | impl ScaleDef { |
| 112 | pub fn intervals(&self) -> Vec<Interval> { |
| 113 | self.formula |
| 114 | .split_whitespace() |
| 115 | .filter_map(Interval::from_token) |
| 116 | .collect() |
| 117 | } |
| 118 | |
| 119 | pub fn spell(&self, root: Note) -> Vec<Note> { |
| 120 | self.intervals() |
| 121 | .into_iter() |
| 122 | .map(|i| root.up(i.steps, i.semis)) |
| 123 | .collect() |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | pub fn parse_scale(name: &str) -> Result<&'static ScaleDef> { |
| 128 | match find(name) { |
| 129 | Some(d) => Ok(d), |
| 130 | None => bail!("unknown scale `{name}`; known: {}", names().join(", ")), |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | /// Signed accidental count of a diatonic scale's key signature (+ sharps, - flats). |
| 135 | pub fn signature(notes: &[Note]) -> i32 { |
| 136 | notes.iter().map(|n| n.acc as i32).sum() |
| 137 | } |
| 138 | |
| 139 | /// Names the accidentals of a key signature in order of appearance, e.g. `F# C#`. |
| 140 | pub fn signature_text(count: i32) -> String { |
| 141 | const SHARPS: [&str; 7] = ["F#", "C#", "G#", "D#", "A#", "E#", "B#"]; |
| 142 | const FLATS: [&str; 7] = ["Bb", "Eb", "Ab", "Db", "Gb", "Cb", "Fb"]; |
| 143 | match count { |
| 144 | 0 => "no sharps or flats".into(), |
| 145 | n if n > 0 && n <= 7 => format!( |
| 146 | "{n} sharp{} ({})", |
| 147 | plural(n), |
| 148 | SHARPS[..n as usize].join(" ") |
| 149 | ), |
| 150 | n if (-7..0).contains(&n) => { |
| 151 | let n = -n; |
| 152 | format!("{n} flat{} ({})", plural(n), FLATS[..n as usize].join(" ")) |
| 153 | } |
| 154 | n => format!("{n} accidentals (theoretical key with double accidentals)"), |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | fn plural(n: i32) -> &'static str { |
| 159 | if n == 1 { "" } else { "s" } |
| 160 | } |
| 161 | |
| 162 | #[cfg(test)] |
| 163 | mod tests { |
| 164 | use super::*; |
| 165 | |
| 166 | fn spelled(root: &str, scale: &str) -> String { |
| 167 | let notes = parse_scale(scale) |
| 168 | .unwrap() |
| 169 | .spell(Note::parse(root).unwrap()); |
| 170 | notes.iter().map(|n| n.name()).collect::<Vec<_>>().join(" ") |
| 171 | } |
| 172 | |
| 173 | #[test] |
| 174 | fn c_sharp_major_has_e_sharp_and_b_sharp() { |
| 175 | assert_eq!(spelled("C#", "major"), "C# D# E# F# G# A# B#"); |
| 176 | let notes = parse_scale("major") |
| 177 | .unwrap() |
| 178 | .spell(Note::parse("C#").unwrap()); |
| 179 | assert_eq!(signature(¬es), 7); |
| 180 | } |
| 181 | |
| 182 | #[test] |
| 183 | fn flat_keys_and_modes() { |
| 184 | assert_eq!(spelled("Gb", "major"), "Gb Ab Bb Cb Db Eb F"); |
| 185 | assert_eq!(spelled("D", "dorian"), "D E F G A B C"); |
| 186 | assert_eq!(spelled("F#", "locrian"), "F# G A B C D E"); |
| 187 | assert_eq!(spelled("A", "harmonic minor"), "A B C D E F G#"); |
| 188 | assert_eq!(spelled("C", "lydian"), "C D E F# G A B"); |
| 189 | assert_eq!(spelled("C", "altered"), "C Db Eb Fb Gb Ab Bb"); |
| 190 | } |
| 191 | |
| 192 | #[test] |
| 193 | fn pentatonic_and_blues() { |
| 194 | assert_eq!(spelled("A", "minor pentatonic"), "A C D E G"); |
| 195 | assert_eq!(spelled("C", "major pentatonic"), "C D E G A"); |
| 196 | assert_eq!(spelled("E", "blues"), "E G A Bb B D"); |
| 197 | assert_eq!(spelled("C", "whole tone"), "C D E F# G# Bb"); |
| 198 | } |
| 199 | |
| 200 | #[test] |
| 201 | fn aliases_and_unknown() { |
| 202 | assert!(find("Aeolian").is_some()); |
| 203 | assert!(find("natural-minor").is_some()); |
| 204 | assert!(parse_scale("klingon").is_err()); |
| 205 | } |
| 206 | |
| 207 | #[test] |
| 208 | fn signature_text_names_accidentals() { |
| 209 | assert_eq!(signature_text(2), "2 sharps (F# C#)"); |
| 210 | assert_eq!(signature_text(-1), "1 flat (Bb)"); |
| 211 | assert_eq!(signature_text(0), "no sharps or flats"); |
| 212 | } |
| 213 | } |