1use super::*;
5use crate::chord::TriadQuality;
6
7const MINOR_SEVENTH_SUBS: [(&str, &str); 4] = [
10 ("b75b3", "7"),
11 ("6b5", "65"),
12 ("b64b3", "43"),
13 ("6b42", "42"),
14];
15
16const 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
29const 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
61const 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
86pub 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 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 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
175fn 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 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 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 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
296pub 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 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 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 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
373pub(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
383pub 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
401pub 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
437pub 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
450fn 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
474pub 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
511pub fn analyze_chord(chord: &Chord, key: Key) -> Result<Option<RomanNumeral>> {
513 RomanNumeral::analyze(chord, key)
514}
515
516pub 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(°ree_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}