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 ChromaticScale, DiatonicScale, PentatonicScale, ScaleDegree, ScaleError, ScaleKind,
10 ScaleName, ScalePattern, ScaleStepPattern, ScaleToneCount,
11 };
12}
13#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
14pub struct ScaleName(String);
15
16impl ScaleName {
17 pub fn new(value: impl AsRef<str>) -> Result<Self, ScaleError> {
18 non_empty_text(value).map(Self)
19 }
20
21 pub fn as_str(&self) -> &str {
22 &self.0
23 }
24
25 pub fn value(&self) -> &str {
26 self.as_str()
27 }
28
29 pub fn into_string(self) -> String {
30 self.0
31 }
32}
33
34impl AsRef<str> for ScaleName {
35 fn as_ref(&self) -> &str {
36 self.as_str()
37 }
38}
39
40impl fmt::Display for ScaleName {
41 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42 formatter.write_str(self.as_str())
43 }
44}
45
46impl FromStr for ScaleName {
47 type Err = ScaleError;
48
49 fn from_str(value: &str) -> Result<Self, Self::Err> {
50 Self::new(value)
51 }
52}
53
54impl TryFrom<&str> for ScaleName {
55 type Error = ScaleError;
56
57 fn try_from(value: &str) -> Result<Self, Self::Error> {
58 Self::new(value)
59 }
60}
61#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
62pub struct ScaleDegree(u8);
63
64impl ScaleDegree {
65 pub fn new(value: u8) -> Result<Self, ScaleError> {
66 if !(1..=64).contains(&value) {
67 return Err(ScaleError::OutOfRange);
68 }
69
70 Ok(Self(value))
71 }
72
73 pub const fn value(self) -> u8 {
74 self.0
75 }
76}
77
78impl fmt::Display for ScaleDegree {
79 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80 self.0.fmt(formatter)
81 }
82}
83
84impl FromStr for ScaleDegree {
85 type Err = ScaleError;
86
87 fn from_str(value: &str) -> Result<Self, Self::Err> {
88 let parsed = value
89 .trim()
90 .parse::<u8>()
91 .map_err(|_| ScaleError::InvalidFormat)?;
92 Self::new(parsed)
93 }
94}
95
96impl TryFrom<u8> for ScaleDegree {
97 type Error = ScaleError;
98
99 fn try_from(value: u8) -> Result<Self, Self::Error> {
100 Self::new(value)
101 }
102}
103#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
104pub struct ScaleToneCount(u8);
105
106impl ScaleToneCount {
107 pub fn new(value: u8) -> Result<Self, ScaleError> {
108 if !(1..=64).contains(&value) {
109 return Err(ScaleError::OutOfRange);
110 }
111
112 Ok(Self(value))
113 }
114
115 pub const fn value(self) -> u8 {
116 self.0
117 }
118}
119
120impl fmt::Display for ScaleToneCount {
121 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122 self.0.fmt(formatter)
123 }
124}
125
126impl FromStr for ScaleToneCount {
127 type Err = ScaleError;
128
129 fn from_str(value: &str) -> Result<Self, Self::Err> {
130 let parsed = value
131 .trim()
132 .parse::<u8>()
133 .map_err(|_| ScaleError::InvalidFormat)?;
134 Self::new(parsed)
135 }
136}
137
138impl TryFrom<u8> for ScaleToneCount {
139 type Error = ScaleError;
140
141 fn try_from(value: u8) -> Result<Self, Self::Error> {
142 Self::new(value)
143 }
144}
145#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
146pub enum ScaleKind {
147 Major,
148 NaturalMinor,
149 HarmonicMinor,
150 MelodicMinor,
151 Chromatic,
152 MajorPentatonic,
153 MinorPentatonic,
154 Blues,
155 WholeTone,
156 Diminished,
157 Custom,
158}
159
160impl ScaleKind {
161 pub const ALL: &'static [Self] = &[
162 Self::Major,
163 Self::NaturalMinor,
164 Self::HarmonicMinor,
165 Self::MelodicMinor,
166 Self::Chromatic,
167 Self::MajorPentatonic,
168 Self::MinorPentatonic,
169 Self::Blues,
170 Self::WholeTone,
171 Self::Diminished,
172 Self::Custom,
173 ];
174
175 pub const fn as_str(self) -> &'static str {
176 match self {
177 Self::Major => "major",
178 Self::NaturalMinor => "natural-minor",
179 Self::HarmonicMinor => "harmonic-minor",
180 Self::MelodicMinor => "melodic-minor",
181 Self::Chromatic => "chromatic",
182 Self::MajorPentatonic => "major-pentatonic",
183 Self::MinorPentatonic => "minor-pentatonic",
184 Self::Blues => "blues",
185 Self::WholeTone => "whole-tone",
186 Self::Diminished => "diminished",
187 Self::Custom => "custom",
188 }
189 }
190}
191
192impl fmt::Display for ScaleKind {
193 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
194 formatter.write_str(self.as_str())
195 }
196}
197
198impl FromStr for ScaleKind {
199 type Err = ScaleError;
200
201 fn from_str(value: &str) -> Result<Self, Self::Err> {
202 match normalized_label(value)?.as_str() {
203 "major" => Ok(Self::Major),
204 "natural-minor" => Ok(Self::NaturalMinor),
205 "harmonic-minor" => Ok(Self::HarmonicMinor),
206 "melodic-minor" => Ok(Self::MelodicMinor),
207 "chromatic" => Ok(Self::Chromatic),
208 "major-pentatonic" => Ok(Self::MajorPentatonic),
209 "minor-pentatonic" => Ok(Self::MinorPentatonic),
210 "blues" => Ok(Self::Blues),
211 "whole-tone" => Ok(Self::WholeTone),
212 "diminished" => Ok(Self::Diminished),
213 "custom" => Ok(Self::Custom),
214 _ => Err(ScaleError::UnknownLabel),
215 }
216 }
217}
218#[derive(Clone, Debug, Eq, PartialEq)]
219pub struct ScalePattern {
220 steps: Vec<u8>,
221}
222
223impl ScalePattern {
224 pub fn new(steps: impl Into<Vec<u8>>) -> Result<Self, ScaleError> {
225 let steps = steps.into();
226 if steps.is_empty() {
227 return Err(ScaleError::Empty);
228 }
229 Ok(Self { steps })
230 }
231
232 pub fn steps(&self) -> &[u8] {
233 &self.steps
234 }
235
236 pub fn tone_count(&self) -> ScaleToneCount {
237 ScaleToneCount(u8::try_from(self.steps.len()).unwrap_or(u8::MAX))
238 }
239
240 pub fn is_heptatonic(&self) -> bool {
241 self.steps.len() == 7
242 }
243 pub fn is_pentatonic(&self) -> bool {
244 self.steps.len() == 5
245 }
246 pub fn is_chromatic(&self) -> bool {
247 self.steps.len() == 12
248 }
249}
250
251pub type ScaleStepPattern = ScalePattern;
252pub type DiatonicScale = ScalePattern;
253pub type PentatonicScale = ScalePattern;
254pub type ChromaticScale = ScalePattern;
255#[derive(Clone, Copy, Debug, Eq, PartialEq)]
256pub enum ScaleError {
257 Empty,
258 InvalidFormat,
259 OutOfRange,
260 NonFinite,
261 NonPositive,
262 UnknownLabel,
263}
264
265impl fmt::Display for ScaleError {
266 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
267 match self {
268 Self::Empty => formatter.write_str("scale metadata text cannot be empty"),
269 Self::InvalidFormat => formatter.write_str("scale metadata has an invalid format"),
270 Self::OutOfRange => formatter.write_str("scale metadata value is out of range"),
271 Self::NonFinite => formatter.write_str("scale metadata value must be finite"),
272 Self::NonPositive => formatter.write_str("scale metadata value must be positive"),
273 Self::UnknownLabel => formatter.write_str("unknown scale metadata label"),
274 }
275 }
276}
277
278impl Error for ScaleError {}
279
280#[allow(dead_code)]
281fn non_empty_text(value: impl AsRef<str>) -> Result<String, ScaleError> {
282 let trimmed = value.as_ref().trim();
283 if trimmed.is_empty() {
284 Err(ScaleError::Empty)
285 } else {
286 Ok(trimmed.to_string())
287 }
288}
289
290fn normalized_label(value: &str) -> Result<String, ScaleError> {
291 let trimmed = value.trim();
292 if trimmed.is_empty() {
293 Err(ScaleError::Empty)
294 } else {
295 Ok(trimmed.to_ascii_lowercase().replace(['_', ' '], "-"))
296 }
297}
298#[cfg(test)]
299#[allow(
300 unused_imports,
301 clippy::unnecessary_wraps,
302 clippy::assertions_on_constants
303)]
304mod tests {
305 use super::{
306 ChromaticScale, DiatonicScale, PentatonicScale, ScaleDegree, ScaleError, ScaleKind,
307 ScaleName, ScalePattern, ScaleStepPattern, ScaleToneCount,
308 };
309 use core::{fmt, str::FromStr};
310
311 fn assert_enum_family<T>(variants: &[T]) -> Result<(), ScaleError>
312 where
313 T: Copy + Eq + fmt::Debug + fmt::Display + FromStr<Err = ScaleError>,
314 {
315 for variant in variants {
316 let label = variant.to_string();
317 assert_eq!(label.parse::<T>()?, *variant);
318 assert_eq!(label.replace('-', "_").parse::<T>()?, *variant);
319 assert_eq!(label.replace('-', " ").parse::<T>()?, *variant);
320 }
321 Ok(())
322 }
323
324 #[test]
325 fn validates_text_newtypes() -> Result<(), ScaleError> {
326 let value = ScaleName::new(" example-value ")?;
327 assert_eq!(value.as_str(), "example-value");
328 assert_eq!(value.value(), "example-value");
329 assert_eq!(value.to_string(), "example-value");
330 assert_eq!(
331 <ScaleName as TryFrom<&str>>::try_from("example-value")?,
332 value
333 );
334 Ok(())
335 }
336
337 #[test]
338 fn validates_numeric_newtypes() -> Result<(), ScaleError> {
339 let value = ScaleDegree::new(1)?;
340 assert_eq!(value.value(), 1);
341 assert_eq!("1".parse::<ScaleDegree>()?, value);
342 assert_eq!(ScaleDegree::new(65), Err(ScaleError::OutOfRange));
343 let value = ScaleToneCount::new(1)?;
344 assert_eq!(value.value(), 1);
345 assert_eq!("1".parse::<ScaleToneCount>()?, value);
346 assert_eq!(ScaleToneCount::new(65), Err(ScaleError::OutOfRange));
347 Ok(())
348 }
349
350 #[test]
351 fn displays_and_parses_enums() -> Result<(), ScaleError> {
352 assert_enum_family(ScaleKind::ALL)?;
353 Ok(())
354 }
355
356 #[test]
357 fn classifies_scale_patterns() -> Result<(), ScaleError> {
358 let major = ScalePattern::new([2, 2, 1, 2, 2, 2, 1])?;
359 let pentatonic = ScalePattern::new([2, 2, 3, 2, 3])?;
360 let chromatic = ScalePattern::new([1; 12])?;
361 assert!(major.is_heptatonic());
362 assert!(pentatonic.is_pentatonic());
363 assert!(chromatic.is_chromatic());
364 assert_eq!(major.tone_count().value(), 7);
365 Ok(())
366 }
367}