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;
11use std::sync::LazyLock;
12
13/// Parsed once: inverting a chord walks this interval one pitch at a time.
14static OCTAVE_UP: LazyLock<Interval> =
15    LazyLock::new(|| Interval::from_name("P8").expect("P8 is a valid interval"));
16
17/// A parsed Roman numeral in a key.
18#[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    /// Parses a Roman numeral figure in a key.
100    ///
101    /// Supports ordinary figures such as `V7/V` and augmented-sixth figures
102    /// such as `It+6`, `Fr+6`, `Ger+6`, and `Sw+6`.
103    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    /// Returns the original figure.
150    pub fn figure(&self) -> &str {
151        &self.figure
152    }
153
154    /// Returns the one-based scale degree.
155    pub fn degree(&self) -> u8 {
156        self.degree
157    }
158
159    /// Returns the chromatic alteration of the scale degree in semitones.
160    ///
161    /// Negative values are flats and positive values are sharps, so `bII`
162    /// returns `-1` and `#iv` returns `1`.
163    pub fn accidental(&self) -> i8 {
164        self.accidental
165    }
166
167    /// Returns the inversion number, where root position is `0`.
168    pub fn inversion(&self) -> u8 {
169        self.inversion
170    }
171
172    /// Returns the secondary/applied target figure, if any.
173    pub fn secondary(&self) -> Option<&str> {
174        self.secondary.as_deref()
175    }
176
177    /// Returns the key context.
178    pub fn key(&self) -> &Key {
179        &self.key
180    }
181
182    /// Realizes the Roman numeral as a chord.
183    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    /// Performs functional Roman-numeral analysis in a key.
223    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    /// Performs Roman-numeral analysis using an explicit harmonic root.
232    ///
233    /// This is useful for pitch-class-set browser views where the caller has
234    /// already chosen a transposition root and does not want inversion or root
235    /// inference to pick a different chord member.
236    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
314/// Performs functional Roman-numeral analysis in a key.
315pub fn analyze_chord(chord: &Chord, key: Key) -> Result<Option<RomanNumeral>> {
316    RomanNumeral::analyze(chord, key)
317}
318
319/// Performs Roman-numeral analysis in a key using an explicit harmonic root.
320pub 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
399/// music21's `roman.figureShorthands`, mapping a full figured-bass string to
400/// the abbreviation musicians actually write.
401///
402/// This is the table the inversion logic used to approximate with
403/// `suffix.contains("64")`-style probes, which read `642` as a second inversion
404/// because `64` matched before `42` was ever tested. Normalizing through the
405/// real table removes that ordering hazard rather than reshuffling the probes.
406const 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
429/// Returns the shorthand for a figure, or the figure itself when it has none.
430fn 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    // Only the figured-bass digits decide the inversion; quality marks such as
440    // `o`, `+` and the half-diminished sign ride along in the suffix.
441    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(&degree_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        // Captured from music21's RomanNumeral(...).inversion(). `V642` is the
850        // case the old `contains("64")` probe got wrong: it matched `64` and
851        // reported a second inversion where music21 reports a third.
852        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}