import pathlib import tempfile import unittest import generator.engraving.fonts import generator.engraving.layout import generator.engraving.types import generator.harmony import generator.model import generator.musicxml import generator.musicxml_writer import generator.pitch import tests.arrangement_fixtures import tests.rhythm_fixtures import tests.test_musicxml def chord_xml(text, kind='other', root='C', extra=''): return f'{root}{kind}{extra}' class HarmonyTests(unittest.TestCase): def test_triad_classification_uses_effective_degrees(self): for symbol in ('CM', 'Dm', 'Cdim', 'Caug', 'Dm/F', 'Dm/B', 'Dm7no7', 'C7no7'): with self.subTest(symbol=symbol): self.assertTrue(generator.harmony.is_triad(generator.harmony.parse_symbol(symbol))) for symbol in ('C7', 'CM7', 'Cm7', 'Cdim7', 'C6', 'C13', 'Csus', 'Csus2', 'C7no5', 'Dmno1', 'Dmno5', 'Cadd9'): with self.subTest(symbol=symbol): self.assertFalse(generator.harmony.is_triad(generator.harmony.parse_symbol(symbol))) custom = {'kind': 'other', 'degrees': [{'value': d, 'alter': a, 'type': 'add'} for d, a in ((1, 0), (3, -1), (5, 0))]} self.assertTrue(generator.harmony.is_triad(custom)) def test_quality_spellings_are_canonical(self): cases = {'C': 'CM', 'Cmaj': 'CM', 'Cmin': 'Cm', 'C-': 'Cm', 'C+': 'Caug', 'Co': 'Cdim', 'Csus4': 'Csus', 'Csus2': 'Csus2', 'Cmaj7': 'CM7', 'C-7': 'Cm7', 'C+7': 'Caug7', 'C+maj7': 'CaugM7', 'Co7': 'Cdim7', 'C-7b5': 'Cm7b5', 'C-maj7': 'CmM7', 'CmM': 'CmM7', 'C6': 'CM6', 'C-6': 'Cm6', 'Fmaj7#11/A': 'FM7#11/A', 'G7b9': 'G7b9'} for spelling, canonical in cases.items(): with self.subTest(spelling=spelling): chord = generator.harmony.parse_symbol(spelling) self.assertEqual(generator.harmony.symbol(chord), canonical) self.assertEqual(generator.harmony.degrees(generator.harmony.parse_symbol(canonical)), generator.harmony.degrees(chord)) def test_extended_chords_include_sevenths_but_added_ninths_do_not(self): for prefix, seventh in (('', -1), ('m', -1), ('M', 0), ('mM', 0)): for highest in (9, 11, 13): with self.subTest(prefix=prefix, highest=highest): chord = generator.harmony.parse_symbol(f'C{prefix}{highest}') values = generator.harmony.degrees(chord) self.assertEqual(values[7], seventh) self.assertTrue(all(values[degree] == 0 for degree in range(9, highest + 1, 2))) self.assertEqual(generator.harmony.symbol(chord), f'C{prefix}{highest}') self.assertEqual(generator.harmony.degrees(generator.harmony.parse_symbol('Cmadd9')), {1: 0, 3: -1, 5: 0, 9: 0}) self.assertEqual(generator.harmony.degrees(generator.harmony.parse_symbol('C7no5')), {1: 0, 3: 0, 7: -1}) self.assertEqual(generator.harmony.degrees(generator.harmony.parse_symbol('C7sus')), {1: 0, 4: 0, 5: 0, 7: -1}) def test_soundslice_text_only_chords_become_structured(self): for text, kind, values in (('mM7', 'major-minor', {1: 0, 3: -1, 5: 0, 7: 0}), ('m7b5', 'half-diminished', {1: 0, 3: -1, 5: -1, 7: -1}), ('7b9', 'dominant', {1: 0, 3: 0, 5: 0, 7: -1, 9: -1})): with self.subTest(text=text): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord_xml(text) + tests.test_musicxml.xml_note())) chord = source['staves'][0]['measures'][0]['harmonies'][0] self.assertEqual(chord['kind'], kind) self.assertEqual(generator.harmony.degrees(chord), values) self.assertEqual(chord['symbol'], 'C' + text) self.assertNotIn('kind_text', chord) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord_xml('m9', 'minor-ninth') + tests.test_musicxml.xml_note())) self.assertEqual(generator.harmony.degrees(source['staves'][0]['measures'][0]['harmonies'][0])[7], -1) def test_parenthesized_full_symbols_transpose_and_roundtrip(self): for label, transposed in (('(Fm7)', '(Gm7)'), ('(Bb7)', '(C7)'), ('(Fm7/Ab)', '(Gm7/Bb)')): with self.subTest(label=label): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord_xml(label) + tests.test_musicxml.xml_note())) chord = source['staves'][0]['measures'][0]['harmonies'][0] self.assertEqual(chord['symbol'], label) self.assertTrue(chord['chord_optional']) self.assertTrue(chord['parenthesized']) arranged = tests.arrangement_fixtures.section_document(source, 'vocal', rhythms=tests.rhythm_fixtures.library())['staves'][0]['measures'][0]['harmonies'][0] self.assertNotIn('chord_optional', arranged) self.assertEqual(generator.harmony.symbol(arranged), label) source['staves'][0]['measures'][0]['transpose'] = {'diatonic': 1, 'chromatic': 2, 'octave_change': 0} shifted = generator.pitch.concert_document(source) result = shifted['staves'][0]['measures'][0]['harmonies'][0] self.assertEqual(result['symbol'], transposed) with tempfile.TemporaryDirectory() as temporary: path = pathlib.Path(temporary) / 'score.xml' generator.musicxml_writer.write_musicxml(path, shifted) restored = generator.musicxml.load_musicxml(path)['staves'][0]['measures'][0]['harmonies'][0] self.assertEqual(restored, result) def test_requested_labels_are_engraved_without_overlaps(self): labels = ('CM', 'Cm7', 'CM13#11', 'CmM7', 'CaugM7', 'Cdim7', 'Csus', 'Csus2', 'C7sus4', '(Fm7/Ab)') for label in labels: with self.subTest(label=label): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord_xml(label) + tests.test_musicxml.xml_note())) layout = generator.engraving.layout.layout_score(source, generator.engraving.types.Style(), generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))) rendered = [p.value for page in layout.pages for p in page.primitives if p.role == 'harmony'] self.assertEqual(rendered, [generator.harmony.symbol(generator.harmony.parse_symbol(label))]) def test_unknown_or_contradictory_symbols_are_rejected(self): for label in ('Cunknown', 'Cm79', 'C7b9#9', 'C7add7', 'C7no3no3', 'C7no#5', '(Cm7', 'Cm7)', 'C7/G/Ab'): with self.subTest(label=label), self.assertRaises(ValueError): generator.harmony.parse_symbol(label) for content in (chord_xml('m7', 'major-seventh'), chord_xml('(Fm7)', 'minor-seventh'), chord_xml('(Fm7)', root='D'), chord_xml('m7/G', extra='A')): with self.subTest(content=content), self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'contradictory'): tests.test_musicxml.parse(tests.test_musicxml.xml_score(content + tests.test_musicxml.xml_note())) def test_kind_text_cannot_override_canonical_output(self): chord = generator.harmony.parse_symbol('Cm7') | {'kind_text': '-7'} self.assertEqual(generator.harmony.symbol(chord), 'Cm7') source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord_xml('7', 'dominant', extra='9-1add') + tests.test_musicxml.xml_note())) self.assertEqual(source['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'C7b9') def test_current_song_exports_import_without_edits(self): for name, count, key in (('autumn_leaves', 33, 'D minor'), ('all-of-me', 32, 'G')): source = generator.musicxml.load_musicxml(pathlib.Path('data/songs') / f'{name}.xml') self.assertEqual(len(source['staves'][0]['measures']), count) self.assertEqual(source['metadata']['key_signature'], key)