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