Skip to main content

acorde_core/model/
interval.rs

1use serde::{Deserialize, Serialize};
2
3use super::pitch::Pitch;
4
5#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
6pub enum IntervalQuality {
7    Perfect,
8    Major,
9    Minor,
10    Augmented,
11    Diminished,
12}
13
14/// Signed chromatic interval between two pitches.
15///
16/// `semitones > 0` = ascending, `< 0` = descending, `0` = unison.
17#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
18pub struct Interval {
19    semitones: i16,
20}
21
22impl Interval {
23    /// Interval from pitch `a` to pitch `b` (positive = ascending).
24    pub fn between(a: &Pitch, b: &Pitch) -> Self {
25        Self { semitones: b.to_midi() - a.to_midi() }
26    }
27
28    /// Raw signed semitone count.
29    pub fn semitones(&self) -> i16 { self.semitones }
30
31    /// Absolute (unsigned) semitone distance.
32    pub fn abs_semitones(&self) -> u16 { self.semitones.unsigned_abs() }
33
34    /// True when `b` was higher than `a` in [`Interval::between`].
35    pub fn is_ascending(&self) -> bool { self.semitones > 0 }
36
37    /// Interval number within an octave (1 = unison, 2 = second … 8 = octave).
38    ///
39    /// Compound intervals (> octave) return the simple equivalent (e.g. a ninth → 2).
40    pub fn simple_number(&self) -> u8 {
41        const TABLE: [u8; 12] = [1, 2, 2, 3, 3, 4, 4, 5, 6, 6, 7, 7];
42        TABLE[(self.semitones.unsigned_abs() as usize) % 12]
43    }
44
45    /// Quality of the simple interval.
46    ///
47    /// The tritone (6 semitones) is treated as Augmented (A4).
48    pub fn quality(&self) -> IntervalQuality {
49        match (self.semitones.unsigned_abs() as usize) % 12 {
50            0  => IntervalQuality::Perfect,
51            1  => IntervalQuality::Minor,
52            2  => IntervalQuality::Major,
53            3  => IntervalQuality::Minor,
54            4  => IntervalQuality::Major,
55            5  => IntervalQuality::Perfect,
56            6  => IntervalQuality::Augmented,
57            7  => IntervalQuality::Perfect,
58            8  => IntervalQuality::Minor,
59            9  => IntervalQuality::Major,
60            10 => IntervalQuality::Minor,
61            11 => IntervalQuality::Major,
62            _  => IntervalQuality::Perfect,
63        }
64    }
65
66    /// Human-readable label: `"P1"`, `"M3"`, `"P5"`, `"m7"`, `"A4"`, `"P8"` etc.
67    ///
68    /// Octave equivalents (12, 24, …) are shown as `"P8"`.
69    pub fn display(&self) -> String {
70        let abs = self.semitones.unsigned_abs() as usize;
71        let q = match self.quality() {
72            IntervalQuality::Perfect    => "P",
73            IntervalQuality::Major      => "M",
74            IntervalQuality::Minor      => "m",
75            IntervalQuality::Augmented  => "A",
76            IntervalQuality::Diminished => "d",
77        };
78        let n = if abs.is_multiple_of(12) && abs >= 12 { 8 } else { self.simple_number() };
79        format!("{}{}", q, n)
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use crate::{Pitch, Step};
87
88    #[test]
89    fn interval_unison_p1() {
90        let c4 = Pitch::new(Step::C, 4);
91        let iv = Interval::between(&c4, &c4);
92        assert_eq!(iv.semitones(), 0);
93        assert_eq!(iv.display(), "P1");
94    }
95
96    #[test]
97    fn interval_major_third_c4_e4() {
98        let a = Pitch::new(Step::C, 4);
99        let b = Pitch::new(Step::E, 4);
100        let iv = Interval::between(&a, &b);
101        assert_eq!(iv.semitones(), 4);
102        assert_eq!(iv.quality(), IntervalQuality::Major);
103        assert_eq!(iv.display(), "M3");
104        assert!(iv.is_ascending());
105    }
106
107    #[test]
108    fn interval_perfect_fifth_c4_g4() {
109        let a = Pitch::new(Step::C, 4);
110        let b = Pitch::new(Step::G, 4);
111        let iv = Interval::between(&a, &b);
112        assert_eq!(iv.semitones(), 7);
113        assert_eq!(iv.display(), "P5");
114    }
115
116    #[test]
117    fn interval_minor_seventh_descending() {
118        // A4 (MIDI 69) to B3 (MIDI 59): descending minor seventh (-10 semitones)
119        let a = Pitch::new(Step::A, 4);
120        let b = Pitch::new(Step::B, 3);
121        let iv = Interval::between(&a, &b);
122        assert_eq!(iv.semitones(), -10);
123        assert_eq!(iv.display(), "m7");
124        assert!(!iv.is_ascending());
125    }
126
127    #[test]
128    fn interval_tritone() {
129        let a = Pitch::new(Step::C, 4);
130        let b = Pitch::with_alter(Step::F, 4, 1); // F#4
131        let iv = Interval::between(&a, &b);
132        assert_eq!(iv.semitones(), 6);
133        assert_eq!(iv.quality(), IntervalQuality::Augmented);
134        assert_eq!(iv.display(), "A4");
135    }
136
137    #[test]
138    fn interval_octave() {
139        let a = Pitch::new(Step::C, 4);
140        let b = Pitch::new(Step::C, 5);
141        let iv = Interval::between(&a, &b);
142        assert_eq!(iv.semitones(), 12);
143        assert_eq!(iv.display(), "P8");
144    }
145
146    #[test]
147    fn interval_minor_second() {
148        let a = Pitch::new(Step::E, 4);
149        let b = Pitch::new(Step::F, 4);
150        let iv = Interval::between(&a, &b);
151        assert_eq!(iv.semitones(), 1);
152        assert_eq!(iv.display(), "m2");
153    }
154}