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