music21_rs/pitch/
pitchclass.rs1use 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#[derive(Clone, Debug, PartialEq)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub enum PitchClassSpecifier {
13 Number(FloatType),
15 String(String),
17 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#[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 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 pub fn number(&self) -> FloatType {
175 self.value
176 }
177
178 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 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 fn convert_pitch_class_to_str(pc: IntegerType) -> String {
202 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
236fn 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 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}