Skip to main content

use_tempo/
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        BeatsPerMinute, MetronomeMark, RubatoKind, TempoChangeKind, TempoError, TempoMapPoint,
10        TempoMarking, TempoRange,
11    };
12}
13#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
14pub struct BeatsPerMinute(f64);
15
16impl BeatsPerMinute {
17    pub fn new(value: f64) -> Result<Self, TempoError> {
18        if !value.is_finite() {
19            return Err(TempoError::NonFinite);
20        }
21        if value <= 0.0 {
22            return Err(TempoError::NonPositive);
23        }
24        Ok(Self(value))
25    }
26
27    pub const fn value(self) -> f64 {
28        self.0
29    }
30}
31
32impl fmt::Display for BeatsPerMinute {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        self.0.fmt(formatter)
35    }
36}
37
38impl FromStr for BeatsPerMinute {
39    type Err = TempoError;
40
41    fn from_str(value: &str) -> Result<Self, Self::Err> {
42        let parsed = value
43            .trim()
44            .parse::<f64>()
45            .map_err(|_| TempoError::InvalidFormat)?;
46        Self::new(parsed)
47    }
48}
49
50impl TryFrom<f64> for BeatsPerMinute {
51    type Error = TempoError;
52
53    fn try_from(value: f64) -> Result<Self, Self::Error> {
54        Self::new(value)
55    }
56}
57#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
58pub enum TempoMarking {
59    Larghissimo,
60    Largo,
61    Larghetto,
62    Adagio,
63    Andante,
64    Moderato,
65    Allegro,
66    Vivace,
67    Presto,
68    Prestissimo,
69    Custom,
70}
71
72impl TempoMarking {
73    pub const ALL: &'static [Self] = &[
74        Self::Larghissimo,
75        Self::Largo,
76        Self::Larghetto,
77        Self::Adagio,
78        Self::Andante,
79        Self::Moderato,
80        Self::Allegro,
81        Self::Vivace,
82        Self::Presto,
83        Self::Prestissimo,
84        Self::Custom,
85    ];
86
87    pub const fn as_str(self) -> &'static str {
88        match self {
89            Self::Larghissimo => "larghissimo",
90            Self::Largo => "largo",
91            Self::Larghetto => "larghetto",
92            Self::Adagio => "adagio",
93            Self::Andante => "andante",
94            Self::Moderato => "moderato",
95            Self::Allegro => "allegro",
96            Self::Vivace => "vivace",
97            Self::Presto => "presto",
98            Self::Prestissimo => "prestissimo",
99            Self::Custom => "custom",
100        }
101    }
102}
103
104impl fmt::Display for TempoMarking {
105    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106        formatter.write_str(self.as_str())
107    }
108}
109
110impl FromStr for TempoMarking {
111    type Err = TempoError;
112
113    fn from_str(value: &str) -> Result<Self, Self::Err> {
114        match normalized_label(value)?.as_str() {
115            "larghissimo" => Ok(Self::Larghissimo),
116            "largo" => Ok(Self::Largo),
117            "larghetto" => Ok(Self::Larghetto),
118            "adagio" => Ok(Self::Adagio),
119            "andante" => Ok(Self::Andante),
120            "moderato" => Ok(Self::Moderato),
121            "allegro" => Ok(Self::Allegro),
122            "vivace" => Ok(Self::Vivace),
123            "presto" => Ok(Self::Presto),
124            "prestissimo" => Ok(Self::Prestissimo),
125            "custom" => Ok(Self::Custom),
126            _ => Err(TempoError::UnknownLabel),
127        }
128    }
129}
130#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
131pub enum TempoChangeKind {
132    Immediate,
133    Gradual,
134    Accelerando,
135    Ritardando,
136    Rallentando,
137    ATempo,
138}
139
140impl TempoChangeKind {
141    pub const ALL: &'static [Self] = &[
142        Self::Immediate,
143        Self::Gradual,
144        Self::Accelerando,
145        Self::Ritardando,
146        Self::Rallentando,
147        Self::ATempo,
148    ];
149
150    pub const fn as_str(self) -> &'static str {
151        match self {
152            Self::Immediate => "immediate",
153            Self::Gradual => "gradual",
154            Self::Accelerando => "accelerando",
155            Self::Ritardando => "ritardando",
156            Self::Rallentando => "rallentando",
157            Self::ATempo => "a-tempo",
158        }
159    }
160}
161
162impl fmt::Display for TempoChangeKind {
163    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
164        formatter.write_str(self.as_str())
165    }
166}
167
168impl FromStr for TempoChangeKind {
169    type Err = TempoError;
170
171    fn from_str(value: &str) -> Result<Self, Self::Err> {
172        match normalized_label(value)?.as_str() {
173            "immediate" => Ok(Self::Immediate),
174            "gradual" => Ok(Self::Gradual),
175            "accelerando" => Ok(Self::Accelerando),
176            "ritardando" => Ok(Self::Ritardando),
177            "rallentando" => Ok(Self::Rallentando),
178            "a-tempo" => Ok(Self::ATempo),
179            _ => Err(TempoError::UnknownLabel),
180        }
181    }
182}
183#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
184pub enum RubatoKind {
185    None,
186    Slight,
187    Expressive,
188    Free,
189    Unknown,
190}
191
192impl RubatoKind {
193    pub const ALL: &'static [Self] = &[
194        Self::None,
195        Self::Slight,
196        Self::Expressive,
197        Self::Free,
198        Self::Unknown,
199    ];
200
201    pub const fn as_str(self) -> &'static str {
202        match self {
203            Self::None => "none",
204            Self::Slight => "slight",
205            Self::Expressive => "expressive",
206            Self::Free => "free",
207            Self::Unknown => "unknown",
208        }
209    }
210}
211
212impl fmt::Display for RubatoKind {
213    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
214        formatter.write_str(self.as_str())
215    }
216}
217
218impl FromStr for RubatoKind {
219    type Err = TempoError;
220
221    fn from_str(value: &str) -> Result<Self, Self::Err> {
222        match normalized_label(value)?.as_str() {
223            "none" => Ok(Self::None),
224            "slight" => Ok(Self::Slight),
225            "expressive" => Ok(Self::Expressive),
226            "free" => Ok(Self::Free),
227            "unknown" => Ok(Self::Unknown),
228            _ => Err(TempoError::UnknownLabel),
229        }
230    }
231}
232#[derive(Clone, Copy, Debug, PartialEq)]
233pub struct TempoRange {
234    min: BeatsPerMinute,
235    max: BeatsPerMinute,
236}
237
238impl TempoRange {
239    pub fn new(min: BeatsPerMinute, max: BeatsPerMinute) -> Result<Self, TempoError> {
240        if min.value() > max.value() {
241            return Err(TempoError::OutOfRange);
242        }
243        Ok(Self { min, max })
244    }
245    pub const fn min(self) -> BeatsPerMinute {
246        self.min
247    }
248    pub const fn max(self) -> BeatsPerMinute {
249        self.max
250    }
251}
252
253#[derive(Clone, Copy, Debug, PartialEq)]
254pub struct TempoMapPoint {
255    beat: f64,
256    bpm: BeatsPerMinute,
257}
258
259impl TempoMapPoint {
260    pub fn new(beat: f64, bpm: BeatsPerMinute) -> Result<Self, TempoError> {
261        if !beat.is_finite() || beat < 0.0 {
262            return Err(TempoError::OutOfRange);
263        }
264        Ok(Self { beat, bpm })
265    }
266    pub const fn beat(self) -> f64 {
267        self.beat
268    }
269    pub const fn bpm(self) -> BeatsPerMinute {
270        self.bpm
271    }
272}
273
274#[derive(Clone, Copy, Debug, PartialEq)]
275pub struct MetronomeMark {
276    beat_unit: &'static str,
277    bpm: BeatsPerMinute,
278}
279
280impl MetronomeMark {
281    pub const fn new(beat_unit: &'static str, bpm: BeatsPerMinute) -> Self {
282        Self { beat_unit, bpm }
283    }
284    pub const fn beat_unit(self) -> &'static str {
285        self.beat_unit
286    }
287    pub const fn bpm(self) -> BeatsPerMinute {
288        self.bpm
289    }
290}
291#[derive(Clone, Copy, Debug, Eq, PartialEq)]
292pub enum TempoError {
293    Empty,
294    InvalidFormat,
295    OutOfRange,
296    NonFinite,
297    NonPositive,
298    UnknownLabel,
299}
300
301impl fmt::Display for TempoError {
302    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
303        match self {
304            Self::Empty => formatter.write_str("tempo metadata text cannot be empty"),
305            Self::InvalidFormat => formatter.write_str("tempo metadata has an invalid format"),
306            Self::OutOfRange => formatter.write_str("tempo metadata value is out of range"),
307            Self::NonFinite => formatter.write_str("tempo metadata value must be finite"),
308            Self::NonPositive => formatter.write_str("tempo metadata value must be positive"),
309            Self::UnknownLabel => formatter.write_str("unknown tempo metadata label"),
310        }
311    }
312}
313
314impl Error for TempoError {}
315
316#[allow(dead_code)]
317fn non_empty_text(value: impl AsRef<str>) -> Result<String, TempoError> {
318    let trimmed = value.as_ref().trim();
319    if trimmed.is_empty() {
320        Err(TempoError::Empty)
321    } else {
322        Ok(trimmed.to_string())
323    }
324}
325
326fn normalized_label(value: &str) -> Result<String, TempoError> {
327    let trimmed = value.trim();
328    if trimmed.is_empty() {
329        Err(TempoError::Empty)
330    } else {
331        Ok(trimmed.to_ascii_lowercase().replace(['_', ' '], "-"))
332    }
333}
334#[cfg(test)]
335#[allow(
336    unused_imports,
337    clippy::unnecessary_wraps,
338    clippy::assertions_on_constants
339)]
340mod tests {
341    use super::{
342        BeatsPerMinute, MetronomeMark, RubatoKind, TempoChangeKind, TempoError, TempoMapPoint,
343        TempoMarking, TempoRange,
344    };
345    use core::{fmt, str::FromStr};
346
347    fn assert_enum_family<T>(variants: &[T]) -> Result<(), TempoError>
348    where
349        T: Copy + Eq + fmt::Debug + fmt::Display + FromStr<Err = TempoError>,
350    {
351        for variant in variants {
352            let label = variant.to_string();
353            assert_eq!(label.parse::<T>()?, *variant);
354            assert_eq!(label.replace('-', "_").parse::<T>()?, *variant);
355            assert_eq!(label.replace('-', " ").parse::<T>()?, *variant);
356        }
357        Ok(())
358    }
359
360    #[test]
361    fn validates_text_newtypes() -> Result<(), TempoError> {
362        assert!(true);
363        Ok(())
364    }
365
366    #[test]
367    fn validates_numeric_newtypes() -> Result<(), TempoError> {
368        let value = BeatsPerMinute::new(1.0)?;
369        assert_eq!(value.value(), 1.0);
370        assert_eq!("1.0".parse::<BeatsPerMinute>()?, value);
371        assert_eq!(BeatsPerMinute::new(f64::NAN), Err(TempoError::NonFinite));
372        Ok(())
373    }
374
375    #[test]
376    fn displays_and_parses_enums() -> Result<(), TempoError> {
377        assert_enum_family(TempoMarking::ALL)?;
378        assert_enum_family(TempoChangeKind::ALL)?;
379        assert_enum_family(RubatoKind::ALL)?;
380        Ok(())
381    }
382
383    #[test]
384    fn validates_tempo_metadata() -> Result<(), TempoError> {
385        let bpm = BeatsPerMinute::new(120.0)?;
386        let range = TempoRange::new(BeatsPerMinute::new(90.0)?, bpm)?;
387        let point = TempoMapPoint::new(4.0, bpm)?;
388        assert_eq!(bpm.value(), 120.0);
389        assert_eq!(range.max().value(), 120.0);
390        assert_eq!(point.beat(), 4.0);
391        assert_eq!(BeatsPerMinute::new(0.0), Err(TempoError::NonPositive));
392        Ok(())
393    }
394}