Skip to main content

music21_rs/
roman.rs

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/// A parsed Roman numeral in a key.
13#[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    /// Parses a Roman numeral figure in a key.
95    ///
96    /// Supports ordinary figures such as `V7/V` and augmented-sixth figures
97    /// such as `It+6`, `Fr+6`, `Ger+6`, and `Sw+6`.
98    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    /// Returns the original figure.
145    pub fn figure(&self) -> &str {
146        &self.figure
147    }
148
149    /// Returns the one-based scale degree.
150    pub fn degree(&self) -> u8 {
151        self.degree
152    }
153
154    /// Returns the chromatic alteration of the scale degree in semitones.
155    ///
156    /// Negative values are flats and positive values are sharps, so `bII`
157    /// returns `-1` and `#iv` returns `1`.
158    pub fn accidental(&self) -> i8 {
159        self.accidental
160    }
161
162    /// Returns the inversion number, where root position is `0`.
163    pub fn inversion(&self) -> u8 {
164        self.inversion
165    }
166
167    /// Returns the secondary/applied target figure, if any.
168    pub fn secondary(&self) -> Option<&str> {
169        self.secondary.as_deref()
170    }
171
172    /// Returns the key context.
173    pub fn key(&self) -> &Key {
174        &self.key
175    }
176
177    /// Realizes the Roman numeral as a chord.
178    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    /// Performs functional Roman-numeral analysis in a key.
218    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    /// Performs Roman-numeral analysis using an explicit harmonic root.
227    ///
228    /// This is useful for pitch-class-set browser views where the caller has
229    /// already chosen a transposition root and does not want inversion or root
230    /// inference to pick a different chord member.
231    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
309/// Performs functional Roman-numeral analysis in a key.
310pub fn analyze_chord(chord: &Chord, key: Key) -> Result<Option<RomanNumeral>> {
311    RomanNumeral::analyze(chord, key)
312}
313
314/// Performs Roman-numeral analysis in a key using an explicit harmonic root.
315pub 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(&degree_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}