Skip to main content

music21_rs/interval/
mod.rs

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/// Direction of a directed interval.
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37pub enum IntervalDirection {
38    /// The end pitch is lower than the start pitch.
39    Descending = -1,
40    /// The interval is an oblique unison.
41    Oblique = 0,
42    /// The end pitch is higher than the start pitch.
43    Ascending = 1,
44}
45
46impl IntervalDirection {
47    /// Returns `-1`, `0`, or `1` for descending, oblique, or ascending.
48    pub fn as_int(self) -> IntegerType {
49        self as IntegerType
50    }
51
52    /// Returns a display label for the direction.
53    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)]
71/// A directed musical interval with diatonic spelling and chromatic size.
72pub 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    /// Parses an interval name such as `"M3"`, `"P5"`, or `"-m6"`.
278    pub fn from_name(name: impl Into<String>) -> Result<Self> {
279        Self::new(IntervalArgument::Str(name.into()))
280    }
281
282    /// Creates an implicit diatonic interval from a chromatic semitone count.
283    pub fn from_semitones(semitones: IntegerType) -> Result<Self> {
284        Self::new(IntervalArgument::Int(semitones))
285    }
286
287    /// Returns the directed interval from `start` to `end`.
288    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    /// Returns the directed interval from `start` to `end`.
296    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    /// Returns the directed chromatic size in semitones.
304    pub fn semitones(&self) -> IntegerType {
305        self.chromatic.semitones
306    }
307
308    /// Returns the directed interval direction.
309    pub fn direction(&self) -> IntervalDirection {
310        public_direction(self.generic().direction())
311    }
312
313    /// Returns the human-readable interval name, such as `"Major Third"`.
314    pub fn name(&self) -> String {
315        self.nice_name()
316    }
317
318    /// Returns the simple or compound generic interval number.
319    pub fn generic_number(&self) -> IntegerType {
320        self.generic().simple_directed()
321    }
322
323    /// Returns `true` when the interval was inferred from semitones only.
324    pub fn is_implicit_diatonic(&self) -> bool {
325        self.implicit_diatonic
326    }
327
328    /// Returns the complementary interval inversion.
329    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    /// Returns the same interval in the opposite direction.
343    pub fn reversed(&self) -> Result<Self> {
344        self.clone().reverse()
345    }
346
347    /// Transposes a pitch by this interval.
348    pub fn transpose_pitch(&self, pitch: &Pitch) -> Result<Pitch> {
349        self.clone()
350            .transpose_pitch_with_options(pitch, false, Some(4))
351    }
352
353    /// Transposes a note by this interval.
354    pub fn transpose_note(&self, note: &Note) -> Result<Note> {
355        let mut out = note.clone();
356        out._pitch = self.transpose_pitch(&note._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    /// reverse default is false
373    /// maxAccidental default is 4
374    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    // Check for '-' and remove them:
501    if value.contains('-') {
502        value = value.replace('-', "");
503        dir_scale = -1;
504    }
505    // Remove directional words:
506    {
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    // Handle whole/half abbreviations:
521    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    // Replace any music ordinal in the string with its index.
530    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    // Extract number and remaining spec:
539    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, &note1._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}