Skip to main content

use_tuning/
lib.rs

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}