import collections import copy import dataclasses import fractions import pathlib import unittest import unittest.mock import xml.etree.ElementTree import PIL.Image import PIL.ImageChops import generator.engraving.audit import generator.engraving.cache import generator.engraving.fonts import generator.engraving.layout import generator.engraving.notation import generator.engraving.raster import generator.engraving.types import generator.guitar import generator.model import generator.musicxml import generator.playback import generator.practice.generate import tests.rhythm_fixtures import tests.test_musicxml class EngravingTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma')) def layout(self, source, style=None): before = copy.deepcopy(source) layout = generator.engraving.layout.layout_score(source, style or generator.engraving.types.Style(), self.fonts) self.assertEqual(source, before) return layout def primitives(self, layout, role): return [p for page in layout.pages for p in page.primitives if p.role == role] def test_notation_fixture_visual_golden(self): layout = self.layout(generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/notation.xml'))) self.assertEqual(len(layout.pages), 1) actual = generator.engraving.raster.rasterize_page(layout.pages[0], self.fonts) with PIL.Image.open('tests/fixtures/notation-python.png') as expected: self.assertIsNone(PIL.ImageChops.difference(actual, expected.convert('RGB')).getbbox()) def test_aligned_bands_match_full_pages_with_spans_tab_and_sampling_phases(self): tied = tests.test_musicxml.xml_note('E', 2, 'half', extra='') + tests.test_musicxml.xml_note('E', 2, 'half', extra='') dense = ''.join(tests.test_musicxml.xml_note('G', 1, '64th') for _ in range(64)) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(dense, divisions=16)) documents = [generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/notation.xml')), tests.test_musicxml.parse(tests.test_musicxml.xml_score(tied)), generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())] checked = 0 for sampling in (1, 2, 3, 4): for document in documents: style = generator.engraving.types.Style(supersampling=sampling, margin_y=18.25) for page in self.layout(document, style).pages: with self.subTest(sampling=sampling, title=document['metadata']['title'], page=page.number): bands = generator.engraving.raster.page_bands(page) self.assertIsNotNone(bands) with generator.engraving.raster.rasterize_band(page, bands[1].primitives, bands[1].top, bands[1].bottom, self.fonts) as body: with generator.engraving.raster.compose_page(page, self.fonts, bands, body) as actual, generator.engraving.raster.rasterize_page(page, self.fonts) as reference: self.assertIsNone(PIL.ImageChops.difference(actual, reference).getbbox()) checked += 1 self.assertGreater(checked, 12) def test_band_key_tracks_positions_dimensions_and_complete_context(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) systems = generator.engraving.layout.layout_systems(source, generator.engraving.types.Style(), self.fonts) page = generator.engraving.layout.paginate(systems, source, self.fonts, generator.engraving.types.Style())[0] key = generator.engraving.cache.band_fingerprint('body', page, generator.engraving.raster.page_bands(page)[1]) source['metadata']['title'] = 'g' * 100 changed = generator.engraving.layout.paginate(systems, source, self.fonts, generator.engraving.types.Style())[0] self.assertNotEqual(page.systems[0].box, changed.systems[0].box) self.assertNotEqual(key, generator.engraving.cache.band_fingerprint('body', changed, generator.engraving.raster.page_bands(changed)[1])) self.assertNotEqual(key, generator.engraving.cache.band_fingerprint('another body', page, generator.engraving.raster.page_bands(page)[1])) self.assertNotEqual(key, generator.engraving.cache.band_fingerprint('body', dataclasses.replace(page, supersampling=2), generator.engraving.raster.page_bands(page)[1])) def test_nonseparable_page_falls_back_to_full_page(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) page = self.layout(source).pages[0] decoration = self.fonts.text('Overlay', 50, page.systems[0].box.top + 4, 12, 'subtitle', ()) primitive = self.fonts.text('Extra', 50, 400, 12, 'other', ()) for altered in (dataclasses.replace(page, primitives=page.primitives + (primitive,)), dataclasses.replace(page, primitives=page.primitives[:1] + (decoration,) + page.primitives[1:])): self.assertIsNone(generator.engraving.raster.page_bands(altered)) def test_musical_style_does_not_become_a_subtitle(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) before = self.layout(source) source['metadata']['style'] = 'Unrelated musical metadata' self.assertEqual(before, self.layout(source)) self.assertNotEqual(before, self.layout(source, generator.engraving.types.Style(column_gap=16))) def test_candidate_columns_are_reused_without_changing_clipped_sustains(self): content = tests.test_musicxml.xml_note(extra='held') + '4' content += ''.join(tests.test_musicxml.xml_note('G', 1, 'quarter', extra='moving') for _ in range(4)) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) index = generator.engraving.notation.ScoreIndex(source) columns = collections.OrderedDict() style = generator.engraving.types.Style() with unittest.mock.patch('generator.engraving.layout.column', wraps=generator.engraving.layout.column) as draw: for end in (2, 3, 4, 4): actual = generator.engraving.layout.make_system(index, fractions.Fraction(), fractions.Fraction(end), self.fonts, style, columns=columns) with unittest.mock.patch('generator.engraving.layout.column', wraps=draw._mock_wraps): expected = generator.engraving.layout.make_system(index, fractions.Fraction(), fractions.Fraction(end), self.fonts, style) self.assertEqual(actual, expected) self.assertLess(draw.call_count, 2 + 3 + 4 + 4) def test_accidental_work_is_bounded_by_local_event_count(self): class Unscannable(list): def __iter__(self): raise AssertionError('whole-score event scan') class Counted(list): visited = 0 def __iter__(self): for value in super().__iter__(): Counted.visited += 1 yield value for count in (100, 200, 400): content = ''.join(tests.test_musicxml.xml_note(duration=1, kind='quarter', alter=i % 2) for i in range(4)) extra = ''.join(f'{content}' for i in range(1, count)) document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content).replace('', extra + '')) index = generator.engraving.notation.ScoreIndex(document) expected = index.accidentals index.events = Unscannable(index.events) index.events_by_onset = {time: Counted(events) for time, events in index.events_by_onset.items()} Counted.visited = 0 self.assertEqual(index.accidental_state(), expected) self.assertEqual(Counted.visited, count * 4) def test_indexed_columns_preserve_sustains_and_timed_annotations(self): class Unscannable(list): def __iter__(self): raise AssertionError('whole-score scan from column layout') content = tests.test_musicxml.xml_note(extra='held') + '4' content += ''.join(tests.test_musicxml.xml_note('G', 1, 'quarter', extra='moving') for _ in range(4)) content += 'Fmajor-2' content += 'quarter90-2' index = generator.engraving.notation.ScoreIndex(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))) expected = generator.engraving.layout.column(index, fractions.Fraction(2), fractions.Fraction(2), fractions.Fraction(4), self.fonts, generator.engraving.types.Style()) index.events = Unscannable(index.events) index.measures = Unscannable(index.measures) actual = generator.engraving.layout.column(index, fractions.Fraction(2), fractions.Fraction(2), fractions.Fraction(4), self.fonts, generator.engraving.types.Style()) self.assertEqual(actual, expected) self.assertEqual(len(index.events_at(fractions.Fraction(2), include_sustained=True)), 2) self.assertTrue(any(p.role == 'harmony' for p in actual[0])) self.assertEqual(len(index.events_at(fractions.Fraction(4), include_sustained=True)), 0) def test_every_written_note_and_rest_value(self): for kind in generator.model.NOTE_TYPES: for dots in (0, 1, 3): duration = generator.model.TYPE_LENGTHS[kind] * (2 - fractions.Fraction(1, 2 ** dots)) for rest in (False, True): with self.subTest(kind=kind, dots=dots, rest=rest): content = tests.test_musicxml.xml_note(duration=duration.numerator, kind=kind, extra='' * dots) if rest: content = f'{duration.numerator}{kind}' + '' * dots + '' source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=duration.denominator, implicit=True)) layout = self.layout(source) self.assertEqual(len(self.primitives(layout, 'dot')), dots) self.assertEqual(len(self.primitives(layout, 'rest' if rest else 'head')), 1) def test_sixteen_dots_are_not_truncated(self): duration = generator.model.TYPE_LENGTHS['quarter'] * (2 - fractions.Fraction(1, 2 ** 16)) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=duration.numerator, kind='quarter', extra='' * 16), divisions=duration.denominator, implicit=True)) self.assertEqual(len(self.primitives(self.layout(source), 'dot')), 16) def test_arbitrary_voice_ids_and_more_than_four_voices(self): content = '' for i in range(6): if i: content += '4' content += tests.test_musicxml.xml_note(extra=f'part-{i}') source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) self.assertEqual(len(self.primitives(self.layout(source), 'head')), 6) def test_microtonal_numeric_fallback_and_explicit_missing_glyph(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter='0.1'))) self.assertEqual(self.primitives(self.layout(source), 'accidental')[0].value, '(+1/10 st)') tests.test_musicxml.notes(source)[0]['pitches'][0]['accidental_display'] = {'name': 'other', 'smufl': 'missingVampGlyph'} with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'measure 1, voice 1.*missingVampGlyph'): self.layout(source) def test_accidentals_are_shared_by_simultaneous_voices_without_losing_conflicts(self): content = tests.test_musicxml.xml_note(duration=1, kind='quarter', alter=1, extra='a') content += tests.test_musicxml.xml_note(duration=1, kind='quarter', alter=1, extra='a') content += tests.test_musicxml.xml_note(duration=2, kind='half', extra='a') source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) self.assertEqual([p.value for p in self.primitives(self.layout(source), 'accidental')], ['accidentalSharp', 'accidentalNatural']) conflict = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter=1, extra='a') + '4' + tests.test_musicxml.xml_note(extra='b'))) self.assertEqual(len(self.primitives(self.layout(conflict), 'accidental')), 2) def test_dense_measure_splits_exactly_without_modifying_music(self): content = ''.join(tests.test_musicxml.xml_note('G', 1, '64th', extra='fast') for _ in range(64)) content += '64' + tests.test_musicxml.xml_note('C', 64, 'whole', extra='held') source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=16)) layout = self.layout(source) systems = [s for p in layout.pages for s in p.systems] self.assertGreater(len(systems), 1) held = source['staves'][0]['measures'][0]['voices'][1]['notes'][0]['pitches'][0]['id'] pieces = [f for s in systems for f in s.fragments if f.source == held] self.assertEqual(sum(f.length() for f in pieces), 4) self.assertGreater(len(pieces), 1) self.assertTrue(self.primitives(layout, 'tie')) for page in layout.pages: self.assertEqual(generator.engraving.raster.rasterize_page(page, self.fonts).size, (976, 484)) def test_hidden_pitch_does_not_suppress_a_visible_accidental(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', alter=1) + tests.test_musicxml.xml_note(duration=2, kind='half', alter=1))) tests.test_musicxml.notes(source)[0]['hidden'] = True self.assertEqual([p.value for p in self.primitives(self.layout(source), 'accidental')], ['accidentalSharp']) def test_octave_shift_changes_display_not_pitch(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=5))) original = generator.engraving.notation.ScoreIndex(source).pitch_position(tests.test_musicxml.notes(source)[0]['pitches'][0], fractions.Fraction()) source['staves'][0]['octave_shifts'] = [{'id': 'octave', 'start': generator.model.rational(0), 'end': generator.model.rational(4), 'octaves': -1}] index = generator.engraving.notation.ScoreIndex(source) self.assertEqual(index.pitch_position(tests.test_musicxml.notes(source)[0]['pitches'][0], fractions.Fraction()), original - 7) self.assertTrue(any(p.value == '8va' for p in self.primitives(self.layout(source), 'octave-shift'))) def test_wide_pitch_range_can_break_inside_a_measure_vertically(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', octave=10) + tests.test_musicxml.xml_note(duration=2, kind='half', octave=-1))) layout = self.layout(source) self.assertEqual(len(layout.pages), 2) self.assertEqual(len(self.primitives(layout, 'head')), 2) def test_cue_grace_and_tremolo_are_drawn(self): grace = 'D416th' source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(grace + tests.test_musicxml.xml_note(duration=4, kind='whole', prefix=''))) layout = self.layout(source) self.assertEqual(len(self.primitives(layout, 'head')), 2) self.assertEqual(len(self.primitives(layout, 'grace-slash')), 1) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', extra='8'), implicit=True)) self.assertEqual(len(self.primitives(self.layout(source), 'tremolo')), 8) def test_percussion_and_tab_use_explicit_display_positions(self): percussion = 'C54wholex' source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(percussion, attributes='percussion')) self.assertEqual(self.primitives(self.layout(source), 'head')[0].value, 'noteheadXWhole') tab = generator.practice.generate.practice_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('C', octave=7))), 'electric', rhythms=tests.rhythm_fixtures.library()) self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '20') def test_generated_tab_shares_columns_and_systems_without_duplicating_music(self): content = ''.join(tests.test_musicxml.xml_note('G', 1, '64th') for _ in range(64)) source = generator.practice.generate.practice_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=16)), 'electric', rhythms=tests.rhythm_fixtures.library()) layout = self.layout(source) systems = [s for page in layout.pages for s in page.systems] self.assertGreater(len(systems), 1) self.assertEqual(sum(len(s.fragments) for s in systems), 64) for system in systems: heads = [p for p in system.primitives if p.role == 'head'] tabs = [p for p in system.primitives if p.role == 'tab'] self.assertEqual(len(heads), len(tabs)) self.assertEqual(len({p.points[0][1] for p in system.primitives if p.role == 'tab-staff'}), 6) self.assertLess(max(p.box.bottom for p in system.primitives if not p.role.startswith('tab')), min(p.box.top for p in system.primitives if p.role.startswith('tab'))) self.assertEqual({p.sources[-1] for p in heads}, {p.sources[-1] for p in tabs}) self.assertTrue(all(any(abs(head.points[0][0] - tab.points[0][0]) < 0.01 for head in heads if head.sources[-1] == tab.sources[-1]) for tab in tabs)) self.assertEqual(len(generator.playback.compile_timeline(source)['events']), 64) def test_generated_tab_ties_and_barline_styles(self): content = tests.test_musicxml.xml_note('E', 2, 'half', extra='') + tests.test_musicxml.xml_note('E', 2, 'half', extra='') + 'dashed' source = generator.practice.generate.practice_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)), 'electric', rhythms=tests.rhythm_fixtures.library()) layout = self.layout(source) self.assertEqual(len(self.primitives(layout, 'tab-tie')), 1) self.assertEqual(len(self.primitives(layout, 'barline')), len(self.primitives(layout, 'tab-barline'))) for system in (s for page in layout.pages for s in page.systems): baselines = {role: max(p.points[0][1] for p in system.primitives if p.role == role) for role in ('staff', 'tab-staff')} notation = [p for p in system.primitives if p.role == 'barline'] tabs = [p for p in system.primitives if p.role == 'tab-barline'] for bar, tab in zip(notation, tabs): for (x1, y1), (x2, y2) in zip(bar.points, tab.points): self.assertAlmostEqual(x1, x2) self.assertAlmostEqual((y1 - baselines['staff']) * 5 / 4, y2 - baselines['tab-staff']) def test_adjacent_string_tab_numerals_do_not_overlap(self): content = ''.join(tests.test_musicxml.xml_note(step, octave=octave, prefix='' if index else '') for index, (step, octave) in enumerate((('G', 3), ('C', 4), ('E', 4), ('G', 4), ('C', 5), ('E', 5)))) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) generator.guitar.place_guitar(source) layout = self.layout(source) tabs = self.primitives(layout, 'tab') self.assertEqual(len(tabs), 6) for i, first in enumerate(tabs): for second in tabs[i + 1:]: self.assertFalse(first.box.overlaps(second.box, 0.1)) def test_lyrics_hyphens_and_extenders(self): begin = 'beginhel' end = 'endlo' source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra=begin) + tests.test_musicxml.xml_note(duration=1, kind='quarter', extra=end) + tests.test_musicxml.xml_note(duration=2, kind='half'))) layout = self.layout(source) self.assertTrue(self.primitives(layout, 'lyric-hyphen')) self.assertTrue(self.primitives(layout, 'lyric-extension')) def test_nested_and_cross_bar_tuplets_keep_span_ownership(self): modulation = '32' content = ''.join(tests.test_musicxml.xml_note(duration=2, kind='quarter', extra=modulation) for _ in range(6)) tree = xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_score(content, divisions=3)) second = xml.etree.ElementTree.SubElement(tree.find('part'), 'measure', number='2') for element in xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_score(content, divisions=3)).findall('part/measure/note'): second.append(element) source = tests.test_musicxml.parse(xml.etree.ElementTree.tostring(tree, encoding='unicode')) events = tests.test_musicxml.notes(source) + tests.test_musicxml.notes(source, 1) spans = [{'id': 'outer', 'voice': '1', 'start': events[0]['id'], 'end': events[-1]['id'], 'note_count': 3, 'space_count': 2, 'depth': 0, 'bracket': 'yes', 'show_number': 'both', 'show_type': 'both', 'line_shape': 'curved'}, {'id': 'inner', 'voice': '1', 'start': events[1]['id'], 'end': events[3]['id'], 'note_count': 3, 'space_count': 2, 'depth': 1, 'bracket': 'yes', 'show_number': 'actual'}] source['staves'][0]['tuplets'] = spans for i, event in enumerate(events): event['tuplet_ids'] = ['outer'] + (['inner'] if 1 <= i <= 3 else []) layout = self.layout(source, generator.engraving.types.Style(width=600)) self.assertGreater(len([s for p in layout.pages for s in p.systems]), 1) self.assertEqual({p.sources[0] for p in self.primitives(layout, 'tuplet-bracket')}, {'outer', 'inner'}) def test_paired_tremolo_and_double_stems(self): modulation = '21' content = ''.join(tests.test_musicxml.xml_note(step, 2, 'whole', extra=modulation + f'3') for step, action in [('C', 'start'), ('G', 'stop')]) self.assertEqual(len(self.primitives(self.layout(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))), 'tremolo')), 3) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='eighth', extra='double'), divisions=2, implicit=True)) layout = self.layout(source) self.assertEqual(len(self.primitives(layout, 'stem')), 2) self.assertEqual(len(self.primitives(layout, 'flag')), 2) def test_meter_symbols_and_timed_attributes(self): for symbol in ('common', 'cut', 'single-number', 'note', 'dotted-note', 'normal'): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()).replace('