Skip to main content

music21_rs/interval/
chromaticinterval.rs

1//! The chromatic half of an interval: a signed semitone count, music21's
2//! `interval.ChromaticInterval`.
3
4use std::fmt;
5
6use crate::{
7    defaults::{FloatType, IntegerType},
8    error::{Error, Result},
9    pitch::Pitch,
10};
11
12use super::{diatonicinterval::DiatonicInterval, direction::Direction};
13
14/// How far from a whole number a semitone count may drift before it stops
15/// counting as that whole number, standing in for music21's
16/// `if semitones == int(semitones)`.
17const WHOLE_SEMITONE_TOLERANCE: FloatType = 1e-9;
18
19/// A signed number of semitones, fractional for microtonal intervals.
20#[derive(Clone, Debug, PartialEq)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22#[must_use]
23pub struct ChromaticInterval {
24    pub(crate) semitones: FloatType,
25}
26
27impl ChromaticInterval {
28    /// A chromatic interval of `semitones`, negative for descending and
29    /// fractional for a microtonal one: a quarter tone is `0.5`.
30    ///
31    /// A count that is not finite spells no interval. It used to spell a
32    /// perfect unison: what reads an interval off a count casts it first, and
33    /// a cast answers nought for anything it cannot represent.
34    pub fn new(semitones: FloatType) -> Result<Self> {
35        if !semitones.is_finite() {
36            return Err(Error::Interval(format!(
37                "a semitone count must be finite, got {semitones}"
38            )));
39        }
40        let rounded = semitones.round();
41        let semitones = if (semitones - rounded).abs() < WHOLE_SEMITONE_TOLERANCE {
42            rounded
43        } else {
44            semitones
45        };
46        Ok(Self { semitones })
47    }
48
49    /// A chromatic interval of a whole number of semitones.
50    pub fn from_int(semitones: IntegerType) -> Self {
51        Self {
52            semitones: FloatType::from(semitones),
53        }
54    }
55
56    /// The signed semitone count.
57    pub fn semitones(&self) -> FloatType {
58        self.semitones
59    }
60
61    /// The signed semitone count: music21's `directed`.
62    pub fn directed(&self) -> FloatType {
63        self.semitones
64    }
65
66    /// The semitone count without its sign.
67    pub fn undirected(&self) -> FloatType {
68        self.semitones.abs()
69    }
70
71    /// The semitone count rounded to a whole number, which is how music21
72    /// reads a microtonal interval wherever it needs a diatonic spelling or a
73    /// pitch class.
74    pub fn whole_semitones(&self) -> IntegerType {
75        self.semitones.round() as IntegerType
76    }
77
78    /// Ascending, descending, or oblique for no semitones at all.
79    pub fn direction(&self) -> Direction {
80        if self.semitones > 0.0 {
81            Direction::Ascending
82        } else if self.semitones < 0.0 {
83            Direction::Descending
84        } else {
85            Direction::Oblique
86        }
87    }
88
89    /// The size in cents, signed, so a quarter tone is `50.0`.
90    pub fn cents(&self) -> FloatType {
91        (self.semitones * 100.0 * 100_000.0).round() / 100_000.0
92    }
93
94    /// The semitones reduced to a pitch class, `0` to `11`, so a descending
95    /// major third is `8`.
96    pub fn mod12(&self) -> IntegerType {
97        self.whole_semitones().rem_euclid(12)
98    }
99
100    /// The semitones within one octave, keeping the sign.
101    pub fn simple_directed(&self) -> IntegerType {
102        if self.direction() == Direction::Descending {
103            -self.simple_undirected()
104        } else {
105            self.simple_undirected()
106        }
107    }
108
109    /// The semitones within one octave, without the sign.
110    pub fn simple_undirected(&self) -> IntegerType {
111        self.whole_semitones().abs() % 12
112    }
113
114    /// The interval class, `0` to `6`: the smaller of the pitch-class
115    /// distance and its complement.
116    pub fn interval_class(&self) -> IntegerType {
117        let mod12 = self.mod12();
118        if mod12 > 6 { 12 - mod12 } else { mod12 }
119    }
120
121    /// Whether the interval is one semitone either way.
122    pub fn is_chromatic_step(&self) -> bool {
123        self.undirected() == 1.0
124    }
125
126    /// The same as [`Self::is_chromatic_step`], as music21 defines `isStep`
127    /// on a chromatic interval.
128    pub fn is_step(&self) -> bool {
129        self.is_chromatic_step()
130    }
131
132    /// The same interval in the other direction.
133    pub fn reverse(&self) -> Self {
134        Self {
135            semitones: -self.semitones,
136        }
137    }
138
139    /// The simplest diatonic spelling of the semitone count, as music21's
140    /// `getDiatonic` reads it: six semitones are a diminished fifth, one a
141    /// minor second, and a microtonal count is rounded to the nearest of
142    /// those.
143    pub fn get_diatonic(&self) -> DiatonicInterval {
144        let (specifier, generic) = super::convert_semitone_to_specifier_generic(self.semitones);
145        let generic = super::GenericInterval::from_int(generic)
146            .unwrap_or_else(|_| super::GenericInterval::from_int(1).expect("P1 is valid"));
147        DiatonicInterval::new(specifier, &generic)
148    }
149
150    /// Moves a pitch by the semitone count, respelling it from its new pitch
151    /// space the way music21's chromatic `transposePitch` does. A pitch
152    /// without an octave keeps having none.
153    pub fn transpose_pitch(&self, pitch: &Pitch) -> Result<Pitch> {
154        let mut p_out = pitch.clone();
155        p_out.set_ps(pitch.ps() + self.semitones);
156        if pitch.octave().is_none() {
157            p_out.octave_setter(None);
158        }
159        Ok(p_out)
160    }
161}
162
163impl fmt::Display for ChromaticInterval {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        if self.semitones == self.semitones.round() {
166            write!(f, "{}", self.whole_semitones())
167        } else {
168            write!(f, "{}", self.semitones)
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    /// A count that is not a number is not a distance. Left to the cast that
176    /// reads an interval off it, every one of these came back a unison.
177    #[test]
178    fn a_semitone_count_that_is_not_finite_is_refused() {
179        use super::ChromaticInterval;
180        use crate::interval::notes_to_chromatic;
181        use crate::pitch::Pitch;
182
183        for count in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
184            assert!(ChromaticInterval::new(count).is_err());
185        }
186        assert_eq!(ChromaticInterval::new(7.0).unwrap().semitones(), 7.0);
187
188        let c4 = Pitch::from_name("C4").unwrap();
189        let g4 = Pitch::from_name("G4").unwrap();
190        assert_eq!(notes_to_chromatic(&c4, &g4).unwrap().semitones(), 7.0);
191    }
192
193    use super::*;
194
195    #[test]
196    fn a_chromatic_interval_names_its_diatonic_reading() {
197        assert_eq!(ChromaticInterval::from_int(4).get_diatonic().name(), "M3");
198        assert_eq!(ChromaticInterval::from_int(6).get_diatonic().name(), "d5");
199        assert_eq!(
200            ChromaticInterval::from_int(-7)
201                .get_diatonic()
202                .directed_name(),
203            "P-5"
204        );
205    }
206
207    fn pitch(name: &str) -> Pitch {
208        Pitch::from_name(name).expect("valid pitch")
209    }
210
211    #[test]
212    fn chromatic_get_diatonic_roundtrip() {
213        let chromatic = ChromaticInterval::new(6.0).unwrap();
214        let diatonic = chromatic.get_diatonic();
215        let roundtrip = diatonic.get_chromatic().unwrap();
216        assert_eq!(roundtrip.semitones, 6.0);
217    }
218
219    #[test]
220    fn chromatic_transpose_pitch() {
221        let c4 = pitch("C4");
222        let out = ChromaticInterval::new(7.0)
223            .unwrap()
224            .transpose_pitch(&c4)
225            .unwrap();
226        assert_eq!(out.name_with_octave(), "G4");
227    }
228
229    #[test]
230    fn chromatic_reverse() {
231        let reversed = ChromaticInterval::new(-4.0).unwrap().reverse();
232        assert_eq!(reversed.semitones, 4.0);
233    }
234
235    #[test]
236    fn chromatic_measures_match_music21() {
237        let descending_third = ChromaticInterval::new(-4.0).unwrap();
238        assert_eq!(descending_third.directed(), -4.0);
239        assert_eq!(descending_third.undirected(), 4.0);
240        assert_eq!(descending_third.direction(), Direction::Descending);
241        assert_eq!(descending_third.mod12(), 8);
242        assert_eq!(descending_third.simple_directed(), -4);
243        assert_eq!(descending_third.simple_undirected(), 4);
244        assert_eq!(descending_third.interval_class(), 4);
245        assert_eq!(descending_third.cents(), -400.0);
246        assert!(ChromaticInterval::new(1.0).unwrap().is_chromatic_step());
247        assert!(!ChromaticInterval::new(13.0).unwrap().is_step());
248        assert_eq!(
249            ChromaticInterval::new(0.0).unwrap().direction(),
250            Direction::Oblique
251        );
252        assert_eq!(ChromaticInterval::new(14.0).unwrap().simple_undirected(), 2);
253        assert_eq!(ChromaticInterval::new(-4.0).unwrap().to_string(), "-4");
254    }
255
256    #[test]
257    fn chromatic_keeps_a_fractional_semitone_count() {
258        let quarter_tone = ChromaticInterval::new(0.5).unwrap();
259        assert_eq!(quarter_tone.semitones(), 0.5);
260        assert_eq!(quarter_tone.cents(), 50.0);
261        assert_eq!(quarter_tone.direction(), Direction::Ascending);
262        assert_eq!(quarter_tone.get_diatonic().name(), "P1");
263        assert_eq!(ChromaticInterval::new(0.1).unwrap().cents(), 10.0);
264        assert_eq!(
265            ChromaticInterval::new(0.4).unwrap().get_diatonic().name(),
266            "P1"
267        );
268        assert_eq!(
269            ChromaticInterval::new(0.6).unwrap().get_diatonic().name(),
270            "m2"
271        );
272        assert_eq!(
273            ChromaticInterval::new(-0.5).unwrap().direction(),
274            Direction::Descending
275        );
276        assert_eq!(quarter_tone.to_string(), "0.5");
277    }
278
279    #[test]
280    fn chromatic_rounds_a_fractional_count_where_music21_rounds() {
281        let three_quarters = ChromaticInterval::new(2.75).unwrap();
282        assert_eq!(three_quarters.whole_semitones(), 3);
283        assert_eq!(three_quarters.mod12(), 3);
284        assert_eq!(three_quarters.simple_undirected(), 3);
285        assert_eq!(three_quarters.interval_class(), 3);
286        assert!(!three_quarters.is_chromatic_step());
287    }
288
289    #[test]
290    fn chromatic_transposes_a_pitch_by_a_quarter_tone() {
291        let out = ChromaticInterval::new(0.5)
292            .unwrap()
293            .transpose_pitch(&pitch("C4"))
294            .unwrap();
295        assert_eq!(out.ps(), 60.5);
296    }
297}