1pub(crate) mod chromaticinterval;
2pub(crate) mod diatonicinterval;
3pub(crate) mod direction;
4pub(crate) mod genericinterval;
5pub(crate) mod intervalbase;
6pub(crate) mod specifier;
7
8use chromaticinterval::ChromaticInterval;
9use diatonicinterval::DiatonicInterval;
10use genericinterval::GenericInterval;
11use intervalbase::IntervalBaseTrait;
12use specifier::Specifier;
13
14use std::str::FromStr;
15use std::sync::Mutex;
16use std::{cmp::Ordering, collections::HashMap, sync::LazyLock};
17
18use crate::common::numbertools::MUSICAL_ORDINAL_STRINGS;
19use crate::common::stringtools::get_num_from_str;
20use crate::error::{Error, Result};
21use crate::{
22 defaults::{FloatType, FractionType, IntegerType},
23 fraction_pow::FractionPow,
24 note::Note,
25 pitch::Pitch,
26};
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub enum IntervalDirection {
32 Descending = -1,
34 Oblique = 0,
36 Ascending = 1,
38}
39
40impl IntervalDirection {
41 pub fn as_int(self) -> IntegerType {
43 self as IntegerType
44 }
45
46 pub fn name(self) -> &'static str {
48 match self {
49 Self::Descending => "Descending",
50 Self::Oblique => "Oblique",
51 Self::Ascending => "Ascending",
52 }
53 }
54}
55
56fn public_direction(value: direction::Direction) -> IntervalDirection {
57 match value {
58 direction::Direction::Descending => IntervalDirection::Descending,
59 direction::Direction::Oblique => IntervalDirection::Oblique,
60 direction::Direction::Ascending => IntervalDirection::Ascending,
61 }
62}
63
64#[derive(Clone, Debug)]
65pub struct Interval {
67 pub(crate) implicit_diatonic: bool,
68 pub(crate) diatonic: DiatonicInterval,
69 pub(crate) chromatic: ChromaticInterval,
70 pitch_start: Option<Pitch>,
71 pitch_end: Option<Pitch>,
72}
73
74pub(crate) enum PitchOrNote {
75 Pitch(Pitch),
76 Note(Note),
77}
78
79pub(crate) enum IntervalArgument {
80 Str(String),
81 Int(IntegerType),
82}
83
84static PYTHAGOREAN_CACHE: LazyLock<Mutex<HashMap<String, (Pitch, FractionType)>>> =
85 LazyLock::new(|| Mutex::new(HashMap::new()));
86
87static PERFECT_FIFTH_UP: LazyLock<Interval> = LazyLock::new(|| {
91 Interval::new(IntervalArgument::Str("P5".to_string())).expect("P5 is a valid interval")
92});
93static PERFECT_FIFTH_DOWN: LazyLock<Interval> = LazyLock::new(|| {
94 Interval::new(IntervalArgument::Str("-P5".to_string())).expect("-P5 is a valid interval")
95});
96
97fn extract_pitch(arg: PitchOrNote) -> Pitch {
98 match arg {
99 PitchOrNote::Pitch(pitch) => pitch,
100 PitchOrNote::Note(note) => note._pitch,
101 }
102}
103
104fn strip_direction_word(value: &str, word: &str) -> (String, bool) {
105 replace_case_insensitive(value, word, "", false, true)
106}
107
108fn replace_music_ordinal(value: &str, ordinal: &str, replacement: &str) -> (String, bool) {
109 replace_case_insensitive(value, ordinal, replacement, true, true)
110}
111
112fn replace_case_insensitive(
113 value: &str,
114 needle: &str,
115 replacement: &str,
116 consume_leading_whitespace: bool,
117 consume_trailing_whitespace: bool,
118) -> (String, bool) {
119 let needle_lower = needle.to_ascii_lowercase();
120 let value_lower = value.to_ascii_lowercase();
121 let mut output = String::with_capacity(value.len());
122 let mut pos = 0;
123 let mut replaced = false;
124
125 while let Some(relative_start) = value_lower[pos..].find(&needle_lower) {
126 let match_start = pos + relative_start;
127 let match_end = match_start + needle.len();
128 let mut copy_end = match_start;
129 let mut next_pos = match_end;
130
131 if consume_leading_whitespace {
132 while copy_end > pos {
133 let Some(ch) = value[pos..copy_end].chars().next_back() else {
134 break;
135 };
136 if !ch.is_whitespace() {
137 break;
138 }
139 copy_end -= ch.len_utf8();
140 }
141 }
142
143 if consume_trailing_whitespace {
144 while next_pos < value.len() {
145 let Some(ch) = value[next_pos..].chars().next() else {
146 break;
147 };
148 if !ch.is_whitespace() {
149 break;
150 }
151 next_pos += ch.len_utf8();
152 }
153 }
154
155 output.push_str(&value[pos..copy_end]);
156 output.push_str(replacement);
157 pos = next_pos;
158 replaced = true;
159 }
160
161 if !replaced {
162 return (value.to_string(), false);
163 }
164
165 output.push_str(&value[pos..]);
166 (output, true)
167}
168
169fn convert_staff_distance_to_interval(staff_dist: IntegerType) -> IntegerType {
170 match staff_dist.cmp(&0) {
171 Ordering::Equal => 1,
172 Ordering::Greater => staff_dist + 1,
173 Ordering::Less => staff_dist - 1,
174 }
175}
176
177fn notes_to_generic(p1: &Pitch, p2: &Pitch) -> Result<GenericInterval> {
178 let dnn1 = p1.step().step_to_dnn_offset() + (7 * p1.octave().unwrap_or(4));
179 let dnn2 = p2.step().step_to_dnn_offset() + (7 * p2.octave().unwrap_or(4));
180 let staff_dist = dnn2 - dnn1;
181 GenericInterval::from_int(convert_staff_distance_to_interval(staff_dist))
182}
183
184fn notes_to_chromatic(p1: &Pitch, p2: &Pitch) -> ChromaticInterval {
185 ChromaticInterval::new((p2.ps() - p1.ps()).round() as IntegerType)
186}
187
188fn specifier_from_generic_chromatic(
189 g_int: &GenericInterval,
190 c_int: &ChromaticInterval,
191) -> Result<Specifier> {
192 let note_vals: [IntegerType; 7] = [0, 2, 4, 5, 7, 9, 11];
193 let normal_semis = note_vals[(g_int.simple_undirected() - 1) as usize]
194 + 12 * g_int.simple_steps_and_octaves().1;
195
196 let c_direction = match c_int.semitones.cmp(&0) {
197 Ordering::Equal => direction::Direction::Oblique,
198 Ordering::Less => direction::Direction::Descending,
199 Ordering::Greater => direction::Direction::Ascending,
200 };
201
202 let these_semis = if g_int.direction() != c_direction
203 && g_int.direction() != direction::Direction::Oblique
204 && c_direction != direction::Direction::Oblique
205 {
206 -c_int.semitones.abs()
207 } else if g_int.undirected() == 1 {
208 c_int.semitones
209 } else {
210 c_int.semitones.abs()
211 };
212
213 let diff = these_semis - normal_semis;
214
215 if g_int.is_perfectable() {
216 match diff {
217 0 => Ok(Specifier::Perfect),
218 1 => Ok(Specifier::Augmented),
219 2 => Ok(Specifier::DoubleAugmented),
220 3 => Ok(Specifier::TripleAugmented),
221 4 => Ok(Specifier::QuadrupleAugmented),
222 -1 => Ok(Specifier::Diminished),
223 -2 => Ok(Specifier::DoubleDiminished),
224 -3 => Ok(Specifier::TripleDiminished),
225 -4 => Ok(Specifier::QuadrupleDiminished),
226 _ => Err(Error::Interval(format!(
227 "cannot get specifier from perfectable diff {diff}"
228 ))),
229 }
230 } else {
231 match diff {
232 0 => Ok(Specifier::Major),
233 -1 => Ok(Specifier::Minor),
234 1 => Ok(Specifier::Augmented),
235 2 => Ok(Specifier::DoubleAugmented),
236 3 => Ok(Specifier::TripleAugmented),
237 4 => Ok(Specifier::QuadrupleAugmented),
238 -2 => Ok(Specifier::Diminished),
239 -3 => Ok(Specifier::DoubleDiminished),
240 -4 => Ok(Specifier::TripleDiminished),
241 -5 => Ok(Specifier::QuadrupleDiminished),
242 _ => Err(Error::Interval(format!(
243 "cannot get specifier from major diff {diff}"
244 ))),
245 }
246 }
247}
248
249fn intervals_to_diatonic(
250 g_int: &GenericInterval,
251 c_int: &ChromaticInterval,
252) -> Result<DiatonicInterval> {
253 let specifier = specifier_from_generic_chromatic(g_int, c_int)?;
254 Ok(DiatonicInterval::new(specifier, g_int))
255}
256
257pub(crate) fn convert_semitone_to_specifier_generic(
258 count: IntegerType,
259) -> (Specifier, IntegerType) {
260 let dir_scale = if count < 0 { -1 } else { 1 };
261 let size = count.abs() % 12;
262 let octave = count.abs() / 12;
263 let (spec, generic) = match size {
264 0 => (Specifier::Perfect, 1),
265 1 => (Specifier::Minor, 2),
266 2 => (Specifier::Major, 2),
267 3 => (Specifier::Minor, 3),
268 4 => (Specifier::Major, 3),
269 5 => (Specifier::Perfect, 4),
270 6 => (Specifier::Diminished, 5),
271 7 => (Specifier::Perfect, 5),
272 8 => (Specifier::Minor, 6),
273 9 => (Specifier::Major, 6),
274 10 => (Specifier::Minor, 7),
275 _ => (Specifier::Major, 7),
276 };
277 (spec, (generic + octave * 7) * dir_scale)
278}
279
280impl Interval {
281 pub(crate) fn between(start: PitchOrNote, end: PitchOrNote) -> Result<Self> {
282 let start_pitch = extract_pitch(start);
283 let end_pitch = extract_pitch(end);
284 let generic = notes_to_generic(&start_pitch, &end_pitch)?;
285 let chromatic = notes_to_chromatic(&start_pitch, &end_pitch);
286 let diatonic = intervals_to_diatonic(&generic, &chromatic)?;
287
288 Ok(Self {
289 implicit_diatonic: false,
290 diatonic,
291 chromatic,
292 pitch_start: Some(start_pitch),
293 pitch_end: Some(end_pitch),
294 })
295 }
296
297 pub(crate) fn from_diatonic_and_chromatic(
298 diatonic: DiatonicInterval,
299 chromatic: ChromaticInterval,
300 ) -> Result<Interval> {
301 Ok(Self {
302 implicit_diatonic: false,
303 diatonic,
304 chromatic,
305 pitch_start: None,
306 pitch_end: None,
307 })
308 }
309
310 pub(crate) fn new(arg: IntervalArgument) -> Result<Interval> {
311 match arg {
312 IntervalArgument::Str(str) => {
313 let name = str;
314 let (diatonic_new, chromatic_new, inferred) = _string_to_diatonic_chromatic(name)?;
315 Ok(Self {
316 implicit_diatonic: inferred,
317 diatonic: diatonic_new,
318 chromatic: chromatic_new,
319 pitch_start: None,
320 pitch_end: None,
321 })
322 }
323 IntervalArgument::Int(int) => {
324 let chromatic = ChromaticInterval::new(int);
325 let diatonic = chromatic.get_diatonic();
326
327 Ok(Self {
328 implicit_diatonic: true,
329 diatonic,
330 chromatic,
331 pitch_start: None,
332 pitch_end: None,
333 })
334 }
335 }
336 }
337
338 pub fn from_name(name: impl Into<String>) -> Result<Self> {
340 Self::new(IntervalArgument::Str(name.into()))
341 }
342
343 pub fn from_semitones(semitones: IntegerType) -> Result<Self> {
345 Self::new(IntervalArgument::Int(semitones))
346 }
347
348 pub fn between_pitches(start: &Pitch, end: &Pitch) -> Result<Self> {
350 Self::between(
351 PitchOrNote::Pitch(start.clone()),
352 PitchOrNote::Pitch(end.clone()),
353 )
354 }
355
356 pub fn between_notes(start: &Note, end: &Note) -> Result<Self> {
358 Self::between(
359 PitchOrNote::Note(start.clone()),
360 PitchOrNote::Note(end.clone()),
361 )
362 }
363
364 pub fn semitones(&self) -> IntegerType {
366 self.chromatic.semitones
367 }
368
369 pub fn direction(&self) -> IntervalDirection {
371 public_direction(self.generic().direction())
372 }
373
374 pub fn name(&self) -> String {
376 self.nice_name()
377 }
378
379 pub fn generic_number(&self) -> IntegerType {
381 self.generic().simple_directed()
382 }
383
384 pub fn is_implicit_diatonic(&self) -> bool {
386 self.implicit_diatonic
387 }
388
389 pub fn inversion(&self) -> Result<Self> {
391 let direction = match self.direction() {
392 IntervalDirection::Oblique => 1,
393 direction => direction.as_int(),
394 };
395 let simple = self.generic().simple_undirected();
396 let inverted_generic = if simple == 1 { 1 } else { 9 - simple };
397 let generic = GenericInterval::from_int(inverted_generic * direction)?;
398 let diatonic = DiatonicInterval::new(self.diatonic.specifier.inversion(), &generic);
399 let chromatic = diatonic.get_chromatic()?;
400 Self::from_diatonic_and_chromatic(diatonic, chromatic)
401 }
402
403 pub fn reversed(&self) -> Result<Self> {
405 self.clone().reverse()
406 }
407
408 pub fn pythagorean_ratio(&self) -> Result<FractionType> {
413 interval_to_pythagorean_ratio(self.clone())
414 }
415
416 pub fn transpose_pitch(&self, pitch: &Pitch) -> Result<Pitch> {
418 self.transpose_pitch_with_options(pitch, false, Some(4))
419 }
420
421 pub fn transpose_note(&self, note: &Note) -> Result<Note> {
423 let mut out = note.clone();
424 out._pitch = self.transpose_pitch(¬e._pitch)?;
425 Ok(out)
426 }
427
428 pub(crate) fn generic(&self) -> &GenericInterval {
429 &self.diatonic.generic
430 }
431
432 pub(crate) fn nice_name(&self) -> String {
433 self.diatonic.nice_name()
434 }
435
436 pub(crate) fn semi_simple_nice_name(&self) -> String {
437 self.diatonic.semi_simple_nice_name()
438 }
439
440 pub(crate) fn transpose_pitch_with_options(
443 &self,
444 p: &Pitch,
445 reverse: bool,
446 max_accidental: Option<IntegerType>,
447 ) -> Result<Pitch> {
448 if reverse {
449 return self
450 .clone()
451 .reverse()?
452 .transpose_pitch_with_options(p, false, Some(4));
453 }
454 let max_accidental = max_accidental.unwrap_or(4);
455
456 if self.implicit_diatonic {
457 return self.chromatic.clone().transpose_pitch(p.clone());
458 }
459
460 let use_implicit_octave = p.octave().is_none();
461 let old_dnn = p.step().step_to_dnn_offset() + (7 * p.octave().unwrap_or(4));
462 let new_dnn = old_dnn + self.diatonic.generic.staff_distance();
463
464 let new_octave = (new_dnn - 1).div_euclid(7);
465 let step_number = (new_dnn - 1).rem_euclid(7);
466 let new_step = crate::stepname::StepName::try_from((step_number + 1) as u8)?;
467
468 let step_char = new_step.as_char();
469 let mut pitch2 = Pitch::from_name(format!("{step_char}{new_octave}"))?;
470
471 let mut half_steps_to_fix = self.chromatic.semitones as FloatType - (pitch2.ps() - p.ps());
472 while half_steps_to_fix >= 12.0 {
473 half_steps_to_fix -= 12.0;
474 pitch2.octave_setter(Some(pitch2.octave().unwrap_or(4) - 1));
475 }
476 while half_steps_to_fix <= -12.0 {
477 half_steps_to_fix += 12.0;
478 pitch2.octave_setter(Some(pitch2.octave().unwrap_or(4) + 1));
479 }
480
481 let rounded_fix = half_steps_to_fix.round() as IntegerType;
482 if half_steps_to_fix != 0.0 {
483 if rounded_fix.abs() > max_accidental {
484 pitch2.set_ps(pitch2.ps() + half_steps_to_fix);
485 } else {
486 let accidental = crate::pitch::accidental::Accidental::new(rounded_fix as i8)?;
487 let accidental_modifier = accidental.modifier().to_string();
488 pitch2 = Pitch::from_name(format!("{step_char}{accidental_modifier}{new_octave}"))?;
489 }
490 }
491
492 if use_implicit_octave {
493 pitch2.octave_setter(None);
494 }
495 Ok(pitch2)
496 }
497
498 pub fn transpose_pitch_in_place(&self, pitch: &mut Pitch) -> Result<()> {
500 *pitch = self.transpose_pitch(pitch)?;
501 Ok(())
502 }
503}
504
505impl FromStr for Interval {
506 type Err = Error;
507
508 fn from_str(value: &str) -> Result<Self> {
509 Self::from_name(value)
510 }
511}
512
513impl TryFrom<&str> for Interval {
514 type Error = Error;
515
516 fn try_from(value: &str) -> Result<Self> {
517 Self::from_name(value)
518 }
519}
520
521impl TryFrom<String> for Interval {
522 type Error = Error;
523
524 fn try_from(value: String) -> Result<Self> {
525 Self::from_name(value)
526 }
527}
528
529impl TryFrom<IntegerType> for Interval {
530 type Error = Error;
531
532 fn try_from(value: IntegerType) -> Result<Self> {
533 Self::from_semitones(value)
534 }
535}
536
537fn _string_to_diatonic_chromatic(
538 mut value: String,
539) -> Result<(DiatonicInterval, ChromaticInterval, bool)> {
540 let mut inferred = false;
541 let mut dir_scale = 1;
542
543 if value.contains('-') {
545 value = value.replace('-', "");
546 dir_scale = -1;
547 }
548 {
550 let (without_descending, found_descending) = strip_direction_word(&value, "descending");
551 if found_descending {
552 value = without_descending;
553 dir_scale = -1;
554 } else {
555 let (without_ascending, found_ascending) = strip_direction_word(&value, "ascending");
556 if found_ascending {
557 value = without_ascending;
558 }
559 }
560 }
561 let value_lower = value.to_lowercase();
562
563 if value_lower == "w" || value_lower == "whole" || value_lower == "tone" {
565 value = "M2".to_string();
566 inferred = true;
567 } else if value_lower == "h" || value_lower == "half" || value_lower == "semitone" {
568 value = "m2".to_string();
569 inferred = true;
570 }
571
572 for (i, ordinal) in MUSICAL_ORDINAL_STRINGS.iter().enumerate() {
574 let replacement = i.to_string();
575 let (next_value, replaced) = replace_music_ordinal(&value, ordinal, &replacement);
576 if replaced {
577 value = next_value;
578 }
579 }
580
581 let (found, remain) = get_num_from_str(&value, "0123456789");
583 let generic_number: IntegerType = found
584 .parse::<IntegerType>()
585 .map_err(|_| Error::Interval(format!("cannot read an interval number from {value:?}")))?
586 * dir_scale;
587 let spec = Specifier::parse(&remain)?;
588
589 let g_interval = GenericInterval::from_int(generic_number)?;
590 let d_interval = g_interval.get_diatonic(spec);
591 let c_interval = d_interval.get_chromatic()?;
592 Ok((d_interval, c_interval, inferred))
593}
594
595impl IntervalBaseTrait for Interval {
596 fn reverse(self) -> Result<Self>
597 where
598 Self: Sized,
599 {
600 if let (Some(start), Some(end)) = (self.pitch_start, self.pitch_end) {
601 Interval::between(PitchOrNote::Pitch(end), PitchOrNote::Pitch(start))
602 } else {
603 Interval::from_diatonic_and_chromatic(
604 self.diatonic.reverse()?,
605 self.chromatic.reverse()?,
606 )
607 }
608 }
609
610 fn transpose_pitch(self, pitch1: Pitch) -> Result<Pitch> {
611 Interval::transpose_pitch_with_options(&self, &pitch1, false, Some(4))
612 }
613}
614
615pub(crate) fn interval_to_pythagorean_ratio(interval: Interval) -> Result<FractionType> {
616 let start_pitch = Pitch::from_name("C1".to_string())?;
617
618 let end_pitch_wanted = interval.transpose_pitch_with_options(&start_pitch, false, Some(4))?;
619
620 let wanted_name = end_pitch_wanted.name();
621
622 let cached = {
626 let cache = match PYTHAGOREAN_CACHE.lock() {
627 Ok(cache) => cache,
628 Err(poisoned) => poisoned.into_inner(),
629 };
630 cache.get(&wanted_name).cloned()
631 };
632
633 if let Some((cached_pitch, cached_ratio)) = cached {
634 let octaves = (end_pitch_wanted.ps() - cached_pitch.ps()) / 12.0;
635 let octave_multiplier = FractionPow::<IntegerType>::powi(
636 &FractionType::new(2 as IntegerType, 1 as IntegerType),
637 octaves as IntegerType,
638 );
639 return Ok(cached_ratio * octave_multiplier);
640 }
641
642 let mut end_pitch_up = start_pitch.clone();
643 let mut end_pitch_down = start_pitch.clone();
644 let mut found: Option<(Pitch, FractionType)> = None;
645 let fifth_up: &Interval = &PERFECT_FIFTH_UP;
646 let fifth_down: &Interval = &PERFECT_FIFTH_DOWN;
647
648 for counter in 0..37 {
649 if end_pitch_up.name() == wanted_name {
650 if counter > 18 {
651 return Err(Error::Interval(format!(
652 "pythagorean ratio for {wanted_name} exceeds integer range"
653 )));
654 }
655 found = Some((
656 end_pitch_up.clone(),
657 FractionPow::<IntegerType>::powi(&FractionType::new(3i32, 2i32), counter),
658 ));
659 break;
660 } else if end_pitch_down.name() == wanted_name {
661 if counter > 18 {
662 return Err(Error::Interval(format!(
663 "pythagorean ratio for {wanted_name} exceeds integer range"
664 )));
665 }
666 found = Some((
667 end_pitch_down.clone(),
668 FractionPow::<IntegerType>::powi(&FractionType::new(2i32, 3i32), counter),
669 ));
670 break;
671 } else {
672 end_pitch_up = fifth_up.transpose_pitch_with_options(&end_pitch_up, false, Some(4))?;
673 end_pitch_down =
674 fifth_down.transpose_pitch_with_options(&end_pitch_down, false, Some(4))?;
675 }
676 }
677
678 let (found_pitch, found_ratio) = match found {
679 Some(val) => val,
680 None => {
681 return Err(Error::Interval(format!(
682 "Could not find a pythagorean ratio for {interval:?}"
683 )));
684 }
685 };
686
687 {
688 let mut cache = match PYTHAGOREAN_CACHE.lock() {
689 Ok(cache) => cache,
690 Err(poisoned) => poisoned.into_inner(),
691 };
692 cache.insert(wanted_name, (found_pitch.clone(), found_ratio));
693 }
694
695 let octaves = (end_pitch_wanted.ps() - found_pitch.ps()) / 12.0;
696 let octave_multiplier =
697 FractionPow::<IntegerType>::powi(&FractionType::new(2i32, 1i32), octaves as IntegerType);
698
699 Ok(found_ratio * octave_multiplier)
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705
706 fn pitch(name: &str) -> Pitch {
707 Pitch::from_name(name.to_string()).expect("valid pitch")
708 }
709
710 #[test]
711 fn malformed_interval_names_error_instead_of_panicking() {
712 for bad in ["", "X", "perfect", "?!", "MM"] {
716 assert!(
717 Interval::from_name(bad).is_err(),
718 "Interval::from_name({bad:?}) should be an error"
719 );
720 }
721 }
722
723 #[test]
724 fn interval_from_string_has_expected_chromatic() {
725 let interval = Interval::new(IntervalArgument::Str("M3".to_string())).unwrap();
726 assert_eq!(interval.chromatic.semitones, 4);
727 assert!(!interval.implicit_diatonic);
728 }
729
730 #[test]
731 fn interval_parser_accepts_direction_words_and_ordinals() {
732 let descending = Interval::from_name("Descending Perfect Twelfth").unwrap();
733 assert_eq!(descending.semitones(), -19);
734 assert_eq!(descending.generic_number(), -5);
735
736 let ascending = Interval::from_name("ascending Major Second").unwrap();
737 assert_eq!(ascending.semitones(), 2);
738 assert_eq!(ascending.generic_number(), 2);
739
740 let major_third = Interval::from_name("Major Third").unwrap();
741 assert_eq!(major_third.semitones(), 4);
742 assert_eq!(major_third.generic_number(), 3);
743 }
744
745 #[test]
746 fn interval_from_int_is_implicit_diatonic() {
747 let interval = Interval::new(IntervalArgument::Int(1)).unwrap();
748 assert!(interval.implicit_diatonic);
749 assert_eq!(interval.chromatic.semitones, 1);
750 }
751
752 #[test]
753 fn interval_between_pitches() {
754 let c4 = pitch("C4");
755 let g4 = pitch("G4");
756 let interval = Interval::between(PitchOrNote::Pitch(c4), PitchOrNote::Pitch(g4)).unwrap();
757 assert_eq!(interval.chromatic.semitones, 7);
758 assert_eq!(interval.generic().staff_distance(), 4);
759 }
760
761 #[test]
762 fn interval_transpose_pitch() {
763 let c4 = pitch("C4");
764 let m3 = Interval::new(IntervalArgument::Str("m3".to_string())).unwrap();
765 let out = m3.transpose_pitch(c4).unwrap();
766 assert_eq!(out.name_with_octave(), "E-4");
767 }
768
769 #[test]
770 fn interval_transpose_pitch_in_place() {
771 let mut c4 = pitch("C4");
772 Interval::from_name("M2")
773 .unwrap()
774 .transpose_pitch_in_place(&mut c4)
775 .unwrap();
776 assert_eq!(c4.name_with_octave(), "D4");
777 }
778
779 #[test]
780 fn interval_pythagorean_ratio() {
781 let ratio = Interval::from_name("P5")
782 .unwrap()
783 .pythagorean_ratio()
784 .unwrap();
785 assert_eq!(ratio, FractionType::new(3, 2));
786 }
787
788 #[test]
789 fn interval_inverts_oblique_unison() {
790 let unison = Interval::from_name("P1").unwrap();
791 let inverted = unison.inversion().unwrap();
792
793 assert_eq!(inverted.semitones(), 0);
794 assert_eq!(inverted.generic_number(), 1);
795 }
796 #[test]
797 fn specifier_case_matters_only_for_major_versus_minor() {
798 for (lower, upper) in [
801 ("p5", "P5"),
802 ("a2", "A2"),
803 ("d5", "D5"),
804 ("aa2", "AA2"),
805 ("dd5", "DD5"),
806 ("aaa2", "AAA2"),
807 ("ddd5", "DDD5"),
808 ] {
809 let a = Interval::from_name(lower).expect("lowercase parses");
810 let b = Interval::from_name(upper).expect("uppercase parses");
811 assert_eq!(a.semitones(), b.semitones(), "{lower} vs {upper}");
812 assert_eq!(a.name(), b.name(), "{lower} vs {upper}");
813 }
814
815 let minor = Interval::from_name("m3").expect("m3 parses");
817 let major = Interval::from_name("M3").expect("M3 parses");
818 assert_eq!(minor.semitones(), 3);
819 assert_eq!(major.semitones(), 4);
820 }
821
822 #[test]
823 fn an_unknown_specifier_errors_instead_of_panicking() {
824 for name in ["Q5", "x3", "5", "zz2"] {
825 assert!(
826 Interval::from_name(name).is_err(),
827 "{name:?} should be rejected, not panic"
828 );
829 }
830 }
831
832 #[test]
833 fn a_hyphen_anywhere_makes_an_interval_name_descending() {
834 for name in ["-M2", "M-2"] {
839 let interval = Interval::from_name(name).expect("descending name parses");
840 assert_eq!(interval.semitones(), -2, "{name}");
841 assert_eq!(interval.generic_number(), -2, "{name}");
842 }
843
844 for name in ["--M2", "-M-2"] {
848 let interval = Interval::from_name(name).expect("repeated hyphen parses");
849 assert_eq!(interval.semitones(), -2, "{name}");
850 }
851
852 assert_eq!(
854 Interval::from_name("d-5").expect("d-5 parses").semitones(),
855 -6
856 );
857 }
858}