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#[derive(Clone, Debug, PartialEq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub enum MicrotoneSpecifier {
15 Cents(FloatType),
17 Text(String),
19 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))]
65pub struct Microtone {
68 _cent_shift: FloatType,
69 _harmonic_shift: IntegerType,
70}
71
72impl Microtone {
73 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 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 pub fn alter(&self) -> FloatType {
122 self.cents() * 0.01
123 }
124
125 pub fn cents(&self) -> FloatType {
127 convert_harmonic_to_cents(self._harmonic_shift) as FloatType + self._cent_shift
128 }
129
130 pub fn cent_shift(&self) -> FloatType {
132 self._cent_shift
133 }
134
135 pub fn set_cent_shift(&mut self, cents: FloatType) {
137 self._cent_shift = cents;
138 }
139
140 pub fn harmonic_shift(&self) -> IntegerType {
142 self._harmonic_shift
143 }
144
145 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 fn into_microtone(self) -> Result<Microtone>;
285 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}