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#[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))]
67pub struct Microtone {
70 proto: ProtoM21Object,
71 slottedobjectmixin: SlottedObjectMixin,
72 _cent_shift: FloatType,
73 _harmonic_shift: IntegerType,
74}
75
76impl Microtone {
77 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 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 pub fn alter(&self) -> FloatType {
128 self.cents() * 0.01
129 }
130
131 pub fn cents(&self) -> FloatType {
133 convert_harmonic_to_cents(self._harmonic_shift) as FloatType + self._cent_shift
134 }
135
136 pub fn cent_shift(&self) -> FloatType {
138 self._cent_shift
139 }
140
141 pub fn set_cent_shift(&mut self, cents: FloatType) {
143 self._cent_shift = cents;
144 }
145
146 pub fn harmonic_shift(&self) -> IntegerType {
148 self._harmonic_shift
149 }
150
151 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 fn into_microtone(self) -> Result<Microtone>;
295 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}