Skip to main content

music21_rs/pitch/
microtone.rs

1use super::{IntegerType, convert_harmonic_to_cents};
2use ordered_float::OrderedFloat;
3use std::hash::{Hash, Hasher};
4
5use crate::defaults::FloatType;
6use crate::error::{Error, Result};
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`.
69#[must_use]
70pub struct Microtone {
71    cent_shift: FloatType,
72    harmonic_shift: IntegerType,
73}
74
75impl Microtone {
76    /// Creates a microtone with no harmonic shift.
77    pub fn new(specifier: impl Into<MicrotoneSpecifier>) -> Result<Self> {
78        match specifier.into() {
79            MicrotoneSpecifier::Microtone(microtone) => Ok(microtone),
80            specifier => Self::with_harmonic_shift(specifier, 1),
81        }
82    }
83
84    /// Creates a microtone with an explicit harmonic shift.
85    pub fn with_harmonic_shift(
86        specifier: impl Into<MicrotoneSpecifier>,
87        harmonic_shift: IntegerType,
88    ) -> Result<Self> {
89        match specifier.into() {
90            MicrotoneSpecifier::Cents(cents) => Ok(Self::from_cents(cents, harmonic_shift)),
91            MicrotoneSpecifier::Text(text) => {
92                Ok(Self::from_cents(Self::parse_string(text)?, harmonic_shift))
93            }
94            MicrotoneSpecifier::Microtone(mut microtone) => {
95                microtone.harmonic_shift = harmonic_shift;
96                Ok(microtone)
97            }
98        }
99    }
100
101    pub(crate) fn from_cents(cent_shift: FloatType, harmonic_shift: IntegerType) -> Self {
102        Self {
103            cent_shift,
104            harmonic_shift,
105        }
106    }
107
108    /// Returns the microtone in accidental alter units, where 100 cents is 1.0.
109    pub fn alter(&self) -> FloatType {
110        self.cents() * 0.01
111    }
112
113    /// Returns the total cent displacement, including harmonic shift.
114    pub fn cents(&self) -> FloatType {
115        convert_harmonic_to_cents(self.harmonic_shift) as FloatType + self.cent_shift
116    }
117
118    /// Returns the direct cent shift before harmonic adjustment.
119    pub fn cent_shift(&self) -> FloatType {
120        self.cent_shift
121    }
122
123    /// Sets the direct cent shift before harmonic adjustment.
124    pub fn set_cent_shift(&mut self, cents: FloatType) {
125        self.cent_shift = cents;
126    }
127
128    /// Returns the harmonic shift.
129    pub fn harmonic_shift(&self) -> IntegerType {
130        self.harmonic_shift
131    }
132
133    /// Sets the harmonic shift.
134    pub fn set_harmonic_shift(&mut self, harmonic_shift: IntegerType) {
135        self.harmonic_shift = harmonic_shift;
136    }
137
138    fn parse_string(value: String) -> Result<FloatType> {
139        let value = value.replace(MICROTONE_OPEN, "");
140        let value = value.replace(MICROTONE_CLOSE, "");
141        let first = match value.chars().next() {
142            Some(first) => first,
143            None => {
144                return Err(Error::Microtone(format!(
145                    "input to Microtone was empty: {value}"
146                )));
147            }
148        };
149
150        let cent_value = if first == '+' || first.is_ascii_digit() {
151            let (num, _) = crate::common::stringtools::get_num_from_str(&value, "0123456789.");
152            if num.is_empty() {
153                return Err(Error::Microtone(format!(
154                    "no numbers found in string value: {value}"
155                )));
156            }
157            num.parse::<FloatType>()
158                .map_err(|e| Error::Microtone(e.to_string()))?
159        } else if first == '-' {
160            let trimmed: String = value.chars().skip(1).collect();
161            let (num, _) = crate::common::stringtools::get_num_from_str(&trimmed, "0123456789.");
162            if num.is_empty() {
163                return Err(Error::Microtone(format!(
164                    "no numbers found in string value: {value}"
165                )));
166            }
167            let parsed = num
168                .parse::<FloatType>()
169                .map_err(|e| Error::Microtone(e.to_string()))?;
170            -parsed
171        } else {
172            0.0
173        };
174        Ok(cent_value)
175    }
176}
177
178impl Display for Microtone {
179    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
180        let rounded = self.cent_shift.round() as IntegerType;
181        let mut text = if self.cent_shift >= 0.0 {
182            format!("+{rounded}c")
183        } else {
184            let text = format!("{rounded}c");
185            if text == "0c" {
186                "-0c".to_string()
187            } else {
188                text
189            }
190        };
191
192        if self.harmonic_shift != 1 {
193            text.push_str(&format!(
194                "+{}{}H",
195                self.harmonic_shift,
196                ordinal_suffix(self.harmonic_shift)
197            ));
198        }
199
200        write!(f, "{MICROTONE_OPEN}{text}{MICROTONE_CLOSE}")
201    }
202}
203
204impl FromStr for Microtone {
205    type Err = Error;
206
207    fn from_str(value: &str) -> Result<Self> {
208        Self::new(value)
209    }
210}
211
212impl TryFrom<&str> for Microtone {
213    type Error = Error;
214
215    fn try_from(value: &str) -> Result<Self> {
216        Self::new(value)
217    }
218}
219
220impl TryFrom<String> for Microtone {
221    type Error = Error;
222
223    fn try_from(value: String) -> Result<Self> {
224        Self::new(value)
225    }
226}
227
228impl TryFrom<IntegerType> for Microtone {
229    type Error = Error;
230
231    fn try_from(value: IntegerType) -> Result<Self> {
232        Self::new(value)
233    }
234}
235
236impl TryFrom<FloatType> for Microtone {
237    type Error = Error;
238
239    fn try_from(value: FloatType) -> Result<Self> {
240        Self::new(value)
241    }
242}
243
244/// music21's `ordinalAbbreviation`, on Python's remainders: `-1` is
245/// `-1th`, as its last digit modulo ten is nine.
246pub(crate) fn ordinal_suffix(value: IntegerType) -> &'static str {
247    if (11..=13).contains(&value.rem_euclid(100)) {
248        return "th";
249    }
250    match value.rem_euclid(10) {
251        1 => "st",
252        2 => "nd",
253        3 => "rd",
254        _ => "th",
255    }
256}
257
258impl PartialEq for Microtone {
259    fn eq(&self, other: &Self) -> bool {
260        self.cents() == other.cents()
261    }
262}
263
264impl Eq for Microtone {}
265
266/// Hashes what equality compares, the total shift in cents.
267impl Hash for Microtone {
268    fn hash<H: Hasher>(&self, state: &mut H) {
269        OrderedFloat(self.cents()).hash(state);
270    }
271}
272
273#[cfg(test)]
274mod tests {
275
276    #[test]
277    fn a_microtone_is_read_from_owned_text_and_from_an_integer() {
278        use super::{Microtone, MicrotoneSpecifier};
279
280        assert_eq!(
281            Microtone::try_from("+20".to_string()).unwrap().cents(),
282            20.0
283        );
284        assert_eq!(
285            Microtone::try_from("(-15)".to_string()).unwrap().cents(),
286            -15.0
287        );
288        assert_eq!(Microtone::try_from(33).unwrap().cents(), 33.0);
289        assert!(Microtone::try_from("".to_string()).is_err());
290        assert!(Microtone::try_from("+abc".to_string()).is_err());
291        assert_eq!(
292            MicrotoneSpecifier::from("+20".to_string()).to_string(),
293            "+20"
294        );
295    }
296
297    use super::{Microtone, MicrotoneSpecifier};
298
299    #[test]
300    fn public_microtone_api_matches_music21_basics() {
301        let microtone = Microtone::new(20).unwrap();
302        assert_eq!(microtone.cent_shift(), 20.0);
303        assert_eq!(microtone.cents(), 20.0);
304        assert_eq!(microtone.alter(), 0.2);
305        assert_eq!(microtone.to_string(), "(+20c)");
306
307        let parsed = Microtone::new("(-33.333333)").unwrap();
308        assert!((parsed.cents() + 33.333333).abs() < 0.000001);
309        assert_eq!(parsed.to_string(), "(-33c)");
310    }
311
312    #[test]
313    fn harmonic_shift_contributes_to_cents_and_display() {
314        let mut microtone = Microtone::new(20).unwrap();
315        microtone.set_harmonic_shift(3);
316        assert_eq!(microtone.harmonic_shift(), 3);
317        assert_eq!(microtone.to_string(), "(+20c+3rdH)");
318        assert!(microtone.cents() > 1900.0);
319    }
320
321    #[test]
322    fn microtone_specifier_can_wrap_existing_microtone() {
323        let microtone = Microtone::with_harmonic_shift(12.5, 5).unwrap();
324        let clone = Microtone::new(MicrotoneSpecifier::from(microtone.clone())).unwrap();
325        assert_eq!(clone, microtone);
326    }
327
328    #[test]
329    fn microtone_supports_rust_conversion_traits_and_errors() {
330        let parsed: Microtone = "(+12c)".parse().unwrap();
331        assert_eq!(parsed.cent_shift(), 12.0);
332
333        let from_cents = Microtone::try_from(-25.0).unwrap();
334        assert_eq!(from_cents.to_string(), "(-25c)");
335
336        assert!(Microtone::try_from("+c").is_err());
337    }
338
339    #[test]
340    fn microtone_parser_covers_text_edge_cases() {
341        assert_eq!(Microtone::try_from("nonsense").unwrap().cent_shift(), 0.0);
342        assert!(Microtone::try_from("").is_err());
343        assert!(Microtone::try_from("-c").is_err());
344
345        assert_eq!(Microtone::new("+19.5c").unwrap().cent_shift(), 19.5);
346    }
347
348    #[test]
349    fn microtone_setters_and_harmonic_suffixes_work() {
350        let mut microtone = Microtone::new(MicrotoneSpecifier::Cents(0.0)).unwrap();
351        microtone.set_cent_shift(-0.4);
352        assert_eq!(microtone.to_string(), "(-0c)");
353
354        microtone.set_harmonic_shift(11);
355        assert_eq!(microtone.harmonic_shift(), 11);
356        assert_eq!(microtone.to_string(), "(-0c+11thH)");
357
358        // music21 takes the suffix of -2 modulo ten, which is eight.
359        microtone.set_harmonic_shift(-2);
360        assert_eq!(microtone.to_string(), "(-0c+-2thH)");
361    }
362
363    #[test]
364    fn microtone_specifier_round_trips_wrapped_microtones() {
365        let microtone = Microtone::new(7).unwrap();
366        let wrapped = MicrotoneSpecifier::from(microtone.clone());
367        assert_eq!(Microtone::new(wrapped).unwrap(), microtone);
368
369        assert_eq!(Microtone::new(25).unwrap().cent_shift(), 25.0);
370        assert_eq!(Microtone::new(-12.5).unwrap().cent_shift(), -12.5);
371    }
372}