Skip to main content

use_pitch/
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        MidiNoteNumber, PitchClass, PitchClassNumber, PitchError, PitchName, PitchNumber,
10        PitchRegister, PitchSpelling, is_valid_midi_note_number,
11    };
12}
13
14#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
15pub struct PitchClassNumber(u8);
16
17impl PitchClassNumber {
18    pub fn new(value: u8) -> Result<Self, PitchError> {
19        if value > 11 {
20            return Err(PitchError::OutOfRange);
21        }
22
23        Ok(Self(value))
24    }
25
26    pub const fn value(self) -> u8 {
27        self.0
28    }
29}
30
31impl fmt::Display for PitchClassNumber {
32    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33        self.0.fmt(formatter)
34    }
35}
36
37impl FromStr for PitchClassNumber {
38    type Err = PitchError;
39
40    fn from_str(value: &str) -> Result<Self, Self::Err> {
41        let parsed = value
42            .trim()
43            .parse::<u8>()
44            .map_err(|_| PitchError::InvalidFormat)?;
45        Self::new(parsed)
46    }
47}
48
49#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
50pub struct PitchNumber(i16);
51
52impl PitchNumber {
53    pub const fn new(value: i16) -> Self {
54        Self(value)
55    }
56
57    pub const fn value(self) -> i16 {
58        self.0
59    }
60}
61
62#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
63pub struct MidiNoteNumber(u8);
64
65impl MidiNoteNumber {
66    pub fn new(value: u8) -> Result<Self, PitchError> {
67        if value > 127 {
68            return Err(PitchError::OutOfRange);
69        }
70
71        Ok(Self(value))
72    }
73
74    pub const fn value(self) -> u8 {
75        self.0
76    }
77
78    pub fn pitch_class(self) -> PitchClassNumber {
79        PitchClassNumber::new(self.0 % 12).expect("MIDI pitch class is always in 0..=11")
80    }
81
82    pub const fn octave(self) -> i8 {
83        (self.0 / 12).cast_signed() - 1
84    }
85}
86
87impl fmt::Display for MidiNoteNumber {
88    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89        self.0.fmt(formatter)
90    }
91}
92
93impl FromStr for MidiNoteNumber {
94    type Err = PitchError;
95
96    fn from_str(value: &str) -> Result<Self, Self::Err> {
97        let parsed = value
98            .trim()
99            .parse::<u8>()
100            .map_err(|_| PitchError::InvalidFormat)?;
101        Self::new(parsed)
102    }
103}
104
105#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
106pub struct PitchName(String);
107
108pub type PitchSpelling = PitchName;
109pub type PitchClass = PitchClassNumber;
110
111impl PitchName {
112    pub fn new(value: impl AsRef<str>) -> Result<Self, PitchError> {
113        let trimmed = value.as_ref().trim();
114        if trimmed.is_empty() {
115            return Err(PitchError::Empty);
116        }
117
118        Ok(Self(trimmed.to_string()))
119    }
120
121    pub fn as_str(&self) -> &str {
122        &self.0
123    }
124
125    pub fn value(&self) -> &str {
126        self.as_str()
127    }
128}
129
130impl fmt::Display for PitchName {
131    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
132        formatter.write_str(self.as_str())
133    }
134}
135
136impl FromStr for PitchName {
137    type Err = PitchError;
138
139    fn from_str(value: &str) -> Result<Self, Self::Err> {
140        Self::new(value)
141    }
142}
143
144impl TryFrom<&str> for PitchName {
145    type Error = PitchError;
146
147    fn try_from(value: &str) -> Result<Self, Self::Error> {
148        Self::new(value)
149    }
150}
151
152#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
153pub enum PitchRegister {
154    SubContra,
155    Contra,
156    Great,
157    Small,
158    OneLine,
159    TwoLine,
160    ThreeLine,
161    FourLine,
162    FiveLine,
163    Unknown,
164}
165
166impl PitchRegister {
167    pub const ALL: &'static [Self] = &[
168        Self::SubContra,
169        Self::Contra,
170        Self::Great,
171        Self::Small,
172        Self::OneLine,
173        Self::TwoLine,
174        Self::ThreeLine,
175        Self::FourLine,
176        Self::FiveLine,
177        Self::Unknown,
178    ];
179
180    pub const fn as_str(self) -> &'static str {
181        match self {
182            Self::SubContra => "sub-contra",
183            Self::Contra => "contra",
184            Self::Great => "great",
185            Self::Small => "small",
186            Self::OneLine => "one-line",
187            Self::TwoLine => "two-line",
188            Self::ThreeLine => "three-line",
189            Self::FourLine => "four-line",
190            Self::FiveLine => "five-line",
191            Self::Unknown => "unknown",
192        }
193    }
194}
195
196impl fmt::Display for PitchRegister {
197    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
198        formatter.write_str(self.as_str())
199    }
200}
201
202impl FromStr for PitchRegister {
203    type Err = PitchError;
204
205    fn from_str(value: &str) -> Result<Self, Self::Err> {
206        match value
207            .trim()
208            .to_ascii_lowercase()
209            .replace(['_', ' '], "-")
210            .as_str()
211        {
212            "sub-contra" => Ok(Self::SubContra),
213            "contra" => Ok(Self::Contra),
214            "great" => Ok(Self::Great),
215            "small" => Ok(Self::Small),
216            "one-line" => Ok(Self::OneLine),
217            "two-line" => Ok(Self::TwoLine),
218            "three-line" => Ok(Self::ThreeLine),
219            "four-line" => Ok(Self::FourLine),
220            "five-line" => Ok(Self::FiveLine),
221            "unknown" => Ok(Self::Unknown),
222            _ => Err(PitchError::UnknownLabel),
223        }
224    }
225}
226
227pub const fn is_valid_midi_note_number(value: u8) -> bool {
228    value <= 127
229}
230
231#[derive(Clone, Copy, Debug, Eq, PartialEq)]
232pub enum PitchError {
233    Empty,
234    InvalidFormat,
235    OutOfRange,
236    UnknownLabel,
237}
238
239impl fmt::Display for PitchError {
240    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
241        match self {
242            Self::Empty => formatter.write_str("pitch metadata text cannot be empty"),
243            Self::InvalidFormat => formatter.write_str("pitch metadata has an invalid format"),
244            Self::OutOfRange => formatter.write_str("pitch metadata value is out of range"),
245            Self::UnknownLabel => formatter.write_str("unknown pitch metadata label"),
246        }
247    }
248}
249
250impl Error for PitchError {}
251
252#[cfg(test)]
253#[allow(
254    unused_imports,
255    clippy::unnecessary_wraps,
256    clippy::assertions_on_constants
257)]
258mod tests {
259    use super::{
260        MidiNoteNumber, PitchClassNumber, PitchError, PitchName, PitchRegister,
261        is_valid_midi_note_number,
262    };
263
264    #[test]
265    fn validates_pitch_classes_and_midi_numbers() -> Result<(), PitchError> {
266        assert_eq!(PitchClassNumber::new(11)?.value(), 11);
267        assert_eq!(PitchClassNumber::new(12), Err(PitchError::OutOfRange));
268
269        let middle_c = MidiNoteNumber::new(60)?;
270        assert_eq!(middle_c.pitch_class().value(), 0);
271        assert_eq!(middle_c.octave(), 4);
272        assert!(is_valid_midi_note_number(127));
273        Ok(())
274    }
275
276    #[test]
277    fn validates_pitch_names_and_registers() -> Result<(), PitchError> {
278        let name = PitchName::new(" C#4 ")?;
279        assert_eq!(name.as_str(), "C#4");
280        assert_eq!("one line".parse::<PitchRegister>()?, PitchRegister::OneLine);
281        Ok(())
282    }
283}