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