1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4pub enum Step {
5 C,
6 D,
7 E,
8 F,
9 G,
10 A,
11 B,
12}
13
14impl Step {
15 pub fn to_semitone(&self) -> u8 {
16 match self {
17 Step::C => 0,
18 Step::D => 2,
19 Step::E => 4,
20 Step::F => 5,
21 Step::G => 7,
22 Step::A => 9,
23 Step::B => 11,
24 }
25 }
26
27 pub fn from_char(c: char) -> Option<Self> {
28 match c.to_ascii_uppercase() {
29 'C' => Some(Step::C),
30 'D' => Some(Step::D),
31 'E' => Some(Step::E),
32 'F' => Some(Step::F),
33 'G' => Some(Step::G),
34 'A' => Some(Step::A),
35 'B' => Some(Step::B),
36 _ => None,
37 }
38 }
39
40 pub fn to_char(&self) -> char {
41 match self {
42 Step::C => 'C',
43 Step::D => 'D',
44 Step::E => 'E',
45 Step::F => 'F',
46 Step::G => 'G',
47 Step::A => 'A',
48 Step::B => 'B',
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct Pitch {
55 pub step: Step,
56 pub octave: i8,
57 pub alter: i8,
58 #[serde(default)]
60 pub microtone_cents: i16,
61}
62
63impl Pitch {
64 pub fn new(step: Step, octave: i8) -> Self {
65 Self {
66 step,
67 octave,
68 alter: 0,
69 microtone_cents: 0,
70 }
71 }
72
73 pub fn with_alter(step: Step, octave: i8, alter: i8) -> Self {
74 Self {
75 step,
76 octave,
77 alter,
78 microtone_cents: 0,
79 }
80 }
81
82 pub fn with_microtone(step: Step, octave: i8, alter: i8, microtone_cents: i16) -> Self {
83 Self {
84 step,
85 octave,
86 alter,
87 microtone_cents: microtone_cents.clamp(-99, 99),
88 }
89 }
90
91 pub fn try_with_microtone(
96 step: Step,
97 octave: i8,
98 alter: i8,
99 microtone_cents: i16,
100 ) -> Option<Self> {
101 (-99..=99).contains(µtone_cents).then_some(Self {
102 step,
103 octave,
104 alter,
105 microtone_cents,
106 })
107 }
108
109 pub fn to_midi(&self) -> i16 {
111 let semitone = self.step.to_semitone() as i16;
112 let base = (self.octave as i16 + 1) * 12;
113 (base as f32 + semitone as f32 + self.alter as f32 + self.microtone_cents as f32 / 100.0)
114 .round() as i16
115 }
116
117 pub fn to_midi_cents(&self) -> i32 {
122 let semitone = self.step.to_semitone() as i32;
123 ((self.octave as i32 + 1) * 12 + semitone + self.alter as i32) * 100
124 + self.microtone_cents as i32
125 }
126
127 pub fn from_midi(midi: u8, prefer_flat: bool) -> Pitch {
133 let pc = midi % 12;
134 let (step, alter): (Step, i8) = if prefer_flat {
135 match pc {
136 0 => (Step::C, 0),
137 1 => (Step::D, -1),
138 2 => (Step::D, 0),
139 3 => (Step::E, -1),
140 4 => (Step::E, 0),
141 5 => (Step::F, 0),
142 6 => (Step::G, -1),
143 7 => (Step::G, 0),
144 8 => (Step::A, -1),
145 9 => (Step::A, 0),
146 10 => (Step::B, -1),
147 11 => (Step::B, 0),
148 _ => (Step::C, 0),
149 }
150 } else {
151 match pc {
152 0 => (Step::C, 0),
153 1 => (Step::C, 1),
154 2 => (Step::D, 0),
155 3 => (Step::D, 1),
156 4 => (Step::E, 0),
157 5 => (Step::F, 0),
158 6 => (Step::F, 1),
159 7 => (Step::G, 0),
160 8 => (Step::G, 1),
161 9 => (Step::A, 0),
162 10 => (Step::A, 1),
163 11 => (Step::B, 0),
164 _ => (Step::C, 0),
165 }
166 };
167 let step_semitone = step.to_semitone() as i16 + alter as i16;
168 let octave = ((midi as i16 - step_semitone) / 12 - 1) as i8;
169 Pitch::with_alter(step, octave, alter)
170 }
171
172 pub fn to_scientific_name(&self) -> String {
174 let accidental = match self.alter.cmp(&0) {
175 std::cmp::Ordering::Greater => "#".repeat(self.alter as usize),
176 std::cmp::Ordering::Less => "b".repeat(self.alter.unsigned_abs() as usize),
177 std::cmp::Ordering::Equal => String::new(),
178 };
179 format!("{}{}{}", self.step.to_char(), accidental, self.octave)
180 }
181
182 pub fn next_enharmonic(&self) -> Pitch {
193 let target = (i16::from(self.octave) + 1) * 12
194 + i16::from(self.step.to_semitone())
195 + i16::from(self.alter);
196 let mut candidates = Vec::new();
197 for alter in [0i8, 1, -1, 2, -2] {
198 for step in [
199 Step::C,
200 Step::D,
201 Step::E,
202 Step::F,
203 Step::G,
204 Step::A,
205 Step::B,
206 ] {
207 let offset = target - i16::from(step.to_semitone()) - i16::from(alter);
208 if offset.rem_euclid(12) != 0 {
209 continue;
210 }
211 if let Ok(octave) = i8::try_from(offset / 12 - 1) {
212 candidates.push(Pitch::with_microtone(
213 step,
214 octave,
215 alter,
216 self.microtone_cents,
217 ));
218 }
219 }
220 }
221 let current = candidates.iter().position(|candidate| {
222 candidate.step == self.step
223 && candidate.octave == self.octave
224 && candidate.alter == self.alter
225 });
226 match current {
227 Some(index) => candidates[(index + 1) % candidates.len()].clone(),
228 None => candidates.first().cloned().unwrap_or_else(|| self.clone()),
229 }
230 }
231
232 pub fn respell(&self, prefer_flat: bool) -> Pitch {
233 let mut pitch = Pitch::from_midi(self.to_midi().clamp(0, 127) as u8, prefer_flat);
234 pitch.microtone_cents = self.microtone_cents;
235 pitch
236 }
237}
238
239impl std::str::FromStr for Pitch {
240 type Err = ();
241
242 fn from_str(s: &str) -> Result<Self, Self::Err> {
246 let mut chars = s.chars().peekable();
247 let step = Step::from_char(chars.next().ok_or(())?).ok_or(())?;
248 let mut alter: i8 = 0;
249 loop {
250 match chars.peek() {
251 Some('#') => {
252 alter = alter.checked_add(1).ok_or(())?;
253 chars.next();
254 }
255 Some('b') => {
256 alter = alter.checked_sub(1).ok_or(())?;
257 chars.next();
258 }
259 _ => break,
260 }
261 }
262 let octave: i8 = chars.collect::<String>().parse().map_err(|_| ())?;
263 Ok(Pitch::with_alter(step, octave, alter))
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 #[test]
270 fn next_enharmonic_cycles_spellings_of_one_semitone() {
271 use super::{Pitch, Step};
272 let c_sharp = Pitch::with_alter(Step::C, 4, 1);
273 let d_flat = c_sharp.next_enharmonic();
274 assert_eq!(d_flat, Pitch::with_alter(Step::D, 4, -1));
275 let b_double_sharp = d_flat.next_enharmonic();
276 assert_eq!(b_double_sharp, Pitch::with_alter(Step::B, 3, 2));
277 assert_eq!(b_double_sharp.next_enharmonic(), c_sharp);
278
279 let c = Pitch::new(Step::C, 4);
280 assert_eq!(c.next_enharmonic(), Pitch::with_alter(Step::B, 3, 1));
281 assert_eq!(
282 c.next_enharmonic().next_enharmonic(),
283 Pitch::with_alter(Step::D, 4, -2)
284 );
285 let g_sharp = Pitch::with_alter(Step::G, 4, 1);
287 assert_eq!(g_sharp.next_enharmonic().next_enharmonic(), g_sharp);
288 let quarter = Pitch::with_microtone(Step::F, 4, 1, 50);
289 let next = quarter.next_enharmonic();
290 assert_eq!(
291 (next.step.clone(), next.alter, next.microtone_cents),
292 (Step::G, -1, 50)
293 );
294 assert_eq!(next.to_midi_cents(), quarter.to_midi_cents());
295 }
296
297 use super::*;
298
299 #[test]
300 fn middle_c_midi() {
301 assert_eq!(Pitch::new(Step::C, 4).to_midi(), 60);
302 }
303
304 #[test]
305 fn a4_midi() {
306 assert_eq!(Pitch::new(Step::A, 4).to_midi(), 69);
307 }
308
309 #[test]
310 fn scientific_name_sharp() {
311 let p = Pitch::with_alter(Step::F, 5, 1);
312 assert_eq!(p.to_scientific_name(), "F#5");
313 }
314
315 #[test]
316 fn scientific_name_flat() {
317 let p = Pitch::with_alter(Step::B, 3, -1);
318 assert_eq!(p.to_scientific_name(), "Bb3");
319 }
320
321 #[test]
322 fn respell_natural_unchanged() {
323 let p = Pitch::new(Step::C, 4);
324 assert_eq!(p.respell(true), Pitch::new(Step::C, 4));
325 assert_eq!(p.respell(false), Pitch::new(Step::C, 4));
326 }
327
328 #[test]
329 fn respell_csharp_to_db() {
330 let p = Pitch::with_alter(Step::C, 4, 1); let flat = p.respell(true);
332 assert_eq!(flat.step, Step::D);
333 assert_eq!(flat.alter, -1);
334 assert_eq!(flat.octave, 4);
335 assert_eq!(flat.to_midi(), 61);
336 }
337
338 #[test]
339 fn respell_db_to_csharp() {
340 let p = Pitch::with_alter(Step::D, 4, -1); let sharp = p.respell(false);
342 assert_eq!(sharp.step, Step::C);
343 assert_eq!(sharp.alter, 1);
344 assert_eq!(sharp.octave, 4);
345 assert_eq!(sharp.to_midi(), 61);
346 }
347
348 #[test]
349 fn respell_bsharp_to_c_next_octave() {
350 let p = Pitch::with_alter(Step::B, 4, 1); let resolved = p.respell(true);
352 assert_eq!(resolved.step, Step::C);
353 assert_eq!(resolved.alter, 0);
354 assert_eq!(resolved.octave, 5);
355 assert_eq!(resolved.to_midi(), 72);
356 }
357
358 #[test]
359 fn respell_cb_to_b_prev_octave() {
360 let p = Pitch::with_alter(Step::C, 5, -1); let resolved = p.respell(false);
362 assert_eq!(resolved.step, Step::B);
363 assert_eq!(resolved.alter, 0);
364 assert_eq!(resolved.octave, 4);
365 assert_eq!(resolved.to_midi(), 71);
366 }
367
368 #[test]
369 fn from_midi_middle_c() {
370 let p = Pitch::from_midi(60, false);
371 assert_eq!(p.step, Step::C);
372 assert_eq!(p.octave, 4);
373 assert_eq!(p.alter, 0);
374 }
375
376 #[test]
377 fn from_midi_c_sharp_prefer_sharp() {
378 let p = Pitch::from_midi(61, false);
379 assert_eq!(p.step, Step::C);
380 assert_eq!(p.octave, 4);
381 assert_eq!(p.alter, 1);
382 }
383
384 #[test]
385 fn from_midi_d_flat_prefer_flat() {
386 let p = Pitch::from_midi(61, true);
387 assert_eq!(p.step, Step::D);
388 assert_eq!(p.octave, 4);
389 assert_eq!(p.alter, -1);
390 }
391
392 #[test]
393 fn from_midi_a4() {
394 let p = Pitch::from_midi(69, false);
395 assert_eq!(p.step, Step::A);
396 assert_eq!(p.octave, 4);
397 assert_eq!(p.alter, 0);
398 }
399
400 #[test]
401 fn from_midi_respell_roundtrip() {
402 for midi in 21u8..=108 {
403 for prefer_flat in [false, true] {
404 let p = Pitch::from_midi(midi, prefer_flat);
405 assert_eq!(
406 p.to_midi() as u8,
407 midi,
408 "from_midi({midi},{prefer_flat}) roundtrip failed: {:?}",
409 p
410 );
411 }
412 }
413 }
414
415 #[test]
416 fn from_str_c4() {
417 let p: Pitch = "C4".parse().unwrap();
418 assert_eq!(p, Pitch::new(Step::C, 4));
419 }
420
421 #[test]
422 fn from_str_fsharp5() {
423 let p: Pitch = "F#5".parse().unwrap();
424 assert_eq!(p, Pitch::with_alter(Step::F, 5, 1));
425 }
426
427 #[test]
428 fn from_str_bflat3() {
429 let p: Pitch = "Bb3".parse().unwrap();
430 assert_eq!(p.step, Step::B);
431 assert_eq!(p.alter, -1);
432 assert_eq!(p.octave, 3);
433 assert_eq!(p.to_midi(), 58);
434 }
435
436 #[test]
437 fn from_str_double_sharp() {
438 let p: Pitch = "C##4".parse().unwrap();
439 assert_eq!(p.step, Step::C);
440 assert_eq!(p.alter, 2);
441 assert_eq!(p.octave, 4);
442 }
443
444 #[test]
445 fn scientific_name_preserves_extended_accidentals() {
446 assert_eq!(
447 Pitch::with_alter(Step::C, 4, 3).to_scientific_name(),
448 "C###4"
449 );
450 assert_eq!(
451 Pitch::with_alter(Step::C, 4, -3).to_scientific_name(),
452 "Cbbb4"
453 );
454 }
455
456 #[test]
457 fn from_str_rejects_accidental_overflow() {
458 assert!(format!("C{}4", "#".repeat(128)).parse::<Pitch>().is_err());
459 assert!(format!("C{}4", "b".repeat(129)).parse::<Pitch>().is_err());
460 }
461
462 #[test]
463 fn from_str_invalid_step_returns_err() {
464 assert!("X4".parse::<Pitch>().is_err());
465 assert!("".parse::<Pitch>().is_err());
466 assert!("C".parse::<Pitch>().is_err()); }
468
469 #[test]
470 fn from_str_roundtrip() {
471 for midi in 21u8..=108 {
472 for prefer_flat in [false, true] {
473 let p = Pitch::from_midi(midi, prefer_flat);
474 let name = p.to_scientific_name();
475 let parsed: Pitch = name
476 .parse()
477 .unwrap_or_else(|_| panic!("parse failed for {:?}", name));
478 assert_eq!(
479 parsed.to_midi() as u8,
480 midi,
481 "roundtrip failed for {:?} (midi {})",
482 name,
483 midi
484 );
485 }
486 }
487 }
488
489 #[test]
490 fn respell_fsharp_to_gb() {
491 let p = Pitch::with_alter(Step::F, 4, 1); let flat = p.respell(true);
493 assert_eq!(flat.step, Step::G);
494 assert_eq!(flat.alter, -1);
495 assert_eq!(flat.to_midi(), 66);
496 }
497
498 #[test]
499 fn midi_cents_preserves_microtonal_boundary() {
500 let quarter_sharp = Pitch::with_microtone(Step::C, 4, 0, 50);
501 let quarter_flat = Pitch::with_microtone(Step::C, 4, 0, -50);
502 assert_eq!(quarter_sharp.to_midi_cents(), 6050);
503 assert_eq!(quarter_flat.to_midi_cents(), 5950);
504 assert_eq!(quarter_sharp.to_midi(), 61);
505 assert_eq!(quarter_flat.to_midi(), 60);
506 }
507
508 #[test]
509 fn try_with_microtone_rejects_without_clamping() {
510 assert_eq!(
511 Pitch::try_with_microtone(Step::C, 4, 0, -99)
512 .unwrap()
513 .microtone_cents,
514 -99
515 );
516 assert_eq!(
517 Pitch::try_with_microtone(Step::C, 4, 0, 99)
518 .unwrap()
519 .microtone_cents,
520 99
521 );
522 assert!(Pitch::try_with_microtone(Step::C, 4, 0, 100).is_none());
523 assert_eq!(
524 Pitch::with_microtone(Step::C, 4, 0, 100).microtone_cents,
525 99
526 );
527 }
528}