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.playback
import tests.arrangement_fixtures
import tests.rhythm_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', rhythms=tests.rhythm_fixtures.library())
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(generator.playback.compile_timeline(source), generator.playback.compile_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', rhythms=tests.rhythm_fixtures.library())
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
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('B', 2, 'half', prefix='') + '2half'
content += '4' + tests.test_musicxml.xml_note('E', extra='2')
document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 16))
positions = {'e': {'string': 5, 'fret': 7}, 'g': {'string': 4, 'fret': 6}, 'b': {'string': 3, 'fret': 4}}
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_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', 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_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('C', octave=6))), '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, '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', 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 = tests.arrangement_fixtures.section_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 * 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((('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('