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> {
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 pub fn from_int(semitones: IntegerType) -> Self {
51 Self {
52 semitones: FloatType::from(semitones),
53 }
54 }
55
56 pub fn semitones(&self) -> FloatType {
58 self.semitones
59 }
60
61 pub fn directed(&self) -> FloatType {
63 self.semitones
64 }
65
66 pub fn undirected(&self) -> FloatType {
68 self.semitones.abs()
69 }
70
71 pub fn whole_semitones(&self) -> IntegerType {
75 self.semitones.round() as IntegerType
76 }
77
78 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 pub fn cents(&self) -> FloatType {
91 (self.semitones * 100.0 * 100_000.0).round() / 100_000.0
92 }
93
94 pub fn mod12(&self) -> IntegerType {
97 self.whole_semitones().rem_euclid(12)
98 }
99
100 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 pub fn simple_undirected(&self) -> IntegerType {
111 self.whole_semitones().abs() % 12
112 }
113
114 pub fn interval_class(&self) -> IntegerType {
117 let mod12 = self.mod12();
118 if mod12 > 6 { 12 - mod12 } else { mod12 }
119 }
120
121 pub fn is_chromatic_step(&self) -> bool {
123 self.undirected() == 1.0
124 }
125
126 pub fn is_step(&self) -> bool {
129 self.is_chromatic_step()
130 }
131
132 pub fn reverse(&self) -> Self {
134 Self {
135 semitones: -self.semitones,
136 }
137 }
138
139 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 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 #[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}