music21_rs/pitch/
microtone.rs1use 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#[derive(Clone, Debug, PartialEq)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16pub enum MicrotoneSpecifier {
17 Cents(FloatType),
19 Text(String),
21 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#[must_use]
70pub struct Microtone {
71 cent_shift: FloatType,
72 harmonic_shift: IntegerType,
73}
74
75impl Microtone {
76 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 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 pub fn alter(&self) -> FloatType {
110 self.cents() * 0.01
111 }
112
113 pub fn cents(&self) -> FloatType {
115 convert_harmonic_to_cents(self.harmonic_shift) as FloatType + self.cent_shift
116 }
117
118 pub fn cent_shift(&self) -> FloatType {
120 self.cent_shift
121 }
122
123 pub fn set_cent_shift(&mut self, cents: FloatType) {
125 self.cent_shift = cents;
126 }
127
128 pub fn harmonic_shift(&self) -> IntegerType {
130 self.harmonic_shift
131 }
132
133 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
244pub(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
266impl 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 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}