Skip to main content

music21_rs/roman/
analysis.rs

1//! Naming a chord as a numeral in a key, and the figure tuples music21
2//! reads a chord's pitches as above its bass.
3
4use super::*;
5
6/// The figures music21 folds into a plain seventh-chord figure when the
7/// chord is a minor seventh, keyed by the figure `post_figure` wrote.
8const MINOR_SEVENTH_SUBS: [(&str, &str); 4] = [
9    ("b75b3", "7"),
10    ("6b5", "65"),
11    ("b64b3", "43"),
12    ("6b42", "42"),
13];
14
15/// The same for a minor-major seventh, which carries its raised seventh in
16/// brackets.
17const MINOR_MAJOR_SEVENTH_SUBS: [(&str, &str); 8] = [
18    ("75b3", "7[#7]"),
19    ("65", "65[#7]"),
20    ("b643", "43[#7]"),
21    ("6b42", "42[#7]"),
22    ("#753", "#7"),
23    ("6#53", "65[#7]"),
24    ("64#3", "43[#7]"),
25    ("42", "42[#7]"),
26];
27
28/// The numerals an augmented sixth reads as in a key, and the name each is
29/// written under instead.
30const AUG6_SUBS: [(&str, &str); 27] = [
31    ("#ivo6b3", "It6"),
32    ("#ivob64", "It64"),
33    ("#ivobb64", "It64"),
34    ("#ivob5b3", "It53"),
35    ("#ivob5bb3", "It53"),
36    ("IIø#643", "Fr43"),
37    ("IIø75#3", "Fr7"),
38    ("IIø7b5#3", "Fr7"),
39    ("IIø6#42", "Fr42"),
40    ("IIøb6#42", "Fr42"),
41    ("IIø65", "Fr65"),
42    ("IIø65b3", "Fr65"),
43    ("#ii64b3", "Sw43"),
44    ("#iiø7", "Sw7"),
45    ("#iib7bb53", "Sw7"),
46    ("#iib642", "Sw42"),
47    ("#iibb642", "Sw42"),
48    ("#ii6b5b3", "Sw65"),
49    ("#ii6b5bb3", "Sw65"),
50    ("#ivo6b5b3", "Ger65"),
51    ("#ivo6bb5b3", "Ger65"),
52    ("#ivob64b3", "Ger43"),
53    ("#ivobb64bb3", "Ger43"),
54    ("#ivob6b42", "Ger42"),
55    ("#ivob6bb42", "Ger42"),
56    ("#ivø7", "Ger7"),
57    ("#ivobb7b5bb3", "Ger7"),
58];
59
60/// The same where no key was given and the chord's root stood in for one.
61const AUG6_NO_KEY_SUBS: [(&str, &str); 14] = [
62    ("io6b3", "It6"),
63    ("iob64", "It64"),
64    ("iob5b3", "It53"),
65    ("Iø64b3", "Fr43"),
66    ("Iøb7b53", "Fr7"),
67    ("Iøb642", "Fr42"),
68    ("Iø6b5b3", "Fr65"),
69    ("i64b3", "Sw43"),
70    ("ib7bb53", "Sw7"),
71    ("ibb642", "Sw42"),
72    ("i6b5bb3", "Sw65"),
73    ("io6b5b3", "Ger65"),
74    ("iob64b3", "Ger43"),
75    ("iob6b42", "Ger42"),
76];
77
78fn substitute(table: &[(&str, &'static str)], figure: &str) -> Option<&'static str> {
79    table
80        .iter()
81        .find(|(from, _)| *from == figure)
82        .map(|(_, to)| *to)
83}
84
85/// music21's `romanNumeralFromChord`: the numeral a chord is in a key,
86/// written the way music21 writes it. With no key the chord's root is the
87/// key, major where the chord has a major third and minor otherwise. The
88/// figure is read off the chord's pitches above its bass, so a chord written
89/// without octaves sounds in octave 4 and `G B D F` in C major is `V43`.
90/// An empty chord is no numeral at all.
91///
92/// [`RomanNumeral::analyze`] is the crate's own reading, which names the
93/// chord's quality rather than every altered degree; this one says what
94/// music21 says, `io5b3` and all.
95pub fn roman_numeral_from_chord(chord: &Chord, key: Option<&Key>) -> Result<Option<RomanNumeral>> {
96    let Some(root) = chord.root() else {
97        return Ok(None);
98    };
99    let chord_has_major_third = chord.semitones_from_chord_step(3) == Some(4);
100    let no_key_given = key.is_none();
101    let mut key = match key {
102        Some(key) => key.clone(),
103        None => {
104            let mode = if chord_has_major_third {
105                "major"
106            } else {
107                "minor"
108            };
109            Key::from_tonic_mode(&root.name(), mode)?
110        }
111    };
112
113    let figure = FigureTuple::from_pitch_and_reference(root, &key, &key.tonic())?;
114    let figure = correct_rn_alteration_for_minor(&figure, &key, chord_has_major_third);
115    let tonic = if figure.alter == 0.0 {
116        key.tonic()
117    } else {
118        Interval::from_generic_and_chromatic(1, figure.alter as IntegerType)?
119            .transpose_pitch(&key.tonic())?
120    };
121    let altered_key = Key::from_tonic_mode(&tonic.name(), key.mode())?;
122
123    let mut step_roman = degree_to_roman(figure.deg_from_ref_pitch).to_string();
124    if !chord_has_major_third {
125        step_roman = step_roman.to_lowercase();
126    }
127    let inversion_string = post_figure(chord, &altered_key)?;
128    let mut rn_string = format!("{}{step_roman}{inversion_string}", figure.prefix);
129
130    let minor_seventh = || chord.is_seventh_of_type(&[0, 3, 7, 10]);
131    let minor_major_seventh = || chord.is_seventh_of_type(&[0, 3, 7, 11]);
132    if !chord_has_major_third
133        && let Some(sub) = substitute(&MINOR_SEVENTH_SUBS, &inversion_string)
134        && minor_seventh()
135    {
136        rn_string = format!("{}{step_roman}{sub}", figure.prefix);
137    } else if !chord_has_major_third
138        && let Some(sub) = substitute(&MINOR_MAJOR_SEVENTH_SUBS, &inversion_string)
139        && minor_major_seventh()
140    {
141        rn_string = format!("{}{step_roman}{sub}", figure.prefix);
142    } else if !no_key_given
143        && let Some(sub) = substitute(&AUG6_SUBS, &rn_string)
144        && chord.is_augmented_sixth(true)
145    {
146        rn_string = sub.to_string();
147    } else if no_key_given
148        && let Some(sub) = substitute(&AUG6_NO_KEY_SUBS, &rn_string)
149        && chord.is_augmented_sixth(true)
150    {
151        rn_string = sub.to_string();
152        // The chord stood in for its own key; an augmented sixth resolves to
153        // the key its fifth, or its seventh, is the tonic of.
154        let tonic = if sub.starts_with("It") || sub.starts_with("Ge") {
155            chord.fifth()
156        } else {
157            chord.seventh()
158        };
159        if let Some(tonic) = tonic {
160            key = Key::from_tonic_mode(&tonic.name(), "minor")?;
161        }
162    }
163
164    RomanNumeral::with_minor_defaults(
165        rn_string,
166        key,
167        Minor67Default::Cautionary,
168        Minor67Default::Cautionary,
169    )
170    .map(Some)
171}
172
173/// music21's `_postFigureFromChordAndKey`: the figures after the numeral,
174/// read off the chord's pitches above its bass in the key, folded to the
175/// shorthand music21 writes and marked for a diminished or augmented fifth.
176fn post_figure(chord: &Chord, key: &Key) -> Result<String> {
177    let mut tuples = figure_tuples(chord, key)?;
178    let bass_alter = tuples.first().map_or(0.0, |each| each.figure.alter);
179    let third = chord.third().cloned();
180    let fifth = chord.fifth().cloned();
181    let (is_major, is_minor, standard_triad) = if chord.pitch_class_cardinality() == 3 {
182        let major = chord.is_major_triad();
183        let minor = !major && chord.is_minor_triad();
184        (
185            major,
186            minor,
187            major || minor || chord.is_diminished_triad() || chord.is_augmented_triad(),
188        )
189    } else {
190        (false, false, false)
191    };
192    tuples.sort_by(|a, b| {
193        let left = (
194            -i32::from(a.figure.deg_from_ref_pitch),
195            a.figure.alter,
196            a.pitch.ps(),
197        );
198        let right = (
199            -i32::from(b.figure.deg_from_ref_pitch),
200            b.figure.alter,
201            b.pitch.ps(),
202        );
203        left.partial_cmp(&right)
204            .unwrap_or(std::cmp::Ordering::Equal)
205    });
206
207    let mut figures: Vec<String> = Vec::new();
208    for each in &tuples {
209        let degree = each.figure.deg_from_ref_pitch;
210        let mut prefix = each.figure.prefix.as_str();
211        if degree != 1 && third.as_ref() == Some(&each.pitch) {
212            if is_major || is_minor {
213                prefix = "";
214            }
215        } else if degree != 1 && fifth.as_ref() == Some(&each.pitch) && standard_triad {
216            prefix = "";
217        }
218        if degree == 1 {
219            if each.figure.alter != bass_alter && !prefix.is_empty() {
220                let figure = format!("{prefix}8");
221                if !figures.contains(&figure) {
222                    figures.insert(0, figure);
223                }
224            }
225        } else {
226            let figure = format!("{prefix}{degree}");
227            if !figures.contains(&figure) {
228                figures.push(figure);
229            }
230        }
231    }
232    let mut all = figures.concat();
233    if let Some(short) = substitute(&super::figure::FIGURE_SHORTHANDS, &all) {
234        all = short.to_string();
235    }
236    if all == "75" || all == "73" {
237        all = "7".to_string();
238    }
239    Ok(correct_suffix_for_chord_quality(chord, &all))
240}
241
242impl RomanNumeral {
243    /// Performs functional Roman-numeral analysis in a key.
244    pub fn analyze(chord: &Chord, key: Key) -> Result<Option<Self>> {
245        let Some(root_name) = chord.root_pitch_name() else {
246            return Ok(None);
247        };
248        let root = Pitch::from_name(normalize_pitch_name(&root_name))?;
249        Self::analyze_with_root(chord, key, &root)
250    }
251
252    /// Performs Roman-numeral analysis using an explicit harmonic root.
253    ///
254    /// This is useful for pitch-class-set browser views where the caller has
255    /// already chosen a transposition root and does not want inversion or root
256    /// inference to pick a different chord member.
257    pub fn analyze_with_root(chord: &Chord, key: Key, root: &Pitch) -> Result<Option<Self>> {
258        if let Some(kind) = augmented_sixth_kind_for_key(chord, &key)? {
259            return Self::new(kind.figure(), key).map(Some);
260        }
261
262        let root_pc = pitch_class(root);
263        let intervals = intervals_above_root(chord, root_pc);
264        if !intervals.contains(&0) {
265            return Ok(None);
266        }
267
268        let Some((degree, accidental)) = degree_for_root(&key, root)? else {
269            return Ok(None);
270        };
271
272        let symbol = chord
273            .chord_symbols_with_root(root_pc)?
274            .into_iter()
275            .find_map(|figure| ChordSymbol::parse(figure).ok());
276        let quality = symbol
277            .as_ref()
278            .map(symbol_quality)
279            .unwrap_or_else(|| quality_from_intervals(&intervals));
280
281        let figure = roman_figure(
282            degree,
283            accidental,
284            quality,
285            symbol.as_ref(),
286            &intervals,
287            roman_inversion(chord),
288        );
289
290        Self::new(figure, key).map(Some)
291    }
292}
293
294/// The figured-bass suffix a Roman numeral takes for a chord's inversion:
295/// music21's `romanInversionName`, `6` and `64` for a triad, `7`, `65`, `43`
296/// and `42` for a seventh chord, and nothing otherwise. The inversion is the
297/// chord's own unless one is given.
298pub fn roman_inversion_name(chord: &Chord, inversion: Option<u8>) -> String {
299    match chord.root() {
300        Some(root) => inversion_name_from_root(chord, root, inversion),
301        None => String::new(),
302    }
303}
304
305pub(super) fn inversion_name_from_root(
306    chord: &Chord,
307    root: &Pitch,
308    inversion: Option<u8>,
309) -> String {
310    let Some(inversion) = inversion.or_else(|| chord.inversion_with_root(root)) else {
311        return String::new();
312    };
313    // music21's `isTriad`, `isIncompleteMajorTriad` and
314    // `isIncompleteMinorTriad`, read from the root given rather than the
315    // chord's own.
316    let has = |step: u8| chord.chord_step_from(step, root).is_some();
317    let names = chord.unique_pitch_names().len();
318    let triad = names == 3 && has(3) && has(5);
319    // A root and its third alone: every note is one or the other.
320    let incomplete_triad = names == 2
321        && has(3)
322        && chord
323            .semitones_from_chord_step_with_root(3, root)
324            .is_some_and(|third| {
325                matches!(third, 3 | 4)
326                    && chord.pitches().iter().all(|pitch| {
327                        let above = (pitch_class(pitch) + 12 - pitch_class(root)) % 12;
328                        above == 0 || above == third
329                    })
330            });
331    let suffix = if has(7) {
332        match inversion {
333            0 => "7",
334            1 => "65",
335            2 => "43",
336            3 => "42",
337            _ => "",
338        }
339    } else if triad || incomplete_triad {
340        match inversion {
341            1 => "6",
342            2 => "64",
343            _ => "",
344        }
345    } else {
346        ""
347    };
348    suffix.to_string()
349}
350
351impl FigureTuple {
352    /// The figure of `pitch` above `reference` in `key`: music21's
353    /// `FigureTuple.fromPitchAndReference`. An `A-3` above an `F#2` bass in C
354    /// major is a third above the bass and a semitone below the `A` C major
355    /// has, so it is `3`, altered by `-1`, written `b`. A minor key is read
356    /// as the natural minor.
357    pub fn from_pitch_and_reference(pitch: &Pitch, key: &Key, reference: &Pitch) -> Result<Self> {
358        let (_, accidental) = key.as_scale()?.degree_and_accidental_of(pitch)?;
359        let degree = Interval::between_pitches(reference, pitch)?
360            .generic()
361            .mod7();
362        let alter = accidental.map_or(0.0, |accidental| accidental.alter());
363        Ok(Self {
364            deg_from_ref_pitch: u8::try_from(degree).unwrap_or(1),
365            alter,
366            prefix: figure_prefix(alter as IntegerType),
367        })
368    }
369}
370
371/// The accidental written in front of a figure for an alteration: sharps
372/// for a positive one, flats for a negative one.
373pub(super) fn figure_prefix(alter: IntegerType) -> String {
374    match alter {
375        0 => String::new(),
376        sharps if sharps > 0 => "#".repeat(sharps as usize),
377        flats => "b".repeat(flats.unsigned_abs() as usize),
378    }
379}
380
381/// One figure per pitch of the chord, each read above the chord's bass:
382/// music21's `figureTuples`.
383pub fn figure_tuples(chord: &Chord, key: &Key) -> Result<Vec<PitchFigureTuple>> {
384    let Some(bass) = chord.bass() else {
385        return Ok(Vec::new());
386    };
387    chord
388        .pitches()
389        .iter()
390        .map(|pitch| {
391            Ok(PitchFigureTuple {
392                figure: FigureTuple::from_pitch_and_reference(pitch, key, bass)?,
393                pitch: pitch.clone(),
394            })
395        })
396        .collect()
397}
398
399/// The figure of a chord's root corrected for a minor key, where the sixth
400/// and seventh degrees are named against the natural minor: music21's
401/// `correctRNAlterationForMinor`, in its cautionary reading.
402///
403/// A raised sixth or seventh loses its sharp, since the lower case numeral
404/// a minor or diminished chord takes already says the degree is raised, and
405/// a natural one gains a flat as a caution. A chord with a major third keeps
406/// its sharp, because `VI` alone would name the chord on the lowered degree.
407/// Figures in a major key, and on any other degree, come back as they are.
408pub fn correct_rn_alteration_for_minor(
409    figure: &FigureTuple,
410    key: &Key,
411    chord_has_major_third: bool,
412) -> FigureTuple {
413    if key.mode() != "minor" || !matches!(figure.deg_from_ref_pitch, 6 | 7) {
414        return figure.clone();
415    }
416    if chord_has_major_third && figure.alter >= 1.0 {
417        return figure.clone();
418    }
419    let (alter, prefix) = if figure.alter == 1.0 {
420        (0.0, String::new())
421    } else if figure.alter == 0.0 {
422        (0.0, "b".to_string())
423    } else if figure.alter > 1.0 {
424        (figure.alter - 1.0, figure.prefix.chars().skip(1).collect())
425    } else {
426        (figure.alter, format!("b{}", figure.prefix))
427    };
428    FigureTuple {
429        deg_from_ref_pitch: figure.deg_from_ref_pitch,
430        alter,
431        prefix,
432    }
433}
434
435/// The inversion figure with the quality mark a chord's fifth and seventh
436/// call for in front of it: music21's `correctSuffixForChordQuality`. A
437/// diminished fifth writes `o`, an augmented one `+`, and a diminished
438/// fifth under a minor seventh `ø`; a figure that already carries the mark
439/// is left as it is.
440pub fn correct_suffix_for_chord_quality(chord: &Chord, inversion_string: &str) -> String {
441    let fifth = chord.semitones_from_chord_step(5);
442    let mut quality = match fifth {
443        Some(6) => "o",
444        Some(8) => "+",
445        _ => "",
446    };
447    let marked = ["o", "°", "/o", "ø"]
448        .iter()
449        .any(|mark| inversion_string.starts_with(mark));
450    if marked && quality == "o" {
451        quality = "";
452    }
453    if fifth == Some(6) && chord.semitones_from_chord_step(7) == Some(10) && quality == "o" {
454        quality = "ø";
455    }
456    format!("{quality}{inversion_string}")
457}
458
459/// music21's `identifyAsTonicOrDominant`, so `G B D F` in C major is `V7`
460/// and `A C E` in A minor is `i`. A chord containing the tonic is the tonic,
461/// one containing the dominant is the dominant, and anything else is judged
462/// by how many of its notes the tonic and dominant sevenths share; `None`
463/// when neither wins.
464pub fn identify_as_tonic_or_dominant(chord: &Chord, key: &Key) -> Result<Option<String>> {
465    let names = chord.pitch_names();
466    let tonic = key.pitch_from_degree(1)?;
467    let dominant = key.pitch_from_degree(5)?;
468    let overlap = |figure: &str| -> Result<usize> {
469        let members = RomanNumeral::new(figure, key.clone())?
470            .to_chord()?
471            .pitch_names();
472        Ok(members.iter().filter(|name| names.contains(name)).count())
473    };
474    let is_tonic = if names.contains(&tonic.name()) {
475        true
476    } else if names.contains(&dominant.name()) {
477        false
478    } else {
479        let (one, five) = (overlap("I7")?, overlap("V7")?);
480        if one == five {
481            return Ok(None);
482        }
483        one > five
484    };
485    let (numeral, root) = if is_tonic {
486        (if key.mode() == "minor" { "i" } else { "I" }, &tonic)
487    } else {
488        ("V", &dominant)
489    };
490    Ok(Some(format!(
491        "{numeral}{}",
492        inversion_name_from_root(chord, root, None)
493    )))
494}
495
496/// Performs functional Roman-numeral analysis in a key.
497pub fn analyze_chord(chord: &Chord, key: Key) -> Result<Option<RomanNumeral>> {
498    RomanNumeral::analyze(chord, key)
499}
500
501/// Performs Roman-numeral analysis in a key using an explicit harmonic root.
502pub fn analyze_chord_with_root(
503    chord: &Chord,
504    key: Key,
505    root: &Pitch,
506) -> Result<Option<RomanNumeral>> {
507    RomanNumeral::analyze_with_root(chord, key, root)
508}
509
510pub(super) fn degree_for_root(key: &Key, root: &Pitch) -> Result<Option<(u8, i8)>> {
511    let root_pc = pitch_class(root);
512    let root_step = root.step();
513    let mut best: Option<(u8, i8, bool)> = None;
514
515    for degree in 1..=7 {
516        let degree_pitch = key.pitch_from_degree(degree)?;
517        let diff = ((root_pc as i16 - pitch_class(&degree_pitch) as i16).rem_euclid(12)) as u8;
518        let Some(accidental) = chromatic_diff_to_accidental(diff) else {
519            continue;
520        };
521        let same_step = degree_pitch.step() == root_step;
522
523        let replace = match best {
524            None => true,
525            Some((_, best_accidental, best_same_step)) => {
526                (same_step && !best_same_step)
527                    || (same_step == best_same_step && accidental.abs() < best_accidental.abs())
528            }
529        };
530        if replace {
531            best = Some((degree as u8, accidental, same_step));
532        }
533    }
534
535    Ok(best.map(|(degree, accidental, _)| (degree, accidental)))
536}
537
538pub(super) fn chromatic_diff_to_accidental(diff: u8) -> Option<i8> {
539    match diff {
540        0 => Some(0),
541        1 => Some(1),
542        2 => Some(2),
543        10 => Some(-2),
544        11 => Some(-1),
545        _ => None,
546    }
547}
548
549pub(super) fn intervals_above_root(chord: &Chord, root_pc: u8) -> Vec<u8> {
550    let mut intervals = chord
551        .pitch_classes()
552        .into_iter()
553        .map(|pc| (pc + 12 - root_pc) % 12)
554        .collect::<Vec<_>>();
555    intervals.sort_unstable();
556    intervals.dedup();
557    intervals
558}
559
560pub(super) fn augmented_sixth_kind_for_key(
561    chord: &Chord,
562    key: &Key,
563) -> Result<Option<AugmentedSixthKind>> {
564    let kind = std::iter::once(chord.common_name())
565        .chain(chord.common_names())
566        .find_map(|name| AugmentedSixthKind::from_common_name(&name));
567    let Some(kind) = kind else {
568        return Ok(None);
569    };
570
571    let pitch_classes = chord.pitch_classes();
572    let tonic = key_degree_pitch_class(key, 1, 0)?;
573    let lowered_sixth_adjust = if key.mode() == "minor" { 0 } else { -1 };
574    let lowered_sixth = key_degree_pitch_class(key, 6, lowered_sixth_adjust)?;
575    let raised_fourth = key_degree_pitch_class(key, 4, 1)?;
576
577    if !pitch_classes.contains(&tonic)
578        || !pitch_classes.contains(&lowered_sixth)
579        || !pitch_classes.contains(&raised_fourth)
580    {
581        return Ok(None);
582    }
583
584    let required_extra = match kind {
585        AugmentedSixthKind::Italian => None,
586        AugmentedSixthKind::French => Some(key_degree_pitch_class(key, 2, 0)?),
587        AugmentedSixthKind::German => {
588            let lowered_third_adjust = if key.mode() == "minor" { 0 } else { -1 };
589            Some(key_degree_pitch_class(key, 3, lowered_third_adjust)?)
590        }
591        AugmentedSixthKind::Swiss => Some(key_degree_pitch_class(key, 2, 1)?),
592    };
593
594    if required_extra.is_some_and(|pitch_class| !pitch_classes.contains(&pitch_class)) {
595        return Ok(None);
596    }
597
598    Ok(Some(kind))
599}
600
601pub(super) fn key_degree_pitch_class(
602    key: &Key,
603    degree: usize,
604    semitones: IntegerType,
605) -> Result<u8> {
606    let mut pitch = key.pitch_from_degree(degree)?;
607    if semitones != 0 {
608        pitch = Interval::from_semitones(semitones)?.transpose_pitch(&pitch)?;
609    }
610    Ok(pitch_class(&pitch))
611}
612
613pub(super) fn roman_inversion(chord: &Chord) -> u8 {
614    if chord.pitches().iter().any(|pitch| pitch.octave().is_some()) {
615        chord.inversion().unwrap_or(0)
616    } else {
617        0
618    }
619}
620
621pub(super) fn symbol_quality(symbol: &ChordSymbol) -> RomanQuality {
622    match symbol.quality() {
623        ChordQuality::Major
624        | ChordQuality::Dominant
625        | ChordQuality::Suspended2
626        | ChordQuality::Suspended4
627        | ChordQuality::Power
628        | ChordQuality::Pedal => RomanQuality::Major,
629        ChordQuality::Minor => RomanQuality::Minor,
630        ChordQuality::Diminished => RomanQuality::Diminished,
631        ChordQuality::HalfDiminished => RomanQuality::HalfDiminished,
632        ChordQuality::Augmented => RomanQuality::Augmented,
633    }
634}
635
636pub(super) fn quality_from_intervals(intervals: &[u8]) -> RomanQuality {
637    if intervals.contains(&3) && intervals.contains(&6) {
638        if intervals.contains(&10) {
639            RomanQuality::HalfDiminished
640        } else {
641            RomanQuality::Diminished
642        }
643    } else if intervals.contains(&4) && intervals.contains(&8) {
644        RomanQuality::Augmented
645    } else if intervals.contains(&3) && intervals.contains(&7) {
646        RomanQuality::Minor
647    } else {
648        RomanQuality::Major
649    }
650}
651
652pub(super) fn roman_figure(
653    degree: u8,
654    accidental: i8,
655    quality: RomanQuality,
656    symbol: Option<&ChordSymbol>,
657    intervals: &[u8],
658    inversion: u8,
659) -> String {
660    let base = degree_to_roman(degree);
661    let prefix = roman_accidental_prefix(accidental);
662    let body = roman_body_for_quality(base, quality);
663    let suffix = functional_suffix(symbol, intervals, inversion, quality);
664    format!("{prefix}{body}{suffix}")
665}
666
667pub(super) fn roman_accidental_prefix(accidental: i8) -> String {
668    match accidental.cmp(&0) {
669        std::cmp::Ordering::Less => "b".repeat(accidental.unsigned_abs() as usize),
670        std::cmp::Ordering::Equal => String::new(),
671        std::cmp::Ordering::Greater => "#".repeat(accidental as usize),
672    }
673}
674
675pub(super) fn roman_body_for_quality(base: &str, quality: RomanQuality) -> String {
676    match quality {
677        RomanQuality::Major => base.to_string(),
678        RomanQuality::Minor => base.to_ascii_lowercase(),
679        RomanQuality::Diminished => format!("{}o", base.to_ascii_lowercase()),
680        RomanQuality::HalfDiminished => format!("{}\u{00f8}", base.to_ascii_lowercase()),
681        RomanQuality::Augmented => format!("{base}+"),
682    }
683}
684
685pub(super) fn functional_suffix(
686    symbol: Option<&ChordSymbol>,
687    intervals: &[u8],
688    inversion: u8,
689    quality: RomanQuality,
690) -> String {
691    if let Some(symbol) = symbol
692        && needs_chord_symbol_suffix(symbol)
693    {
694        return chord_symbol_suffix_for_roman(symbol, quality);
695    }
696    figured_bass_suffix(intervals, inversion, quality)
697}
698
699pub(super) fn needs_chord_symbol_suffix(symbol: &ChordSymbol) -> bool {
700    matches!(
701        symbol.quality(),
702        ChordQuality::Suspended2 | ChordQuality::Suspended4 | ChordQuality::Power
703    ) || !symbol.additions().is_empty()
704        || symbol.alterations().iter().any(|alteration| {
705            !(matches!(symbol.quality(), ChordQuality::HalfDiminished)
706                && alteration.degree() == 5
707                && alteration.semitones() == -1)
708        })
709        || symbol.extensions().iter().any(|degree| *degree != 7)
710        || chord_symbol_suffix(symbol).contains("maj7")
711}
712
713pub(super) fn chord_symbol_suffix_for_roman(symbol: &ChordSymbol, quality: RomanQuality) -> String {
714    let suffix = chord_symbol_suffix(symbol);
715    let converted = match quality {
716        RomanQuality::Major => suffix.to_string(),
717        RomanQuality::Minor => suffix
718            .strip_prefix('m')
719            .filter(|rest| !rest.starts_with("aj"))
720            .unwrap_or(suffix)
721            .to_string(),
722        RomanQuality::Diminished => suffix.strip_prefix("dim").unwrap_or(suffix).to_string(),
723        RomanQuality::HalfDiminished => {
724            suffix.strip_prefix('m').unwrap_or(suffix).replace("b5", "")
725        }
726        RomanQuality::Augmented => suffix
727            .strip_prefix("aug")
728            .or_else(|| suffix.strip_prefix('+'))
729            .unwrap_or(suffix)
730            .to_string(),
731    };
732
733    if converted == "6" {
734        " add(13)".to_string()
735    } else {
736        converted
737    }
738}
739
740pub(super) fn chord_symbol_suffix(symbol: &ChordSymbol) -> &str {
741    let body = symbol
742        .figure()
743        .split_once('/')
744        .map_or(symbol.figure(), |(body, _)| body);
745    let root_name = normalize_symbol_root_name(&symbol.root().name());
746    body.strip_prefix(&root_name).unwrap_or(body)
747}
748
749pub(super) fn normalize_symbol_root_name(name: &str) -> String {
750    name.replace('-', "b")
751}
752
753pub(super) fn figured_bass_suffix(
754    intervals: &[u8],
755    inversion: u8,
756    quality: RomanQuality,
757) -> String {
758    if has_seventh(intervals) {
759        let suffix = match inversion {
760            1 => "65",
761            2 => "43",
762            3 => "42",
763            _ => "7",
764        };
765        if matches!(quality, RomanQuality::Major) && intervals.contains(&11) {
766            format!("maj{suffix}")
767        } else {
768            suffix.to_string()
769        }
770    } else if has_triad_shape(intervals) {
771        match inversion {
772            1 => "6".to_string(),
773            2 => "64".to_string(),
774            _ => String::new(),
775        }
776    } else {
777        String::new()
778    }
779}
780
781pub(super) fn has_seventh(intervals: &[u8]) -> bool {
782    intervals.contains(&10) || intervals.contains(&11) || intervals.contains(&9)
783}
784
785pub(super) fn has_triad_shape(intervals: &[u8]) -> bool {
786    (intervals.contains(&3) || intervals.contains(&4))
787        && intervals.iter().any(|interval| matches!(interval, 6..=8))
788}
789
790pub(super) fn normalize_pitch_name(name: &str) -> String {
791    let mut chars = name.chars();
792    let Some(first) = chars.next() else {
793        return String::new();
794    };
795    let mut normalized = first.to_string();
796    for ch in chars {
797        if ch == 'b' {
798            normalized.push('-');
799        } else {
800            normalized.push(ch);
801        }
802    }
803    normalized
804}