import generator.validation import copy import fractions import pathlib import unittest import unittest.mock import generator.acoustic import generator.arrange.generate import generator.engraving.fonts import generator.engraving.layout import generator.engraving.types import generator.model import tests.score_events import tests.arrangement_fixtures import tests.test_accompaniment import tests.test_musicxml import tests.test_parts def sounding(document): return sorted(tuple(generator.model.fraction(event[key]) for key in ('onset', 'duration', 'pitch')) for event in tests.score_events.timeline(document)['events']) def acoustic_document(bars): document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * len(bars))) staff = document['staves'][0] staff.update(arrangement_part_type='acoustic', tablature=True) for measure, (bass, upper) in zip(staff['measures'], bars): measure['transpose']['octave_change'] = -1 measure['voices'] = [] for lane, attacks, positions in (('bass', bass, (('d', 5, 5),)), ('upper', upper, (('f', 4, 3), ('a', 3, 2)))): notes, cursor = [], fractions.Fraction() for onset, duration in attacks: onset, duration = fractions.Fraction(onset), fractions.Fraction(duration) if onset > cursor: notes.append(generator.model.make_event(f'{measure["id"]}-{lane}-{len(notes)}', cursor, onset - cursor, 'rest')) pitches = [{'letter': letter, 'alter': generator.model.rational(0), 'octave': 4, 'tablature': {'string': string, 'fret': fret}} for letter, string, fret in positions] notes.append(generator.model.make_event(f'{measure["id"]}-{lane}-{len(notes)}', onset, duration, 'note', pitches)) cursor = onset + duration if cursor < 4: notes.append(generator.model.make_event(f'{measure["id"]}-{lane}-{len(notes)}', cursor, 4 - cursor, 'rest')) for note in notes: note.update(voice=lane, stem='down' if lane == 'bass' else 'up') measure['voices'].append({'id': lane, 'notes': notes}) generator.validation.validate_document(document) return document class AcousticTests(unittest.TestCase): def combine(self, document): generator.acoustic.prepare(document) generator.validation.validate_document(document) prepared = copy.deepcopy(document) generator.acoustic.prepare(document) self.assertEqual(document, prepared) before = sounding(document) generator.acoustic.combine(document) generator.validation.validate_document(document) self.assertEqual(sounding(document), before) self.assertTrue(all(len(m['voices']) == 1 for m in document['staves'][0]['measures'])) for measure in document['staves'][0]['measures']: for note in measure['voices'][0]['notes']: self.assertNotIn('stem', note) self.assertNotIn('beams', note) for pitch in note['pitches']: self.assertNotIn('stem', pitch) self.assertNotIn('tie_from', pitch) self.assertNotIn('tie_to', pitch) once = copy.deepcopy(document) generator.acoustic.combine(document) self.assertEqual(document, once) return [m['voices'][0]['notes'] for m in document['staves'][0]['measures']] def test_simultaneous_attacks_share_one_chord(self): notes = self.combine(acoustic_document([([(0, 2), (3, 1)], [(0, 2), (3, 1)])]))[0] self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 2, 'note'), (2, 1, 'rest'), (3, 1, 'note')]) self.assertEqual([len(n['pitches']) for n in notes], [3, 0, 3]) def test_bass_releases_at_next_upper_attack_without_reattacking(self): notes = self.combine(acoustic_document([([(0, 4)], [(1, 1), (3, 1)])]))[0] self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 1, 'note'), (1, 1, 'note'), (2, 1, 'rest'), (3, 1, 'note')]) self.assertEqual([len(n['pitches']) for n in notes], [1, 2, 0, 2]) def test_repeated_pitch_is_an_attack_not_a_tie(self): notes = self.combine(acoustic_document([([(0, 2), (2, 2)], [(0, 4)])]))[0] self.assertEqual(len(notes), 2) self.assertNotIn('tie_to', notes[0]['pitches'][0]) self.assertNotIn('tie_from', notes[1]['pitches'][0]) self.assertEqual([len(n['pitches']) for n in notes], [3, 1]) def test_simultaneous_tones_release_at_shortest_planned_duration(self): notes = self.combine(acoustic_document([([(0, 4)], [(0, 1)])]))[0] self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 1, 'note'), (1, 3, 'rest')]) self.assertEqual(len(notes[0]['pitches']), 3) def test_barline_releases_notes_before_next_measures_attacks(self): document = acoustic_document([([(0, 4)], [(2, 2)]), ([(0, 2)], [(0, 1), (2, 1)])]) notes = self.combine(document) self.assertEqual(tests.test_accompaniment.timing(notes[0]), [(0, 2, 'note'), (2, 2, 'note')]) self.assertEqual(tests.test_accompaniment.timing(notes[1]), [(0, 1, 'note'), (1, 1, 'rest'), (2, 1, 'note'), (3, 1, 'rest')]) self.assertEqual(notes[1][-1]['kind'], 'rest') def test_silence_collapses_to_a_single_measure_rest(self): notes = self.combine(acoustic_document([([], [])]))[0] self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 4, 'rest')]) self.assertTrue(notes[0]['measure_rest']) def test_pickup_keeps_its_actual_duration(self): document = acoustic_document([([(0, 1)], [(0, 1)])]) measure = document['staves'][0]['measures'][0] measure.update(duration=generator.model.rational(1), implicit=True) for voice in measure['voices']: voice['notes'] = voice['notes'][:1] notes = self.combine(document)[0] self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 1, 'note')]) def test_five_eighths_shortens_to_one_note_without_ties_or_reattacks(self): notes = self.combine(acoustic_document([([(0, fractions.Fraction(5, 2))], [])]))[0] self.assertEqual([n['written'] for n in notes], [{'type': 'half', 'dots': 0}, {'type': 'half', 'dots': 0}]) self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 2, 'note'), (2, 2, 'rest')]) def test_unrelated_voices_and_solo_slashes_are_unchanged(self): document = acoustic_document([([(0, 4)], [(0, 4)])]) for part in ('electric', 'vocal', None): document['staves'][0]['arrangement_part_type'] = part before = copy.deepcopy(document) generator.acoustic.prepare(document) generator.acoustic.combine(document) self.assertEqual(document, before) document['staves'][0]['arrangement_part_type'] = 'acoustic' measure = document['staves'][0]['measures'][0] measure['voices'] = [{'id': 'voice_arranged', 'notes': [generator.model.make_event('slash', fractions.Fraction(), fractions.Fraction(4), 'note') | {'voice': 'voice_arranged', 'notehead': 'slash'}]}] before = copy.deepcopy(document) generator.acoustic.prepare(document) generator.acoustic.combine(document) self.assertEqual(document, before) def test_song_and_section_integration_preserve_playback(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note())) for builder in (generator.arrange.generate.arrange_document, tests.arrangement_fixtures.section_document): with unittest.mock.patch('generator.acoustic.combine'): separate = builder(source, 'acoustic') combined = builder(source, 'acoustic') self.assertEqual(sounding(separate), sounding(combined)) generator.validation.validate_document(combined) for measure in combined['staves'][0]['measures']: self.assertIn(len(measure['voices']), (1, 2)) if len(measure['voices']) == 2: self.assertEqual({v['id'] for v in measure['voices']}, {'upper', 'bass'}) def test_engraved_chord_has_one_stem_and_no_competing_rests(self): document = acoustic_document([([(0, 4)], [(1, 2), (3, 1)])]) self.combine(document) fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma')) layout = generator.engraving.layout.layout_score(document, generator.engraving.types.Style(), fonts) primitives = [p for page in layout.pages for p in page.primitives] self.assertFalse([p for p in primitives if p.role in ('rest', 'tab-rest')]) for note in document['staves'][0]['measures'][0]['voices'][0]['notes']: self.assertEqual(len([p for p in primitives if p.role == 'stem' and note['id'] in p.sources]), 1) self.assertFalse([p for p in primitives if p.role in ('tie', 'tab_tie')])