Files
product-vamp/tests/test_score_bundle.py
T
khodowany bc7baea7c4
ci / checks (push) Failing after 23m22s
Cleanup
2026-10-08 02:13:45 -05:00

276 lines
19 KiB
Python

import generator.score_io
import generator.validation
import tests.production_fixtures
import argparse
import copy
import fractions
import hashlib
import json
import pathlib
import shutil
import tempfile
import unittest
import unittest.mock
import xml.etree.ElementTree
import PIL.Image
import PIL.ImageChops
import generator.arrange.generate
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.notation
import generator.engraving.pipeline
import generator.engraving.types
import generator.engraving.validation
import generator.model
import generator.musicxml
import generator.musicxml_writer
import generator.pico_artwork
import generator.performance
import generator.pitch
import tests.score_events
import tests.arrangement_fixtures
import generator.theme
import tests.musicxml_roundtrip
import tests.test_musicxml
class ScoreBundleTests(unittest.TestCase):
def setUp(self):
tests.production_fixtures.install(self)
def round_trip(self, document):
with tempfile.TemporaryDirectory() as temporary:
path = pathlib.Path(temporary) / 'roundtrip.xml'
generator.musicxml_writer.write_musicxml(path, document)
return generator.musicxml.load_musicxml(path)
def test_score_structure_is_validated_without_schema_labels(self):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
self.assertEqual(set(document), {'metadata', 'staves'})
for malformed in (None, [], {}, {'metadata': {}, 'staves': []}, {**document, 'staves': None}, {**document, 'staves': [{}]}):
with self.subTest(document=malformed), self.assertRaises(ValueError):
generator.validation.validate_document(malformed)
def test_multiple_voices_keep_independent_ties_and_transformations(self):
def half(action):
return tests.test_musicxml.xml_note(duration=2, kind='half', extra=f'<voice>1</voice><tie type="{action}"/>')
content = half('start') + half('stop') + '<backup><duration>4</duration></backup>'
content += tests.test_musicxml.xml_note('C', extra='<voice>2</voice>')
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
measure = source['staves'][0]['measures'][0]
self.assertEqual([v['id'] for v in measure['voices']], ['1', '2'])
self.assertNotIn('tie_from', measure['voices'][1]['notes'][0]['pitches'][0])
for document in (source, generator.pitch.concert_document(source), generator.arrange.generate.arrange_document(source, 'vocal')):
generator.validation.validate_document(document)
restored = self.round_trip(document)
generator.validation.validate_document(restored)
self.assertTrue(any(len(m['voices']) == 2 for m in restored['staves'][0]['measures']))
def test_contiguous_ties_continue_across_systems_without_repeat_state(self):
text = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<tie type="start"/>'))
text = text.replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note(extra='<tie type="stop"/>') + '<barline><bar-style>light-heavy</bar-style></barline></measure></part>')
source = tests.test_musicxml.parse(text)
index = generator.engraving.notation.ScoreIndex(source)
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
for start, end in ((0, 4), (4, 8)):
system = generator.engraving.layout.make_system(index, start, end, fonts, generator.engraving.types.Style())
self.assertTrue(any(p.role == 'tie' and len(p.sources) == 2 for p in system.primitives))
self.assertEqual([generator.model.fraction(e['duration']) for e in tests.score_events.timeline(source)['events']], [8])
restored = self.round_trip(source)
self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(source)[0]['pitches'])
self.assertEqual(restored['staves'][0]['measures'][1]['barlines'], {'right': 'light-heavy'})
def test_microtones_remain_exact_after_transposition_and_export(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter='0.5', extra='<accidental>quarter-sharp</accidental>')))
source['staves'][0]['measures'][0]['transpose'] = {'diatonic': 1, 'chromatic': 2, 'octave_change': 0}
transposed = generator.pitch.concert_document(source)
self.assertEqual(generator.model.pitch_number(tests.test_musicxml.notes(transposed)[0]['pitches'][0]), fractions.Fraction(125, 2))
restored = self.round_trip(transposed)
self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(transposed)[0]['pitches'])
unqualified = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter='0.1')))
self.assertEqual(generator.model.fraction(tests.test_musicxml.notes(unqualified)[0]['pitches'][0]['alter']), fractions.Fraction(1, 10))
layout = generator.engraving.layout.layout_score(unqualified, generator.engraving.types.Style(), generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma')))
self.assertTrue(any(p.role == 'accidental' and p.value == '(+1/10 st)' for page in layout.pages for p in page.primitives))
def test_octave_shift_stop_at_a_bar_boundary_is_emitted_once(self):
tree = xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
xml.etree.ElementTree.SubElement(tree.find('part'), 'measure', number='2').append(xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_note()))
source = tests.test_musicxml.parse(xml.etree.ElementTree.tostring(tree, encoding='unicode'))
source['staves'][0]['octave_shifts'] = [{'id': 'octave_1', 'start': generator.model.rational(0), 'end': generator.model.rational(4), 'octaves': 1}]
restored = self.round_trip(source)
self.assertEqual(source['staves'][0]['octave_shifts'], restored['staves'][0]['octave_shifts'])
def test_unequal_chord_tones_are_retained_but_never_silently_flattened(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note() + tests.test_musicxml.xml_note('E', 2, 'half', prefix='<chord/>')))
restored = self.round_trip(source)
self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(source)[0]['pitches'])
layout = generator.engraving.layout.layout_score(source, generator.engraving.types.Style(), generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma')))
heads = [p.value for page in layout.pages for p in page.primitives if p.role == 'head']
self.assertEqual(heads, ['noteheadWhole', 'noteheadHalf'])
def test_overlapping_notes_within_one_voice_are_rejected(self):
with self.assertRaisesRegex(ValueError, 'overlapping'):
tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note() + '<backup><duration>4</duration></backup>' + tests.test_musicxml.xml_note('E')))
def test_multiple_lyric_verses_and_elisions_round_trip(self):
lyrics = '<lyric number="1"><syllabic>begin</syllabic><text>one</text><elision> </elision><text>two</text><extend type="start"/></lyric>'
lyrics += '<lyric number="2"><syllabic>single</syllabic><text>three</text></lyric>'
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra=lyrics)))
restored = self.round_trip(source)
self.assertEqual(tests.test_musicxml.notes(restored)[0]['lyrics'], tests.test_musicxml.notes(source)[0]['lyrics'])
def test_percussion_and_tablature_are_semantic_not_pitch_guesses(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>'))
restored = self.round_trip(source)
self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(source)[0]['pitches'])
with self.assertRaisesRegex(ValueError, 'authored TAB'):
tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes='<clef><sign>TAB</sign></clef>'))
for part in ('vocal', 'acoustic', 'electric'):
with self.assertRaisesRegex(ValueError, 'pitched'):
tests.arrangement_fixtures.section_document(source, part)
with self.assertRaisesRegex(ValueError, 'pitched'):
generator.arrange.generate.arrange_document(source, part)
def test_percussion_instrument_definitions_round_trip_and_are_audited(self):
tree = xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_score('<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><instrument id="drum"/><type>whole</type></note>', attributes='<clef><sign>percussion</sign></clef>'))
definition = tree.find('part-list/score-part')
definition.append(xml.etree.ElementTree.fromstring('<score-instrument id="drum"><instrument-name>Snare</instrument-name><instrument-sound>drum.snare-drum</instrument-sound><solo/><virtual-instrument><virtual-name>Kit</virtual-name></virtual-instrument></score-instrument>'))
definition.append(xml.etree.ElementTree.fromstring('<midi-instrument id="drum"><midi-channel>10</midi-channel><midi-unpitched>39</midi-unpitched></midi-instrument>'))
source = tests.test_musicxml.parse(xml.etree.ElementTree.tostring(tree, encoding='unicode'))
self.assertEqual(self.round_trip(source)['staves'][0]['instruments'], source['staves'][0]['instruments'])
changed = copy.deepcopy(source)
changed['staves'][0]['instruments'][0]['midi']['midi-unpitched'] = '40'
with tempfile.TemporaryDirectory() as temporary:
before, after = pathlib.Path(temporary) / 'source.xml', pathlib.Path(temporary) / 'output.xml'
generator.musicxml_writer.write_musicxml(before, source)
generator.musicxml_writer.write_musicxml(after, changed)
with self.assertRaisesRegex(ValueError, 'percussion instrument'):
tests.musicxml_roundtrip.audit_roundtrip(before, after)
source['staves'][0]['instruments'] = []
with self.assertRaisesRegex(ValueError, 'unknown score instrument'):
generator.validation.validate_document(source)
def test_catalog_roundtrip_preserves_written_pitch_and_rhythm(self):
for path in sorted(pathlib.Path('data/songs').glob('*.xml')):
with self.subTest(path=path):
source = generator.musicxml.load_musicxml(path)
restored = self.round_trip(source)
a, b = source['staves'][0]['measures'], restored['staves'][0]['measures']
self.assertEqual(len(a), len(b))
for original, result in zip(a, b):
self.assertEqual(original['duration'], result['duration'])
for v, w in zip(original['voices'], result['voices']):
self.assertEqual(v['notes'], w['notes'])
def test_missing_font_does_not_produce_a_bundle(self):
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
path = root / 'fixture.json'
path.write_text(json.dumps(source))
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'cannot load bundled'):
generator.engraving.pipeline.engrave_tree(root, root / 'missing-fonts')
self.assertEqual(json.loads(path.read_text()), source)
def test_blank_and_wrong_sized_pages_are_rejected(self):
with tempfile.TemporaryDirectory() as temporary:
path = pathlib.Path(temporary) / 'page.png'
for size in ((976, 484), (100, 100)):
PIL.Image.new('RGB', size, 'white').save(path)
with self.assertRaises(generator.engraving.types.EngravingError):
generator.engraving.validation.check_page(path)
def test_audit_rejects_an_engraver_pitch_change(self):
with tempfile.TemporaryDirectory() as temporary:
source, output = pathlib.Path(temporary) / 'source.xml', pathlib.Path(temporary) / 'output.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
output.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')))
with self.assertRaisesRegex(ValueError, 'pitch letter'):
tests.musicxml_roundtrip.audit_roundtrip(source, output)
def test_audit_rejects_staff_details_and_barline_changes(self):
with tempfile.TemporaryDirectory() as temporary:
source, output = pathlib.Path(temporary) / 'source.xml', pathlib.Path(temporary) / 'output.xml'
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
for field, value in (('staff_details', {'lines': 6, 'tunings': []}), ('barlines', {'right': 'light-light'})):
with self.subTest(field=field):
original = copy.deepcopy(document)
original['staves'][0]['measures'][0][field] = value
generator.musicxml_writer.write_musicxml(source, original)
generator.musicxml_writer.write_musicxml(output, document)
with self.assertRaisesRegex(ValueError, 'engraver changed musical content'):
tests.musicxml_roundtrip.audit_roundtrip(source, output)
def test_audit_rejects_relocated_tuplet_brackets(self):
modulation = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join(tests.test_musicxml.xml_note(duration=2, kind='quarter', extra=modulation) for _ in range(6)), divisions=3))
span = {'id': 'tuplet_1', 'voice': '1', 'start': tests.test_musicxml.notes(source)[0]['id'], 'end': tests.test_musicxml.notes(source)[2]['id'], 'note_count': 3, 'space_count': 2, 'depth': 0, 'bracket': 'auto', 'show_number': 'actual'}
source['staves'][0]['tuplets'] = [span]
for note in tests.test_musicxml.notes(source)[:3]:
note['tuplet_ids'] = ['tuplet_1']
changed = copy.deepcopy(source)
changed['staves'][0]['tuplets'][0].update(start=tests.test_musicxml.notes(changed)[3]['id'], end=tests.test_musicxml.notes(changed)[5]['id'])
for index, note in enumerate(tests.test_musicxml.notes(changed)):
note['tuplet_ids'] = ['tuplet_1'] if index >= 3 else []
with tempfile.TemporaryDirectory() as temporary:
before, after = pathlib.Path(temporary) / 'source.xml', pathlib.Path(temporary) / 'output.xml'
generator.musicxml_writer.write_musicxml(before, source)
generator.musicxml_writer.write_musicxml(after, changed)
with self.assertRaisesRegex(ValueError, 'members'):
tests.musicxml_roundtrip.audit_roundtrip(before, after)
def test_bundle_validation_checks_pages_and_rejects_symlink_escape(self):
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
assets = root / 'assets'
assets.mkdir()
data = pathlib.Path('tests/fixtures/engraving_python.png').read_bytes()
page = assets / 'page.png'
page.write_bytes(data)
document = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml'))
bundle = {'music': document, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
layout = generator.engraving.layout.layout_score(document, generator.engraving.types.Style(), fonts)
bundle['presentation']['pages'][0].update(generator.performance.page_timing(document, layout.pages[0]))
bundle['presentation']['playback_pages'] = copy.deepcopy(bundle['presentation']['pages'])
bundle['performance'] = generator.performance.timing(document)
path = assets / 'score.json'
path.write_text(json.dumps(bundle))
self.assertEqual(generator.score_io.load_json(path), document)
page.write_bytes(data + b'corrupt')
with self.assertRaisesRegex(ValueError, 'checksum'):
generator.score_io.load_json(path)
page.unlink()
outside = root / 'outside.png'
outside.write_bytes(data)
page.symlink_to(outside)
with self.assertRaisesRegex(ValueError, 'escapes'):
generator.score_io.load_json(path)
def test_final_validation_reuses_content_checks_and_rechecks_files(self):
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
for index in range(3):
generator.score_io.write_json(root / 'songs' / f'{index}.json', document)
trusted = generator.engraving.pipeline.engrave_tree(root, pathlib.Path('package/assets/fonts/petaluma'))
generator.pico_artwork.generate_titles(root, pathlib.Path('package/assets/fonts/petaluma'))
with unittest.mock.patch('generator.engraving.validation.check_content', side_effect=AssertionError('already validated content')):
generator.score_io.validate_generated_tree(root, trusted)
page = root / 'songs/2.pages/0001.png'
original = page.read_bytes()
page.write_bytes(original + b'changed')
with self.assertRaisesRegex(ValueError, 'checksum'):
generator.score_io.validate_generated_tree(root, trusted)
page.write_bytes(original)
with unittest.mock.patch('generator.engraving.validation.check_content', wraps=generator.engraving.validation.check_content) as decode:
generator.score_io.validate_generated_tree(root)
decode.assert_called_once()
PIL.Image.new('RGB', (976, 484), generator.theme.rgb('background')).save(page)
path = root / 'songs/2.json'
bundle = json.loads(path.read_text())
bundle['presentation']['pages'][0]['sha256'] = hashlib.sha256(page.read_bytes()).hexdigest()
path.write_text(json.dumps(bundle))
with self.assertRaisesRegex(ValueError, 'blank page'):
generator.score_io.validate_generated_tree(root, trusted)
def test_python_engraver_visual_golden_and_determinism(self):
document = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml'))
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
before = None
for _ in range(2):
generator.score_io.write_json(root / 'fixture.json', document)
generator.engraving.pipeline.engrave_tree(root, pathlib.Path('package/assets/fonts/petaluma'))
bundle = json.loads((root / 'fixture.json').read_text())
self.assertEqual(bundle['music'], document)
self.assertEqual(len(bundle['presentation']['pages']), 1)
page = root / bundle['presentation']['pages'][0]['path']
with PIL.Image.open(page) as actual, PIL.Image.open('tests/fixtures/engraving_python.png') as expected:
self.assertIsNone(PIL.ImageChops.difference(actual.convert('RGB'), expected.convert('RGB')).getbbox())
if before is not None:
self.assertEqual(bundle, before)
before = bundle