music21_rs/pitch/
microtone.rs1use 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))]
65#[must_use]
68pub struct Microtone {
69 cent_shift: FloatType,
70 harmonic_shift: IntegerType,
71}
72
73impl Microtone {
74 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 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 pub fn alter(&self) -> FloatType {
108 self.cents() * 0.01
109 }
110
111 pub fn cents(&self) -> FloatType {
113 convert_harmonic_to_cents(self.harmonic_shift) as FloatType + self.cent_shift
114 }
115
116 pub fn cent_shift(&self) -> FloatType {
118 self.cent_shift
119 }
120
121 pub fn set_cent_shift(&mut self, cents: FloatType) {
123 self.cent_shift = cents;
124 }
125
126 pub fn harmonic_shift(&self) -> IntegerType {
128 self.harmonic_shift
129 }
130
131 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}