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}