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