Skip to main content

music21_rs/pitch/
microtone.rs

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