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}