Skip to main content

acorde_core/model/
scale.rs

1use super::notation::KeySignature;
2use super::pitch::Pitch;
3use serde::{Deserialize, Serialize};
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" {
63            ScaleKind::NaturalMinor
64        } else {
65            ScaleKind::Major
66        };
67        Self { root, kind }
68    }
69
70    /// All pitches of this scale in ascending order (root octave preserved).
71    pub fn pitches(&self) -> Vec<Pitch> {
72        let root_midi = self.root.to_midi() as i32;
73        self.kind
74            .intervals()
75            .iter()
76            .map(|&interval| {
77                let midi = (root_midi + interval as i32).clamp(0, 127) as u8;
78                Pitch::from_midi(midi, self.root.alter < 0)
79            })
80            .collect()
81    }
82
83    /// True if `pitch` is a member of this scale (octave-independent, by pitch class).
84    pub fn contains(&self, pitch: &Pitch) -> bool {
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().contains(&interval)
89    }
90
91    /// Scale degree of `pitch` (1-based), or `None` if not in the scale.
92    pub fn degree(&self, pitch: &Pitch) -> Option<usize> {
93        let root_class = self.root.to_midi() % 12;
94        let pitch_class = pitch.to_midi() % 12;
95        let interval = (pitch_class as i32 - root_class as i32).rem_euclid(12) as u8;
96        self.kind
97            .intervals()
98            .iter()
99            .position(|&i| i == interval)
100            .map(|pos| pos + 1)
101    }
102
103    /// Transpose this scale by `semitones`.
104    pub fn transpose(&self, semitones: i8) -> Scale {
105        let new_midi = (self.root.to_midi() as i32 + semitones as i32).clamp(0, 127) as u8;
106        Scale {
107            root: Pitch::from_midi(new_midi, self.root.alter < 0),
108            kind: self.kind.clone(),
109        }
110    }
111
112    /// Find the scale that best fits the given pitches (octave-independent).
113    ///
114    /// Tries all 12 roots × all non-Chromatic ScaleKinds and picks the one
115    /// with the most pitches covered, breaking ties by preferring scales with
116    /// more degrees (7-note > 6-note > 5-note) then by `ScaleKind` declaration
117    /// order (Major first). Returns `None` if `pitches` is empty.
118    pub fn best_fit(pitches: &[Pitch]) -> Option<Scale> {
119        if pitches.is_empty() {
120            return None;
121        }
122
123        const CANDIDATES: &[ScaleKind] = &[
124            ScaleKind::Major,
125            ScaleKind::NaturalMinor,
126            ScaleKind::HarmonicMinor,
127            ScaleKind::MelodicMinor,
128            ScaleKind::Dorian,
129            ScaleKind::Phrygian,
130            ScaleKind::Lydian,
131            ScaleKind::Mixolydian,
132            ScaleKind::Aeolian,
133            ScaleKind::Locrian,
134            ScaleKind::MajorPentatonic,
135            ScaleKind::MinorPentatonic,
136            ScaleKind::Blues,
137            ScaleKind::WholeTone,
138        ];
139
140        let mut best_scale: Option<Scale> = None;
141        let mut best_covered: usize = 0;
142        let mut best_degrees: usize = 0;
143
144        for root_pc in 0u8..12 {
145            let root = Pitch::from_midi(60 + root_pc, false);
146            for kind in CANDIDATES {
147                let scale = Scale::new(root.clone(), kind.clone());
148                let covered = pitches.iter().filter(|p| scale.contains(p)).count();
149                let degrees = kind.intervals().len();
150                if covered > best_covered || (covered == best_covered && degrees > best_degrees) {
151                    best_covered = covered;
152                    best_degrees = degrees;
153                    best_scale = Some(scale);
154                }
155            }
156        }
157
158        best_scale
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::super::pitch::Step;
165    use super::*;
166
167    fn c4() -> Pitch {
168        Pitch::new(Step::C, 4)
169    }
170    fn d4() -> Pitch {
171        Pitch::new(Step::D, 4)
172    }
173    fn g4() -> Pitch {
174        Pitch::new(Step::G, 4)
175    }
176
177    #[test]
178    fn c_major_pitches() {
179        let scale = Scale::new(c4(), ScaleKind::Major);
180        let pitches = scale.pitches();
181        assert_eq!(pitches.len(), 7);
182        assert_eq!(pitches[0].step, Step::C);
183        assert_eq!(pitches[2].step, Step::E);
184        assert_eq!(pitches[4].step, Step::G);
185        assert_eq!(pitches[6].step, Step::B);
186    }
187
188    #[test]
189    fn c_major_contains() {
190        let scale = Scale::new(c4(), ScaleKind::Major);
191        assert!(scale.contains(&c4()));
192        assert!(scale.contains(&g4()));
193        assert!(!scale.contains(&Pitch::with_alter(Step::F, 4, 1))); // F# not in C major
194    }
195
196    #[test]
197    fn c_major_degree() {
198        let scale = Scale::new(c4(), ScaleKind::Major);
199        assert_eq!(scale.degree(&c4()), Some(1));
200        assert_eq!(scale.degree(&d4()), Some(2));
201        assert_eq!(scale.degree(&g4()), Some(5));
202        assert_eq!(scale.degree(&Pitch::with_alter(Step::F, 4, 1)), None);
203    }
204
205    #[test]
206    fn c_major_transpose_up_fifth() {
207        let scale = Scale::new(c4(), ScaleKind::Major);
208        let g_major = scale.transpose(7);
209        assert_eq!(g_major.root.step, Step::G);
210        assert_eq!(g_major.kind, ScaleKind::Major);
211    }
212
213    #[test]
214    fn a_natural_minor_pitches() {
215        let a4 = Pitch::new(Step::A, 4);
216        let scale = Scale::new(a4, ScaleKind::NaturalMinor);
217        let pitches = scale.pitches();
218        assert_eq!(pitches.len(), 7);
219        assert_eq!(pitches[0].step, Step::A);
220        assert_eq!(pitches[2].step, Step::C);
221    }
222
223    #[test]
224    fn blues_scale_has_six_degrees() {
225        let scale = Scale::new(c4(), ScaleKind::Blues);
226        assert_eq!(scale.pitches().len(), 6);
227    }
228
229    #[test]
230    fn chromatic_has_twelve_degrees() {
231        let scale = Scale::new(c4(), ScaleKind::Chromatic);
232        assert_eq!(scale.pitches().len(), 12);
233    }
234
235    #[test]
236    fn from_key_g_major() {
237        let key = KeySignature {
238            fifths: 1,
239            mode: "major".to_string(),
240        };
241        let scale = Scale::from_key(&key);
242        assert_eq!(scale.root.step, Step::G);
243        assert_eq!(scale.kind, ScaleKind::Major);
244    }
245
246    #[test]
247    fn from_key_a_minor() {
248        let key = KeySignature {
249            fifths: 0,
250            mode: "minor".to_string(),
251        };
252        let scale = Scale::from_key(&key);
253        assert_eq!(scale.root.step, Step::A);
254        assert_eq!(scale.kind, ScaleKind::NaturalMinor);
255    }
256
257    #[test]
258    fn best_fit_c_major() {
259        let pitches = [
260            Pitch::new(Step::C, 4),
261            Pitch::new(Step::D, 4),
262            Pitch::new(Step::E, 4),
263            Pitch::new(Step::F, 4),
264            Pitch::new(Step::G, 4),
265            Pitch::new(Step::A, 4),
266            Pitch::new(Step::B, 4),
267        ];
268        let scale = Scale::best_fit(&pitches).unwrap();
269        assert_eq!(scale.root.step, Step::C);
270        assert_eq!(scale.kind, ScaleKind::Major);
271    }
272
273    #[test]
274    fn best_fit_c_blues() {
275        // C Blues: C Eb F Gb G Bb — flat-5 prevents any 7-note diatonic match
276        let pitches = [
277            Pitch::new(Step::C, 4),
278            Pitch::with_alter(Step::E, 4, -1), // Eb
279            Pitch::new(Step::F, 4),
280            Pitch::with_alter(Step::G, 4, -1), // Gb
281            Pitch::new(Step::G, 4),
282            Pitch::with_alter(Step::B, 4, -1), // Bb
283        ];
284        let scale = Scale::best_fit(&pitches).unwrap();
285        assert_eq!(scale.root.step, Step::C);
286        assert_eq!(scale.kind, ScaleKind::Blues);
287    }
288
289    #[test]
290    fn best_fit_empty_returns_none() {
291        assert!(Scale::best_fit(&[]).is_none());
292    }
293}