Skip to main content

music21_rs/pitch/
pitchclass.rs

1use crate::{
2    defaults::{FloatType, IntegerType, PITCH_SPACE_SIGNIFICANT_DIGITS},
3    error::{Error, Result},
4};
5
6use std::fmt::{Display, Formatter};
7use std::str::FromStr;
8
9/// Input accepted by [`PitchClass::new`] and pitch-class builders.
10#[derive(Clone, Debug, PartialEq)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub enum PitchClassSpecifier {
13    /// A numeric pitch class.
14    Number(FloatType),
15    /// A string pitch class, including `A`/`T` for 10 and `B`/`E` for 11.
16    String(String),
17    /// An existing pitch class to clone.
18    PitchClass(PitchClass),
19}
20
21impl PitchClassSpecifier {
22    pub(crate) fn to_number(&self) -> Result<FloatType> {
23        match self {
24            Self::Number(value) => Ok(*value),
25            Self::String(value) => parse_pitch_class_string(value),
26            Self::PitchClass(pitch_class) => Ok(pitch_class.number()),
27        }
28    }
29}
30
31impl From<IntegerType> for PitchClassSpecifier {
32    fn from(value: IntegerType) -> Self {
33        Self::Number(value as FloatType)
34    }
35}
36
37impl From<u8> for PitchClassSpecifier {
38    fn from(value: u8) -> Self {
39        Self::Number(value as FloatType)
40    }
41}
42
43impl From<FloatType> for PitchClassSpecifier {
44    fn from(value: FloatType) -> Self {
45        Self::Number(value)
46    }
47}
48
49impl From<char> for PitchClassSpecifier {
50    fn from(value: char) -> Self {
51        Self::String(value.to_string())
52    }
53}
54
55impl From<&str> for PitchClassSpecifier {
56    fn from(value: &str) -> Self {
57        Self::String(value.to_string())
58    }
59}
60
61impl From<String> for PitchClassSpecifier {
62    fn from(value: String) -> Self {
63        Self::String(value)
64    }
65}
66
67impl From<PitchClass> for PitchClassSpecifier {
68    fn from(value: PitchClass) -> Self {
69        Self::PitchClass(value)
70    }
71}
72
73impl Display for PitchClassSpecifier {
74    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Self::Number(number) => write!(f, "{number}"),
77            Self::String(value) => write!(f, "{value}"),
78            Self::PitchClass(pitch_class) => write!(f, "{pitch_class}"),
79        }
80    }
81}
82
83impl FromStr for PitchClass {
84    type Err = Error;
85
86    fn from_str(value: &str) -> Result<Self> {
87        Self::new(value)
88    }
89}
90
91impl TryFrom<&str> for PitchClass {
92    type Error = Error;
93
94    fn try_from(value: &str) -> Result<Self> {
95        Self::new(value)
96    }
97}
98
99impl TryFrom<String> for PitchClass {
100    type Error = Error;
101
102    fn try_from(value: String) -> Result<Self> {
103        Self::new(value)
104    }
105}
106
107impl TryFrom<char> for PitchClass {
108    type Error = Error;
109
110    fn try_from(value: char) -> Result<Self> {
111        Self::new(value)
112    }
113}
114
115impl TryFrom<IntegerType> for PitchClass {
116    type Error = Error;
117
118    fn try_from(value: IntegerType) -> Result<Self> {
119        Self::new(value)
120    }
121}
122
123impl TryFrom<u8> for PitchClass {
124    type Error = Error;
125
126    fn try_from(value: u8) -> Result<Self> {
127        Self::new(value)
128    }
129}
130
131impl TryFrom<FloatType> for PitchClass {
132    type Error = Error;
133
134    fn try_from(value: FloatType) -> Result<Self> {
135        Self::new(value)
136    }
137}
138
139/// A normalized pitch-class value.
140///
141/// Pitch classes wrap into the range `0 <= pc < 12`. Integer pitch classes
142/// display using music21's hexadecimal-style spellings: `A` for 10 and `B` for
143/// 11.
144#[derive(Clone, Copy, Debug, PartialEq)]
145#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
146#[must_use]
147pub struct PitchClass {
148    value: FloatType,
149}
150
151impl PitchClass {
152    /// Creates a normalized pitch class from a number, string, or existing
153    /// pitch class.
154    pub fn new(specifier: impl Into<PitchClassSpecifier>) -> Result<Self> {
155        match specifier.into() {
156            PitchClassSpecifier::PitchClass(pitch_class) => Ok(pitch_class),
157            specifier => Self::from_number(specifier.to_number()?),
158        }
159    }
160
161    pub(crate) fn from_number(value: FloatType) -> Result<Self> {
162        if !value.is_finite() {
163            return Err(Error::PitchClass(format!(
164                "pitch class must be finite, got {value}"
165            )));
166        }
167
168        Ok(Self {
169            value: normalize_pitch_class(value),
170        })
171    }
172
173    /// Returns the normalized numeric pitch class.
174    pub fn number(&self) -> FloatType {
175        self.value
176    }
177
178    /// Returns the integer pitch class if this value is not microtonal.
179    pub fn integer(&self) -> Option<IntegerType> {
180        if self.value.fract() == 0.0 {
181            Some(self.value as IntegerType)
182        } else {
183            None
184        }
185    }
186
187    /// Returns the music21-style pitch-class string.
188    pub fn string(&self) -> String {
189        pitch_class_to_string(self.value)
190    }
191}
192
193impl Display for PitchClass {
194    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
195        write!(f, "{}", self.string())
196    }
197}
198
199/// music21's `convertPitchClassToStr`: an integer pitch class as one
200/// character, `A` for ten and `B` for eleven, reduced modulo twelve first.
201pub fn convert_pitch_class_to_str(pc: IntegerType) -> String {
202    // Mimic Python's modulo: always a non-negative remainder.
203    let pc = pc.rem_euclid(12);
204    format!("{pc:X}")
205}
206
207fn pitch_class_to_string(pc: FloatType) -> String {
208    let pc = normalize_pitch_class(pc);
209    if pc.fract() == 0.0 {
210        return convert_pitch_class_to_str(pc as IntegerType);
211    }
212
213    trim_float(pc)
214}
215
216fn normalize_pitch_class(pc: FloatType) -> FloatType {
217    let factor = (10 as FloatType).powi(PITCH_SPACE_SIGNIFICANT_DIGITS as IntegerType);
218    let normalized = (pc.rem_euclid(12.0) * factor).round() / factor;
219    if normalized == 12.0 { 0.0 } else { normalized }
220}
221
222fn parse_pitch_class_string(value: &str) -> Result<FloatType> {
223    let value = value.trim();
224    let mut letters = value.chars();
225    if let (Some(letter), None) = (letters.next(), letters.next())
226        && let Some(number) = pitch_class_letter_number(letter)
227    {
228        return Ok(number as FloatType);
229    }
230
231    value
232        .parse::<FloatType>()
233        .map_err(|err| Error::PitchClass(format!("cannot parse pitch class {value:?}: {err}")))
234}
235
236/// The single-letter spellings music21 accepts for pitch classes ten and
237/// eleven, in either case.
238fn pitch_class_letter_number(letter: char) -> Option<IntegerType> {
239    match letter.to_ascii_lowercase() {
240        'a' | 't' => Some(10),
241        'b' | 'e' => Some(11),
242        _ => None,
243    }
244}
245
246fn trim_float(value: FloatType) -> String {
247    let text = format!("{value:.6}");
248    text.trim_end_matches('0').trim_end_matches('.').to_string()
249}
250
251pub(crate) fn convert_ps_to_oct(ps: FloatType) -> IntegerType {
252    let factor = (10 as FloatType).powi(PITCH_SPACE_SIGNIFICANT_DIGITS as IntegerType);
253    let ps_rounded = (ps * factor).round() / factor;
254    (ps_rounded / 12.0).floor() as IntegerType - 1
255}
256
257#[cfg(test)]
258mod tests {
259    #[test]
260    fn a_pitch_class_is_read_from_a_number_a_name_or_a_string() {
261        use super::{PitchClass, PitchClassSpecifier};
262
263        assert_eq!(PitchClass::try_from("A").unwrap().to_string(), "A");
264        assert_eq!(
265            PitchClass::try_from("B".to_string()).unwrap().to_string(),
266            "B"
267        );
268        assert_eq!(PitchClass::try_from(3u8).unwrap().to_string(), "3");
269        assert_eq!(PitchClass::try_from(1.5).unwrap().to_string(), "1.5");
270        assert!(PitchClass::try_from("Z").is_err());
271        assert_eq!(PitchClass::try_from(12u8).unwrap().to_string(), "0");
272        assert_eq!(PitchClassSpecifier::from(4.0).to_string(), "4");
273        assert_eq!(PitchClassSpecifier::from("E".to_string()).to_string(), "E");
274    }
275
276    use super::*;
277
278    #[test]
279    fn test_positive() {
280        assert_eq!(convert_pitch_class_to_str(3), "3");
281        assert_eq!(convert_pitch_class_to_str(10), "A");
282    }
283
284    #[test]
285    fn test_wraparound() {
286        assert_eq!(convert_pitch_class_to_str(12), "0");
287        assert_eq!(convert_pitch_class_to_str(13), "1");
288    }
289
290    #[test]
291    fn test_negative() {
292        // In Python: -1 % 12 == 11, so expect "B"
293        assert_eq!(convert_pitch_class_to_str(-1), "B");
294    }
295
296    #[test]
297    fn pitch_class_normalizes_numeric_values() {
298        let pitch_class = PitchClass::new(13).unwrap();
299        assert_eq!(pitch_class.number(), 1.0);
300        assert_eq!(pitch_class.integer(), Some(1));
301        assert_eq!(pitch_class.string(), "1");
302
303        let pitch_class = PitchClass::new(-1).unwrap();
304        assert_eq!(pitch_class.number(), 11.0);
305        assert_eq!(pitch_class.string(), "B");
306    }
307
308    #[test]
309    fn pitch_class_accepts_music21_strings() {
310        assert_eq!(PitchClass::new("A").unwrap().number(), 10.0);
311        assert_eq!(PitchClass::new("t").unwrap().number(), 10.0);
312        assert_eq!(PitchClass::new("B").unwrap().number(), 11.0);
313        assert_eq!(PitchClass::new("e").unwrap().number(), 11.0);
314
315        let microtonal = PitchClass::new("10.5").unwrap();
316        assert_eq!(microtonal.number(), 10.5);
317        assert_eq!(microtonal.integer(), None);
318        assert_eq!(microtonal.string(), "10.5");
319    }
320
321    #[test]
322    fn pitch_class_specifier_can_wrap_existing_pitch_class() {
323        let pitch_class = PitchClass::new(14).unwrap();
324        let clone = PitchClass::new(PitchClassSpecifier::from(pitch_class)).unwrap();
325        assert_eq!(clone, pitch_class);
326    }
327
328    #[test]
329    fn pitch_class_supports_rust_conversion_traits() {
330        let parsed: PitchClass = "A".parse().unwrap();
331        assert_eq!(parsed.integer(), Some(10));
332
333        let from_char = PitchClass::try_from('B').unwrap();
334        assert_eq!(from_char.number(), 11.0);
335
336        let from_number = PitchClass::try_from(13).unwrap();
337        assert_eq!(from_number.string(), "1");
338    }
339}