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