Skip to main content

music21_rs/analysis/
enharmonics.rs

1//! The best spelling of a run of pitches: music21's `analysis.enharmonics`.
2//!
3//! Every pitch may be written as itself or as any common enharmonic with at
4//! most one sharp or flat. Each combination is scored by three penalties and
5//! the lowest score wins, the first of equals in the order the combinations
6//! are listed:
7//!
8//! ```text
9//!   aug/dim     (augmented + diminished steps + 1) * aug_dim penalty
10//! + alteration  (sharps + flats + 1)               * alteration penalty
11//! + mixture     min(sharps, flats)                 * mixture penalty
12//! ```
13//!
14//! A penalty that is off scores 1 rather than 0, as music21's does.
15
16use itertools::Itertools;
17
18use crate::{
19    defaults::IntegerType,
20    error::{Error, Result},
21    pitch::Pitch,
22};
23
24/// The alteration count a spelling is allowed besides the one it came in.
25const ALTER_LIMIT: IntegerType = 1;
26
27/// What a penalty that is switched off scores.
28const PENALTY_OFF: IntegerType = 1;
29
30/// The places of the natural steps in Hewlett's base-40 system, before the
31/// three places below each natural that its flats take:
32/// `C-- = 1, C = 3, D = 9, ... B## = 40`.
33const BASE40_STEPS: [(char, IntegerType); 7] = [
34    ('C', 3),
35    ('D', 9),
36    ('E', 15),
37    ('F', 20),
38    ('G', 26),
39    ('A', 32),
40    ('B', 38),
41];
42
43/// The accidentals base 40 has room for, with the places each moves a step.
44const BASE40_MODIFIERS: [(&str, IntegerType); 5] =
45    [("bb", -2), ("b", -1), ("", 0), ("#", 1), ("##", 2)];
46
47/// The number of places in one base-40 octave.
48const BASE40_OCTAVE: IntegerType = 40;
49
50/// music21's `base40IntervalTable`: the interval each distance between two
51/// base-40 places spells, within an octave. A distance with no interval
52/// reads as `ddd`.
53const BASE40_INTERVALS: [(IntegerType, &str); 25] = [
54    (0, "P1"),
55    (1, "A1"),
56    (4, "d2"),
57    (5, "m2"),
58    (6, "M2"),
59    (7, "A2"),
60    (10, "d3"),
61    (11, "m3"),
62    (12, "M3"),
63    (13, "A3"),
64    (16, "d4"),
65    (17, "P4"),
66    (18, "A4"),
67    (22, "d5"),
68    (23, "P5"),
69    (24, "A5"),
70    (27, "d6"),
71    (28, "m6"),
72    (29, "M6"),
73    (30, "A6"),
74    (33, "d7"),
75    (34, "m7"),
76    (35, "M7"),
77    (36, "A7"),
78    (39, "d8"),
79];
80
81/// What a distance base 40 has no interval for is read as.
82const BASE40_UNKNOWN: &str = "ddd";
83
84/// The three penalties a spelling is scored by: music21's
85/// `EnharmonicScoreRules` and its subclasses. A penalty of `None` or nought
86/// is off, and scores 1 whatever the spelling.
87#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
88#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
89pub struct EnharmonicRules {
90    alteration: Option<IntegerType>,
91    aug_dim: Option<IntegerType>,
92    mixture: Option<IntegerType>,
93}
94
95impl Default for EnharmonicRules {
96    fn default() -> Self {
97        Self::MELODIC
98    }
99}
100
101impl EnharmonicRules {
102    /// music21's `EnharmonicScoreRules`: mixing sharps and flats costs
103    /// nothing.
104    pub const MELODIC: Self = Self {
105        alteration: Some(4),
106        aug_dim: Some(2),
107        mixture: None,
108    };
109
110    /// music21's `ChordEnharmonicScoreRules`: mixing sharps and flats costs
111    /// two for each of the rarer kind.
112    pub const CHORDAL: Self = Self {
113        mixture: Some(2),
114        ..Self::MELODIC
115    };
116
117    /// Rules with the penalties given: what each sharp or flat costs, each
118    /// augmented or diminished step, and each accidental of the rarer kind.
119    pub fn new(
120        alteration: Option<IntegerType>,
121        aug_dim: Option<IntegerType>,
122        mixture: Option<IntegerType>,
123    ) -> Self {
124        let on = |penalty: Option<IntegerType>| penalty.filter(|penalty| *penalty != 0);
125        Self {
126            alteration: on(alteration),
127            aug_dim: on(aug_dim),
128            mixture: on(mixture),
129        }
130    }
131
132    /// What each sharp or flat costs, if anything.
133    pub fn alteration_penalty(self) -> Option<IntegerType> {
134        self.alteration
135    }
136
137    /// What each augmented or diminished step costs, if anything.
138    pub fn aug_dim_penalty(self) -> Option<IntegerType> {
139        self.aug_dim
140    }
141
142    /// What each accidental of the rarer kind costs, if anything.
143    pub fn mixture_penalty(self) -> Option<IntegerType> {
144        self.mixture
145    }
146
147    /// music21's `getAlterationScore`: one more than the sharps and flats in
148    /// `spelling`, times the alteration penalty.
149    pub fn alteration_score(self, spelling: &[Pitch]) -> IntegerType {
150        let Some(penalty) = self.alteration else {
151            return PENALTY_OFF;
152        };
153
154        let (flats, sharps) = accidental_counts(spelling);
155        (flats + sharps + 1) * penalty
156    }
157
158    /// music21's `getMixSharpFlatsScore`: the count of the rarer of sharps
159    /// and flats in `spelling`, times the mixture penalty.
160    pub fn mixture_score(self, spelling: &[Pitch]) -> IntegerType {
161        let Some(penalty) = self.mixture else {
162            return PENALTY_OFF;
163        };
164
165        let (flats, sharps) = accidental_counts(spelling);
166        flats.min(sharps) * penalty
167    }
168
169    /// music21's `getAugDimScore`: one more than the augmented and
170    /// diminished steps between neighbours in `spelling`, times the
171    /// aug/dim penalty. `E- G#` is one, an augmented third.
172    ///
173    /// # Errors
174    ///
175    /// A neighbour spelled with an accidental base 40 has no place for,
176    /// such as a triple sharp or a microtone's step.
177    pub fn aug_dim_score(self, spelling: &[Pitch]) -> Result<IntegerType> {
178        let Some(penalty) = self.aug_dim else {
179            return Ok(PENALTY_OFF);
180        };
181
182        let mut odd_steps = 0;
183        for pair in spelling.windows(2) {
184            let low = base40(&pair[0])?;
185            let high = base40(&pair[1])?;
186
187            // music21 counts the letters of the interval names joined up,
188            // so `ddd` counts three.
189            let name = base40_interval((high - low).rem_euclid(BASE40_OCTAVE));
190            odd_steps += name.chars().filter(|c| matches!(c, 'A' | 'd')).count() as IntegerType;
191        }
192
193        Ok((odd_steps + 1) * penalty)
194    }
195
196    /// The whole score of `spelling`; lower is better.
197    fn score(self, spelling: &[Pitch]) -> Result<IntegerType> {
198        Ok(self.aug_dim_score(spelling)?
199            + self.alteration_score(spelling)
200            + self.mixture_score(spelling))
201    }
202}
203
204/// Every spelling each pitch may take: itself first, then its common
205/// enharmonics with at most one sharp or flat. music21's
206/// `EnharmonicSimplifier.getRepresentations`.
207pub fn spelling_options(pitches: &[Pitch]) -> Vec<Vec<Pitch>> {
208    pitches
209        .iter()
210        .map(|pitch| {
211            let mut options = vec![pitch.clone()];
212            options.extend(pitch.all_common_enharmonics(ALTER_LIMIT));
213            options
214        })
215        .collect()
216}
217
218/// Which option of each position scores best under `rules`, one index per
219/// position: the heart of music21's `bestPitches`. Combinations are tried
220/// in the order `itertools.product` lists them, and the first strictly
221/// lowest wins. A position with no options leaves nothing to choose.
222///
223/// # Errors
224///
225/// A spelling base 40 has no place for.
226pub fn best_choice(options: &[Vec<Pitch>], rules: EnharmonicRules) -> Result<Vec<usize>> {
227    let positions = options.iter().map(|option| 0..option.len());
228
229    let mut best: Option<(IntegerType, Vec<usize>)> = None;
230    for choice in positions.multi_cartesian_product() {
231        let spelling: Vec<Pitch> = choice
232            .iter()
233            .zip(options)
234            .map(|(index, option)| option[*index].clone())
235            .collect();
236        let score = rules.score(&spelling)?;
237        if best.as_ref().is_some_and(|(lowest, _)| score >= *lowest) {
238            continue;
239        }
240        best = Some((score, choice));
241    }
242
243    Ok(best.map(|(_, choice)| choice).unwrap_or_default())
244}
245
246/// The best spelling of `pitches` under `rules`: music21's
247/// `EnharmonicSimplifier.bestPitches`. Octaves move with a respelling, so
248/// `B#3` may come back as `C4`.
249///
250/// ```
251/// use music21_rs::{Pitch, analysis::enharmonics::{EnharmonicRules, best_spelling}};
252///
253/// let pitches: Vec<Pitch> = ["D--", "E", "F##"].iter().map(|n| n.parse()).collect::<Result<_, _>>()?;
254/// let best = best_spelling(&pitches, EnharmonicRules::MELODIC)?;
255/// let names: Vec<String> = best.iter().map(Pitch::name).collect();
256/// assert_eq!(names, ["C", "E", "G"]);
257/// # Ok::<(), music21_rs::Error>(())
258/// ```
259///
260/// # Errors
261///
262/// No pitches, which music21 refuses too, or a spelling base 40 has no
263/// place for.
264pub fn best_spelling(pitches: &[Pitch], rules: EnharmonicRules) -> Result<Vec<Pitch>> {
265    if pitches.is_empty() {
266        return Err(Error::Analysis(
267            "an enharmonic spelling needs at least one pitch".to_string(),
268        ));
269    }
270
271    let options = spelling_options(pitches);
272    let choice = best_choice(&options, rules)?;
273    Ok(choice
274        .into_iter()
275        .zip(options)
276        .map(|(index, mut option)| option.swap_remove(index))
277        .collect())
278}
279
280/// The flats and the sharps written in `spelling`'s names.
281fn accidental_counts(spelling: &[Pitch]) -> (IntegerType, IntegerType) {
282    spelling.iter().fold((0, 0), |(flats, sharps), pitch| {
283        let modifier = pitch.accidental().modifier();
284        let count = |mark: char| modifier.chars().filter(|c| *c == mark).count() as IntegerType;
285        (flats + count('b'), sharps + count('#'))
286    })
287}
288
289/// `pitch`'s place in Hewlett's base 40, octave aside.
290fn base40(pitch: &Pitch) -> Result<IntegerType> {
291    let name = pitch.name();
292    let mut chars = name.chars();
293    let step = chars.next();
294    let modifier = chars.as_str();
295
296    let natural = BASE40_STEPS
297        .iter()
298        .find(|(letter, _)| Some(*letter) == step);
299    let shift = BASE40_MODIFIERS
300        .iter()
301        .find(|(written, _)| *written == modifier);
302    let (Some((_, natural)), Some((_, shift))) = (natural, shift) else {
303        return Err(Error::Analysis(format!("{name} has no place in base 40")));
304    };
305
306    Ok(natural + shift)
307}
308
309/// The interval base 40 spells for a distance within an octave.
310fn base40_interval(distance: IntegerType) -> &'static str {
311    BASE40_INTERVALS
312        .iter()
313        .find(|(known, _)| *known == distance)
314        .map_or(BASE40_UNKNOWN, |(_, name)| name)
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    fn pitches(names: &[&str]) -> Vec<Pitch> {
322        names.iter().map(|name| name.parse().unwrap()).collect()
323    }
324
325    fn names(pitches: &[Pitch]) -> Vec<String> {
326        pitches.iter().map(Pitch::name_with_octave).collect()
327    }
328
329    #[test]
330    fn a_plain_spelling_stays() {
331        let best = best_spelling(&pitches(&["C", "D", "E"]), EnharmonicRules::MELODIC).unwrap();
332        assert_eq!(names(&best), ["C", "D", "E"]);
333    }
334
335    #[test]
336    fn double_accidentals_are_simplified() {
337        let best = best_spelling(&pitches(&["Dbb", "E", "F##"]), EnharmonicRules::MELODIC).unwrap();
338        assert_eq!(names(&best), ["C", "E", "G"]);
339    }
340
341    #[test]
342    fn an_octave_moves_with_its_respelling() {
343        let best =
344            best_spelling(&pitches(&["B#3", "F-4", "G4"]), EnharmonicRules::MELODIC).unwrap();
345        assert_eq!(names(&best), ["C4", "E4", "G4"]);
346    }
347
348    #[test]
349    fn no_pitches_are_refused() {
350        assert!(best_spelling(&[], EnharmonicRules::MELODIC).is_err());
351    }
352
353    #[test]
354    fn scores_add_up_as_music21_adds_them() {
355        // C E G# has no odd step; C E- G# has one, the augmented third E- G#.
356        let spelling = pitches(&["C", "E", "G#"]);
357        assert_eq!(EnharmonicRules::MELODIC.alteration_score(&spelling), 8);
358        assert_eq!(
359            EnharmonicRules::MELODIC.aug_dim_score(&spelling).unwrap(),
360            2
361        );
362        let augmented = pitches(&["C", "E-", "G#"]);
363        assert_eq!(
364            EnharmonicRules::MELODIC.aug_dim_score(&augmented).unwrap(),
365            4
366        );
367        assert_eq!(
368            EnharmonicRules::MELODIC.mixture_score(&spelling),
369            PENALTY_OFF
370        );
371
372        let mixed = pitches(&["C#", "E-", "G#"]);
373        assert_eq!(EnharmonicRules::CHORDAL.mixture_score(&mixed), 2);
374    }
375
376    #[test]
377    fn a_distance_base40_cannot_spell_reads_as_three_odd_steps() {
378        // C to E##: 17 - 3 = 14 places, which base 40 has no name for.
379        let odd = pitches(&["C", "E##"]);
380        assert_eq!(EnharmonicRules::MELODIC.aug_dim_score(&odd).unwrap(), 8);
381    }
382
383    #[test]
384    fn a_penalty_that_is_off_scores_one() {
385        let off = EnharmonicRules::new(Some(0), None, Some(0));
386        let spelling = pitches(&["C###", "E-", "G#"]);
387        assert_eq!(off.alteration_score(&spelling), PENALTY_OFF);
388        assert_eq!(off.aug_dim_score(&spelling).unwrap(), PENALTY_OFF);
389        assert_eq!(off.mixture_score(&spelling), PENALTY_OFF);
390    }
391
392    #[test]
393    fn the_best_choice_indexes_each_position_s_options() {
394        let options = spelling_options(&pitches(&["Dbb", "E"]));
395        assert_eq!(names(&options[0])[0], "Dbb");
396        let choice = best_choice(&options, EnharmonicRules::MELODIC).unwrap();
397        assert_eq!(options[0][choice[0]].name(), "C");
398        assert_eq!(choice[1], 0);
399    }
400
401    #[test]
402    fn the_presets_are_music21s_rule_classes() {
403        assert_eq!(EnharmonicRules::default(), EnharmonicRules::MELODIC);
404
405        let melodic = EnharmonicRules::MELODIC;
406        assert_eq!(melodic.alteration_penalty(), Some(4));
407        assert_eq!(melodic.aug_dim_penalty(), Some(2));
408        assert_eq!(melodic.mixture_penalty(), None);
409        assert_eq!(EnharmonicRules::CHORDAL.mixture_penalty(), Some(2));
410
411        assert_eq!(EnharmonicRules::new(Some(4), Some(2), Some(0)), melodic);
412    }
413
414    #[test]
415    fn a_triple_sharp_has_no_base40_place() {
416        let spelling = pitches(&["C###", "E"]);
417        assert!(EnharmonicRules::MELODIC.aug_dim_score(&spelling).is_err());
418    }
419}