Skip to main content

music21/musedata/
base40.rs

1//! Walter Hewlett's base-40 pitch numbers: music21's `musedata.base40`.
2//!
3//! Base 40 numbers every pitch from a double flat to a double sharp on each
4//! of the seven letters, forty to the octave, leaving a number unused
5//! between two letters a whole step apart. Its gift is that the distance
6//! between two numbers names the interval between the pitches, spelling
7//! and all: 23 is always a perfect fifth.
8//!
9//! ```
10//! use music21::musedata::base40;
11//!
12//! assert_eq!(base40::pitch_name(186), Some("G"));
13//! assert_eq!(base40::from_pitch_name("F##", 4), Some(142));
14//! assert_eq!(base40::delta_to_interval(-52).unwrap().directed_name(), "M-10");
15//! ```
16
17use std::fmt;
18
19use crate::interval::Interval;
20use crate::pitch::Pitch;
21
22/// music21's `base40Equivalent`: the pitch name each number of an octave
23/// stands for, from 1 to 40, and the empty name for the five unused.
24pub const EQUIVALENTS: [(i32, &str); 40] = [
25    (1, "C--"),
26    (2, "C-"),
27    (3, "C"),
28    (4, "C#"),
29    (5, "C##"),
30    (6, ""),
31    (7, "D--"),
32    (8, "D-"),
33    (9, "D"),
34    (10, "D#"),
35    (11, "D##"),
36    (12, ""),
37    (13, "E--"),
38    (14, "E-"),
39    (15, "E"),
40    (16, "E#"),
41    (17, "E##"),
42    (18, "F--"),
43    (19, "F-"),
44    (20, "F"),
45    (21, "F#"),
46    (22, "F##"),
47    (23, ""),
48    (24, "G--"),
49    (25, "G-"),
50    (26, "G"),
51    (27, "G#"),
52    (28, "G##"),
53    (29, ""),
54    (30, "A--"),
55    (31, "A-"),
56    (32, "A"),
57    (33, "A#"),
58    (34, "A##"),
59    (35, ""),
60    (36, "B--"),
61    (37, "B-"),
62    (38, "B"),
63    (39, "B#"),
64    (40, "B##"),
65];
66
67/// music21's `base40IntervalTable`: the simple interval each distance
68/// within an octave is. A distance missing here is no interval base 40
69/// can say.
70pub const INTERVALS: [(i32, &str); 25] = [
71    (0, "P1"),
72    (1, "A1"),
73    (4, "d2"),
74    (5, "m2"),
75    (6, "M2"),
76    (7, "A2"),
77    (10, "d3"),
78    (11, "m3"),
79    (12, "M3"),
80    (13, "A3"),
81    (16, "d4"),
82    (17, "P4"),
83    (18, "A4"),
84    (22, "d5"),
85    (23, "P5"),
86    (24, "A5"),
87    (27, "d6"),
88    (28, "m6"),
89    (29, "M6"),
90    (30, "A6"),
91    (33, "d7"),
92    (34, "m7"),
93    (35, "M7"),
94    (36, "A7"),
95    (39, "d8"),
96];
97
98/// Why a base-40 question has no answer, in music21's words: what music21
99/// raises as `Base40Exception`.
100#[derive(Clone, Debug, PartialEq, Eq)]
101pub struct Base40Error(String);
102
103impl Base40Error {
104    fn new(message: impl Into<String>) -> Self {
105        Self(message.into())
106    }
107
108    /// What went wrong, as music21 says it.
109    pub fn message(&self) -> &str {
110        &self.0
111    }
112}
113
114impl fmt::Display for Base40Error {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        f.write_str(&self.0)
117    }
118}
119
120impl std::error::Error for Base40Error {}
121
122/// The pitch name a number of the octave (1 to 40) stands for, the empty
123/// name for one unused, and `None` for a number outside the octave.
124pub fn equivalent(number: i32) -> Option<&'static str> {
125    EQUIVALENTS
126        .iter()
127        .find(|(known, _)| *known == number)
128        .map(|(_, name)| *name)
129}
130
131/// The pitch name a base-40 number stands for, in whatever octave: `None`
132/// for one of the unused numbers.
133pub fn pitch_name(number: i32) -> Option<&'static str> {
134    equivalent((number - 1).rem_euclid(40) + 1).filter(|name| !name.is_empty())
135}
136
137/// music21's `base40Representation`: the number of the octave a pitch name
138/// is, for the thirty-five names base 40 has.
139pub fn representation(name: &str) -> Option<i32> {
140    EQUIVALENTS
141        .iter()
142        .find(|(_, known)| !known.is_empty() && *known == name)
143        .map(|(number, _)| *number)
144}
145
146/// The base-40 number of a pitch name in an octave, as music21's
147/// `pitchToBase40` numbers it: octave 1's C double flat is 1.
148pub fn from_pitch_name(name: &str, octave: i32) -> Option<i32> {
149    representation(name).map(|number| 40 * (octave - 1) + number)
150}
151
152/// The octave and the number within it music21's `base40ToPitch` reads a
153/// base-40 number as. music21 divides with a float and lets the octave
154/// setter drop the fraction, so the octave is the quotient cut toward
155/// nought, and the number within it is what is left after that many
156/// octaves.
157pub fn octave_and_number(number: i32) -> (i32, i32) {
158    let octave = (f64::from(number - 1) / 40.0 + 1.0).trunc() as i32;
159    (octave, number - 40 * (octave - 1))
160}
161
162/// music21's `base40ToPitch`: the pitch a base-40 number is.
163///
164/// # Errors
165///
166/// A number base 40 leaves unused.
167pub fn to_pitch(number: i32) -> Result<Pitch, Base40Error> {
168    let refused = || {
169        Base40Error::new(format!(
170            "Pitch name not assigned to this Base40 number {number}"
171        ))
172    };
173    let (octave, within) = octave_and_number(number);
174    let name = equivalent(within)
175        .filter(|name| !name.is_empty())
176        .ok_or_else(refused)?;
177    Pitch::from_name_and_octave(name, octave).map_err(|_| refused())
178}
179
180/// music21's `pitchToBase40`: a pitch's base-40 number.
181///
182/// # Errors
183///
184/// A pitch base 40 has no number for: a triple sharp, a quarter tone, or
185/// one with no octave.
186pub fn from_pitch(pitch: &Pitch) -> Result<i32, Base40Error> {
187    let number = pitch
188        .octave()
189        .and_then(|octave| from_pitch_name(&music21_name(pitch)?, octave));
190    number.ok_or_else(|| {
191        Base40Error::new(format!(
192            "Base40 cannot handle this pitch {}",
193            pitch.name_with_octave()
194        ))
195    })
196}
197
198/// A pitch's name spelled as music21 spells it, a flat as `-`, for the
199/// accidentals base 40 has: none for a quarter tone or a triple.
200fn music21_name(pitch: &Pitch) -> Option<String> {
201    let alter = pitch.alter();
202    let modifier = match alter {
203        -2.0 => "--",
204        -1.0 => "-",
205        0.0 => "",
206        1.0 => "#",
207        2.0 => "##",
208        _ => return None,
209    };
210    let step = pitch.name().chars().next()?;
211    Some(format!("{step}{modifier}"))
212}
213
214/// music21's `base40DeltaToInterval`: the interval a distance between two
215/// base-40 numbers is, its octaves counted and its direction kept.
216///
217/// # Errors
218///
219/// A distance that is no interval base 40 can say.
220pub fn delta_to_interval(delta: i32) -> Result<Interval, Base40Error> {
221    let simple = delta.abs() % 40;
222    let name = INTERVALS
223        .iter()
224        .find(|(distance, _)| *distance == simple)
225        .map(|(_, name)| *name)
226        .ok_or_else(|| Base40Error::new(format!("Interval not handled by Base40 {simple}")))?;
227    let (specifier, number) = name.split_at(1);
228    let number: i32 = number.parse().unwrap_or(1);
229    let generic = number + 7 * (delta.abs() / 40);
230    let generic = if delta < 0 { -generic } else { generic };
231    Interval::from_name(format!("{specifier}{generic}"))
232        .map_err(|error| Base40Error::new(error.to_string()))
233}
234
235/// music21's `base40Interval`: the interval between two base-40 numbers,
236/// read from their distance alone.
237///
238/// # Errors
239///
240/// Either number unused, or a distance base 40 cannot say -- which it
241/// cannot past an augmented or diminished interval on one letter.
242pub fn interval(from: i32, to: i32) -> Result<Interval, Base40Error> {
243    let first = pitch_name(from);
244    let second = pitch_name(to);
245    match (first, second) {
246        (None, None) => {
247            return Err(Base40Error::new(format!(
248                "Pitch name not assigned to these Base40 numbers {from} and {to} \
249                 Interval does not exist"
250            )));
251        }
252        (None, _) => {
253            return Err(Base40Error::new(format!(
254                "Pitch name not assigned to this Base40 number {from} Interval does not exist"
255            )));
256        }
257        (_, None) => {
258            return Err(Base40Error::new(format!(
259                "Pitch name not assigned to this Base40 number {to} Interval does not exist"
260            )));
261        }
262        _ => {}
263    }
264    let delta = to - from;
265    let same_letter =
266        first.and_then(|name| name.chars().next()) == second.and_then(|name| name.chars().next());
267    if delta > 3 && same_letter {
268        return Err(Base40Error::new(format!(
269            "Base40 cannot compute interval between {from} and {to}."
270        )));
271    }
272    delta_to_interval(delta)
273}
274
275/// music21's `base40ActualInterval`: the interval between the pitches two
276/// base-40 numbers are, worked out from the pitches rather than read from
277/// the distance.
278///
279/// # Errors
280///
281/// Either number unused, or an interval no specifier can name.
282pub fn actual_interval(from: i32, to: i32) -> Result<Interval, Base40Error> {
283    let start = to_pitch(from)?;
284    let end = to_pitch(to)?;
285    Interval::between_pitches(&start, &end)
286        .map_err(|_| Base40Error::new("Unusual interval - Limitation of music21.interval"))
287}
288
289/// The way an enharmonic is looked for: a diminished second up or down.
290#[derive(Clone, Copy, Debug, PartialEq, Eq)]
291pub enum Direction {
292    /// Higher spellings: the next letter up.
293    Up,
294    /// Lower spellings: the next letter down.
295    Down,
296}
297
298/// music21's `_quickEnharmonicString`: the other spellings of a pitch name
299/// one way, each a diminished second from the last, until a letter comes
300/// round again or base 40 runs out of names. `double_accidentals` false
301/// leaves the double sharps and flats out.
302pub fn quick_enharmonics(
303    name: &str,
304    direction: Direction,
305    double_accidentals: bool,
306) -> Vec<String> {
307    let Some(mut number) = representation(name) else {
308        return Vec::new();
309    };
310    let step = match direction {
311        Direction::Up => 4,
312        Direction::Down => -4,
313    };
314    let letter = name.chars().next();
315    let mut found: Vec<String> = Vec::new();
316    loop {
317        number = (number + step).rem_euclid(40);
318        if number == 0 {
319            number = 40;
320        }
321        let spelled = equivalent(number)
322            .filter(|spelled| double_accidentals || spelled.len() <= 2)
323            .unwrap_or("");
324        let first = spelled.chars().next();
325        if spelled.is_empty() || first == letter {
326            return found;
327        }
328        if found.iter().any(|known| known.chars().next() == first) {
329            return found;
330        }
331        found.push(spelled.to_string());
332    }
333}
334
335/// music21's `quickEnharmonicString`: the lower spellings, then the higher.
336pub fn quick_enharmonic_string(name: &str, double_accidentals: bool) -> Vec<String> {
337    let mut found = quick_enharmonics(name, Direction::Down, double_accidentals);
338    found.extend(quick_enharmonics(name, Direction::Up, double_accidentals));
339    found
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn a_number_is_a_pitch_and_back() {
348        let lowest = to_pitch(1).unwrap();
349        assert_eq!((lowest.alter(), lowest.octave()), (-2.0, Some(1)));
350        assert_eq!(to_pitch(40).unwrap().name_with_octave(), "B##1");
351        assert_eq!(from_pitch(&Pitch::from_name("B-4").unwrap()).unwrap(), 157);
352        assert_eq!(to_pitch(186).unwrap().name_with_octave(), "G5");
353        assert_eq!(
354            to_pitch(23).unwrap_err().message(),
355            "Pitch name not assigned to this Base40 number 23"
356        );
357        let pitch = Pitch::from_name("C--5").unwrap();
358        assert_eq!(from_pitch(&pitch).unwrap(), 161);
359        let pitch = Pitch::from_name("F###4").unwrap();
360        assert_eq!(
361            from_pitch(&pitch).unwrap_err().message(),
362            "Base40 cannot handle this pitch F###4"
363        );
364    }
365
366    #[test]
367    fn the_octave_is_cut_toward_nought() {
368        assert_eq!(octave_and_number(0), (0, 40));
369        assert_eq!(octave_and_number(-40), (0, 0));
370        assert_eq!(octave_and_number(41), (2, 1));
371    }
372
373    #[test]
374    fn a_distance_is_an_interval_with_its_octaves() {
375        assert_eq!(delta_to_interval(-11).unwrap().directed_name(), "m-3");
376        assert_eq!(delta_to_interval(63).unwrap().directed_name(), "P12");
377        assert_eq!(
378            delta_to_interval(77).unwrap_err().message(),
379            "Interval not handled by Base40 37"
380        );
381    }
382
383    #[test]
384    fn an_interval_between_numbers_needs_both_named() {
385        assert_eq!(interval(163, 191).unwrap().directed_name(), "m6");
386        assert_eq!(interval(186, 174).unwrap().directed_name(), "M-3");
387        assert_eq!(
388            interval(1, 5).unwrap_err().message(),
389            "Base40 cannot compute interval between 1 and 5."
390        );
391        assert_eq!(
392            interval(12, 6).unwrap_err().message(),
393            "Pitch name not assigned to these Base40 numbers 12 and 6 Interval does not exist"
394        );
395        assert_eq!(actual_interval(1, 5).unwrap().directed_name(), "AAAA1");
396    }
397
398    #[test]
399    fn enharmonics_stop_where_a_letter_comes_round() {
400        assert_eq!(quick_enharmonics("C##", Direction::Up, true), ["D", "E--"]);
401        assert_eq!(quick_enharmonics("C##", Direction::Up, false), ["D"]);
402        assert_eq!(quick_enharmonics("C-", Direction::Down, true), ["B", "A##"]);
403        assert_eq!(quick_enharmonic_string("G", true), ["F##", "A--"]);
404        assert!(quick_enharmonics("C###", Direction::Up, true).is_empty());
405    }
406}