Skip to main content

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