488 lines
36 KiB
Python
488 lines
36 KiB
Python
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 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) + '<note><rest/><duration>2</duration><type>half</type></note>'
|
|
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='<lyric><syllabic>single</syllabic><text>doo</text></lyric>')
|
|
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 = '<harmony><root><root-step>E</root-step></root><kind>major</kind></harmony>'
|
|
content = harmony + tests.test_musicxml.xml_note('G', 2, 'half', alter=1) + tests.test_musicxml.xml_note('E', 2, 'half', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>'
|
|
content += '<backup><duration>4</duration></backup>' + tests.test_musicxml.xml_note('B', octave=3, extra='<voice>2</voice>')
|
|
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_aligned_bands_match_full_pages_with_spans_tab_and_sampling_phases(self):
|
|
tied = tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>')
|
|
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='<voice>held</voice>') + '<backup><duration>4</duration></backup>'
|
|
content += ''.join(tests.test_musicxml.xml_note('G', 1, 'quarter', extra='<voice>moving</voice>') 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'<measure number="{i + 1}">{content}</measure>' for i in range(1, count))
|
|
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content).replace('</part></score-partwise>', extra + '</part></score-partwise>'))
|
|
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='<voice>held</voice>') + '<backup><duration>4</duration></backup>'
|
|
content += ''.join(tests.test_musicxml.xml_note('G', 1, 'quarter', extra='<voice>moving</voice>') for _ in range(4))
|
|
content += '<harmony><root><root-step>F</root-step></root><kind>major</kind><offset>-2</offset></harmony>'
|
|
content += '<direction><direction-type><metronome><beat-unit>quarter</beat-unit><per-minute>90</per-minute></metronome></direction-type><offset>-2</offset></direction>'
|
|
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='<dot/>' * dots)
|
|
if rest:
|
|
content = f'<note><rest/><duration>{duration.numerator}</duration><type>{kind}</type>' + '<dot/>' * dots + '</note>'
|
|
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='<dot/>' * 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 += '<backup><duration>4</duration></backup>'
|
|
content += tests.test_musicxml.xml_note(extra=f'<voice>part-{i}</voice>')
|
|
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='<voice>a</voice>')
|
|
content += tests.test_musicxml.xml_note(duration=1, kind='quarter', alter=1, extra='<voice>a</voice>')
|
|
content += tests.test_musicxml.xml_note(duration=2, kind='half', extra='<voice>a</voice>')
|
|
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='<voice>a</voice>') + '<backup><duration>4</duration></backup>' + tests.test_musicxml.xml_note(extra='<voice>b</voice>')))
|
|
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 = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
|
|
notes = [tests.test_musicxml.xml_note('G', 1, 'eighth', extra=modification + f'<beam number="1">{action}</beam>') 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 = '<note><grace slash="yes"/><pitch><step>D</step><octave>4</octave></pitch><type>16th</type></note>'
|
|
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 ('<tie type="start"/>', '<tie type="stop"/><tie type="start"/>', '<tie type="stop"/>'):
|
|
content = tests.test_musicxml.xml_note('E', octave=5, extra='<voice>held</voice>' + ties) + '<backup><duration>4</duration></backup>'
|
|
content += ''.join(tests.test_musicxml.xml_note('G', 1, 'quarter', octave=5, extra='<voice>moving</voice>') 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='<voice>fast</voice>') for _ in range(64))
|
|
content += '<backup><duration>64</duration></backup>' + tests.test_musicxml.xml_note('C', 64, 'whole', extra='<voice>held</voice>')
|
|
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 = '<note><grace slash="yes"/><pitch><step>D</step><octave>4</octave></pitch><type>16th</type></note>'
|
|
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(grace + tests.test_musicxml.xml_note(duration=4, kind='whole', prefix='<cue/>')))
|
|
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='<notations><ornaments><tremolo type="single">8</tremolo></ornaments></notations>'), implicit=True))
|
|
self.assertEqual(len(self.primitives(self.layout(source), 'tremolo')), 8)
|
|
def test_percussion_and_tab_use_explicit_display_positions(self):
|
|
percussion = '<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><type>whole</type><notehead>x</notehead></note>'
|
|
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
|
|
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='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>') + '<barline location="right"><bar-style>dashed</bar-style></barline>'
|
|
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='<chord/>' 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 = '<lyric number="1"><syllabic>begin</syllabic><text>hel</text></lyric>'
|
|
end = '<lyric number="1"><syllabic>end</syllabic><text>lo</text><extend type="start"/></lyric>'
|
|
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 = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
|
|
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 = '<time-modification><actual-notes>2</actual-notes><normal-notes>1</normal-notes></time-modification>'
|
|
content = ''.join(tests.test_musicxml.xml_note(step, 2, 'whole', extra=modulation + f'<notations><ornaments><tremolo type="{action}">3</tremolo></ornaments></notations>') 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='<stem>double</stem>'), 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('<time>', f'<time symbol="{symbol}">'))
|
|
with self.subTest(symbol=symbol):
|
|
self.assertTrue(self.primitives(self.layout(source), 'meter'))
|
|
change = '<attributes><key><fifths>-3</fifths></key><clef><sign>C</sign><line>3</line></clef></attributes>'
|
|
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + change + tests.test_musicxml.xml_note(duration=2, kind='half')))
|
|
layout = self.layout(source)
|
|
self.assertEqual([p.value for p in self.primitives(layout, 'clef')], ['gClef', 'cClef'])
|
|
def test_lyric_extender_continues_on_systems_without_syllables(self):
|
|
lyrics = '<lyric number="1"><text>Ah</text><extend type="start"/></lyric>'
|
|
bars = [''.join(tests.test_musicxml.xml_note('G', 1, 'quarter', extra=lyrics if bar == i == 0 else '') for i in range(4)) for bar in range(8)]
|
|
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars(bars))
|
|
layout = self.layout(source)
|
|
systems = [s for p in layout.pages for s in p.systems]
|
|
self.assertGreater(len(systems), 1)
|
|
self.assertTrue(all(any(p.role == 'lyric_extension' for p in s.primitives) for s in systems))
|
|
def test_audit_catches_removed_pitch_and_accidental_primitives(self):
|
|
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter=1)))
|
|
layout = self.layout(source)
|
|
for role in ('head', 'accidental'):
|
|
pages = tuple(dataclasses.replace(p, primitives=tuple(g for g in p.primitives if g.role != role)) for p in layout.pages)
|
|
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'no drawing primitive|accidental was omitted'):
|
|
generator.engraving.audit.audit_layout(source, dataclasses.replace(layout, pages=pages), self.fonts)
|
|
def test_fingerprint_includes_style_and_fixed_overflow_errors(self):
|
|
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
|
a = self.layout(source)
|
|
b = self.layout(source, generator.engraving.types.Style(column_gap=14))
|
|
self.assertNotEqual(a.fingerprint, b.fingerprint)
|
|
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'page width'):
|
|
self.layout(source, generator.engraving.types.Style(width=100))
|