import generator.validation import copy import fractions import pathlib import unittest import generator.arrange.generate import generator.arrange.pickup import generator.model import generator.musicxml import generator.parts import tests.score_events import generator.score import tests.test_musicxml import tests.test_parts def source(pickup, body=None, *, implicit=True, divisions=1): body = body or tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=4 * divisions) xml = tests.test_musicxml.xml_score(pickup, implicit=implicit, divisions=divisions) return tests.test_musicxml.parse(xml.replace('', '' + body + '')) class ArrangementPickupTests(unittest.TestCase): def test_catalog_body_lengths_and_part_timelines(self): for path in sorted(pathlib.Path('data/songs').glob('*.xml')): document = generator.musicxml.load_musicxml(path) before = copy.deepcopy(document) prepared = generator.parts.prepare_document(document) timelines = [] for part in generator.parts.PART_TYPES: with self.subTest(song=path.stem, part=part): result = generator.arrange.generate.arrange_document(document, part) generator.validation.validate_document(result) measures = result['staves'][0]['measures'] self.assertEqual(len(measures), 171) self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 172)]) self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137', '169']) self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 684) timelines.append([m['duration'] for m in measures]) if path.stem == 'autumn_leaves' and part in ('vocal', 'electric'): for boundary in ([8, 136] if part == 'vocal' else [40]): notes = [n for n in generator.score.measure_notes(measures[boundary - 1]) if generator.model.fraction(n['onset']) >= 1 and n['pitches']] self.assertEqual([generator.model.fraction(n['onset']) for n in notes], [1, 2, 3]) self.assertEqual([n['pitches'][-1]['letter'] for n in notes], ['d', 'e', 'f']) self.assertTrue(all(n['pitches'][-1]['octave'] == (5 if part == 'electric' else 4) for n in notes)) self.assertFalse(measures[boundary - 1]['attributes']) self.assertTrue(all(timeline == timelines[0] for timeline in timelines)) self.assertEqual(document, before) def test_unmarked_leading_rests_are_not_pickups(self): opening = '1quarter' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='') document = source(opening, implicit=False) result = generator.arrange.generate.arrange_document(document, 'vocal') self.assertEqual(len(result['staves'][0]['measures']), 15) self.assertEqual(generator.score.body_start_measure_index(document['staves'][0]['measures']), 0) def test_short_pickup_ties_and_octave_spans_survive_each_melody_entry(self): document = source(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra=''), tests.test_musicxml.xml_note(extra='')) document['staves'][0]['octave_shifts'] = [{'id': 'pickup-shift', 'start': generator.model.rational(0), 'end': generator.model.rational(1), 'octaves': -1}] for part, boundaries in (('vocal', (1, 5)), ('electric', (2,))): with self.subTest(part=part): result = generator.arrange.generate._arrange_document(generator.parts.prepare_document(document), part, owned={'melody', 'vocal'}) generator.validation.validate_document(result) measures = result['staves'][0]['measures'] self.assertEqual(len(measures), 9) for boundary in boundaries: pickup = generator.score.measure_notes(measures[boundary - 1])[-1] downbeat = generator.score.measure_notes(measures[boundary])[0] self.assertEqual(generator.model.fraction(pickup['onset']), 3) self.assertEqual(pickup['pitches'][0]['tie_to'], downbeat['pitches'][0]['id']) self.assertEqual(downbeat['pitches'][0]['tie_from'], pickup['pitches'][0]['id']) self.assertTrue(any(generator.model.fraction(s['start']) == boundary * 4 - 1 and generator.model.fraction(s['end']) == boundary * 4 for s in result['staves'][0]['octave_shifts'])) self.assertTrue(tests.score_events.timeline(result)['events']) def test_pickup_truncates_outgoing_music_without_discarding_earlier_notes(self): opening = '1quarter' + tests.test_musicxml.xml_note('D', 3, 'half', extra='doo') document = source(opening) pickup, destination = copy.deepcopy(document['staves'][0]['measures']) before = copy.deepcopy(destination['voices'][0]['notes'][0]) start = generator.arrange.pickup.splice(destination, pickup) self.assertEqual(start, 1) notes = generator.score.measure_notes(destination) self.assertEqual(len(notes), 2) self.assertEqual(notes[0]['id'], before['id']) self.assertEqual(notes[0]['pitches'][0]['id'], before['pitches'][0]['id']) self.assertEqual(generator.model.fraction(notes[0]['duration']), 1) self.assertEqual(notes[0]['written']['type'], 'quarter') self.assertEqual(notes[1]['lyrics'][0]['syllables'][0]['text'], 'doo') self.assertEqual([generator.model.fraction(n['onset']) for n in notes], [0, 1]) document['staves'][0]['measures'] = [destination] generator.validation.validate_document(document) def test_pickup_harmonies_are_planned_for_every_part_at_the_same_offset(self): document = source(tests.test_parts.xml_harmony('F') + tests.test_musicxml.xml_note('F', 1, 'quarter')) prepared = generator.parts.prepare_document(document) for part in generator.parts.PART_TYPES: result = generator.arrange.generate.arrange_document(document, part) generator.validation.validate_document(result) for index in (0, 1, 4): chords = result['staves'][0]['measures'][index]['harmonies'] self.assertEqual([(generator.model.fraction(h['onset']), h['root']['letter']) for h in chords], [(0, 'c'), (3, 'f')]) def test_pickup_tuplets_keep_unique_identities_across_iterations(self): notes = '' for index, pitch in enumerate(('C', 'D', 'E')): tuplet = '' if index == 0 else '' if index == 2 else '' notes += tests.test_musicxml.xml_note(pitch, 1, 'eighth', extra='32eighth' + tuplet) document = source(notes, divisions=3) for part in ('vocal', 'electric'): result = generator.arrange.generate.arrange_document(document, part) generator.validation.validate_document(result) spans = result['staves'][0]['tuplets'] self.assertEqual(len(spans), 2 if part == 'vocal' else 1) self.assertEqual(len({s['id'] for s in spans}), len(spans)) for span in spans: members = [n for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) if span['id'] in n.get('tuplet_ids', [])] self.assertEqual(len(members), 3) self.assertEqual([generator.model.fraction(n['onset']) for n in members], [3, fractions.Fraction(10, 3), fractions.Fraction(11, 3)]) def test_overlong_pickup_is_rejected_with_measure_context(self): document = source(tests.test_musicxml.xml_note(duration=4), '' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='')) with self.assertRaisesRegex(ValueError, 'measure 2: pickup is longer'): generator.arrange.generate.arrange_document(document, 'vocal')