music21_rs/interval/
chromaticinterval.rs1use 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
14const WHOLE_SEMITONE_TOLERANCE: FloatType = 1e-9;
18
19#[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 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 pub fn from_int(semitones: IntegerType) -> Self {
49 Self {
50 semitones: FloatType::from(semitones),
51 }
52 }
53
54 pub fn semitones(&self) -> FloatType {
56 self.semitones
57 }
58
59 pub fn directed(&self) -> FloatType {
61 self.semitones
62 }
63
64 pub fn undirected(&self) -> FloatType {
66 self.semitones.abs()
67 }
68
69 pub fn whole_semitones(&self) -> IntegerType {
73 self.semitones.round() as IntegerType
74 }
75
76 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 pub fn cents(&self) -> FloatType {
89 (self.semitones * 100.0 * 100_000.0).round() / 100_000.0
90 }
91
92 pub fn mod12(&self) -> IntegerType {
95 self.whole_semitones().rem_euclid(12)
96 }
97
98 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 pub fn simple_undirected(&self) -> IntegerType {
109 self.whole_semitones().abs() % 12
110 }
111
112 pub fn interval_class(&self) -> IntegerType {
115 let mod12 = self.mod12();
116 if mod12 > 6 { 12 - mod12 } else { mod12 }
117 }
118
119 pub fn is_chromatic_step(&self) -> bool {
121 self.undirected() == 1.0
122 }
123
124 pub fn is_step(&self) -> bool {
127 self.is_chromatic_step()
128 }
129
130 pub fn reverse(&self) -> Self {
132 Self {
133 semitones: -self.semitones,
134 }
135 }
136
137 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 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 #[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}