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