1use crate::{
2 chord::Chord,
3 chordsymbol::{ChordQuality, ChordSymbol},
4 defaults::IntegerType,
5 error::{Error, Result},
6 interval::Interval,
7 key::Key,
8 pitch::Pitch,
9};
10use std::fmt;
11use std::sync::LazyLock;
12
13static OCTAVE_UP: LazyLock<Interval> =
15 LazyLock::new(|| Interval::from_name("P8").expect("P8 is a valid interval"));
16
17#[derive(Clone, Debug)]
19pub struct RomanNumeral {
20 figure: String,
21 key: Key,
22 degree: u8,
23 accidental: i8,
24 inversion: u8,
25 seventh: bool,
26 quality: RomanQuality,
27 secondary: Option<String>,
28 kind: RomanKind,
29}
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32enum RomanKind {
33 Diatonic,
34 AugmentedSixth(AugmentedSixthKind),
35}
36
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38enum AugmentedSixthKind {
39 Italian,
40 French,
41 German,
42 Swiss,
43}
44
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46enum RomanQuality {
47 Major,
48 Minor,
49 Diminished,
50 HalfDiminished,
51 Augmented,
52}
53
54impl AugmentedSixthKind {
55 fn from_figure(figure: &str) -> Option<Self> {
56 match figure.trim() {
57 "It+6" | "It6" => Some(Self::Italian),
58 "Fr+6" | "Fr6" => Some(Self::French),
59 "Ger+6" | "Ger6" => Some(Self::German),
60 "Sw+6" | "Sw6" => Some(Self::Swiss),
61 _ => None,
62 }
63 }
64
65 fn from_common_name(name: &str) -> Option<Self> {
66 if name.contains("Italian augmented sixth chord") {
67 Some(Self::Italian)
68 } else if name.contains("French augmented sixth chord") {
69 Some(Self::French)
70 } else if name.contains("German augmented sixth chord") {
71 Some(Self::German)
72 } else if name.contains("Swiss augmented sixth chord") {
73 Some(Self::Swiss)
74 } else {
75 None
76 }
77 }
78
79 fn figure(self) -> &'static str {
80 match self {
81 Self::Italian => "It+6",
82 Self::French => "Fr+6",
83 Self::German => "Ger+6",
84 Self::Swiss => "Sw+6",
85 }
86 }
87
88 fn interval_names(self) -> Vec<&'static str> {
89 match self {
90 Self::Italian => vec!["P1", "M3", "a6"],
91 Self::French => vec!["P1", "M3", "a4", "a6"],
92 Self::German => vec!["P1", "M3", "P5", "a6"],
93 Self::Swiss => vec!["P1", "M3", "aa4", "a6"],
94 }
95 }
96}
97
98impl RomanNumeral {
99 pub fn new(figure: impl Into<String>, key: Key) -> Result<Self> {
104 let figure = figure.into();
105 let trimmed = figure.trim();
106 if trimmed.is_empty() {
107 return Err(Error::Chord("roman numeral cannot be empty".to_string()));
108 }
109
110 if let Some(kind) = AugmentedSixthKind::from_figure(trimmed) {
111 return Ok(Self {
112 figure: kind.figure().to_string(),
113 key,
114 degree: 6,
115 accidental: -1,
116 inversion: 0,
117 seventh: false,
118 quality: RomanQuality::Augmented,
119 secondary: None,
120 kind: RomanKind::AugmentedSixth(kind),
121 });
122 }
123
124 let (primary, secondary) = match trimmed.split_once('/') {
125 Some((primary, secondary)) => (primary, Some(secondary.to_string())),
126 None => (trimmed, None),
127 };
128
129 let (accidental, primary) = split_roman_accidental_prefix(primary);
130 let (roman, suffix) = split_roman_prefix(primary)?;
131 let degree = roman_degree(roman)?;
132 let quality = roman_quality(roman, suffix);
133 let inversion = parse_inversion(suffix);
134 let seventh = suffix_has_seventh(suffix);
135
136 Ok(Self {
137 figure: trimmed.to_string(),
138 key,
139 degree,
140 accidental,
141 inversion,
142 seventh,
143 quality,
144 secondary,
145 kind: RomanKind::Diatonic,
146 })
147 }
148
149 pub fn figure(&self) -> &str {
151 &self.figure
152 }
153
154 pub fn degree(&self) -> u8 {
156 self.degree
157 }
158
159 pub fn accidental(&self) -> i8 {
164 self.accidental
165 }
166
167 pub fn inversion(&self) -> u8 {
169 self.inversion
170 }
171
172 pub fn secondary(&self) -> Option<&str> {
174 self.secondary.as_deref()
175 }
176
177 pub fn key(&self) -> &Key {
179 &self.key
180 }
181
182 pub fn to_chord(&self) -> Result<Chord> {
184 if let RomanKind::AugmentedSixth(kind) = self.kind {
185 return self.augmented_sixth_chord(kind);
186 }
187
188 let effective_key = self.effective_key()?;
189 let mut root = effective_key.pitch_from_degree(self.degree as usize)?;
190 if self.accidental != 0 {
191 root =
192 Interval::from_semitones(self.accidental as IntegerType)?.transpose_pitch(&root)?;
193 }
194 let mut pitches = self
195 .interval_names()
196 .into_iter()
197 .map(|name| Interval::from_name(name)?.transpose_pitch(&root))
198 .collect::<Result<Vec<_>>>()?;
199
200 for _ in 0..self.inversion.min(pitches.len().saturating_sub(1) as u8) {
201 let pitch = pitches.remove(0);
202 let transposed = OCTAVE_UP.transpose_pitch(&pitch)?;
203 pitches.push(transposed);
204 }
205
206 Chord::new(pitches.as_slice())
207 }
208
209 fn augmented_sixth_chord(&self, kind: AugmentedSixthKind) -> Result<Chord> {
210 let mut lowered_sixth = self.key.pitch_from_degree(6)?;
211 if self.key.mode() != "minor" {
212 lowered_sixth = Interval::from_semitones(-1)?.transpose_pitch(&lowered_sixth)?;
213 }
214 let pitches = kind
215 .interval_names()
216 .into_iter()
217 .map(|name| Interval::from_name(name)?.transpose_pitch(&lowered_sixth))
218 .collect::<Result<Vec<_>>>()?;
219 Chord::new(pitches.as_slice())
220 }
221
222 pub fn analyze(chord: &Chord, key: Key) -> Result<Option<Self>> {
224 let Some(root_name) = chord.root_pitch_name() else {
225 return Ok(None);
226 };
227 let root = Pitch::from_name(normalize_pitch_name(&root_name))?;
228 Self::analyze_with_root(chord, key, &root)
229 }
230
231 pub fn analyze_with_root(chord: &Chord, key: Key, root: &Pitch) -> Result<Option<Self>> {
237 if let Some(kind) = augmented_sixth_kind_for_key(chord, &key)? {
238 return Self::new(kind.figure(), key).map(Some);
239 }
240
241 let root_pc = pitch_class(root);
242 let intervals = intervals_above_root(chord, root_pc);
243 if !intervals.contains(&0) {
244 return Ok(None);
245 }
246
247 let Some((degree, accidental)) = degree_for_root(&key, root)? else {
248 return Ok(None);
249 };
250
251 let symbol = chord
252 .chord_symbols_with_root(root_pc)?
253 .into_iter()
254 .find_map(|figure| ChordSymbol::parse(figure).ok());
255 let quality = symbol
256 .as_ref()
257 .map(symbol_quality)
258 .unwrap_or_else(|| quality_from_intervals(&intervals));
259
260 let figure = roman_figure(
261 degree,
262 accidental,
263 quality,
264 symbol.as_ref(),
265 &intervals,
266 roman_inversion(chord),
267 );
268
269 Self::new(figure, key).map(Some)
270 }
271
272 fn effective_key(&self) -> Result<Key> {
273 let Some(secondary) = &self.secondary else {
274 return Ok(self.key.clone());
275 };
276
277 let (accidental, secondary) = split_roman_accidental_prefix(secondary);
278 let (roman, _) = split_roman_prefix(secondary)?;
279 let degree = roman_degree(roman)?;
280 let mut tonic = self.key.pitch_from_degree(degree as usize)?;
281 if accidental != 0 {
282 tonic = Interval::from_semitones(accidental as IntegerType)?.transpose_pitch(&tonic)?;
283 }
284 let mode = if roman.chars().next().is_some_and(char::is_uppercase) {
285 "major"
286 } else {
287 "minor"
288 };
289 Key::from_tonic_mode(&tonic.name(), mode)
290 }
291
292 fn interval_names(&self) -> Vec<&'static str> {
293 match (self.quality, self.seventh) {
294 (RomanQuality::Major, false) => vec!["P1", "M3", "P5"],
295 (RomanQuality::Major, true) => vec!["P1", "M3", "P5", "m7"],
296 (RomanQuality::Minor, false) => vec!["P1", "m3", "P5"],
297 (RomanQuality::Minor, true) => vec!["P1", "m3", "P5", "m7"],
298 (RomanQuality::Diminished, false) => vec!["P1", "m3", "d5"],
299 (RomanQuality::Diminished, true) => vec!["P1", "m3", "d5", "d7"],
300 (RomanQuality::HalfDiminished, false) => vec!["P1", "m3", "d5"],
301 (RomanQuality::HalfDiminished, true) => vec!["P1", "m3", "d5", "m7"],
302 (RomanQuality::Augmented, false) => vec!["P1", "M3", "a5"],
303 (RomanQuality::Augmented, true) => vec!["P1", "M3", "a5", "m7"],
304 }
305 }
306}
307
308impl fmt::Display for RomanNumeral {
309 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
310 formatter.write_str(self.figure())
311 }
312}
313
314pub fn analyze_chord(chord: &Chord, key: Key) -> Result<Option<RomanNumeral>> {
316 RomanNumeral::analyze(chord, key)
317}
318
319pub fn analyze_chord_with_root(
321 chord: &Chord,
322 key: Key,
323 root: &Pitch,
324) -> Result<Option<RomanNumeral>> {
325 RomanNumeral::analyze_with_root(chord, key, root)
326}
327
328fn split_roman_accidental_prefix(value: &str) -> (i8, &str) {
329 let mut accidental = 0;
330 let mut end = 0;
331 for (idx, ch) in value.char_indices() {
332 match ch {
333 '#' => {
334 accidental += 1;
335 end = idx + ch.len_utf8();
336 }
337 'b' | '-' => {
338 accidental -= 1;
339 end = idx + ch.len_utf8();
340 }
341 _ => break,
342 }
343 }
344 (accidental, &value[end..])
345}
346
347fn split_roman_prefix(value: &str) -> Result<(&str, &str)> {
348 let end = value
349 .char_indices()
350 .find_map(|(idx, ch)| (!matches!(ch, 'I' | 'V' | 'X' | 'i' | 'v' | 'x')).then_some(idx))
351 .unwrap_or(value.len());
352
353 if end == 0 {
354 return Err(Error::Chord(format!("missing roman numeral in {value:?}")));
355 }
356
357 Ok((&value[..end], &value[end..]))
358}
359
360fn roman_degree(roman: &str) -> Result<u8> {
361 match roman.to_ascii_uppercase().as_str() {
362 "I" => Ok(1),
363 "II" => Ok(2),
364 "III" => Ok(3),
365 "IV" => Ok(4),
366 "V" => Ok(5),
367 "VI" => Ok(6),
368 "VII" => Ok(7),
369 _ => Err(Error::Chord(format!("unsupported roman numeral {roman:?}"))),
370 }
371}
372
373fn roman_quality(roman: &str, suffix: &str) -> RomanQuality {
374 let lower = suffix.to_ascii_lowercase();
375 if suffix.contains('\u{00f8}') || lower.contains("m7b5") {
376 RomanQuality::HalfDiminished
377 } else if lower.contains('o') || lower.contains("dim") {
378 RomanQuality::Diminished
379 } else if lower.contains('+') || lower.contains("aug") {
380 RomanQuality::Augmented
381 } else if roman.chars().next().is_some_and(char::is_lowercase) {
382 RomanQuality::Minor
383 } else {
384 RomanQuality::Major
385 }
386}
387
388fn suffix_has_seventh(suffix: &str) -> bool {
389 let suffix = strip_roman_addition_groups(suffix);
390 suffix.contains('7')
391 || suffix.contains('9')
392 || suffix.contains("11")
393 || suffix.contains("13")
394 || suffix.contains("65")
395 || suffix.contains("43")
396 || suffix.contains("42")
397}
398
399const FIGURE_SHORTHANDS: [(&str, &str); 20] = [
407 ("53", ""),
408 ("3", ""),
409 ("63", "6"),
410 ("753", "7"),
411 ("75", "7"),
412 ("73", "7"),
413 ("9753", "9"),
414 ("975", "9"),
415 ("953", "9"),
416 ("97", "9"),
417 ("95", "9"),
418 ("93", "9"),
419 ("653", "65"),
420 ("6b53", "6b5"),
421 ("643", "43"),
422 ("642", "42"),
423 ("bb7b5b3", "o7"),
424 ("b7b5b3", "\u{00f8}7"),
425 ("bb7b53", "o7"),
426 ("b7b53", "\u{00f8}7"),
427];
428
429fn normalize_figure(figure: &str) -> &str {
431 FIGURE_SHORTHANDS
432 .iter()
433 .find(|(full, _)| *full == figure)
434 .map_or(figure, |(_, short)| *short)
435}
436
437fn parse_inversion(suffix: &str) -> u8 {
438 let suffix = strip_roman_addition_groups(suffix);
439 let digits: String = suffix.chars().filter(char::is_ascii_digit).collect();
442
443 match normalize_figure(&digits) {
444 "42" => 3,
445 "43" | "64" => 2,
446 "65" | "6" => 1,
447 _ => 0,
448 }
449}
450
451fn strip_roman_addition_groups(suffix: &str) -> String {
452 let mut stripped = String::with_capacity(suffix.len());
453 let mut rest = suffix;
454 while let Some(index) = rest.find("add(") {
455 stripped.push_str(&rest[..index]);
456 let addition = &rest[index + 4..];
457 let Some(end) = addition.find(')') else {
458 rest = addition;
459 continue;
460 };
461 rest = &addition[end + 1..];
462 }
463 stripped.push_str(rest);
464 stripped
465}
466
467fn degree_for_root(key: &Key, root: &Pitch) -> Result<Option<(u8, i8)>> {
468 let root_pc = pitch_class(root);
469 let root_step = root.step();
470 let mut best: Option<(u8, i8, bool)> = None;
471
472 for degree in 1..=7 {
473 let degree_pitch = key.pitch_from_degree(degree)?;
474 let diff = ((root_pc as i16 - pitch_class(°ree_pitch) as i16).rem_euclid(12)) as u8;
475 let Some(accidental) = chromatic_diff_to_accidental(diff) else {
476 continue;
477 };
478 let same_step = degree_pitch.step() == root_step;
479
480 let replace = match best {
481 None => true,
482 Some((_, best_accidental, best_same_step)) => {
483 (same_step && !best_same_step)
484 || (same_step == best_same_step && accidental.abs() < best_accidental.abs())
485 }
486 };
487 if replace {
488 best = Some((degree as u8, accidental, same_step));
489 }
490 }
491
492 Ok(best.map(|(degree, accidental, _)| (degree, accidental)))
493}
494
495fn chromatic_diff_to_accidental(diff: u8) -> Option<i8> {
496 match diff {
497 0 => Some(0),
498 1 => Some(1),
499 2 => Some(2),
500 10 => Some(-2),
501 11 => Some(-1),
502 _ => None,
503 }
504}
505
506fn intervals_above_root(chord: &Chord, root_pc: u8) -> Vec<u8> {
507 let mut intervals = chord
508 .pitch_classes()
509 .into_iter()
510 .map(|pc| (pc + 12 - root_pc) % 12)
511 .collect::<Vec<_>>();
512 intervals.sort_unstable();
513 intervals.dedup();
514 intervals
515}
516
517fn augmented_sixth_kind_for_key(chord: &Chord, key: &Key) -> Result<Option<AugmentedSixthKind>> {
518 let kind = std::iter::once(chord.common_name())
519 .chain(chord.common_names())
520 .find_map(|name| AugmentedSixthKind::from_common_name(&name));
521 let Some(kind) = kind else {
522 return Ok(None);
523 };
524
525 let pitch_classes = chord.pitch_classes();
526 let tonic = key_degree_pitch_class(key, 1, 0)?;
527 let lowered_sixth_adjust = if key.mode() == "minor" { 0 } else { -1 };
528 let lowered_sixth = key_degree_pitch_class(key, 6, lowered_sixth_adjust)?;
529 let raised_fourth = key_degree_pitch_class(key, 4, 1)?;
530
531 if !pitch_classes.contains(&tonic)
532 || !pitch_classes.contains(&lowered_sixth)
533 || !pitch_classes.contains(&raised_fourth)
534 {
535 return Ok(None);
536 }
537
538 let required_extra = match kind {
539 AugmentedSixthKind::Italian => None,
540 AugmentedSixthKind::French => Some(key_degree_pitch_class(key, 2, 0)?),
541 AugmentedSixthKind::German => {
542 let lowered_third_adjust = if key.mode() == "minor" { 0 } else { -1 };
543 Some(key_degree_pitch_class(key, 3, lowered_third_adjust)?)
544 }
545 AugmentedSixthKind::Swiss => Some(key_degree_pitch_class(key, 2, 1)?),
546 };
547
548 if required_extra.is_some_and(|pitch_class| !pitch_classes.contains(&pitch_class)) {
549 return Ok(None);
550 }
551
552 Ok(Some(kind))
553}
554
555fn key_degree_pitch_class(key: &Key, degree: usize, semitones: IntegerType) -> Result<u8> {
556 let mut pitch = key.pitch_from_degree(degree)?;
557 if semitones != 0 {
558 pitch = Interval::from_semitones(semitones)?.transpose_pitch(&pitch)?;
559 }
560 Ok(pitch_class(&pitch))
561}
562
563fn roman_inversion(chord: &Chord) -> u8 {
564 if chord.pitches().iter().any(|pitch| pitch.octave().is_some()) {
565 chord.inversion().unwrap_or(0)
566 } else {
567 0
568 }
569}
570
571fn symbol_quality(symbol: &ChordSymbol) -> RomanQuality {
572 match symbol.quality() {
573 ChordQuality::Major
574 | ChordQuality::Dominant
575 | ChordQuality::Suspended2
576 | ChordQuality::Suspended4
577 | ChordQuality::Power => RomanQuality::Major,
578 ChordQuality::Minor => RomanQuality::Minor,
579 ChordQuality::Diminished => RomanQuality::Diminished,
580 ChordQuality::HalfDiminished => RomanQuality::HalfDiminished,
581 ChordQuality::Augmented => RomanQuality::Augmented,
582 }
583}
584
585fn quality_from_intervals(intervals: &[u8]) -> RomanQuality {
586 if intervals.contains(&3) && intervals.contains(&6) {
587 if intervals.contains(&10) {
588 RomanQuality::HalfDiminished
589 } else {
590 RomanQuality::Diminished
591 }
592 } else if intervals.contains(&4) && intervals.contains(&8) {
593 RomanQuality::Augmented
594 } else if intervals.contains(&3) && intervals.contains(&7) {
595 RomanQuality::Minor
596 } else {
597 RomanQuality::Major
598 }
599}
600
601fn roman_figure(
602 degree: u8,
603 accidental: i8,
604 quality: RomanQuality,
605 symbol: Option<&ChordSymbol>,
606 intervals: &[u8],
607 inversion: u8,
608) -> String {
609 let base = degree_to_roman(degree);
610 let prefix = roman_accidental_prefix(accidental);
611 let body = roman_body_for_quality(base, quality);
612 let suffix = functional_suffix(symbol, intervals, inversion, quality);
613 format!("{prefix}{body}{suffix}")
614}
615
616fn roman_accidental_prefix(accidental: i8) -> String {
617 match accidental.cmp(&0) {
618 std::cmp::Ordering::Less => "b".repeat(accidental.unsigned_abs() as usize),
619 std::cmp::Ordering::Equal => String::new(),
620 std::cmp::Ordering::Greater => "#".repeat(accidental as usize),
621 }
622}
623
624fn roman_body_for_quality(base: &str, quality: RomanQuality) -> String {
625 match quality {
626 RomanQuality::Major => base.to_string(),
627 RomanQuality::Minor => base.to_ascii_lowercase(),
628 RomanQuality::Diminished => format!("{}o", base.to_ascii_lowercase()),
629 RomanQuality::HalfDiminished => format!("{}\u{00f8}", base.to_ascii_lowercase()),
630 RomanQuality::Augmented => format!("{base}+"),
631 }
632}
633
634fn functional_suffix(
635 symbol: Option<&ChordSymbol>,
636 intervals: &[u8],
637 inversion: u8,
638 quality: RomanQuality,
639) -> String {
640 if let Some(symbol) = symbol
641 && needs_chord_symbol_suffix(symbol)
642 {
643 return chord_symbol_suffix_for_roman(symbol, quality);
644 }
645 figured_bass_suffix(intervals, inversion, quality)
646}
647
648fn needs_chord_symbol_suffix(symbol: &ChordSymbol) -> bool {
649 matches!(
650 symbol.quality(),
651 ChordQuality::Suspended2 | ChordQuality::Suspended4 | ChordQuality::Power
652 ) || !symbol.additions().is_empty()
653 || symbol.alterations().iter().any(|alteration| {
654 !(matches!(symbol.quality(), ChordQuality::HalfDiminished)
655 && alteration.degree() == 5
656 && alteration.semitones() == -1)
657 })
658 || symbol.extensions().iter().any(|degree| *degree != 7)
659 || chord_symbol_suffix(symbol).contains("maj7")
660}
661
662fn chord_symbol_suffix_for_roman(symbol: &ChordSymbol, quality: RomanQuality) -> String {
663 let suffix = chord_symbol_suffix(symbol);
664 let converted = match quality {
665 RomanQuality::Major => suffix.to_string(),
666 RomanQuality::Minor => suffix
667 .strip_prefix('m')
668 .filter(|rest| !rest.starts_with("aj"))
669 .unwrap_or(suffix)
670 .to_string(),
671 RomanQuality::Diminished => suffix.strip_prefix("dim").unwrap_or(suffix).to_string(),
672 RomanQuality::HalfDiminished => {
673 suffix.strip_prefix('m').unwrap_or(suffix).replace("b5", "")
674 }
675 RomanQuality::Augmented => suffix
676 .strip_prefix("aug")
677 .or_else(|| suffix.strip_prefix('+'))
678 .unwrap_or(suffix)
679 .to_string(),
680 };
681
682 if converted == "6" {
683 " add(13)".to_string()
684 } else {
685 converted
686 }
687}
688
689fn chord_symbol_suffix(symbol: &ChordSymbol) -> &str {
690 let body = symbol
691 .figure()
692 .split_once('/')
693 .map_or(symbol.figure(), |(body, _)| body);
694 let root_name = normalize_symbol_root_name(&symbol.root().name());
695 body.strip_prefix(&root_name).unwrap_or(body)
696}
697
698fn normalize_symbol_root_name(name: &str) -> String {
699 name.replace('-', "b")
700}
701
702fn figured_bass_suffix(intervals: &[u8], inversion: u8, quality: RomanQuality) -> String {
703 if has_seventh(intervals) {
704 let suffix = match inversion {
705 1 => "65",
706 2 => "43",
707 3 => "42",
708 _ => "7",
709 };
710 if matches!(quality, RomanQuality::Major) && intervals.contains(&11) {
711 format!("maj{suffix}")
712 } else {
713 suffix.to_string()
714 }
715 } else if has_triad_shape(intervals) {
716 match inversion {
717 1 => "6".to_string(),
718 2 => "64".to_string(),
719 _ => String::new(),
720 }
721 } else {
722 String::new()
723 }
724}
725
726fn has_seventh(intervals: &[u8]) -> bool {
727 intervals.contains(&10) || intervals.contains(&11) || intervals.contains(&9)
728}
729
730fn has_triad_shape(intervals: &[u8]) -> bool {
731 (intervals.contains(&3) || intervals.contains(&4))
732 && intervals.iter().any(|interval| matches!(interval, 6..=8))
733}
734
735fn degree_to_roman(degree: u8) -> &'static str {
736 match degree {
737 1 => "I",
738 2 => "II",
739 3 => "III",
740 4 => "IV",
741 5 => "V",
742 6 => "VI",
743 7 => "VII",
744 _ => "I",
745 }
746}
747
748fn normalize_pitch_name(name: &str) -> String {
749 let mut chars = name.chars();
750 let Some(first) = chars.next() else {
751 return String::new();
752 };
753 let mut normalized = first.to_string();
754 for ch in chars {
755 if ch == 'b' {
756 normalized.push('-');
757 } else {
758 normalized.push(ch);
759 }
760 }
761 normalized
762}
763
764fn pitch_class(pitch: &Pitch) -> u8 {
765 (pitch.ps().round() as IntegerType).rem_euclid(12) as u8
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771
772 #[test]
773 fn secondary_dominant_resolves_to_chord() {
774 let key = Key::from_tonic_mode("C", "major").unwrap();
775 let rn = RomanNumeral::new("V7/V", key).unwrap();
776 assert_eq!(rn.degree(), 5);
777 assert_eq!(rn.secondary(), Some("V"));
778 assert_eq!(
779 rn.to_chord().unwrap().pitched_common_name(),
780 "D-dominant seventh chord"
781 );
782 }
783
784 #[test]
785 fn analyzes_chord_in_key() {
786 let key = Key::from_tonic_mode("C", "major").unwrap();
787 let chord = Chord::new("G B D F").unwrap();
788 let rn = RomanNumeral::analyze(&chord, key).unwrap().unwrap();
789 assert_eq!(rn.figure(), "V7");
790 }
791
792 #[test]
793 fn analyzes_accidentals_inversions_and_half_diminished_quality() {
794 let key = Key::from_tonic_mode("C", "major").unwrap();
795
796 let neapolitan = Chord::new("D- F A-").unwrap();
797 let rn = RomanNumeral::analyze(&neapolitan, key.clone())
798 .unwrap()
799 .unwrap();
800 assert_eq!(rn.figure(), "bII");
801 assert_eq!(rn.degree(), 2);
802 assert_eq!(rn.accidental(), -1);
803
804 let first_inversion = Chord::new("E4 G4 C5").unwrap();
805 let rn = RomanNumeral::analyze(&first_inversion, key.clone())
806 .unwrap()
807 .unwrap();
808 assert_eq!(rn.figure(), "I6");
809
810 let leading_tone = Chord::new("B D F A").unwrap();
811 let rn = RomanNumeral::analyze(&leading_tone, key).unwrap().unwrap();
812 assert_eq!(rn.figure(), "vii\u{00f8}7");
813 }
814
815 #[test]
816 fn analyzes_with_explicit_root_for_browser_style_sets() {
817 let key = Key::from_tonic_mode("C", "major").unwrap();
818 let root = Pitch::from_name("C").unwrap();
819 let chord = Chord::new("C E G").unwrap();
820 let rn = RomanNumeral::analyze_with_root(&chord, key.clone(), &root)
821 .unwrap()
822 .unwrap();
823 assert_eq!(rn.figure(), "I");
824
825 let seventh = Chord::new("C E G B-").unwrap();
826 let rn = RomanNumeral::analyze_with_root(&seventh, key, &root)
827 .unwrap()
828 .unwrap();
829 assert_eq!(rn.figure(), "I7");
830 }
831
832 #[test]
833 fn analyzes_augmented_sixth_chords_functionally() {
834 let key = Key::from_tonic_mode("C", "minor").unwrap();
835 let root = Pitch::from_name("C").unwrap();
836 let french = Chord::new("C D F# A-").unwrap();
837 let rn = RomanNumeral::analyze_with_root(&french, key.clone(), &root)
838 .unwrap()
839 .unwrap();
840 assert_eq!(rn.figure(), "Fr+6");
841
842 let german = Chord::new("A- C E- F#").unwrap();
843 let rn = RomanNumeral::analyze(&german, key).unwrap().unwrap();
844 assert_eq!(rn.figure(), "Ger+6");
845 }
846
847 #[test]
848 fn figured_bass_shorthands_give_music21_inversions() {
849 let key = Key::from_tonic_mode("C", "major").unwrap();
853 for (figure, expected) in [
854 ("V", 0),
855 ("V6", 1),
856 ("V64", 2),
857 ("V7", 0),
858 ("V65", 1),
859 ("V43", 2),
860 ("V42", 3),
861 ("V642", 3),
862 ("V653", 1),
863 ("V643", 2),
864 ("V63", 1),
865 ("V53", 0),
866 ("V9", 0),
867 ] {
868 let numeral = RomanNumeral::new(figure, key.clone())
869 .unwrap_or_else(|err| panic!("{figure} should parse: {err}"));
870 assert_eq!(numeral.inversion(), expected, "{figure}");
871 }
872 }
873
874 #[test]
875 fn roman_numerals_parse_inversions_and_qualities() {
876 let key = Key::from_tonic_mode("C", "major").unwrap();
877 let first_inversion = RomanNumeral::new("I6", key.clone()).unwrap();
878 assert_eq!(first_inversion.inversion(), 1);
879 assert_eq!(
880 first_inversion
881 .to_chord()
882 .unwrap()
883 .pitches()
884 .into_iter()
885 .map(|pitch| pitch.name())
886 .collect::<Vec<_>>(),
887 vec!["E", "G", "C"]
888 );
889
890 let diminished = RomanNumeral::new("viio7", key.clone()).unwrap();
891 assert_eq!(diminished.degree(), 7);
892 assert!(
893 diminished
894 .to_chord()
895 .unwrap()
896 .common_name()
897 .contains("diminished")
898 );
899
900 let half_diminished = RomanNumeral::new("vii\u{00f8}7", key.clone()).unwrap();
901 assert_eq!(half_diminished.degree(), 7);
902 assert_eq!(half_diminished.accidental(), 0);
903 assert!(
904 half_diminished
905 .to_chord()
906 .unwrap()
907 .common_name()
908 .contains("half-diminished")
909 );
910
911 let borrowed = RomanNumeral::new("bII", key.clone()).unwrap();
912 assert_eq!(borrowed.degree(), 2);
913 assert_eq!(borrowed.accidental(), -1);
914
915 let added_thirteenth = RomanNumeral::new("I add(13)", key.clone()).unwrap();
916 assert_eq!(added_thirteenth.inversion(), 0);
917 assert_eq!(
918 added_thirteenth.to_chord().unwrap().common_name(),
919 "major triad"
920 );
921
922 let augmented = RomanNumeral::new("III+", key).unwrap();
923 assert_eq!(
924 augmented
925 .to_chord()
926 .unwrap()
927 .pitches()
928 .into_iter()
929 .map(|pitch| pitch.name())
930 .collect::<Vec<_>>(),
931 vec!["E", "G#", "B#"]
932 );
933 }
934
935 #[test]
936 fn roman_numerals_parse_augmented_sixth_figures() {
937 let key = Key::from_tonic_mode("C", "minor").unwrap();
938 let french = RomanNumeral::new("Fr+6", key).unwrap();
939 assert_eq!(french.degree(), 6);
940 assert_eq!(french.accidental(), -1);
941 assert_eq!(
942 french
943 .to_chord()
944 .unwrap()
945 .pitches()
946 .into_iter()
947 .map(|pitch| pitch.name())
948 .collect::<Vec<_>>(),
949 vec!["A-", "C", "D", "F#"]
950 );
951 }
952
953 #[test]
954 fn roman_numerals_report_invalid_figures_and_empty_analysis() {
955 let key = Key::from_tonic_mode("C", "major").unwrap();
956
957 assert!(RomanNumeral::new("", key.clone()).is_err());
958 assert!(RomanNumeral::new("Q", key.clone()).is_err());
959 assert!(
960 analyze_chord(&Chord::empty().unwrap(), key)
961 .unwrap()
962 .is_none()
963 );
964 }
965}