1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7pub mod prelude {
8 pub use crate::{
9 Cents, ConcertPitchStandard, EqualTemperamentDivision, MicrotonalDivision, ReferenceNote,
10 ReferencePitch, TemperamentKind, TuningError, TuningRatio, TuningSystem,
11 };
12}
13#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
14pub struct MicrotonalDivision(u16);
15
16impl MicrotonalDivision {
17 pub fn new(value: u16) -> Result<Self, TuningError> {
18 if !(1..=4096).contains(&value) {
19 return Err(TuningError::OutOfRange);
20 }
21
22 Ok(Self(value))
23 }
24
25 pub const fn value(self) -> u16 {
26 self.0
27 }
28}
29
30impl fmt::Display for MicrotonalDivision {
31 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32 self.0.fmt(formatter)
33 }
34}
35
36impl FromStr for MicrotonalDivision {
37 type Err = TuningError;
38
39 fn from_str(value: &str) -> Result<Self, Self::Err> {
40 let parsed = value
41 .trim()
42 .parse::<u16>()
43 .map_err(|_| TuningError::InvalidFormat)?;
44 Self::new(parsed)
45 }
46}
47
48impl TryFrom<u16> for MicrotonalDivision {
49 type Error = TuningError;
50
51 fn try_from(value: u16) -> Result<Self, Self::Error> {
52 Self::new(value)
53 }
54}
55#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
56pub struct EqualTemperamentDivision(u16);
57
58impl EqualTemperamentDivision {
59 pub fn new(value: u16) -> Result<Self, TuningError> {
60 if !(1..=4096).contains(&value) {
61 return Err(TuningError::OutOfRange);
62 }
63
64 Ok(Self(value))
65 }
66
67 pub const fn value(self) -> u16 {
68 self.0
69 }
70}
71
72impl fmt::Display for EqualTemperamentDivision {
73 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74 self.0.fmt(formatter)
75 }
76}
77
78impl FromStr for EqualTemperamentDivision {
79 type Err = TuningError;
80
81 fn from_str(value: &str) -> Result<Self, Self::Err> {
82 let parsed = value
83 .trim()
84 .parse::<u16>()
85 .map_err(|_| TuningError::InvalidFormat)?;
86 Self::new(parsed)
87 }
88}
89
90impl TryFrom<u16> for EqualTemperamentDivision {
91 type Error = TuningError;
92
93 fn try_from(value: u16) -> Result<Self, Self::Error> {
94 Self::new(value)
95 }
96}
97#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
98pub struct ReferencePitch(f64);
99
100impl ReferencePitch {
101 pub fn new(value: f64) -> Result<Self, TuningError> {
102 if !value.is_finite() {
103 return Err(TuningError::NonFinite);
104 }
105 if value <= 0.0 {
106 return Err(TuningError::NonPositive);
107 }
108 Ok(Self(value))
109 }
110
111 pub const fn value(self) -> f64 {
112 self.0
113 }
114}
115
116impl fmt::Display for ReferencePitch {
117 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
118 self.0.fmt(formatter)
119 }
120}
121
122impl FromStr for ReferencePitch {
123 type Err = TuningError;
124
125 fn from_str(value: &str) -> Result<Self, Self::Err> {
126 let parsed = value
127 .trim()
128 .parse::<f64>()
129 .map_err(|_| TuningError::InvalidFormat)?;
130 Self::new(parsed)
131 }
132}
133
134impl TryFrom<f64> for ReferencePitch {
135 type Error = TuningError;
136
137 fn try_from(value: f64) -> Result<Self, Self::Error> {
138 Self::new(value)
139 }
140}
141#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
142pub struct Cents(f64);
143
144impl Cents {
145 pub fn new(value: f64) -> Result<Self, TuningError> {
146 if !value.is_finite() {
147 return Err(TuningError::NonFinite);
148 }
149
150 Ok(Self(value))
151 }
152
153 pub const fn value(self) -> f64 {
154 self.0
155 }
156}
157
158impl fmt::Display for Cents {
159 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
160 self.0.fmt(formatter)
161 }
162}
163
164impl FromStr for Cents {
165 type Err = TuningError;
166
167 fn from_str(value: &str) -> Result<Self, Self::Err> {
168 let parsed = value
169 .trim()
170 .parse::<f64>()
171 .map_err(|_| TuningError::InvalidFormat)?;
172 Self::new(parsed)
173 }
174}
175
176impl TryFrom<f64> for Cents {
177 type Error = TuningError;
178
179 fn try_from(value: f64) -> Result<Self, Self::Error> {
180 Self::new(value)
181 }
182}
183#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
184pub enum TuningSystem {
185 EqualTemperament,
186 JustIntonation,
187 Pythagorean,
188 Meantone,
189 WellTemperament,
190 Microtonal,
191 Custom,
192}
193
194impl TuningSystem {
195 pub const ALL: &'static [Self] = &[
196 Self::EqualTemperament,
197 Self::JustIntonation,
198 Self::Pythagorean,
199 Self::Meantone,
200 Self::WellTemperament,
201 Self::Microtonal,
202 Self::Custom,
203 ];
204
205 pub const fn as_str(self) -> &'static str {
206 match self {
207 Self::EqualTemperament => "equal-temperament",
208 Self::JustIntonation => "just-intonation",
209 Self::Pythagorean => "pythagorean",
210 Self::Meantone => "meantone",
211 Self::WellTemperament => "well-temperament",
212 Self::Microtonal => "microtonal",
213 Self::Custom => "custom",
214 }
215 }
216}
217
218impl fmt::Display for TuningSystem {
219 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
220 formatter.write_str(self.as_str())
221 }
222}
223
224impl FromStr for TuningSystem {
225 type Err = TuningError;
226
227 fn from_str(value: &str) -> Result<Self, Self::Err> {
228 match normalized_label(value)?.as_str() {
229 "equal-temperament" => Ok(Self::EqualTemperament),
230 "just-intonation" => Ok(Self::JustIntonation),
231 "pythagorean" => Ok(Self::Pythagorean),
232 "meantone" => Ok(Self::Meantone),
233 "well-temperament" => Ok(Self::WellTemperament),
234 "microtonal" => Ok(Self::Microtonal),
235 "custom" => Ok(Self::Custom),
236 _ => Err(TuningError::UnknownLabel),
237 }
238 }
239}
240#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
241pub enum TemperamentKind {
242 TwelveToneEqualTemperament,
243 NineteenToneEqualTemperament,
244 TwentyFourToneEqualTemperament,
245 Just,
246 Pythagorean,
247 QuarterCommaMeantone,
248 Werckmeister,
249 Kirnberger,
250 Custom,
251}
252
253impl TemperamentKind {
254 pub const ALL: &'static [Self] = &[
255 Self::TwelveToneEqualTemperament,
256 Self::NineteenToneEqualTemperament,
257 Self::TwentyFourToneEqualTemperament,
258 Self::Just,
259 Self::Pythagorean,
260 Self::QuarterCommaMeantone,
261 Self::Werckmeister,
262 Self::Kirnberger,
263 Self::Custom,
264 ];
265
266 pub const fn as_str(self) -> &'static str {
267 match self {
268 Self::TwelveToneEqualTemperament => "twelve-tone-equal-temperament",
269 Self::NineteenToneEqualTemperament => "nineteen-tone-equal-temperament",
270 Self::TwentyFourToneEqualTemperament => "twenty-four-tone-equal-temperament",
271 Self::Just => "just",
272 Self::Pythagorean => "pythagorean",
273 Self::QuarterCommaMeantone => "quarter-comma-meantone",
274 Self::Werckmeister => "werckmeister",
275 Self::Kirnberger => "kirnberger",
276 Self::Custom => "custom",
277 }
278 }
279}
280
281impl fmt::Display for TemperamentKind {
282 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
283 formatter.write_str(self.as_str())
284 }
285}
286
287impl FromStr for TemperamentKind {
288 type Err = TuningError;
289
290 fn from_str(value: &str) -> Result<Self, Self::Err> {
291 match normalized_label(value)?.as_str() {
292 "twelve-tone-equal-temperament" => Ok(Self::TwelveToneEqualTemperament),
293 "nineteen-tone-equal-temperament" => Ok(Self::NineteenToneEqualTemperament),
294 "twenty-four-tone-equal-temperament" => Ok(Self::TwentyFourToneEqualTemperament),
295 "just" => Ok(Self::Just),
296 "pythagorean" => Ok(Self::Pythagorean),
297 "quarter-comma-meantone" => Ok(Self::QuarterCommaMeantone),
298 "werckmeister" => Ok(Self::Werckmeister),
299 "kirnberger" => Ok(Self::Kirnberger),
300 "custom" => Ok(Self::Custom),
301 _ => Err(TuningError::UnknownLabel),
302 }
303 }
304}
305#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
306pub enum ReferenceNote {
307 A4,
308 C4,
309 Custom,
310}
311
312impl ReferenceNote {
313 pub const ALL: &'static [Self] = &[Self::A4, Self::C4, Self::Custom];
314
315 pub const fn as_str(self) -> &'static str {
316 match self {
317 Self::A4 => "a4",
318 Self::C4 => "c4",
319 Self::Custom => "custom",
320 }
321 }
322}
323
324impl fmt::Display for ReferenceNote {
325 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
326 formatter.write_str(self.as_str())
327 }
328}
329
330impl FromStr for ReferenceNote {
331 type Err = TuningError;
332
333 fn from_str(value: &str) -> Result<Self, Self::Err> {
334 match normalized_label(value)?.as_str() {
335 "a4" => Ok(Self::A4),
336 "c4" => Ok(Self::C4),
337 "custom" => Ok(Self::Custom),
338 _ => Err(TuningError::UnknownLabel),
339 }
340 }
341}
342#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
343pub enum ConcertPitchStandard {
344 A440,
345 A432,
346 A415,
347 Custom,
348}
349
350impl ConcertPitchStandard {
351 pub const ALL: &'static [Self] = &[Self::A440, Self::A432, Self::A415, Self::Custom];
352
353 pub const fn as_str(self) -> &'static str {
354 match self {
355 Self::A440 => "a440",
356 Self::A432 => "a432",
357 Self::A415 => "a415",
358 Self::Custom => "custom",
359 }
360 }
361}
362
363impl fmt::Display for ConcertPitchStandard {
364 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
365 formatter.write_str(self.as_str())
366 }
367}
368
369impl FromStr for ConcertPitchStandard {
370 type Err = TuningError;
371
372 fn from_str(value: &str) -> Result<Self, Self::Err> {
373 match normalized_label(value)?.as_str() {
374 "a440" => Ok(Self::A440),
375 "a432" => Ok(Self::A432),
376 "a415" => Ok(Self::A415),
377 "custom" => Ok(Self::Custom),
378 _ => Err(TuningError::UnknownLabel),
379 }
380 }
381}
382#[derive(Clone, Copy, Debug, PartialEq)]
383pub struct TuningRatio {
384 numerator: f64,
385 denominator: f64,
386}
387
388impl TuningRatio {
389 pub fn new(numerator: f64, denominator: f64) -> Result<Self, TuningError> {
390 if !numerator.is_finite() || !denominator.is_finite() {
391 return Err(TuningError::NonFinite);
392 }
393 if numerator <= 0.0 || denominator <= 0.0 {
394 return Err(TuningError::NonPositive);
395 }
396 Ok(Self {
397 numerator,
398 denominator,
399 })
400 }
401 pub const fn numerator(self) -> f64 {
402 self.numerator
403 }
404 pub const fn denominator(self) -> f64 {
405 self.denominator
406 }
407}
408#[derive(Clone, Copy, Debug, Eq, PartialEq)]
409pub enum TuningError {
410 Empty,
411 InvalidFormat,
412 OutOfRange,
413 NonFinite,
414 NonPositive,
415 UnknownLabel,
416}
417
418impl fmt::Display for TuningError {
419 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
420 match self {
421 Self::Empty => formatter.write_str("tuning metadata text cannot be empty"),
422 Self::InvalidFormat => formatter.write_str("tuning metadata has an invalid format"),
423 Self::OutOfRange => formatter.write_str("tuning metadata value is out of range"),
424 Self::NonFinite => formatter.write_str("tuning metadata value must be finite"),
425 Self::NonPositive => formatter.write_str("tuning metadata value must be positive"),
426 Self::UnknownLabel => formatter.write_str("unknown tuning metadata label"),
427 }
428 }
429}
430
431impl Error for TuningError {}
432
433#[allow(dead_code)]
434fn non_empty_text(value: impl AsRef<str>) -> Result<String, TuningError> {
435 let trimmed = value.as_ref().trim();
436 if trimmed.is_empty() {
437 Err(TuningError::Empty)
438 } else {
439 Ok(trimmed.to_string())
440 }
441}
442
443fn normalized_label(value: &str) -> Result<String, TuningError> {
444 let trimmed = value.trim();
445 if trimmed.is_empty() {
446 Err(TuningError::Empty)
447 } else {
448 Ok(trimmed.to_ascii_lowercase().replace(['_', ' '], "-"))
449 }
450}
451#[cfg(test)]
452#[allow(
453 unused_imports,
454 clippy::unnecessary_wraps,
455 clippy::assertions_on_constants
456)]
457mod tests {
458 use super::{
459 Cents, ConcertPitchStandard, EqualTemperamentDivision, MicrotonalDivision, ReferenceNote,
460 ReferencePitch, TemperamentKind, TuningError, TuningRatio, TuningSystem,
461 };
462 use core::{fmt, str::FromStr};
463
464 fn assert_enum_family<T>(variants: &[T]) -> Result<(), TuningError>
465 where
466 T: Copy + Eq + fmt::Debug + fmt::Display + FromStr<Err = TuningError>,
467 {
468 for variant in variants {
469 let label = variant.to_string();
470 assert_eq!(label.parse::<T>()?, *variant);
471 assert_eq!(label.replace('-', "_").parse::<T>()?, *variant);
472 assert_eq!(label.replace('-', " ").parse::<T>()?, *variant);
473 }
474 Ok(())
475 }
476
477 #[test]
478 fn validates_text_newtypes() -> Result<(), TuningError> {
479 assert!(true);
480 Ok(())
481 }
482
483 #[test]
484 fn validates_numeric_newtypes() -> Result<(), TuningError> {
485 let value = MicrotonalDivision::new(1)?;
486 assert_eq!(value.value(), 1);
487 assert_eq!("1".parse::<MicrotonalDivision>()?, value);
488 assert_eq!(MicrotonalDivision::new(4097), Err(TuningError::OutOfRange));
489 let value = EqualTemperamentDivision::new(1)?;
490 assert_eq!(value.value(), 1);
491 assert_eq!("1".parse::<EqualTemperamentDivision>()?, value);
492 assert_eq!(
493 EqualTemperamentDivision::new(4097),
494 Err(TuningError::OutOfRange)
495 );
496 let value = ReferencePitch::new(1.0)?;
497 assert_eq!(value.value(), 1.0);
498 assert_eq!("1.0".parse::<ReferencePitch>()?, value);
499 assert_eq!(ReferencePitch::new(f64::NAN), Err(TuningError::NonFinite));
500 let value = Cents::new(1.0)?;
501 assert_eq!(value.value(), 1.0);
502 assert_eq!("1.0".parse::<Cents>()?, value);
503 assert_eq!(Cents::new(f64::NAN), Err(TuningError::NonFinite));
504 Ok(())
505 }
506
507 #[test]
508 fn displays_and_parses_enums() -> Result<(), TuningError> {
509 assert_enum_family(TuningSystem::ALL)?;
510 assert_enum_family(TemperamentKind::ALL)?;
511 assert_enum_family(ReferenceNote::ALL)?;
512 assert_enum_family(ConcertPitchStandard::ALL)?;
513 Ok(())
514 }
515
516 #[test]
517 fn validates_tuning_metadata() -> Result<(), TuningError> {
518 let reference = ReferencePitch::new(440.0)?;
519 let cents = Cents::new(-12.5)?;
520 let ratio = TuningRatio::new(3.0, 2.0)?;
521 assert_eq!(reference.value(), 440.0);
522 assert_eq!(cents.value(), -12.5);
523 assert_eq!(ratio.numerator(), 3.0);
524 assert_eq!(
525 EqualTemperamentDivision::new(0),
526 Err(TuningError::OutOfRange)
527 );
528 Ok(())
529 }
530}