use-tempo 0.0.1

Tempo metadata primitives for RustUse.
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::{fmt, str::FromStr};
use std::error::Error;

pub mod prelude {
    pub use crate::{
        BeatsPerMinute, MetronomeMark, RubatoKind, TempoChangeKind, TempoError, TempoMapPoint,
        TempoMarking, TempoRange,
    };
}
#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
pub struct BeatsPerMinute(f64);

impl BeatsPerMinute {
    pub fn new(value: f64) -> Result<Self, TempoError> {
        if !value.is_finite() {
            return Err(TempoError::NonFinite);
        }
        if value <= 0.0 {
            return Err(TempoError::NonPositive);
        }
        Ok(Self(value))
    }

    pub const fn value(self) -> f64 {
        self.0
    }
}

impl fmt::Display for BeatsPerMinute {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(formatter)
    }
}

impl FromStr for BeatsPerMinute {
    type Err = TempoError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let parsed = value
            .trim()
            .parse::<f64>()
            .map_err(|_| TempoError::InvalidFormat)?;
        Self::new(parsed)
    }
}

impl TryFrom<f64> for BeatsPerMinute {
    type Error = TempoError;

    fn try_from(value: f64) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum TempoMarking {
    Larghissimo,
    Largo,
    Larghetto,
    Adagio,
    Andante,
    Moderato,
    Allegro,
    Vivace,
    Presto,
    Prestissimo,
    Custom,
}

impl TempoMarking {
    pub const ALL: &'static [Self] = &[
        Self::Larghissimo,
        Self::Largo,
        Self::Larghetto,
        Self::Adagio,
        Self::Andante,
        Self::Moderato,
        Self::Allegro,
        Self::Vivace,
        Self::Presto,
        Self::Prestissimo,
        Self::Custom,
    ];

    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Larghissimo => "larghissimo",
            Self::Largo => "largo",
            Self::Larghetto => "larghetto",
            Self::Adagio => "adagio",
            Self::Andante => "andante",
            Self::Moderato => "moderato",
            Self::Allegro => "allegro",
            Self::Vivace => "vivace",
            Self::Presto => "presto",
            Self::Prestissimo => "prestissimo",
            Self::Custom => "custom",
        }
    }
}

impl fmt::Display for TempoMarking {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for TempoMarking {
    type Err = TempoError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match normalized_label(value)?.as_str() {
            "larghissimo" => Ok(Self::Larghissimo),
            "largo" => Ok(Self::Largo),
            "larghetto" => Ok(Self::Larghetto),
            "adagio" => Ok(Self::Adagio),
            "andante" => Ok(Self::Andante),
            "moderato" => Ok(Self::Moderato),
            "allegro" => Ok(Self::Allegro),
            "vivace" => Ok(Self::Vivace),
            "presto" => Ok(Self::Presto),
            "prestissimo" => Ok(Self::Prestissimo),
            "custom" => Ok(Self::Custom),
            _ => Err(TempoError::UnknownLabel),
        }
    }
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum TempoChangeKind {
    Immediate,
    Gradual,
    Accelerando,
    Ritardando,
    Rallentando,
    ATempo,
}

impl TempoChangeKind {
    pub const ALL: &'static [Self] = &[
        Self::Immediate,
        Self::Gradual,
        Self::Accelerando,
        Self::Ritardando,
        Self::Rallentando,
        Self::ATempo,
    ];

    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Immediate => "immediate",
            Self::Gradual => "gradual",
            Self::Accelerando => "accelerando",
            Self::Ritardando => "ritardando",
            Self::Rallentando => "rallentando",
            Self::ATempo => "a-tempo",
        }
    }
}

impl fmt::Display for TempoChangeKind {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for TempoChangeKind {
    type Err = TempoError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match normalized_label(value)?.as_str() {
            "immediate" => Ok(Self::Immediate),
            "gradual" => Ok(Self::Gradual),
            "accelerando" => Ok(Self::Accelerando),
            "ritardando" => Ok(Self::Ritardando),
            "rallentando" => Ok(Self::Rallentando),
            "a-tempo" => Ok(Self::ATempo),
            _ => Err(TempoError::UnknownLabel),
        }
    }
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RubatoKind {
    None,
    Slight,
    Expressive,
    Free,
    Unknown,
}

impl RubatoKind {
    pub const ALL: &'static [Self] = &[
        Self::None,
        Self::Slight,
        Self::Expressive,
        Self::Free,
        Self::Unknown,
    ];

    pub const fn as_str(self) -> &'static str {
        match self {
            Self::None => "none",
            Self::Slight => "slight",
            Self::Expressive => "expressive",
            Self::Free => "free",
            Self::Unknown => "unknown",
        }
    }
}

impl fmt::Display for RubatoKind {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for RubatoKind {
    type Err = TempoError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match normalized_label(value)?.as_str() {
            "none" => Ok(Self::None),
            "slight" => Ok(Self::Slight),
            "expressive" => Ok(Self::Expressive),
            "free" => Ok(Self::Free),
            "unknown" => Ok(Self::Unknown),
            _ => Err(TempoError::UnknownLabel),
        }
    }
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TempoRange {
    min: BeatsPerMinute,
    max: BeatsPerMinute,
}

impl TempoRange {
    pub fn new(min: BeatsPerMinute, max: BeatsPerMinute) -> Result<Self, TempoError> {
        if min.value() > max.value() {
            return Err(TempoError::OutOfRange);
        }
        Ok(Self { min, max })
    }
    pub const fn min(self) -> BeatsPerMinute {
        self.min
    }
    pub const fn max(self) -> BeatsPerMinute {
        self.max
    }
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TempoMapPoint {
    beat: f64,
    bpm: BeatsPerMinute,
}

impl TempoMapPoint {
    pub fn new(beat: f64, bpm: BeatsPerMinute) -> Result<Self, TempoError> {
        if !beat.is_finite() || beat < 0.0 {
            return Err(TempoError::OutOfRange);
        }
        Ok(Self { beat, bpm })
    }
    pub const fn beat(self) -> f64 {
        self.beat
    }
    pub const fn bpm(self) -> BeatsPerMinute {
        self.bpm
    }
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MetronomeMark {
    beat_unit: &'static str,
    bpm: BeatsPerMinute,
}

impl MetronomeMark {
    pub const fn new(beat_unit: &'static str, bpm: BeatsPerMinute) -> Self {
        Self { beat_unit, bpm }
    }
    pub const fn beat_unit(self) -> &'static str {
        self.beat_unit
    }
    pub const fn bpm(self) -> BeatsPerMinute {
        self.bpm
    }
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TempoError {
    Empty,
    InvalidFormat,
    OutOfRange,
    NonFinite,
    NonPositive,
    UnknownLabel,
}

impl fmt::Display for TempoError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("tempo metadata text cannot be empty"),
            Self::InvalidFormat => formatter.write_str("tempo metadata has an invalid format"),
            Self::OutOfRange => formatter.write_str("tempo metadata value is out of range"),
            Self::NonFinite => formatter.write_str("tempo metadata value must be finite"),
            Self::NonPositive => formatter.write_str("tempo metadata value must be positive"),
            Self::UnknownLabel => formatter.write_str("unknown tempo metadata label"),
        }
    }
}

impl Error for TempoError {}

#[allow(dead_code)]
fn non_empty_text(value: impl AsRef<str>) -> Result<String, TempoError> {
    let trimmed = value.as_ref().trim();
    if trimmed.is_empty() {
        Err(TempoError::Empty)
    } else {
        Ok(trimmed.to_string())
    }
}

fn normalized_label(value: &str) -> Result<String, TempoError> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        Err(TempoError::Empty)
    } else {
        Ok(trimmed.to_ascii_lowercase().replace(['_', ' '], "-"))
    }
}
#[cfg(test)]
#[allow(
    unused_imports,
    clippy::unnecessary_wraps,
    clippy::assertions_on_constants
)]
mod tests {
    use super::{
        BeatsPerMinute, MetronomeMark, RubatoKind, TempoChangeKind, TempoError, TempoMapPoint,
        TempoMarking, TempoRange,
    };
    use core::{fmt, str::FromStr};

    fn assert_enum_family<T>(variants: &[T]) -> Result<(), TempoError>
    where
        T: Copy + Eq + fmt::Debug + fmt::Display + FromStr<Err = TempoError>,
    {
        for variant in variants {
            let label = variant.to_string();
            assert_eq!(label.parse::<T>()?, *variant);
            assert_eq!(label.replace('-', "_").parse::<T>()?, *variant);
            assert_eq!(label.replace('-', " ").parse::<T>()?, *variant);
        }
        Ok(())
    }

    #[test]
    fn validates_text_newtypes() -> Result<(), TempoError> {
        assert!(true);
        Ok(())
    }

    #[test]
    fn validates_numeric_newtypes() -> Result<(), TempoError> {
        let value = BeatsPerMinute::new(1.0)?;
        assert_eq!(value.value(), 1.0);
        assert_eq!("1.0".parse::<BeatsPerMinute>()?, value);
        assert_eq!(BeatsPerMinute::new(f64::NAN), Err(TempoError::NonFinite));
        Ok(())
    }

    #[test]
    fn displays_and_parses_enums() -> Result<(), TempoError> {
        assert_enum_family(TempoMarking::ALL)?;
        assert_enum_family(TempoChangeKind::ALL)?;
        assert_enum_family(RubatoKind::ALL)?;
        Ok(())
    }

    #[test]
    fn validates_tempo_metadata() -> Result<(), TempoError> {
        let bpm = BeatsPerMinute::new(120.0)?;
        let range = TempoRange::new(BeatsPerMinute::new(90.0)?, bpm)?;
        let point = TempoMapPoint::new(4.0, bpm)?;
        assert_eq!(bpm.value(), 120.0);
        assert_eq!(range.max().value(), 120.0);
        assert_eq!(point.beat(), 4.0);
        assert_eq!(BeatsPerMinute::new(0.0), Err(TempoError::NonPositive));
        Ok(())
    }
}