1use std::fmt;
18
19use crate::interval::Interval;
20use crate::pitch::Pitch;
21
22pub const EQUIVALENTS: [(i32, &str); 40] = [
25 (1, "C--"),
26 (2, "C-"),
27 (3, "C"),
28 (4, "C#"),
29 (5, "C##"),
30 (6, ""),
31 (7, "D--"),
32 (8, "D-"),
33 (9, "D"),
34 (10, "D#"),
35 (11, "D##"),
36 (12, ""),
37 (13, "E--"),
38 (14, "E-"),
39 (15, "E"),
40 (16, "E#"),
41 (17, "E##"),
42 (18, "F--"),
43 (19, "F-"),
44 (20, "F"),
45 (21, "F#"),
46 (22, "F##"),
47 (23, ""),
48 (24, "G--"),
49 (25, "G-"),
50 (26, "G"),
51 (27, "G#"),
52 (28, "G##"),
53 (29, ""),
54 (30, "A--"),
55 (31, "A-"),
56 (32, "A"),
57 (33, "A#"),
58 (34, "A##"),
59 (35, ""),
60 (36, "B--"),
61 (37, "B-"),
62 (38, "B"),
63 (39, "B#"),
64 (40, "B##"),
65];
66
67pub const INTERVALS: [(i32, &str); 25] = [
71 (0, "P1"),
72 (1, "A1"),
73 (4, "d2"),
74 (5, "m2"),
75 (6, "M2"),
76 (7, "A2"),
77 (10, "d3"),
78 (11, "m3"),
79 (12, "M3"),
80 (13, "A3"),
81 (16, "d4"),
82 (17, "P4"),
83 (18, "A4"),
84 (22, "d5"),
85 (23, "P5"),
86 (24, "A5"),
87 (27, "d6"),
88 (28, "m6"),
89 (29, "M6"),
90 (30, "A6"),
91 (33, "d7"),
92 (34, "m7"),
93 (35, "M7"),
94 (36, "A7"),
95 (39, "d8"),
96];
97
98#[derive(Clone, Debug, PartialEq, Eq)]
101pub struct Base40Error(String);
102
103impl Base40Error {
104 fn new(message: impl Into<String>) -> Self {
105 Self(message.into())
106 }
107
108 pub fn message(&self) -> &str {
110 &self.0
111 }
112}
113
114impl fmt::Display for Base40Error {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 f.write_str(&self.0)
117 }
118}
119
120impl std::error::Error for Base40Error {}
121
122pub fn equivalent(number: i32) -> Option<&'static str> {
125 EQUIVALENTS
126 .iter()
127 .find(|(known, _)| *known == number)
128 .map(|(_, name)| *name)
129}
130
131pub fn pitch_name(number: i32) -> Option<&'static str> {
134 equivalent((number - 1).rem_euclid(40) + 1).filter(|name| !name.is_empty())
135}
136
137pub fn representation(name: &str) -> Option<i32> {
140 EQUIVALENTS
141 .iter()
142 .find(|(_, known)| !known.is_empty() && *known == name)
143 .map(|(number, _)| *number)
144}
145
146pub fn from_pitch_name(name: &str, octave: i32) -> Option<i32> {
149 representation(name).map(|number| 40 * (octave - 1) + number)
150}
151
152pub fn octave_and_number(number: i32) -> (i32, i32) {
158 let octave = (f64::from(number - 1) / 40.0 + 1.0).trunc() as i32;
159 (octave, number - 40 * (octave - 1))
160}
161
162pub fn to_pitch(number: i32) -> Result<Pitch, Base40Error> {
168 let refused = || {
169 Base40Error::new(format!(
170 "Pitch name not assigned to this Base40 number {number}"
171 ))
172 };
173 let (octave, within) = octave_and_number(number);
174 let name = equivalent(within)
175 .filter(|name| !name.is_empty())
176 .ok_or_else(refused)?;
177 Pitch::from_name_and_octave(name, octave).map_err(|_| refused())
178}
179
180pub fn from_pitch(pitch: &Pitch) -> Result<i32, Base40Error> {
187 let number = pitch
188 .octave()
189 .and_then(|octave| from_pitch_name(&music21_name(pitch)?, octave));
190 number.ok_or_else(|| {
191 Base40Error::new(format!(
192 "Base40 cannot handle this pitch {}",
193 pitch.name_with_octave()
194 ))
195 })
196}
197
198fn music21_name(pitch: &Pitch) -> Option<String> {
201 let alter = pitch.alter();
202 let modifier = match alter {
203 -2.0 => "--",
204 -1.0 => "-",
205 0.0 => "",
206 1.0 => "#",
207 2.0 => "##",
208 _ => return None,
209 };
210 let step = pitch.name().chars().next()?;
211 Some(format!("{step}{modifier}"))
212}
213
214pub fn delta_to_interval(delta: i32) -> Result<Interval, Base40Error> {
221 let simple = delta.abs() % 40;
222 let name = INTERVALS
223 .iter()
224 .find(|(distance, _)| *distance == simple)
225 .map(|(_, name)| *name)
226 .ok_or_else(|| Base40Error::new(format!("Interval not handled by Base40 {simple}")))?;
227 let (specifier, number) = name.split_at(1);
228 let number: i32 = number.parse().unwrap_or(1);
229 let generic = number + 7 * (delta.abs() / 40);
230 let generic = if delta < 0 { -generic } else { generic };
231 Interval::from_name(format!("{specifier}{generic}"))
232 .map_err(|error| Base40Error::new(error.to_string()))
233}
234
235pub fn interval(from: i32, to: i32) -> Result<Interval, Base40Error> {
243 let first = pitch_name(from);
244 let second = pitch_name(to);
245 match (first, second) {
246 (None, None) => {
247 return Err(Base40Error::new(format!(
248 "Pitch name not assigned to these Base40 numbers {from} and {to} \
249 Interval does not exist"
250 )));
251 }
252 (None, _) => {
253 return Err(Base40Error::new(format!(
254 "Pitch name not assigned to this Base40 number {from} Interval does not exist"
255 )));
256 }
257 (_, None) => {
258 return Err(Base40Error::new(format!(
259 "Pitch name not assigned to this Base40 number {to} Interval does not exist"
260 )));
261 }
262 _ => {}
263 }
264 let delta = to - from;
265 let same_letter =
266 first.and_then(|name| name.chars().next()) == second.and_then(|name| name.chars().next());
267 if delta > 3 && same_letter {
268 return Err(Base40Error::new(format!(
269 "Base40 cannot compute interval between {from} and {to}."
270 )));
271 }
272 delta_to_interval(delta)
273}
274
275pub fn actual_interval(from: i32, to: i32) -> Result<Interval, Base40Error> {
283 let start = to_pitch(from)?;
284 let end = to_pitch(to)?;
285 Interval::between_pitches(&start, &end)
286 .map_err(|_| Base40Error::new("Unusual interval - Limitation of music21.interval"))
287}
288
289#[derive(Clone, Copy, Debug, PartialEq, Eq)]
291pub enum Direction {
292 Up,
294 Down,
296}
297
298pub fn quick_enharmonics(
303 name: &str,
304 direction: Direction,
305 double_accidentals: bool,
306) -> Vec<String> {
307 let Some(mut number) = representation(name) else {
308 return Vec::new();
309 };
310 let step = match direction {
311 Direction::Up => 4,
312 Direction::Down => -4,
313 };
314 let letter = name.chars().next();
315 let mut found: Vec<String> = Vec::new();
316 loop {
317 number = (number + step).rem_euclid(40);
318 if number == 0 {
319 number = 40;
320 }
321 let spelled = equivalent(number)
322 .filter(|spelled| double_accidentals || spelled.len() <= 2)
323 .unwrap_or("");
324 let first = spelled.chars().next();
325 if spelled.is_empty() || first == letter {
326 return found;
327 }
328 if found.iter().any(|known| known.chars().next() == first) {
329 return found;
330 }
331 found.push(spelled.to_string());
332 }
333}
334
335pub fn quick_enharmonic_string(name: &str, double_accidentals: bool) -> Vec<String> {
337 let mut found = quick_enharmonics(name, Direction::Down, double_accidentals);
338 found.extend(quick_enharmonics(name, Direction::Up, double_accidentals));
339 found
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn a_number_is_a_pitch_and_back() {
348 let lowest = to_pitch(1).unwrap();
349 assert_eq!((lowest.alter(), lowest.octave()), (-2.0, Some(1)));
350 assert_eq!(to_pitch(40).unwrap().name_with_octave(), "B##1");
351 assert_eq!(from_pitch(&Pitch::from_name("B-4").unwrap()).unwrap(), 157);
352 assert_eq!(to_pitch(186).unwrap().name_with_octave(), "G5");
353 assert_eq!(
354 to_pitch(23).unwrap_err().message(),
355 "Pitch name not assigned to this Base40 number 23"
356 );
357 let pitch = Pitch::from_name("C--5").unwrap();
358 assert_eq!(from_pitch(&pitch).unwrap(), 161);
359 let pitch = Pitch::from_name("F###4").unwrap();
360 assert_eq!(
361 from_pitch(&pitch).unwrap_err().message(),
362 "Base40 cannot handle this pitch F###4"
363 );
364 }
365
366 #[test]
367 fn the_octave_is_cut_toward_nought() {
368 assert_eq!(octave_and_number(0), (0, 40));
369 assert_eq!(octave_and_number(-40), (0, 0));
370 assert_eq!(octave_and_number(41), (2, 1));
371 }
372
373 #[test]
374 fn a_distance_is_an_interval_with_its_octaves() {
375 assert_eq!(delta_to_interval(-11).unwrap().directed_name(), "m-3");
376 assert_eq!(delta_to_interval(63).unwrap().directed_name(), "P12");
377 assert_eq!(
378 delta_to_interval(77).unwrap_err().message(),
379 "Interval not handled by Base40 37"
380 );
381 }
382
383 #[test]
384 fn an_interval_between_numbers_needs_both_named() {
385 assert_eq!(interval(163, 191).unwrap().directed_name(), "m6");
386 assert_eq!(interval(186, 174).unwrap().directed_name(), "M-3");
387 assert_eq!(
388 interval(1, 5).unwrap_err().message(),
389 "Base40 cannot compute interval between 1 and 5."
390 );
391 assert_eq!(
392 interval(12, 6).unwrap_err().message(),
393 "Pitch name not assigned to these Base40 numbers 12 and 6 Interval does not exist"
394 );
395 assert_eq!(actual_interval(1, 5).unwrap().directed_name(), "AAAA1");
396 }
397
398 #[test]
399 fn enharmonics_stop_where_a_letter_comes_round() {
400 assert_eq!(quick_enharmonics("C##", Direction::Up, true), ["D", "E--"]);
401 assert_eq!(quick_enharmonics("C##", Direction::Up, false), ["D"]);
402 assert_eq!(quick_enharmonics("C-", Direction::Down, true), ["B", "A##"]);
403 assert_eq!(quick_enharmonic_string("G", true), ["F##", "A--"]);
404 assert!(quick_enharmonics("C###", Direction::Up, true).is_empty());
405 }
406}