temperaments 1.0.2

Calculate frequency tables of a wide variety of musical temperaments and calculate cent offsets.
Documentation
//! Types and utilities for dealing with frequencies (hertz).

use crate::{cent::Cent, check_float, Result};
use core::fmt;
use std::{
    fmt::Display,
    ops::{Add, Div, Mul, Sub},
};

#[derive(Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Frequency(pub f64);

impl Frequency {
    pub fn adding(&self, cents: Cent) -> Result<Self> {
        check_float(f64::powf(2.0, f64::log2(self.0) + (cents.0 / 1200.0)))
            .map_err(|| format!("Adding {} cents to {} resulted in NaN", cents.0, self.0))
            .map(Frequency)
    }

    pub fn subtracting(&self, cents: Cent) -> Result<Self> {
        check_float(f64::powf(2.0, f64::log2(self.0) - (cents.0 / 1200.0)))
            .map_err(|| {
                format!(
                    "Subtracting {} cents from {} resulted in NaN",
                    cents.0, self.0
                )
            })
            .map(Frequency)
    }

    pub fn cents_to(&self, rhs: Self) -> Result<Cent> {
        check_float(f64::log2(rhs.0 / self.0) * 1200.0)
            .map_err(|| {
                format!(
                    "Calculating distance from {} to {} resulted in NaN",
                    self.0, rhs.0
                )
            })
            .map(Cent)
    }

    /// Get the halfway point to another frequency based on cent-distance.
    ///
    /// There are two different "midpoints" between two frequencies. One is by taking the average
    /// between two frequencies ((a + b) / 2). Another is to take the number of cents between two
    /// frequencies and then adding half of those cents to the bottom frequency. The latter is
    /// more closely aligned to the perceptual midpoint between two frequencies. This function will
    /// return the latter: the cent-based perceptual midpoint rather than the naive average of the
    /// two frequencies.
    pub fn halfway_to(&self, frequency: Frequency) -> Result<Self> {
        let distance = self.cents_to(frequency)?;
        self.adding(distance / 2)
    }
}

impl Add for Frequency {
    type Output = Self;
    fn add(self, rhs: Self) -> Self::Output {
        Frequency(self.0 + rhs.0)
    }
}

impl Add<f64> for Frequency {
    type Output = Self;
    fn add(self, rhs: f64) -> Self::Output {
        Self(self.0 + rhs)
    }
}

impl Sub for Frequency {
    type Output = Self;
    fn sub(self, rhs: Self) -> Self::Output {
        Frequency(self.0 - rhs.0)
    }
}

impl Sub<f64> for Frequency {
    type Output = Self;
    fn sub(self, rhs: f64) -> Self::Output {
        Self(self.0 - rhs)
    }
}

impl Mul for Frequency {
    type Output = Self;
    fn mul(self, rhs: Self) -> Self::Output {
        Frequency(self.0 * rhs.0)
    }
}

impl Mul<f64> for Frequency {
    type Output = Self;
    fn mul(self, rhs: f64) -> Self::Output {
        Frequency(self.0 * rhs)
    }
}

impl Div for Frequency {
    type Output = Self;
    fn div(self, rhs: Self) -> Self::Output {
        Frequency(self.0 / rhs.0)
    }
}

impl Div<f64> for Frequency {
    type Output = Self;
    fn div(self, rhs: f64) -> Self::Output {
        Frequency(self.0 / rhs)
    }
}

impl PartialOrd for Frequency {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        self.0.partial_cmp(&other.0)
    }
}

impl Display for Frequency {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_fmt(format_args!("{}Hz", self.0))
    }
}

impl fmt::Debug for Frequency {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_fmt(format_args!("{}Hz", self.0))
    }
}

#[cfg(test)]
mod tests {

    use crate::{
        frequency::Frequency,
        pitch::Pitch,
        temperaments::{equal::EqualTemperament, table::FrequencyTable},
        test_utils::{assert_flt_eq, assert_flt_ne},
    };

    #[test]
    fn test_freq_subtraction() {
        let eqt = FrequencyTable::new::<EqualTemperament>(Frequency(440.0), Pitch::A4).unwrap();
        println!(
            "Cents from A4 ({}) to Bb4 ({}): {}",
            eqt.bb4,
            eqt.a4,
            eqt.bb4.cents_to(eqt.a4).unwrap()
        )
    }

    #[test]
    fn test_halfway_point() {
        // A4 and Bb4 in Equal Temperament based on A4=440Hz.
        let pitch_1 = Frequency(440.0);
        let pitch_2 = Frequency(466.1637615180902);

        // There are two different "midpoints". One is by taking the average between two
        // frequencies. Another is to take the number of cents between two frequencies and then
        // adding half of those cents to the bottom frequency. The latter is closer align to the
        // perceptual midpoint between two frequencies. This test ensures that the find_closest()
        // function uses the cent-based perceptual midpoint rather than the naive average of the
        // two frequencies.

        // The average-based midpoint will be higher than the cent-based percptual midpoint.
        // In equal temperament based on A4 = 440Hz, the mid point between A4 and Bb4 are:
        // - Based on hertz: 453.08188075904513Hz
        // - Based on cents: 452.89298412313696Hz
        let midpoint_hz = (pitch_1 + pitch_2) / 2.0;
        let midpoint_cents = pitch_1
            .adding(pitch_1.cents_to(pitch_2).unwrap() / 2)
            .unwrap();

        let halfway = pitch_1.halfway_to(pitch_2).unwrap();

        assert_flt_ne!(halfway, midpoint_hz);
        assert_flt_eq!(halfway, midpoint_cents);
    }
}