Skip to main content

music21_rs/pitch/
microtone.rs

1use super::{IntegerType, convert_harmonic_to_cents};
2
3use crate::defaults::FloatType;
4use crate::error::{Error, Result};
5use std::fmt::{Display, Formatter};
6use std::str::FromStr;
7
8const MICROTONE_OPEN: &str = "(";
9const MICROTONE_CLOSE: &str = ")";
10
11/// Input accepted by [`Microtone::new`] and [`Microtone::with_harmonic_shift`].
12#[derive(Clone, Debug, PartialEq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub enum MicrotoneSpecifier {
15    /// A cent offset from the notated pitch.
16    Cents(FloatType),
17    /// A textual cent offset such as `"+20c"` or `"(-33.333)"`.
18    Text(String),
19    /// An existing microtone to clone.
20    Microtone(Microtone),
21}
22
23impl From<&str> for MicrotoneSpecifier {
24    fn from(value: &str) -> Self {
25        Self::Text(value.to_string())
26    }
27}
28
29impl From<String> for MicrotoneSpecifier {
30    fn from(value: String) -> Self {
31        Self::Text(value)
32    }
33}
34
35impl From<IntegerType> for MicrotoneSpecifier {
36    fn from(value: IntegerType) -> Self {
37        Self::Cents(value as FloatType)
38    }
39}
40
41impl From<FloatType> for MicrotoneSpecifier {
42    fn from(value: FloatType) -> Self {
43        Self::Cents(value)
44    }
45}
46
47impl From<Microtone> for MicrotoneSpecifier {
48    fn from(value: Microtone) -> Self {
49        Self::Microtone(value)
50    }
51}
52
53impl Display for MicrotoneSpecifier {
54    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
55        match self {
56            Self::Cents(cents) => write!(f, "{cents}"),
57            Self::Text(text) => write!(f, "{text}"),
58            Self::Microtone(microtone) => write!(f, "{microtone}"),
59        }
60    }
61}
62
63#[derive(Clone, Debug)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65/// A microtonal pitch adjustment measured in cents, optionally shifted by a
66/// harmonic like Python music21's `music21.pitch.Microtone`.
67pub struct Microtone {
68    _cent_shift: FloatType,
69    _harmonic_shift: IntegerType,
70}
71
72impl Microtone {
73    /// Creates a microtone with no harmonic shift.
74    pub fn new(specifier: impl Into<MicrotoneSpecifier>) -> Result<Self> {
75        match specifier.into() {
76            MicrotoneSpecifier::Microtone(microtone) => Ok(microtone),
77            specifier => Self::with_harmonic_shift(specifier, 1),
78        }
79    }
80
81    /// Creates a microtone with an explicit harmonic shift.
82    pub fn with_harmonic_shift(
83        specifier: impl Into<MicrotoneSpecifier>,
84        harmonic_shift: IntegerType,
85    ) -> Result<Self> {
86        match specifier.into() {
87            MicrotoneSpecifier::Cents(cents) => {
88                Self::from_cent_shift(Some(cents), Some(harmonic_shift))
89            }
90            MicrotoneSpecifier::Text(text) => {
91                Self::from_cent_shift(Some(Self::parse_string(text)?), Some(harmonic_shift))
92            }
93            MicrotoneSpecifier::Microtone(mut microtone) => {
94                microtone._harmonic_shift = harmonic_shift;
95                Ok(microtone)
96            }
97        }
98    }
99
100    pub(crate) fn from_cent_shift<T>(
101        cents_or_string: Option<T>,
102        harmonic_shift: Option<IntegerType>,
103    ) -> Result<Self>
104    where
105        T: IntoCentShift,
106    {
107        let _harmonic_shift = harmonic_shift.unwrap_or(1);
108
109        let _cent_shift = match cents_or_string {
110            Some(cents_or_string) => cents_or_string.into_cent_shift(),
111            None => 0.0,
112        };
113
114        Ok(Self {
115            _cent_shift,
116            _harmonic_shift,
117        })
118    }
119
120    /// Returns the microtone in accidental alter units, where 100 cents is 1.0.
121    pub fn alter(&self) -> FloatType {
122        self.cents() * 0.01
123    }
124
125    /// Returns the total cent displacement, including harmonic shift.
126    pub fn cents(&self) -> FloatType {
127        convert_harmonic_to_cents(self._harmonic_shift) as FloatType + self._cent_shift
128    }
129
130    /// Returns the direct cent shift before harmonic adjustment.
131    pub fn cent_shift(&self) -> FloatType {
132        self._cent_shift
133    }
134
135    /// Sets the direct cent shift before harmonic adjustment.
136    pub fn set_cent_shift(&mut self, cents: FloatType) {
137        self._cent_shift = cents;
138    }
139
140    /// Returns the harmonic shift.
141    pub fn harmonic_shift(&self) -> IntegerType {
142        self._harmonic_shift
143    }
144
145    /// Sets the harmonic shift.
146    pub fn set_harmonic_shift(&mut self, harmonic_shift: IntegerType) {
147        self._harmonic_shift = harmonic_shift;
148    }
149
150    fn parse_string(value: String) -> Result<FloatType> {
151        let value = value.replace(MICROTONE_OPEN, "");
152        let value = value.replace(MICROTONE_CLOSE, "");
153        let first = match value.chars().next() {
154            Some(first) => first,
155            None => {
156                return Err(Error::Microtone(format!(
157                    "input to Microtone was empty: {value}"
158                )));
159            }
160        };
161
162        let cent_value = if first == '+' || first.is_ascii_digit() {
163            let (num, _) = crate::common::stringtools::get_num_from_str(&value, "0123456789.");
164            if num.is_empty() {
165                return Err(Error::Microtone(format!(
166                    "no numbers found in string value: {value}"
167                )));
168            }
169            num.parse::<FloatType>()
170                .map_err(|e| Error::Microtone(e.to_string()))?
171        } else if first == '-' {
172            let trimmed: String = value.chars().skip(1).collect();
173            let (num, _) = crate::common::stringtools::get_num_from_str(&trimmed, "0123456789.");
174            if num.is_empty() {
175                return Err(Error::Microtone(format!(
176                    "no numbers found in string value: {value}"
177                )));
178            }
179            let parsed = num
180                .parse::<FloatType>()
181                .map_err(|e| Error::Microtone(e.to_string()))?;
182            -parsed
183        } else {
184            0.0
185        };
186        Ok(cent_value)
187    }
188}
189
190impl Display for Microtone {
191    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
192        let rounded = self._cent_shift.round() as IntegerType;
193        let mut text = if self._cent_shift >= 0.0 {
194            format!("+{rounded}c")
195        } else {
196            let text = format!("{rounded}c");
197            if text == "0c" {
198                "-0c".to_string()
199            } else {
200                text
201            }
202        };
203
204        if self._harmonic_shift != 1 {
205            text.push_str(&format!(
206                "+{}{}H",
207                self._harmonic_shift,
208                ordinal_suffix(self._harmonic_shift)
209            ));
210        }
211
212        write!(f, "{MICROTONE_OPEN}{text}{MICROTONE_CLOSE}")
213    }
214}
215
216impl FromStr for Microtone {
217    type Err = Error;
218
219    fn from_str(value: &str) -> Result<Self> {
220        Self::new(value)
221    }
222}
223
224impl TryFrom<&str> for Microtone {
225    type Error = Error;
226
227    fn try_from(value: &str) -> Result<Self> {
228        Self::new(value)
229    }
230}
231
232impl TryFrom<String> for Microtone {
233    type Error = Error;
234
235    fn try_from(value: String) -> Result<Self> {
236        Self::new(value)
237    }
238}
239
240impl TryFrom<IntegerType> for Microtone {
241    type Error = Error;
242
243    fn try_from(value: IntegerType) -> Result<Self> {
244        Self::new(value)
245    }
246}
247
248impl TryFrom<FloatType> for Microtone {
249    type Error = Error;
250
251    fn try_from(value: FloatType) -> Result<Self> {
252        Self::new(value)
253    }
254}
255
256fn ordinal_suffix(value: IntegerType) -> &'static str {
257    if (value % 100).abs() >= 11 && (value % 100).abs() <= 13 {
258        return "th";
259    }
260
261    match value.abs() % 10 {
262        1 => "st",
263        2 => "nd",
264        3 => "rd",
265        _ => "th",
266    }
267}
268
269impl PartialEq for Microtone {
270    fn eq(&self, other: &Self) -> bool {
271        self.cents() == other.cents()
272    }
273}
274
275pub(crate) trait IntoCentShift {
276    fn into_cent_shift(self) -> FloatType;
277    fn is_microtone(&self) -> bool;
278    /// tries to construct a microtone.
279    ///
280    /// # Panics
281    ///
282    /// This method assumes that `is_microtone()` is `false`.
283    /// Calling this method when `is_microtone()` is `true` will panic.
284    fn into_microtone(self) -> Result<Microtone>;
285    /// Returns the contained microtone.
286    ///
287    /// # Panics
288    ///
289    /// This method assumes that `is_microtone()` is `true`.
290    /// Calling this method when `is_microtone()` is `false` will panic.
291    fn microtone(self) -> Microtone;
292}
293
294impl IntoCentShift for String {
295    fn into_cent_shift(self) -> FloatType {
296        Microtone::parse_string(self).unwrap_or(0.0)
297    }
298
299    fn is_microtone(&self) -> bool {
300        false
301    }
302
303    fn into_microtone(self) -> Result<Microtone> {
304        Microtone::new(self)
305    }
306
307    fn microtone(self) -> Microtone {
308        panic!("only call this on Microtones");
309    }
310}
311
312impl IntoCentShift for &str {
313    fn into_cent_shift(self) -> FloatType {
314        Microtone::parse_string(self.to_string()).unwrap_or(0.0)
315    }
316
317    fn is_microtone(&self) -> bool {
318        false
319    }
320
321    fn into_microtone(self) -> Result<Microtone> {
322        Microtone::new(self)
323    }
324
325    fn microtone(self) -> Microtone {
326        panic!("only call this on Microtones");
327    }
328}
329
330impl IntoCentShift for IntegerType {
331    fn into_cent_shift(self) -> FloatType {
332        self as FloatType
333    }
334
335    fn is_microtone(&self) -> bool {
336        false
337    }
338
339    fn into_microtone(self) -> Result<Microtone> {
340        Microtone::from_cent_shift(Some(self), None)
341    }
342
343    fn microtone(self) -> Microtone {
344        panic!("only call this on Microtones");
345    }
346}
347
348impl IntoCentShift for FloatType {
349    fn into_cent_shift(self) -> FloatType {
350        self
351    }
352
353    fn is_microtone(&self) -> bool {
354        false
355    }
356
357    fn into_microtone(self) -> Result<Microtone> {
358        Microtone::from_cent_shift(Some(self), None)
359    }
360
361    fn microtone(self) -> Microtone {
362        panic!("only call this on Microtones");
363    }
364}
365
366impl IntoCentShift for Microtone {
367    fn into_cent_shift(self) -> FloatType {
368        panic!("don't call this on Microtones");
369    }
370
371    fn is_microtone(&self) -> bool {
372        true
373    }
374
375    fn into_microtone(self) -> Result<Microtone> {
376        panic!("don't call this on Microtones");
377    }
378
379    fn microtone(self) -> Microtone {
380        self
381    }
382}
383
384impl IntoCentShift for MicrotoneSpecifier {
385    fn into_cent_shift(self) -> FloatType {
386        match self {
387            MicrotoneSpecifier::Cents(cents) => cents,
388            MicrotoneSpecifier::Text(text) => text.into_cent_shift(),
389            MicrotoneSpecifier::Microtone(microtone) => microtone.cents(),
390        }
391    }
392
393    fn is_microtone(&self) -> bool {
394        matches!(self, MicrotoneSpecifier::Microtone(_))
395    }
396
397    fn into_microtone(self) -> Result<Microtone> {
398        Microtone::new(self)
399    }
400
401    fn microtone(self) -> Microtone {
402        match self {
403            MicrotoneSpecifier::Microtone(microtone) => microtone,
404            _ => panic!("only call this on Microtones"),
405        }
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::{IntoCentShift, Microtone, MicrotoneSpecifier};
412
413    #[test]
414    fn public_microtone_api_matches_music21_basics() {
415        let microtone = Microtone::new(20).unwrap();
416        assert_eq!(microtone.cent_shift(), 20.0);
417        assert_eq!(microtone.cents(), 20.0);
418        assert_eq!(microtone.alter(), 0.2);
419        assert_eq!(microtone.to_string(), "(+20c)");
420
421        let parsed = Microtone::new("(-33.333333)").unwrap();
422        assert!((parsed.cents() + 33.333333).abs() < 0.000001);
423        assert_eq!(parsed.to_string(), "(-33c)");
424    }
425
426    #[test]
427    fn harmonic_shift_contributes_to_cents_and_display() {
428        let mut microtone = Microtone::new(20).unwrap();
429        microtone.set_harmonic_shift(3);
430        assert_eq!(microtone.harmonic_shift(), 3);
431        assert_eq!(microtone.to_string(), "(+20c+3rdH)");
432        assert!(microtone.cents() > 1900.0);
433    }
434
435    #[test]
436    fn microtone_specifier_can_wrap_existing_microtone() {
437        let microtone = Microtone::with_harmonic_shift(12.5, 5).unwrap();
438        let clone = Microtone::new(MicrotoneSpecifier::from(microtone.clone())).unwrap();
439        assert_eq!(clone, microtone);
440    }
441
442    #[test]
443    fn microtone_supports_rust_conversion_traits_and_errors() {
444        let parsed: Microtone = "(+12c)".parse().unwrap();
445        assert_eq!(parsed.cent_shift(), 12.0);
446
447        let from_cents = Microtone::try_from(-25.0).unwrap();
448        assert_eq!(from_cents.to_string(), "(-25c)");
449
450        assert!(Microtone::try_from("+c").is_err());
451    }
452
453    #[test]
454    fn microtone_parser_covers_text_edge_cases() {
455        assert_eq!(Microtone::try_from("nonsense").unwrap().cent_shift(), 0.0);
456        assert!(Microtone::try_from("").is_err());
457        assert!(Microtone::try_from("-c").is_err());
458
459        assert_eq!("not-a-cent-value".into_cent_shift(), 0.0);
460        assert_eq!("+19.5c".to_string().into_cent_shift(), 19.5);
461    }
462
463    #[test]
464    fn microtone_setters_and_harmonic_suffixes_work() {
465        let mut microtone = Microtone::new(MicrotoneSpecifier::Cents(0.0)).unwrap();
466        microtone.set_cent_shift(-0.4);
467        assert_eq!(microtone.to_string(), "(-0c)");
468
469        microtone.set_harmonic_shift(11);
470        assert_eq!(microtone.harmonic_shift(), 11);
471        assert_eq!(microtone.to_string(), "(-0c+11thH)");
472
473        microtone.set_harmonic_shift(-2);
474        assert_eq!(microtone.to_string(), "(-0c+-2ndH)");
475    }
476
477    #[test]
478    fn microtone_specifier_reports_wrapped_microtones() {
479        let wrapped = MicrotoneSpecifier::from(Microtone::new(7).unwrap());
480        assert!(wrapped.is_microtone());
481        assert_eq!(wrapped.clone().into_cent_shift(), 7.0);
482        assert_eq!(wrapped.microtone().cent_shift(), 7.0);
483
484        assert!(!MicrotoneSpecifier::from(7).is_microtone());
485        assert_eq!(25.into_microtone().unwrap().cent_shift(), 25.0);
486        assert_eq!((-12.5).into_microtone().unwrap().cent_shift(), -12.5);
487    }
488}