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 super::pitchclassstring::PitchClassString;
7use std::fmt::{Display, Formatter};
8use std::str::FromStr;
9
10/// Input accepted by [`PitchClass::new`] and pitch-class builders.
11#[derive(Clone, Debug, PartialEq)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub enum PitchClassSpecifier {
14    /// A numeric pitch class.
15    Number(FloatType),
16    /// A string pitch class, including `A`/`T` for 10 and `B`/`E` for 11.
17    String(String),
18    /// An existing pitch class to clone.
19    PitchClass(PitchClass),
20}
21
22impl PitchClassSpecifier {
23    pub(crate) fn to_number(&self) -> Result<FloatType> {
24        match self {
25            Self::Number(value) => Ok(*value),
26            Self::String(value) => parse_pitch_class_string(value),
27            Self::PitchClass(pitch_class) => Ok(pitch_class.number()),
28        }
29    }
30}
31
32impl From<IntegerType> for PitchClassSpecifier {
33    fn from(value: IntegerType) -> Self {
34        Self::Number(value as FloatType)
35    }
36}
37
38impl From<u8> for PitchClassSpecifier {
39    fn from(value: u8) -> Self {
40        Self::Number(value as FloatType)
41    }
42}
43
44impl From<FloatType> for PitchClassSpecifier {
45    fn from(value: FloatType) -> Self {
46        Self::Number(value)
47    }
48}
49
50impl From<char> for PitchClassSpecifier {
51    fn from(value: char) -> Self {
52        Self::String(value.to_string())
53    }
54}
55
56impl From<&str> for PitchClassSpecifier {
57    fn from(value: &str) -> Self {
58        Self::String(value.to_string())
59    }
60}
61
62impl From<String> for PitchClassSpecifier {
63    fn from(value: String) -> Self {
64        Self::String(value)
65    }
66}
67
68impl From<PitchClass> for PitchClassSpecifier {
69    fn from(value: PitchClass) -> Self {
70        Self::PitchClass(value)
71    }
72}
73
74impl Display for PitchClassSpecifier {
75    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
76        match self {
77            Self::Number(number) => write!(f, "{number}"),
78            Self::String(value) => write!(f, "{value}"),
79            Self::PitchClass(pitch_class) => write!(f, "{pitch_class}"),
80        }
81    }
82}
83
84impl FromStr for PitchClass {
85    type Err = Error;
86
87    fn from_str(value: &str) -> Result<Self> {
88        Self::new(value)
89    }
90}
91
92impl TryFrom<&str> for PitchClass {
93    type Error = Error;
94
95    fn try_from(value: &str) -> Result<Self> {
96        Self::new(value)
97    }
98}
99
100impl TryFrom<String> for PitchClass {
101    type Error = Error;
102
103    fn try_from(value: String) -> Result<Self> {
104        Self::new(value)
105    }
106}
107
108impl TryFrom<char> for PitchClass {
109    type Error = Error;
110
111    fn try_from(value: char) -> Result<Self> {
112        Self::new(value)
113    }
114}
115
116impl TryFrom<IntegerType> for PitchClass {
117    type Error = Error;
118
119    fn try_from(value: IntegerType) -> Result<Self> {
120        Self::new(value)
121    }
122}
123
124impl TryFrom<u8> for PitchClass {
125    type Error = Error;
126
127    fn try_from(value: u8) -> Result<Self> {
128        Self::new(value)
129    }
130}
131
132impl TryFrom<FloatType> for PitchClass {
133    type Error = Error;
134
135    fn try_from(value: FloatType) -> Result<Self> {
136        Self::new(value)
137    }
138}
139
140/// A normalized pitch-class value.
141///
142/// Pitch classes wrap into the range `0 <= pc < 12`. Integer pitch classes
143/// display using music21's hexadecimal-style spellings: `A` for 10 and `B` for
144/// 11.
145#[derive(Clone, Copy, Debug, PartialEq)]
146#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
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
199pub(crate) fn convert_pitch_class_to_str(pc: IntegerType) -> String {
200    // Mimic Python's modulo: always a non-negative remainder.
201    let pc = pc.rem_euclid(12);
202    format!("{pc:X}")
203}
204
205fn pitch_class_to_string(pc: FloatType) -> String {
206    let pc = normalize_pitch_class(pc);
207    if pc.fract() == 0.0 {
208        return convert_pitch_class_to_str(pc as IntegerType);
209    }
210
211    trim_float(pc)
212}
213
214fn normalize_pitch_class(pc: FloatType) -> FloatType {
215    let factor = (10 as FloatType).powi(PITCH_SPACE_SIGNIFICANT_DIGITS as IntegerType);
216    let normalized = (pc.rem_euclid(12.0) * factor).round() / factor;
217    if normalized == 12.0 { 0.0 } else { normalized }
218}
219
220fn parse_pitch_class_string(value: &str) -> Result<FloatType> {
221    let value = value.trim();
222    if value.chars().count() == 1
223        && let Ok(pc_string) = PitchClassString::try_from(value.chars().next().unwrap())
224    {
225        return Ok(pc_string.to_number() as FloatType);
226    }
227
228    value
229        .parse::<FloatType>()
230        .map_err(|err| Error::PitchClass(format!("cannot parse pitch class {value:?}: {err}")))
231}
232
233fn trim_float(value: FloatType) -> String {
234    let text = format!("{value:.6}");
235    text.trim_end_matches('0').trim_end_matches('.').to_string()
236}
237
238pub(crate) fn convert_ps_to_oct(ps: FloatType) -> IntegerType {
239    let factor = (10 as FloatType).powi(PITCH_SPACE_SIGNIFICANT_DIGITS as IntegerType);
240    let ps_rounded = (ps * factor).round() / factor;
241    (ps_rounded / 12.0).floor() as IntegerType - 1
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn test_positive() {
250        assert_eq!(convert_pitch_class_to_str(3), "3");
251        assert_eq!(convert_pitch_class_to_str(10), "A");
252    }
253
254    #[test]
255    fn test_wraparound() {
256        assert_eq!(convert_pitch_class_to_str(12), "0");
257        assert_eq!(convert_pitch_class_to_str(13), "1");
258    }
259
260    #[test]
261    fn test_negative() {
262        // In Python: -1 % 12 == 11, so expect "B"
263        assert_eq!(convert_pitch_class_to_str(-1), "B");
264    }
265
266    #[test]
267    fn pitch_class_normalizes_numeric_values() {
268        let pitch_class = PitchClass::new(13).unwrap();
269        assert_eq!(pitch_class.number(), 1.0);
270        assert_eq!(pitch_class.integer(), Some(1));
271        assert_eq!(pitch_class.string(), "1");
272
273        let pitch_class = PitchClass::new(-1).unwrap();
274        assert_eq!(pitch_class.number(), 11.0);
275        assert_eq!(pitch_class.string(), "B");
276    }
277
278    #[test]
279    fn pitch_class_accepts_music21_strings() {
280        assert_eq!(PitchClass::new("A").unwrap().number(), 10.0);
281        assert_eq!(PitchClass::new("t").unwrap().number(), 10.0);
282        assert_eq!(PitchClass::new("B").unwrap().number(), 11.0);
283        assert_eq!(PitchClass::new("e").unwrap().number(), 11.0);
284
285        let microtonal = PitchClass::new("10.5").unwrap();
286        assert_eq!(microtonal.number(), 10.5);
287        assert_eq!(microtonal.integer(), None);
288        assert_eq!(microtonal.string(), "10.5");
289    }
290
291    #[test]
292    fn pitch_class_specifier_can_wrap_existing_pitch_class() {
293        let pitch_class = PitchClass::new(14).unwrap();
294        let clone = PitchClass::new(PitchClassSpecifier::from(pitch_class)).unwrap();
295        assert_eq!(clone, pitch_class);
296    }
297
298    #[test]
299    fn pitch_class_supports_rust_conversion_traits() {
300        let parsed: PitchClass = "A".parse().unwrap();
301        assert_eq!(parsed.integer(), Some(10));
302
303        let from_char = PitchClass::try_from('B').unwrap();
304        assert_eq!(from_char.number(), 11.0);
305
306        let from_number = PitchClass::try_from(13).unwrap();
307        assert_eq!(from_number.string(), "1");
308    }
309}