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 BeatDivision, DottedDuration, DurationValue, NoteDuration, RestDuration, RhythmError,
10 RhythmPatternName, RhythmicPosition, SyncopationKind, TupletRatio,
11 };
12}
13#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
14pub struct RhythmPatternName(String);
15
16impl RhythmPatternName {
17 pub fn new(value: impl AsRef<str>) -> Result<Self, RhythmError> {
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 RhythmPatternName {
35 fn as_ref(&self) -> &str {
36 self.as_str()
37 }
38}
39
40impl fmt::Display for RhythmPatternName {
41 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42 formatter.write_str(self.as_str())
43 }
44}
45
46impl FromStr for RhythmPatternName {
47 type Err = RhythmError;
48
49 fn from_str(value: &str) -> Result<Self, Self::Err> {
50 Self::new(value)
51 }
52}
53
54impl TryFrom<&str> for RhythmPatternName {
55 type Error = RhythmError;
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 enum DurationValue {
63 DoubleWhole,
64 Whole,
65 Half,
66 Quarter,
67 Eighth,
68 Sixteenth,
69 ThirtySecond,
70 SixtyFourth,
71 OneTwentyEighth,
72}
73
74impl DurationValue {
75 pub const ALL: &'static [Self] = &[
76 Self::DoubleWhole,
77 Self::Whole,
78 Self::Half,
79 Self::Quarter,
80 Self::Eighth,
81 Self::Sixteenth,
82 Self::ThirtySecond,
83 Self::SixtyFourth,
84 Self::OneTwentyEighth,
85 ];
86
87 pub const fn as_str(self) -> &'static str {
88 match self {
89 Self::DoubleWhole => "double-whole",
90 Self::Whole => "whole",
91 Self::Half => "half",
92 Self::Quarter => "quarter",
93 Self::Eighth => "eighth",
94 Self::Sixteenth => "sixteenth",
95 Self::ThirtySecond => "thirty-second",
96 Self::SixtyFourth => "sixty-fourth",
97 Self::OneTwentyEighth => "one-twenty-eighth",
98 }
99 }
100}
101
102impl fmt::Display for DurationValue {
103 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
104 formatter.write_str(self.as_str())
105 }
106}
107
108impl FromStr for DurationValue {
109 type Err = RhythmError;
110
111 fn from_str(value: &str) -> Result<Self, Self::Err> {
112 match normalized_label(value)?.as_str() {
113 "double-whole" => Ok(Self::DoubleWhole),
114 "whole" => Ok(Self::Whole),
115 "half" => Ok(Self::Half),
116 "quarter" => Ok(Self::Quarter),
117 "eighth" => Ok(Self::Eighth),
118 "sixteenth" => Ok(Self::Sixteenth),
119 "thirty-second" => Ok(Self::ThirtySecond),
120 "sixty-fourth" => Ok(Self::SixtyFourth),
121 "one-twenty-eighth" => Ok(Self::OneTwentyEighth),
122 _ => Err(RhythmError::UnknownLabel),
123 }
124 }
125}
126#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
127pub enum BeatDivision {
128 Duple,
129 Triple,
130 Quadruple,
131 Quintuple,
132 Septuple,
133 Custom,
134}
135
136impl BeatDivision {
137 pub const ALL: &'static [Self] = &[
138 Self::Duple,
139 Self::Triple,
140 Self::Quadruple,
141 Self::Quintuple,
142 Self::Septuple,
143 Self::Custom,
144 ];
145
146 pub const fn as_str(self) -> &'static str {
147 match self {
148 Self::Duple => "duple",
149 Self::Triple => "triple",
150 Self::Quadruple => "quadruple",
151 Self::Quintuple => "quintuple",
152 Self::Septuple => "septuple",
153 Self::Custom => "custom",
154 }
155 }
156}
157
158impl fmt::Display for BeatDivision {
159 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
160 formatter.write_str(self.as_str())
161 }
162}
163
164impl FromStr for BeatDivision {
165 type Err = RhythmError;
166
167 fn from_str(value: &str) -> Result<Self, Self::Err> {
168 match normalized_label(value)?.as_str() {
169 "duple" => Ok(Self::Duple),
170 "triple" => Ok(Self::Triple),
171 "quadruple" => Ok(Self::Quadruple),
172 "quintuple" => Ok(Self::Quintuple),
173 "septuple" => Ok(Self::Septuple),
174 "custom" => Ok(Self::Custom),
175 _ => Err(RhythmError::UnknownLabel),
176 }
177 }
178}
179#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
180pub enum SyncopationKind {
181 None,
182 WeakBeatAccent,
183 OffBeat,
184 Anticipation,
185 Suspension,
186 Unknown,
187}
188
189impl SyncopationKind {
190 pub const ALL: &'static [Self] = &[
191 Self::None,
192 Self::WeakBeatAccent,
193 Self::OffBeat,
194 Self::Anticipation,
195 Self::Suspension,
196 Self::Unknown,
197 ];
198
199 pub const fn as_str(self) -> &'static str {
200 match self {
201 Self::None => "none",
202 Self::WeakBeatAccent => "weak-beat-accent",
203 Self::OffBeat => "off-beat",
204 Self::Anticipation => "anticipation",
205 Self::Suspension => "suspension",
206 Self::Unknown => "unknown",
207 }
208 }
209}
210
211impl fmt::Display for SyncopationKind {
212 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
213 formatter.write_str(self.as_str())
214 }
215}
216
217impl FromStr for SyncopationKind {
218 type Err = RhythmError;
219
220 fn from_str(value: &str) -> Result<Self, Self::Err> {
221 match normalized_label(value)?.as_str() {
222 "none" => Ok(Self::None),
223 "weak-beat-accent" => Ok(Self::WeakBeatAccent),
224 "off-beat" => Ok(Self::OffBeat),
225 "anticipation" => Ok(Self::Anticipation),
226 "suspension" => Ok(Self::Suspension),
227 "unknown" => Ok(Self::Unknown),
228 _ => Err(RhythmError::UnknownLabel),
229 }
230 }
231}
232#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
233pub struct NoteDuration {
234 value: DurationValue,
235}
236
237impl NoteDuration {
238 pub const fn new(value: DurationValue) -> Self {
239 Self { value }
240 }
241 pub const fn value(self) -> DurationValue {
242 self.value
243 }
244 pub const fn is_rest(self) -> bool {
245 false
246 }
247 pub const fn is_shorter_than_quarter_like(self) -> bool {
248 self.value.is_shorter_than_quarter_like()
249 }
250}
251
252#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
253pub struct RestDuration {
254 value: DurationValue,
255}
256
257impl RestDuration {
258 pub const fn new(value: DurationValue) -> Self {
259 Self { value }
260 }
261 pub const fn value(self) -> DurationValue {
262 self.value
263 }
264 pub const fn is_rest(self) -> bool {
265 true
266 }
267}
268
269#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
270pub struct TupletRatio {
271 actual: u8,
272 normal: u8,
273}
274
275impl TupletRatio {
276 pub fn new(actual: u8, normal: u8) -> Result<Self, RhythmError> {
277 if actual == 0 || normal == 0 {
278 return Err(RhythmError::OutOfRange);
279 }
280 Ok(Self { actual, normal })
281 }
282 pub const fn actual(self) -> u8 {
283 self.actual
284 }
285 pub const fn normal(self) -> u8 {
286 self.normal
287 }
288}
289
290#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
291pub struct DottedDuration {
292 value: DurationValue,
293 dots: u8,
294}
295
296impl DottedDuration {
297 pub const fn new(value: DurationValue, dots: u8) -> Self {
298 Self { value, dots }
299 }
300 pub const fn value(self) -> DurationValue {
301 self.value
302 }
303 pub const fn dot_count(self) -> u8 {
304 self.dots
305 }
306}
307
308#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
309pub struct RhythmicPosition {
310 beat: u16,
311 subdivision: u16,
312}
313
314impl RhythmicPosition {
315 pub const fn new(beat: u16, subdivision: u16) -> Self {
316 Self { beat, subdivision }
317 }
318 pub const fn beat(self) -> u16 {
319 self.beat
320 }
321 pub const fn subdivision(self) -> u16 {
322 self.subdivision
323 }
324}
325
326impl DurationValue {
327 pub const fn is_shorter_than_quarter_like(self) -> bool {
328 matches!(
329 self,
330 Self::Eighth
331 | Self::Sixteenth
332 | Self::ThirtySecond
333 | Self::SixtyFourth
334 | Self::OneTwentyEighth
335 )
336 }
337}
338#[derive(Clone, Copy, Debug, Eq, PartialEq)]
339pub enum RhythmError {
340 Empty,
341 InvalidFormat,
342 OutOfRange,
343 NonFinite,
344 NonPositive,
345 UnknownLabel,
346}
347
348impl fmt::Display for RhythmError {
349 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
350 match self {
351 Self::Empty => formatter.write_str("rhythm metadata text cannot be empty"),
352 Self::InvalidFormat => formatter.write_str("rhythm metadata has an invalid format"),
353 Self::OutOfRange => formatter.write_str("rhythm metadata value is out of range"),
354 Self::NonFinite => formatter.write_str("rhythm metadata value must be finite"),
355 Self::NonPositive => formatter.write_str("rhythm metadata value must be positive"),
356 Self::UnknownLabel => formatter.write_str("unknown rhythm metadata label"),
357 }
358 }
359}
360
361impl Error for RhythmError {}
362
363#[allow(dead_code)]
364fn non_empty_text(value: impl AsRef<str>) -> Result<String, RhythmError> {
365 let trimmed = value.as_ref().trim();
366 if trimmed.is_empty() {
367 Err(RhythmError::Empty)
368 } else {
369 Ok(trimmed.to_string())
370 }
371}
372
373fn normalized_label(value: &str) -> Result<String, RhythmError> {
374 let trimmed = value.trim();
375 if trimmed.is_empty() {
376 Err(RhythmError::Empty)
377 } else {
378 Ok(trimmed.to_ascii_lowercase().replace(['_', ' '], "-"))
379 }
380}
381#[cfg(test)]
382#[allow(
383 unused_imports,
384 clippy::unnecessary_wraps,
385 clippy::assertions_on_constants
386)]
387mod tests {
388 use super::{
389 BeatDivision, DottedDuration, DurationValue, NoteDuration, RestDuration, RhythmError,
390 RhythmPatternName, RhythmicPosition, SyncopationKind, TupletRatio,
391 };
392 use core::{fmt, str::FromStr};
393
394 fn assert_enum_family<T>(variants: &[T]) -> Result<(), RhythmError>
395 where
396 T: Copy + Eq + fmt::Debug + fmt::Display + FromStr<Err = RhythmError>,
397 {
398 for variant in variants {
399 let label = variant.to_string();
400 assert_eq!(label.parse::<T>()?, *variant);
401 assert_eq!(label.replace('-', "_").parse::<T>()?, *variant);
402 assert_eq!(label.replace('-', " ").parse::<T>()?, *variant);
403 }
404 Ok(())
405 }
406
407 #[test]
408 fn validates_text_newtypes() -> Result<(), RhythmError> {
409 let value = RhythmPatternName::new(" example-value ")?;
410 assert_eq!(value.as_str(), "example-value");
411 assert_eq!(value.value(), "example-value");
412 assert_eq!(value.to_string(), "example-value");
413 assert_eq!(
414 <RhythmPatternName as TryFrom<&str>>::try_from("example-value")?,
415 value
416 );
417 Ok(())
418 }
419
420 #[test]
421 fn validates_numeric_newtypes() -> Result<(), RhythmError> {
422 assert!(true);
423 Ok(())
424 }
425
426 #[test]
427 fn displays_and_parses_enums() -> Result<(), RhythmError> {
428 assert_enum_family(DurationValue::ALL)?;
429 assert_enum_family(BeatDivision::ALL)?;
430 assert_enum_family(SyncopationKind::ALL)?;
431 Ok(())
432 }
433
434 #[test]
435 fn models_symbolic_durations() -> Result<(), RhythmError> {
436 let note = NoteDuration::new(DurationValue::Eighth);
437 let rest = RestDuration::new(DurationValue::Quarter);
438 let dotted = DottedDuration::new(DurationValue::Half, 2);
439 let tuplet = TupletRatio::new(3, 2)?;
440 assert!(note.is_shorter_than_quarter_like());
441 assert!(!note.is_rest());
442 assert!(rest.is_rest());
443 assert_eq!(dotted.dot_count(), 2);
444 assert_eq!(tuplet.actual(), 3);
445 Ok(())
446 }
447}