Sizing
ci / checks (push) Successful in 47m22s

This commit is contained in:
khodowany
2026-09-30 02:49:54 -05:00
parent 923b7f14e2
commit 7cad6db980
17 changed files with 1443 additions and 1298 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 52 KiB

+118
View File
@@ -0,0 +1,118 @@
import copy
import fractions
import pathlib
import unittest
import generator.arrange.form
import generator.arrange.generate
import generator.arrange.pickup
import generator.model
import generator.musicxml
import generator.parts
import generator.playback
import generator.score
import tests.rhythm_fixtures
import tests.test_musicxml
import tests.test_parts
def source(pickup, body=None, *, implicit=True, divisions=1):
body = body or tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=4 * divisions)
xml = tests.test_musicxml.xml_score(pickup, implicit=implicit, divisions=divisions)
return tests.test_musicxml.parse(xml.replace('</measure></part>', '</measure><measure number="2">' + body + '</measure></part>'))
class ArrangementPickupTests(unittest.TestCase):
def test_catalog_body_lengths_and_part_timelines(self):
for path in sorted(pathlib.Path('data/songs').glob('*.xml')):
document = generator.musicxml.load_musicxml(path)
before = copy.deepcopy(document)
prepared = generator.parts.prepare_document(document)
plans = generator.arrange.generate.plan_arrangement(prepared, tests.rhythm_fixtures.library())
timelines = []
for part in generator.parts.PART_TYPES:
with self.subTest(song=path.stem, part=part):
result = generator.arrange.generate._arrange_document(prepared, part, plans)
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 176)
self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 704)
timelines.append([m['duration'] for m in measures])
if path.stem == 'autumn-leaves' and part in ('vocal', 'electric'):
for boundary in ([8, 136] if part == 'vocal' else [40]):
notes = [n for n in generator.score.measure_notes(measures[boundary - 1]) if generator.model.fraction(n['onset']) >= 1 and n['pitches']]
self.assertEqual([generator.model.fraction(n['onset']) for n in notes], [1, 2, 3])
self.assertEqual([n['pitches'][-1]['letter'] for n in notes], ['d', 'e', 'f'])
self.assertTrue(all(n['pitches'][-1]['octave'] == (5 if part == 'electric' else 4) for n in notes))
self.assertFalse(measures[boundary - 1]['attributes'])
self.assertTrue(all(timeline == timelines[0] for timeline in timelines))
self.assertEqual(document, before)
def test_unmarked_leading_rests_are_not_pickups(self):
opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')
document = source(opening, implicit=False)
result = generator.arrange.generate.arrange_document(document, 'vocal', rhythms=tests.rhythm_fixtures.library())
self.assertEqual(len(result['staves'][0]['measures']), 14)
self.assertEqual(generator.score.body_start_measure_index(document['staves'][0]['measures']), 0)
def test_short_pickup_ties_and_octave_spans_survive_each_melody_entry(self):
document = source(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), tests.test_musicxml.xml_note(extra='<tie type="stop"/>'))
document['staves'][0]['octave_shifts'] = [{'id': 'pickup-shift', 'start': generator.model.rational(0), 'end': generator.model.rational(1), 'octaves': -1}]
for part, boundaries in (('vocal', (1, 5)), ('electric', (2,))):
with self.subTest(part=part):
result = generator.arrange.generate.arrange_document(document, part, rhythms=tests.rhythm_fixtures.library())
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 7)
for boundary in boundaries:
pickup = generator.score.measure_notes(measures[boundary - 1])[-1]
downbeat = generator.score.measure_notes(measures[boundary])[0]
self.assertEqual(generator.model.fraction(pickup['onset']), 3)
self.assertEqual(pickup['pitches'][0]['tie_to'], downbeat['pitches'][0]['id'])
self.assertEqual(downbeat['pitches'][0]['tie_from'], pickup['pitches'][0]['id'])
self.assertTrue(any(generator.model.fraction(s['start']) == boundary * 4 - 1 and generator.model.fraction(s['end']) == boundary * 4 for s in result['staves'][0]['octave_shifts']))
self.assertTrue(generator.playback.compile_timeline(result)['events'])
def test_pickup_truncates_outgoing_music_without_discarding_earlier_notes(self):
opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note('D', 3, 'half', extra='<dot/><lyric><text>doo</text></lyric>')
document = source(opening)
pickup, destination = copy.deepcopy(document['staves'][0]['measures'])
before = copy.deepcopy(destination['voices'][0]['notes'][0])
start = generator.arrange.pickup.splice(destination, pickup)
self.assertEqual(start, 1)
notes = generator.score.measure_notes(destination)
self.assertEqual(len(notes), 2)
self.assertEqual(notes[0]['id'], before['id'])
self.assertEqual(notes[0]['pitches'][0]['id'], before['pitches'][0]['id'])
self.assertEqual(generator.model.fraction(notes[0]['duration']), 1)
self.assertEqual(notes[0]['written']['type'], 'quarter')
self.assertEqual(notes[1]['lyrics'][0]['syllables'][0]['text'], 'doo')
self.assertEqual([generator.model.fraction(n['onset']) for n in notes], [0, 1])
document['staves'][0]['measures'] = [destination]
generator.model.validate_document(document)
def test_pickup_harmonies_are_planned_for_every_part_at_the_same_offset(self):
document = source(tests.test_parts.xml_harmony('F') + tests.test_musicxml.xml_note('F', 1, 'quarter'))
prepared = generator.parts.prepare_document(document)
plans = generator.arrange.generate.plan_arrangement(prepared, tests.rhythm_fixtures.library())
for part in generator.parts.PART_TYPES:
result = generator.arrange.generate._arrange_document(prepared, part, plans)
generator.model.validate_document(result)
for index in (0, 1, 4):
chords = result['staves'][0]['measures'][index]['harmonies']
self.assertEqual([(generator.model.fraction(h['onset']), h['root']['letter']) for h in chords], [(0, 'c'), (3, 'f')])
self.assertIn(fractions.Fraction(3), [event.onset for event in plans[0].acoustic_upper[0].events])
def test_pickup_tuplets_keep_unique_identities_across_iterations(self):
notes = ''
for index, pitch in enumerate(('C', 'D', 'E')):
tuplet = '<notations><tuplet type="start"/></notations>' if index == 0 else '<notations><tuplet type="stop"/></notations>' if index == 2 else ''
notes += tests.test_musicxml.xml_note(pitch, 1, 'eighth', extra='<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes><normal-type>eighth</normal-type></time-modification>' + tuplet)
document = source(notes, divisions=3)
for part in ('vocal', 'electric'):
result = generator.arrange.generate.arrange_document(document, part, rhythms=tests.rhythm_fixtures.library())
generator.model.validate_document(result)
spans = result['staves'][0]['tuplets']
self.assertEqual(len(spans), 2 if part == 'vocal' else 1)
self.assertEqual(len({s['id'] for s in spans}), len(spans))
for span in spans:
members = [n for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) if span['id'] in n.get('tuplet_ids', [])]
self.assertEqual(len(members), 3)
self.assertEqual([generator.model.fraction(n['onset']) for n in members], [3, fractions.Fraction(10, 3), fractions.Fraction(11, 3)])
def test_overlong_pickup_is_rejected_with_measure_context(self):
document = source(tests.test_musicxml.xml_note(duration=4), '<attributes><time><beats>3</beats><beat-type>4</beat-type></time></attributes>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>'))
with self.assertRaisesRegex(ValueError, 'measure 2: pickup is longer'):
generator.arrange.generate.arrange_document(document, 'vocal', rhythms=tests.rhythm_fixtures.library())
+12
View File
@@ -33,6 +33,18 @@ def shape(event):
return tuple(sorted((p['tablature']['string'], p['tablature']['fret']) for p in event['pitches']))
class ElectricVoicingTests(unittest.TestCase):
def test_mixed_pickup_bar_links_each_role_to_its_original_passage(self):
chord = harmony('C7')
pickup = note('pickup', 3, chord, (2, 3, 1, 3), melody=True)
outgoing = note('outgoing', 0, chord, (5, 3, 5, 3))
target = note('target', 0, chord, (10, 9, 11, 8))
mixed = measure([outgoing, pickup])
source = [measure([], [chord]), measure([], [harmony('G7')]), measure([], [chord])]
origins = [generator.electric.MeasureOrigin(fractions.Fraction(8), 'accompaniment', mixed, end=fractions.Fraction(3)), generator.electric.MeasureOrigin(fractions.Fraction(-3), 'melody', mixed, start=fractions.Fraction(3)), generator.electric.MeasureOrigin(fractions.Fraction(0), 'accompaniment', measure([target]))]
links = generator.electric.link_voicings(source, origins)
self.assertEqual(len(links), 1)
self.assertIs(links[0].target, target)
self.assertEqual(links[0].references, (pickup,))
def test_corresponding_melody_shapes_change_within_one_harmony(self):
chord = harmony('C7')
first = note('melody-1', 1, chord, (2, 3, 1, 3), melody=True, duration=2)
+29
View File
@@ -49,6 +49,7 @@ class EngravingTests(unittest.TestCase):
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:
@@ -59,6 +60,34 @@ class EngravingTests(unittest.TestCase):
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))