import tests.production_fixtures import argparse import fractions import pathlib import tempfile import unittest import unittest.mock import generator.__main__ import generator.accompaniment import generator.arrange.generate import generator.guitar import generator.fretboard import generator.harmony import generator.model import generator.musicxml import generator.parts import generator.pitch import tests.solo_fixtures import tests.test_musicxml class MusicModelTests(unittest.TestCase): def setUp(self): tests.production_fixtures.install(self) engraving = unittest.mock.patch('generator.engraving.pipeline.engrave_tree') engraving.start() self.addCleanup(engraving.stop) catalog = unittest.mock.patch('generator.catalog.build_catalog') catalog.start() self.addCleanup(catalog.stop) artwork = unittest.mock.patch('generator.pico_artwork.generate_titles') artwork.start() self.addCleanup(artwork.stop) def test_chord_semantics_are_not_voicing_heuristics(self): cases = {'C': {0, 4, 7}, 'Cdim': {0, 3, 6}, 'Cdim7': {0, 3, 6, 9}, 'Csus2': {0, 2, 7}, 'C7b5': {0, 4, 6, 10}, 'G7b9': {7, 11, 2, 5, 8}, 'Fmaj7#11': {5, 9, 0, 4, 11}} for label, expected in cases.items(): self.assertEqual(set(generator.harmony.pitch_classes(generator.harmony.parse_symbol(label))), expected, label) for label, essentials in [('G7b9', {11, 5, 8}), ('C13', {4, 10, 9})]: definition = generator.harmony.parse_symbol(label) pitches = generator.fretboard.chord_pitches(definition, generator.guitar.chord_positions(generator.fretboard.chord_requirements(definition))[0]) self.assertTrue(essentials.issubset({generator.model.pitch_number(p) % 12 for p in pitches}), label) with self.assertRaises(ValueError): generator.harmony.parse_symbol('Cunrecognized') def test_transposition_preserves_spelling_octaves_modes(self): for p in ({'letter': 'b', 'alter': 1, 'octave': 4}, {'letter': 'c', 'alter': -1, 'octave': 4}, {'letter': 'f', 'alter': 2, 'octave': 4}): p['alter'] = generator.model.rational(p['alter']) self.assertEqual(generator.pitch.shift_pitch(p, 0, 0), p) self.assertEqual(generator.model.pitch_number(generator.pitch.shift_pitch(p, 1, 2)), generator.model.pitch_number(p) + 2) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes='-3minor12')) result = generator.pitch.concert_document(source) self.assertEqual(result['metadata']['key_signature'], 'D minor') self.assertEqual(result['metadata']['key']['alterations'], generator.model.key_from_fifths(-1, 'minor')['alterations']) def test_additive_and_compound_grouping(self): self.assertEqual(generator.model.beat_lengths({'groups': [{'beats': [3, 2], 'unit': 8}]}), [fractions.Fraction(3, 2), fractions.Fraction(1)]) self.assertEqual(generator.model.beat_lengths({'groups': [{'beats': [6], 'unit': 8}]}), [fractions.Fraction(3, 2)] * 2) self.assertEqual(generator.model.beat_lengths({'groups': [{'beats': [3], 'unit': 4}]}), [fractions.Fraction(1)] * 3) def test_generated_rhythm_follows_mid_measure_meter(self): change = '' source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter') + change + tests.test_musicxml.xml_note(duration=3, kind='half', extra=''))) measure = source['staves'][0]['measures'][0] events = generator.parts.generated_voice(measure, 'slash') self.assertEqual([generator.model.fraction(n['onset']) for n in events], [0, 1, fractions.Fraction(5, 2)]) self.assertEqual([generator.model.fraction(n['duration']) for n in events], [1, fractions.Fraction(3, 2), fractions.Fraction(3, 2)]) def test_harmony_carry_uses_last_event_and_never_future_event(self): c = generator.harmony.parse_symbol('C7') | {'onset': generator.model.rational(0)} f = generator.harmony.parse_symbol('F7') | {'onset': generator.model.rational(2)} measures = [{'harmonies': [c, f]}, {'harmonies': []}] self.assertEqual(generator.accompaniment.carried_chords(measures), [None, f]) self.assertIsNone(generator.accompaniment.active_chord({'harmonies': [f]}, fractions.Fraction(0), None)) self.assertEqual(generator.accompaniment.active_chord(measures[0], fractions.Fraction(1), None), c) def test_tuplet_ratios_and_tie_links_validated(self): document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) tests.test_musicxml.notes(document)[0]['pitches'][0]['tie_to'] = 'missing' with self.assertRaisesRegex(ValueError, 'dangling tie'): generator.model.validate_document(document) document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) tests.test_musicxml.notes(document)[0]['duration'] = {'numerator': 8, 'denominator': 2} with self.assertRaisesRegex(ValueError, 'reduced'): generator.model.validate_document(document) def test_optional_turnaround_and_form(self): source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/all-of-me.xml')) for part in ('acoustic', 'electric', 'vocal'): result = generator.arrange.generate.arrange_document(source, part) generator.model.validate_document(result) measures = result['staves'][0]['measures'] self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody', 'Ending']) self.assertFalse(measures[-1]['harmonies']) self.assertTrue(any(h for m in measures[:-1] for h in m['harmonies'])) self.assertFalse(any('chord_optional' in h for m in measures for h in m['harmonies'])) labels = [h['symbol'] for m in measures[:-1] for h in m['harmonies']] self.assertIn('(Am7)', labels) self.assertIn('(D7)', labels) def test_legacy_repeat_model_fields_are_rejected(self): for field in ('repeats', 'endings', 'initial_attributes'): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) source['staves'][0]['measures'][0][field] = {} with self.assertRaisesRegex(ValueError, 'repeat notation'): generator.model.validate_document(source) for field in ('sound_ties', 'printed_ties'): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) tests.test_musicxml.notes(source)[0]['pitches'][0][field] = [] with self.assertRaisesRegex(ValueError, 'pass-specific tie'): generator.model.validate_document(source) def test_failure_preserves_generated_assets(self): with tempfile.TemporaryDirectory() as temp: root = pathlib.Path(temp) data = root / 'data' (data / 'songs').mkdir(parents=True) (data / 'songs' / 'bad.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=5)), encoding='utf-8') output = root / 'output' output.mkdir() (output / 'last-good').write_text('preserved') args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False) with self.assertRaises(ValueError): generator.__main__.generate_assets(args) self.assertEqual((output / 'last-good').read_text(), 'preserved') (data / 'songs' / 'bad.xml').write_text(tests.test_musicxml.xml_score('Cmajor' + tests.test_musicxml.xml_note()), encoding='utf-8') args.check = True # --check uses the validation-only repository fixture. An alternate # source tree fails its form contract before atomic publication. with self.assertRaisesRegex(ValueError, 'performance generator drift'): generator.__main__.generate_assets(args) self.assertEqual((output / 'last-good').read_text(), 'preserved') def test_instrument_transposition_is_converted_once(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes='-1-2')) concert = generator.pitch.concert_document(source) p = tests.test_musicxml.notes(concert)[0]['pitches'][0] self.assertEqual((p['letter'], generator.model.fraction(p['alter']), p['octave']), ('b', -1, 3)) self.assertEqual(generator.pitch.concert_document(concert), concert) self.assertEqual(concert['metadata']['key_signature'], 'Bb') def test_deterministic_generation_and_removal(self): with tempfile.TemporaryDirectory() as temp: data, output = pathlib.Path(temp) / 'data', pathlib.Path(temp) / 'output' (data / 'songs').mkdir(parents=True) source = data / 'songs' / 'fixture.xml' source.write_text(tests.test_musicxml.xml_score('Cmajor' + tests.test_musicxml.xml_note()), encoding='utf-8') args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False) generator.__main__.generate_assets(args) first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()} generator.__main__.generate_assets(args) self.assertEqual(first, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}) source.unlink() generator.__main__.generate_assets(args) self.assertFalse(list(output.rglob('*.json'))) self.assertFalse(list(output.rglob('*.png'))) def test_pickup_tie_survives_vocal_restoration(self): source = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra=''), implicit=True) source = source.replace('', '' + tests.test_musicxml.xml_note(extra='') + '') result = generator.arrange.generate.arrange_document(tests.test_musicxml.parse(source), 'vocal') generator.model.validate_document(result) measures = result['staves'][0]['measures'] start = measures[0]['voices'][0]['notes'][-1]['pitches'][0] end = measures[1]['voices'][0]['notes'][0]['pitches'][0] self.assertEqual(start['tie_to'], end['id'])