Skip to main content

acorde_core/model/
scale.rs

1use serde::{Deserialize, Serialize};
2use super::pitch::Pitch;
3use super::notation::KeySignature;
4
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6pub enum ScaleKind {
7    Major,
8    NaturalMinor,
9    HarmonicMinor,
10    MelodicMinor,
11    Dorian,
12    Phrygian,
13    Lydian,
14    Mixolydian,
15    Aeolian,
16    Locrian,
17    MajorPentatonic,
18    MinorPentatonic,
19    Blues,
20    WholeTone,
21    Chromatic,
22}
23
24impl ScaleKind {
25    /// Semitone intervals from root to each degree (not including the octave).
26    pub fn intervals(&self) -> &'static [u8] {
27        match self {
28            ScaleKind::Major           => &[0, 2, 4, 5, 7, 9, 11],
29            ScaleKind::NaturalMinor    => &[0, 2, 3, 5, 7, 8, 10],
30            ScaleKind::HarmonicMinor   => &[0, 2, 3, 5, 7, 8, 11],
31            ScaleKind::MelodicMinor    => &[0, 2, 3, 5, 7, 9, 11],
32            ScaleKind::Dorian          => &[0, 2, 3, 5, 7, 9, 10],
33            ScaleKind::Phrygian        => &[0, 1, 3, 5, 7, 8, 10],
34            ScaleKind::Lydian          => &[0, 2, 4, 6, 7, 9, 11],
35            ScaleKind::Mixolydian      => &[0, 2, 4, 5, 7, 9, 10],
36            ScaleKind::Aeolian         => &[0, 2, 3, 5, 7, 8, 10],
37            ScaleKind::Locrian         => &[0, 1, 3, 5, 6, 8, 10],
38            ScaleKind::MajorPentatonic => &[0, 2, 4, 7, 9],
39            ScaleKind::MinorPentatonic => &[0, 3, 5, 7, 10],
40            ScaleKind::Blues           => &[0, 3, 5, 6, 7, 10],
41            ScaleKind::WholeTone       => &[0, 2, 4, 6, 8, 10],
42            ScaleKind::Chromatic       => &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
43        }
44    }
45}
46
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48pub struct Scale {
49    pub root: Pitch,
50    pub kind: ScaleKind,
51}
52
53impl Scale {
54    pub fn new(root: Pitch, kind: ScaleKind) -> Self {
55        Self { root, kind }
56    }
57
58    /// Build a scale from a key signature (uses major or natural minor based on mode).
59    pub fn from_key(key: &KeySignature) -> Self {
60        let (step, alter) = key.tonic();
61        let root = Pitch::with_alter(step, 4, alter);
62        let kind = if key.mode == "minor" { ScaleKind::NaturalMinor } else { ScaleKind::Major };
63        Self { root, kind }
64    }
65
66    /// All pitches of this scale in ascending order (root octave preserved).
67    pub fn pitches(&self) -> Vec<Pitch> {
68        let root_midi = self.root.to_midi() as i32;
69        self.kind.intervals().iter().map(|&interval| {
70            let midi = (root_midi + interval as i32).clamp(0, 127) as u8;
71            Pitch::from_midi(midi, self.root.alter < 0)
72        }).collect()
73    }
74
75    /// True if `pitch` is a member of this scale (octave-independent, by pitch class).
76    pub fn contains(&self, pitch: &Pitch) -> bool {
77        let root_class = self.root.to_midi() % 12;
78        let pitch_class = pitch.to_midi() % 12;
79        let interval = (pitch_class as i32 - root_class as i32).rem_euclid(12) as u8;
80        self.kind.intervals().contains(&interval)
81    }
82
83    /// Scale degree of `pitch` (1-based), or `None` if not in the scale.
84    pub fn degree(&self, pitch: &Pitch) -> Option<usize> {
85        let root_class = self.root.to_midi() % 12;
86        let pitch_class = pitch.to_midi() % 12;
87        let interval = (pitch_class as i32 - root_class as i32).rem_euclid(12) as u8;
88        self.kind.intervals().iter().position(|&i| i == interval).map(|pos| pos + 1)
89    }
90
91    /// Transpose this scale by `semitones`.
92    pub fn transpose(&self, semitones: i8) -> Scale {
93        let new_midi = (self.root.to_midi() as i32 + semitones as i32).clamp(0, 127) as u8;
94        Scale {
95            root: Pitch::from_midi(new_midi, self.root.alter < 0),
96            kind: self.kind.clone(),
97        }
98    }
99
100    /// Find the scale that best fits the given pitches (octave-independent).
101    ///
102    /// Tries all 12 roots × all non-Chromatic ScaleKinds and picks the one
103    /// with the most pitches covered, breaking ties by preferring scales with
104    /// more degrees (7-note > 6-note > 5-note) then by `ScaleKind` declaration
105    /// order (Major first). Returns `None` if `pitches` is empty.
106    pub fn best_fit(pitches: &[Pitch]) -> Option<Scale> {
107        if pitches.is_empty() {
108            return None;
109        }
110
111        const CANDIDATES: &[ScaleKind] = &[
112            ScaleKind::Major,
113            ScaleKind::NaturalMinor,
114            ScaleKind::HarmonicMinor,
115            ScaleKind::MelodicMinor,
116            ScaleKind::Dorian,
117            ScaleKind::Phrygian,
118            ScaleKind::Lydian,
119            ScaleKind::Mixolydian,
120            ScaleKind::Aeolian,
121            ScaleKind::Locrian,
122            ScaleKind::MajorPentatonic,
123            ScaleKind::MinorPentatonic,
124            ScaleKind::Blues,
125            ScaleKind::WholeTone,
126        ];
127
128        let mut best_scale: Option<Scale> = None;
129        let mut best_covered: usize = 0;
130        let mut best_degrees: usize = 0;
131
132        for root_pc in 0u8..12 {
133            let root = Pitch::from_midi(60 + root_pc, false);
134            for kind in CANDIDATES {
135                let scale = Scale::new(root.clone(), kind.clone());
136                let covered = pitches.iter().filter(|p| scale.contains(p)).count();
137                let degrees = kind.intervals().len();
138                if covered > best_covered
139                    || (covered == best_covered && degrees > best_degrees)
140                {
141                    best_covered = covered;
142                    best_degrees = degrees;
143                    best_scale = Some(scale);
144                }
145            }
146        }
147
148        best_scale
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use super::super::pitch::Step;
156
157    fn c4() -> Pitch { Pitch::new(Step::C, 4) }
158    fn d4() -> Pitch { Pitch::new(Step::D, 4) }
159    fn g4() -> Pitch { Pitch::new(Step::G, 4) }
160
161    #[test]
162    fn c_major_pitches() {
163        let scale = Scale::new(c4(), ScaleKind::Major);
164        let pitches = scale.pitches();
165        assert_eq!(pitches.len(), 7);
166        assert_eq!(pitches[0].step, Step::C);
167        assert_eq!(pitches[2].step, Step::E);
168        assert_eq!(pitches[4].step, Step::G);
169        assert_eq!(pitches[6].step, Step::B);
170    }
171
172    #[test]
173    fn c_major_contains() {
174        let scale = Scale::new(c4(), ScaleKind::Major);
175        assert!(scale.contains(&c4()));
176        assert!(scale.contains(&g4()));
177        assert!(!scale.contains(&Pitch::with_alter(Step::F, 4, 1))); // F# not in C major
178    }
179
180    #[test]
181    fn c_major_degree() {
182        let scale = Scale::new(c4(), ScaleKind::Major);
183        assert_eq!(scale.degree(&c4()), Some(1));
184        assert_eq!(scale.degree(&d4()), Some(2));
185        assert_eq!(scale.degree(&g4()), Some(5));
186        assert_eq!(scale.degree(&Pitch::with_alter(Step::F, 4, 1)), None);
187    }
188
189    #[test]
190    fn c_major_transpose_up_fifth() {
191        let scale = Scale::new(c4(), ScaleKind::Major);
192        let g_major = scale.transpose(7);
193        assert_eq!(g_major.root.step, Step::G);
194        assert_eq!(g_major.kind, ScaleKind::Major);
195    }
196
197    #[test]
198    fn a_natural_minor_pitches() {
199        let a4 = Pitch::new(Step::A, 4);
200        let scale = Scale::new(a4, ScaleKind::NaturalMinor);
201        let pitches = scale.pitches();
202        assert_eq!(pitches.len(), 7);
203        assert_eq!(pitches[0].step, Step::A);
204        assert_eq!(pitches[2].step, Step::C);
205    }
206
207    #[test]
208    fn blues_scale_has_six_degrees() {
209        let scale = Scale::new(c4(), ScaleKind::Blues);
210        assert_eq!(scale.pitches().len(), 6);
211    }
212
213    #[test]
214    fn chromatic_has_twelve_degrees() {
215        let scale = Scale::new(c4(), ScaleKind::Chromatic);
216        assert_eq!(scale.pitches().len(), 12);
217    }
218
219    #[test]
220    fn from_key_g_major() {
221        let key = KeySignature { fifths: 1, mode: "major".to_string() };
222        let scale = Scale::from_key(&key);
223        assert_eq!(scale.root.step, Step::G);
224        assert_eq!(scale.kind, ScaleKind::Major);
225    }
226
227    #[test]
228    fn from_key_a_minor() {
229        let key = KeySignature { fifths: 0, mode: "minor".to_string() };
230        let scale = Scale::from_key(&key);
231        assert_eq!(scale.root.step, Step::A);
232        assert_eq!(scale.kind, ScaleKind::NaturalMinor);
233    }
234
235    #[test]
236    fn best_fit_c_major() {
237        let pitches = [
238            Pitch::new(Step::C, 4), Pitch::new(Step::D, 4), Pitch::new(Step::E, 4),
239            Pitch::new(Step::F, 4), Pitch::new(Step::G, 4), Pitch::new(Step::A, 4),
240            Pitch::new(Step::B, 4),
241        ];
242        let scale = Scale::best_fit(&pitches).unwrap();
243        assert_eq!(scale.root.step, Step::C);
244        assert_eq!(scale.kind, ScaleKind::Major);
245    }
246
247    #[test]
248    fn best_fit_c_blues() {
249        // C Blues: C Eb F Gb G Bb — flat-5 prevents any 7-note diatonic match
250        let pitches = [
251            Pitch::new(Step::C, 4),
252            Pitch::with_alter(Step::E, 4, -1), // Eb
253            Pitch::new(Step::F, 4),
254            Pitch::with_alter(Step::G, 4, -1), // Gb
255            Pitch::new(Step::G, 4),
256            Pitch::with_alter(Step::B, 4, -1), // Bb
257        ];
258        let scale = Scale::best_fit(&pitches).unwrap();
259        assert_eq!(scale.root.step, Step::C);
260        assert_eq!(scale.kind, ScaleKind::Blues);
261    }
262
263    #[test]
264    fn best_fit_empty_returns_none() {
265        assert!(Scale::best_fit(&[]).is_none());
266    }
267}