1use super::*;
5
6const MINOR_SEVENTH_SUBS: [(&str, &str); 4] = [
9 ("b75b3", "7"),
10 ("6b5", "65"),
11 ("b64b3", "43"),
12 ("6b42", "42"),
13];
14
15const 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
28const 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
60const 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
85pub 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 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
173fn 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 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 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
294pub 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 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 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 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
371pub(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
381pub 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
399pub 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
435pub 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
459pub 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
496pub fn analyze_chord(chord: &Chord, key: Key) -> Result<Option<RomanNumeral>> {
498 RomanNumeral::analyze(chord, key)
499}
500
501pub 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(°ree_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}