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