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 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/// Direction of a directed interval.
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32pub enum IntervalDirection {
33    /// The end pitch is lower than the start pitch.
34    Descending = -1,
35    /// The interval is an oblique unison.
36    Oblique = 0,
37    /// The end pitch is higher than the start pitch.
38    Ascending = 1,
39}
40
41impl IntervalDirection {
42    /// Returns `-1`, `0`, or `1` for descending, oblique, or ascending.
43    pub fn as_int(self) -> IntegerType {
44        self as IntegerType
45    }
46
47    /// Returns a display label for the direction.
48    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)]
66/// A directed musical interval with diatonic spelling and chromatic size.
67pub 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    /// Parses an interval name such as `"M3"`, `"P5"`, or `"-m6"`.
330    pub fn from_name(name: impl Into<String>) -> Result<Self> {
331        Self::new(IntervalArgument::Str(name.into()))
332    }
333
334    /// Creates an implicit diatonic interval from a chromatic semitone count.
335    pub fn from_semitones(semitones: IntegerType) -> Result<Self> {
336        Self::new(IntervalArgument::Int(semitones))
337    }
338
339    /// Returns the directed interval from `start` to `end`.
340    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    /// Returns the directed interval from `start` to `end`.
348    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    /// Returns the directed chromatic size in semitones.
356    pub fn semitones(&self) -> IntegerType {
357        self.chromatic.semitones
358    }
359
360    /// Returns the directed interval direction.
361    pub fn direction(&self) -> IntervalDirection {
362        public_direction(self.generic().direction())
363    }
364
365    /// Returns the human-readable interval name, such as `"Major Third"`.
366    pub fn name(&self) -> String {
367        self.nice_name()
368    }
369
370    /// Returns the simple or compound generic interval number.
371    pub fn generic_number(&self) -> IntegerType {
372        self.generic().simple_directed()
373    }
374
375    /// Returns `true` when the interval was inferred from semitones only.
376    pub fn is_implicit_diatonic(&self) -> bool {
377        self.implicit_diatonic
378    }
379
380    /// Returns the complementary interval inversion.
381    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    /// Returns the same interval in the opposite direction.
395    pub fn reversed(&self) -> Result<Self> {
396        self.clone().reverse()
397    }
398
399    /// Returns the Pythagorean tuning ratio for this interval.
400    ///
401    /// The ratio is expressed as a rational fraction built from pure fifths,
402    /// matching the helper music21 uses for enharmonic scoring.
403    pub fn pythagorean_ratio(&self) -> Result<FractionType> {
404        interval_to_pythagorean_ratio(self.clone())
405    }
406
407    /// Transposes a pitch by this interval.
408    pub fn transpose_pitch(&self, pitch: &Pitch) -> Result<Pitch> {
409        self.clone()
410            .transpose_pitch_with_options(pitch, false, Some(4))
411    }
412
413    /// Transposes a note by this interval.
414    pub fn transpose_note(&self, note: &Note) -> Result<Note> {
415        let mut out = note.clone();
416        out._pitch = self.transpose_pitch(&note._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    /// reverse default is false
433    /// maxAccidental default is 4
434    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    /// Transposes a pitch in place by this interval.
510    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    // Check for '-' and remove them:
555    if value.contains('-') {
556        value = value.replace('-', "");
557        dir_scale = -1;
558    }
559    // Remove directional words:
560    {
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    // Handle whole/half abbreviations:
575    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    // Replace any music ordinal in the string with its index.
584    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    // Extract number and remaining spec:
593    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}