import collections import copy import dataclasses import fractions import math 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.performance import tests.score_events import tests.arrangement_fixtures import tests.test_accompaniment 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_tab_omits_rests_but_keeps_standard_notation(self): content = tests.test_musicxml.xml_note('E', 2, 'half', octave=5) + '2half' source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) document = tests.arrangement_fixtures.section_document(source, 'electric') layout = self.layout(document) self.assertEqual(len(self.primitives(layout, 'rest')), 1) self.assertEqual(self.primitives(layout, 'tab-rest'), []) self.assertEqual(len(self.primitives(layout, 'tab')), 1) def assert_paired_pages(self, layout): self.assertTrue(all(len(page.systems) == 2 for page in layout.pages[:-1])) self.assertIn(len(layout.pages[-1].systems), (1, 2)) self.assertEqual(len({system.scale for page in layout.pages for system in page.systems}), 1) for page in layout.pages: self.assertEqual(len({system.scale for system in page.systems}), 1) for system in page.systems: for role in ('staff', 'tab_staff'): lines = [p for p in system.primitives if p.role == role] if lines: self.assertAlmostEqual(min(x for p in lines for x, _ in p.points), layout.style.margin_x + 1) self.assertAlmostEqual(max(x for p in lines for x, _ in p.points), page.width - layout.style.margin_x - 1) for first, second in zip(page.systems, page.systems[1:]): self.assertGreaterEqual(second.box.top - first.box.bottom, layout.style.system_gap - 0.001) def test_titles_sections_and_tempos_do_not_change_size_between_pages(self): source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * 40)) source['metadata']['composer'] = 'Composer' for index in (0, 9, 18, 27): measure = source['staves'][0]['measures'][index] measure['section_label'] = f'Section {index}' measure['tempos'] = [{'onset': generator.model.rational(fractions.Fraction(1, 2)), 'written': {'type': 'quarter', 'dots': 0}, 'bpm': generator.model.rational(124)}] layout = self.layout(source) self.assertGreater(len(layout.pages), 2) self.assert_paired_pages(layout) self.assertEqual(layout.pages[0].systems[0].scale, 1) self.assertEqual(self.primitives(layout, 'tempo'), []) self.assertEqual(len(self.primitives(layout, 'section')), 4) self.assertEqual(len(self.primitives(layout, 'subtitle')), len(layout.pages)) without_tempos = copy.deepcopy(source) for measure in without_tempos['staves'][0]['measures']: measure.pop('tempos', None) self.assertEqual(layout, self.layout(without_tempos)) self.assertEqual(tests.score_events.timeline(source), tests.score_events.timeline(without_tempos)) def test_long_credits_wrap_without_overflow_or_page_specific_sizing(self): source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * 32)) source['metadata']['composer'] = 'A moderately long composer credit ' * 6 layout = self.layout(source) self.assert_paired_pages(layout) header = [p for p in layout.pages[0].primitives if p.role in ('title', 'subtitle')] self.assertGreater(len(header), 2) for page in layout.pages: self.assertEqual(header, [p for p in page.primitives if p.role in ('title', 'subtitle')]) def test_guitar_pages_always_pair_systems_including_tall_passages(self): content = tests.test_musicxml.xml_note('E', octave=5) source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 32)) document = tests.arrangement_fixtures.section_document(source, 'electric') layout = self.layout(document) self.assert_paired_pages(layout) self.assertEqual(layout.style.space, 10) self.assertEqual(layout.style.tab_font_size, 12) wide = tests.test_musicxml.xml_note('F', 2, 'half', octave=3) + tests.test_musicxml.xml_note('C', 2, 'half', octave=7) document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([wide] * 32)) for measure in document['staves'][0]['measures']: measure['transpose']['octave_change'] = -1 # Keep the tall written range while sounding inside the single-note limit. measure['attributes'].append({'onset': generator.model.rational(2), 'transpose': {**measure['transpose'], 'octave_change': -2}}) generator.guitar.place_guitar(document) tall = self.layout(document) self.assertGreater(len(tall.pages), 1) self.assert_paired_pages(tall) self.assertTrue(all(system.scale < 1 for page in tall.pages for system in page.systems)) self.assertEqual(tall.style, layout.style) def test_vocal_pages_have_two_systems_and_an_odd_final_system(self): content = tests.test_musicxml.xml_note(extra='singledoo') source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 32)) style = generator.engraving.types.Style() systems = generator.engraving.layout.layout_systems(source, style, self.fonts) self.assertGreaterEqual(len(systems), 3) pages = generator.engraving.layout.paginate(systems[:3], source, self.fonts, style) self.assertEqual([len(page.systems) for page in pages], [2, 1]) self.assert_paired_pages(self.layout(source)) def test_two_voice_acoustic_systems_can_share_a_page(self): harmony = 'Emajor' content = harmony + tests.test_musicxml.xml_note('G', 2, 'half', alter=1) + tests.test_musicxml.xml_note('E', 2, 'half', prefix='') + '2half' content += '4' + tests.test_musicxml.xml_note('B', octave=3, extra='2') document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 16)) positions = {'b': {'string': 5, 'fret': 2}, 'e': {'string': 4, 'fret': 2}, 'g': {'string': 3, 'fret': 1}} for measure in document['staves'][0]['measures']: measure['transpose']['octave_change'] = -1 for voice in measure['voices']: for note in voice['notes']: for pitch in note['pitches']: pitch['tablature'] = positions[pitch['letter']] generator.guitar.place_guitar(document) layout = self.layout(document) self.assert_paired_pages(layout) self.assertTrue(self.primitives(layout, 'rest')) self.assertFalse(self.primitives(layout, 'tab-rest')) 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_playback_windows_reuse_complete_systems_and_finish_on_top(self): for count in (1, 2, 3, 4): with self.subTest(systems=count): source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * count)) style = generator.engraving.types.Style() index = generator.engraving.notation.ScoreIndex(source) systems = [generator.engraving.layout.make_system(index, i * 4, (i + 1) * 4, self.fonts, style) for i in range(count)] pages, playback = generator.engraving.layout.page_views(systems, source, self.fonts, style, index=index) generator.engraving.audit.audit_layout(source, generator.engraving.types.LayoutDocument(pages, style, ''), self.fonts) generator.engraving.audit.audit_playback(pages, playback, style, self.fonts) self.assertEqual(len(pages), (count + 1) // 2) self.assertEqual(len(playback), count) self.assertEqual([[s.start for s in p.systems] for p in playback], [[i * 4, (i + 1) * 4] if i + 1 < count else [i * 4] for i in range(count)]) self.assertLess(playback[-1].systems[0].box.top, style.height / 2) for sequence in (pages, playback): for page in sequence: self.assertEqual(len({s.scale for s in page.systems}), 1) normal = [generator.performance.page_timing(source, p) for p in pages] sliding = [generator.performance.page_timing(source, p) for p in playback] generator.performance.validate_views(source, normal, sliding) def test_transition_pair_of_tall_systems_fits_at_shared_scale(self): short = tests.test_musicxml.xml_note() tall = tests.test_musicxml.xml_note('C', 2, 'half', octave=2) + tests.test_musicxml.xml_note('C', 2, 'half', octave=7) source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([short, tall, tall, short])) style = generator.engraving.types.Style() index = generator.engraving.notation.ScoreIndex(source) systems = [generator.engraving.layout.make_system(index, i * 4, (i + 1) * 4, self.fonts, style) for i in range(4)] pages, playback = generator.engraving.layout.page_views(systems, source, self.fonts, style) generator.engraving.audit.audit_playback(pages, playback, style, self.fonts) self.assertEqual(playback[1].systems[0].scale, pages[0].systems[0].scale) self.assertLess(playback[1].systems[1].box.bottom, style.height - style.margin_y) def test_measure_numbers_clear_high_notes_and_follow_barlines(self): source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note('C', octave=7)] * 3)) for i, measure in enumerate(source['staves'][0]['measures']): measure['number'] = str(169 + i) layout = self.layout(source) for page in layout.pages: for system in page.systems: labels = [p for p in system.primitives if p.role == 'measure_number'] self.assertEqual(len({round(p.points[0][1], 6) for p in labels}), 1) bars = [p for p in system.primitives if p.role == 'barline'] for i, label in enumerate(labels): center = (label.box.left + label.box.right) / 2 if i == 0: self.assertAlmostEqual(center, layout.style.margin_x + 1, places=2) else: self.assertTrue(any(abs(center - p.points[0][0]) < system.scale for p in bars)) for other in system.primitives: if other is not label: self.assertFalse(label.box.overlaps(other.box, 3 * system.scale)) 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='') content = ''.join(tests.test_musicxml.xml_note('G', 1, 'eighth') for _ in range(8)) source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 8, divisions=2)) documents = [generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/notation.xml')), tests.test_musicxml.parse(tests.test_musicxml.xml_score(tied)), tests.arrangement_fixtures.section_document(source, 'electric')] 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_line_breaks_only_use_complete_measures_even_when_part_would_fit(self): content = ''.join(tests.test_musicxml.xml_note(duration=1, kind='quarter') for _ in range(4)) source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 3)) index = generator.engraving.notation.ScoreIndex(source) style = generator.engraving.types.Style() trial = generator.engraving.layout.make_system(index, fractions.Fraction(), fractions.Fraction(5), self.fonts, style) style = dataclasses.replace(style, width=math.ceil(trial.box.right - trial.box.left + 2 * style.margin_x + 3)) layout = self.layout(source, style) self.assertEqual(layout.pages[0].systems[0].end, 4) self.assertEqual(layout.pages[0].systems[1].start, 4) self.assertTrue(all(s.start in index.boundaries and s.end in index.boundaries for p in layout.pages for s in p.systems)) self.assertFalse(self.primitives(layout, 'continuation')) self.assertEqual(len(self.primitives(layout, 'barline')), 3) self.assert_paired_pages(layout) def test_final_short_line_extends_staff_without_stretching_notes(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') * 2)) style = generator.engraving.types.Style() natural = generator.engraving.layout.layout_systems(source, style, self.fonts)[0] layout = self.layout(source, style) final = layout.pages[0].systems[0] for before, after in zip(natural.anchors, final.anchors): self.assertAlmostEqual(after.x, style.margin_x + 1 + before.x * final.scale) self.assert_paired_pages(layout) def test_beamed_tuplet_can_continue_across_an_ordinary_line_break(self): modification = '32' notes = [tests.test_musicxml.xml_note('G', 1, 'eighth', extra=modification + f'{action}') for action in ('begin', *(['continue'] * 22), 'end')] source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([''.join(notes[:12]), ''.join(notes[12:])], divisions=3)) events = tests.test_musicxml.notes(source) + tests.test_musicxml.notes(source, 1) source['staves'][0]['tuplets'] = [{'id': 'across-line', 'voice': '1', 'start': events[0]['id'], 'end': events[-1]['id'], 'note_count': 3, 'space_count': 2, 'depth': 0, 'bracket': 'yes'}] for event in events: event['tuplet_ids'] = ['across-line'] layout = self.layout(source, generator.engraving.types.Style(width=800)) systems = [s for p in layout.pages for s in p.systems] self.assertGreater(len(systems), 1) for system in systems: self.assertTrue(any(p.role == 'beam' for p in system.primitives)) self.assertTrue(any(p.role == 'tuplet-number' and 'across-line' in p.sources for p in system.primitives)) self.assertEqual(len(self.primitives(layout, 'barline')), 2) def test_accidental_memory_and_grace_groups_follow_the_measure_not_the_line(self): grace = 'D416th' content = ''.join(grace + tests.test_musicxml.xml_note(duration=1, kind='quarter', alter=1) for _ in range(4)) source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 2)) layout = self.layout(source, generator.engraving.types.Style(width=700)) systems = [s for p in layout.pages for s in p.systems] self.assertGreater(len(systems), 1) self.assertEqual([p.value for p in self.primitives(layout, 'accidental')], ['accidentalSharp', 'accidentalSharp']) events = tests.test_musicxml.notes(source) + tests.test_musicxml.notes(source, 1) for grace, principal in zip(events[::2], events[1::2]): for system in systems: identifiers = {a.event for a in system.anchors} self.assertEqual(grace['id'] in identifiers, principal['id'] in identifiers) def test_tab_ties_enter_and_leave_whole_measure_systems(self): bars = [] for ties in ('', '', ''): content = tests.test_musicxml.xml_note('E', octave=5, extra='held' + ties) + '4' content += ''.join(tests.test_musicxml.xml_note('G', 1, 'quarter', octave=5, extra='moving') for _ in range(4)) bars.append(content) source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars(bars)) for measure in source['staves'][0]['measures']: measure['transpose']['octave_change'] = -1 generator.guitar.place_guitar(source) style = generator.engraving.types.score_style(source, generator.engraving.types.Style()) index = generator.engraving.notation.ScoreIndex(source) for i, (start, end) in enumerate(((0, 4), (4, 8), (8, 12))): tone = source['staves'][0]['measures'][i]['voices'][0]['notes'][0]['pitches'][0] held = tone['id'] system = generator.engraving.layout.make_system(index, fractions.Fraction(start), fractions.Fraction(end), self.fonts, style) number = next(p for p in system.primitives if p.role == 'tab' and held in p.sources) ties = [p for p in system.primitives if p.role == 'tab_tie' and {held, tone.get('tie_from')} & set(p.sources)] self.assertEqual(sum(p.points[-1][0] < number.box.left for p in ties), int(start > 0)) self.assertEqual(sum(p.points[0][0] > number.box.right for p in ties), int(end < 12)) def test_dense_measure_fails_contextually_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)) before = copy.deepcopy(source) with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'measure 1 .*complete measure exceeds.*readable spacing'): self.layout(source) self.assertEqual(source, before) 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_excessive_pitch_range_fails_instead_of_splitting_the_measure(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))) with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'measure 1 .*complete measure exceeds.*height'): self.layout(source) 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 = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=5))), 'electric') self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '12') def test_generated_tab_shares_columns_and_systems_without_duplicating_music(self): content = ''.join(tests.test_musicxml.xml_note('G', 1, 'eighth') for _ in range(8)) source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 8, divisions=2)), 'electric') 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(tests.score_events.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 = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)), 'electric') 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 * layout.style.tab_space / (4 * layout.style.space), 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((('E', 3), ('B', 3), ('E', 4), ('G', 4), ('B', 4), ('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('