Generation

This commit is contained in:
khodowany
2026-09-30 01:38:16 -05:00
parent b1a7b44d7a
commit deaa0468a7
20 changed files with 417 additions and 198 deletions
+93 -22
View File
@@ -2,6 +2,7 @@ import collections
import copy
import dataclasses
import fractions
import math
import pathlib
import unittest
import unittest.mock
@@ -50,6 +51,12 @@ class EngravingTests(unittest.TestCase):
self.assertIn(len(layout.pages[-1].systems), (1, 2))
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_guitar_pages_always_pair_systems_including_tall_passages(self):
@@ -60,7 +67,7 @@ class EngravingTests(unittest.TestCase):
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('E', 2, 'half', octave=3) + tests.test_musicxml.xml_note('C', 2, 'half', octave=7)
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
@@ -104,8 +111,8 @@ class EngravingTests(unittest.TestCase):
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"/>')
dense = ''.join(tests.test_musicxml.xml_note('G', 1, '64th') for _ in range(64))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(dense, divisions=16))
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())]
@@ -238,20 +245,85 @@ class EngravingTests(unittest.TestCase):
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_dense_measure_splits_exactly_without_modifying_music(self):
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))
layout = self.layout(source)
systems = [s for p in layout.pages for s in p.systems]
self.assertGreater(len(systems), 1)
held = source['staves'][0]['measures'][0]['voices'][1]['notes'][0]['pitches'][0]['id']
pieces = [f for s in systems for f in s.fragments if f.source == held]
self.assertEqual(sum(f.length() for f in pieces), 4)
self.assertGreater(len(pieces), 1)
self.assertTrue(self.primitives(layout, 'tie'))
for page in layout.pages:
self.assertEqual(generator.engraving.raster.rasterize_page(page, self.fonts).size, (976, 484))
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
@@ -263,11 +335,10 @@ class EngravingTests(unittest.TestCase):
index = generator.engraving.notation.ScoreIndex(source)
self.assertEqual(index.pitch_position(tests.test_musicxml.notes(source)[0]['pitches'][0], fractions.Fraction()), original - 7)
self.assertTrue(any(p.value == '8va' for p in self.primitives(self.layout(source), 'octave-shift')))
def test_wide_pitch_range_can_break_inside_a_measure_vertically(self):
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)))
layout = self.layout(source)
self.assertEqual([len(page.systems) for page in layout.pages], [2])
self.assertEqual(len(self.primitives(layout, 'head')), 2)
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/>')))
@@ -283,8 +354,8 @@ class EngravingTests(unittest.TestCase):
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, '64th') for _ in range(64))
source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=16)), 'electric', rhythms=tests.rhythm_fixtures.library())
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)
@@ -364,8 +435,8 @@ class EngravingTests(unittest.TestCase):
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>'
content = ''.join(tests.test_musicxml.xml_note('G', 1, '64th', extra=lyrics if i == 0 else '') for i in range(64))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=16))
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)