280 lines
17 KiB
Python
280 lines
17 KiB
Python
import copy
|
|
import argparse
|
|
from fractions import Fraction
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
from xml.etree import ElementTree
|
|
|
|
from generator.arrange.generate import arrange_document
|
|
from generator.__main__ import generate_assets
|
|
from generator.engraving import ENGINE_NAME, EngravingError, Fonts, Style, check_page, engrave_tree, layout_score
|
|
from tests.musicxml_roundtrip import audit_roundtrip
|
|
from generator.model import fraction, pitch_number, rational, validate_document
|
|
from generator.musicxml import load_musicxml
|
|
from generator.musicxml_writer import write_musicxml
|
|
from generator.practice.generate import practice_document
|
|
from generator.score import load_json
|
|
from generator.pitch import concert_document
|
|
from tests.test_musicxml import notes, parse, xml_note, xml_score
|
|
|
|
class ScoreBundleTests(unittest.TestCase):
|
|
def test_real_asset_regeneration_removes_pages_and_rolls_back_font_failure(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
data, output = root / 'data', root / 'output'
|
|
(data / 'songs').mkdir(parents=True)
|
|
(data / 'practice').mkdir()
|
|
source = data / 'practice/001.xml'
|
|
source.write_text(xml_score(xml_note()))
|
|
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False)
|
|
generate_assets(args)
|
|
original = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
|
|
self.assertTrue(list(output.rglob('*.png')))
|
|
(data / 'rhythms' / 'drafts').mkdir(parents=True)
|
|
for path in ('rhythms/001.xml', 'rhythms/drafts/002.xml'):
|
|
(data / path).write_text('<unfinished')
|
|
generate_assets(args)
|
|
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
|
misplaced = data / '001.xml'
|
|
misplaced.write_text(xml_score(xml_note()))
|
|
with self.assertRaisesRegex(ValueError, 'belong directly under'):
|
|
generate_assets(args)
|
|
misplaced.unlink()
|
|
with patch('generator.__main__.compile_tree', side_effect=ValueError('invalid generated audio')):
|
|
with self.assertRaisesRegex(ValueError, 'audio'):
|
|
generate_assets(args)
|
|
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
|
source.write_text(xml_score(xml_note() + '<barline><repeat direction="backward"/></barline>'))
|
|
with self.assertRaisesRegex(ValueError, 'write the music out'):
|
|
generate_assets(args)
|
|
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
|
source.write_text(xml_score(xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
|
|
with self.assertRaisesRegex(EngravingError, 'unavailableGlyph'):
|
|
generate_assets(args)
|
|
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
|
source.write_text(xml_score(xml_note()))
|
|
renamed = data / 'practice/127.xml'
|
|
source.rename(renamed)
|
|
source = renamed
|
|
generate_assets(args)
|
|
self.assertFalse((output / 'practice/001').exists())
|
|
self.assertEqual(len(list((output / 'practice/127').glob('*.json'))), 3)
|
|
source.unlink()
|
|
generate_assets(args)
|
|
self.assertFalse(list(output.rglob('*.png')))
|
|
self.assertFalse(list(output.rglob('*.json')))
|
|
def round_trip(self, document):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
path = Path(temporary) / 'roundtrip.xml'
|
|
write_musicxml(path, document)
|
|
return load_musicxml(path)
|
|
def test_score_structure_is_validated_without_schema_labels(self):
|
|
document = parse(xml_score(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):
|
|
validate_document(malformed)
|
|
def test_multiple_voices_keep_independent_ties_and_transformations(self):
|
|
def half(action):
|
|
return 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 += xml_note('C', extra='<voice>2</voice>')
|
|
source = parse(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, concert_document(source), arrange_document(source, 'vocal')):
|
|
validate_document(document)
|
|
restored = self.round_trip(document)
|
|
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):
|
|
from generator.engraving.layout import make_system
|
|
from generator.engraving.notation import ScoreIndex
|
|
from generator.playback import compile_timeline
|
|
text = xml_score(xml_note(extra='<tie type="start"/>'))
|
|
text = text.replace('</measure></part>', '</measure><measure number="2">' + xml_note(extra='<tie type="stop"/>') + '<barline><bar-style>light-heavy</bar-style></barline></measure></part>')
|
|
source = parse(text)
|
|
index = ScoreIndex(source)
|
|
fonts = Fonts(Path('package/assets/fonts/petaluma'))
|
|
for start, end in ((0, 4), (4, 8)):
|
|
system = make_system(index, start, end, fonts, Style())
|
|
self.assertTrue(any(p.role == 'tie' and len(p.sources) == 2 for p in system.primitives))
|
|
self.assertEqual([fraction(e['duration']) for e in compile_timeline(source)['events']], [8])
|
|
restored = self.round_trip(source)
|
|
self.assertEqual(notes(restored)[0]['pitches'], 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 = parse(xml_score(xml_note(alter='0.5', extra='<accidental>quarter-sharp</accidental>')))
|
|
source['staves'][0]['measures'][0]['transpose'] = {'diatonic': 1, 'chromatic': 2, 'octave_change': 0}
|
|
transposed = concert_document(source)
|
|
self.assertEqual(pitch_number(notes(transposed)[0]['pitches'][0]), Fraction(125, 2))
|
|
restored = self.round_trip(transposed)
|
|
self.assertEqual(notes(restored)[0]['pitches'], notes(transposed)[0]['pitches'])
|
|
unqualified = parse(xml_score(xml_note(alter='0.1')))
|
|
self.assertEqual(fraction(notes(unqualified)[0]['pitches'][0]['alter']), Fraction(1, 10))
|
|
layout = layout_score(unqualified, Style(), Fonts(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 = ElementTree.fromstring(xml_score(xml_note()))
|
|
ElementTree.SubElement(tree.find('part'), 'measure', number='2').append(ElementTree.fromstring(xml_note()))
|
|
source = parse(ElementTree.tostring(tree, encoding='unicode'))
|
|
source['staves'][0]['octave_shifts'] = [{'id': 'octave-1', 'start': rational(0), 'end': 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 = parse(xml_score(xml_note() + xml_note('E', 2, 'half', prefix='<chord/>')))
|
|
restored = self.round_trip(source)
|
|
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
|
|
layout = layout_score(source, Style(), Fonts(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'):
|
|
parse(xml_score(xml_note() + '<backup><duration>4</duration></backup>' + 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 = parse(xml_score(xml_note(extra=lyrics)))
|
|
restored = self.round_trip(source)
|
|
self.assertEqual(notes(restored)[0]['lyrics'], 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 = parse(xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
|
|
restored = self.round_trip(source)
|
|
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
|
|
with self.assertRaisesRegex(ValueError, 'authored TAB'):
|
|
parse(xml_score(xml_note(), attributes='<clef><sign>TAB</sign></clef>'))
|
|
for part in ('vocal', 'acoustic', 'electric'):
|
|
with self.assertRaisesRegex(ValueError, 'pitched'):
|
|
practice_document(source, part)
|
|
with self.assertRaisesRegex(ValueError, 'pitched'):
|
|
arrange_document(source, part)
|
|
def test_percussion_instrument_definitions_round_trip_and_are_audited(self):
|
|
tree = ElementTree.fromstring(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(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(ElementTree.fromstring('<midi-instrument id="drum"><midi-channel>10</midi-channel><midi-unpitched>39</midi-unpitched></midi-instrument>'))
|
|
source = parse(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 = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
|
|
write_musicxml(before, source)
|
|
write_musicxml(after, changed)
|
|
with self.assertRaisesRegex(ValueError, 'percussion instrument'):
|
|
audit_roundtrip(before, after)
|
|
source['staves'][0]['instruments'] = []
|
|
with self.assertRaisesRegex(ValueError, 'unknown score instrument'):
|
|
validate_document(source)
|
|
def test_catalog_roundtrip_preserves_written_pitch_and_rhythm(self):
|
|
for path in sorted(path for category in ('practice', 'songs') for path in (Path('data') / category).glob('*.xml')):
|
|
with self.subTest(path=path):
|
|
source = 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 = Path(temporary)
|
|
source = parse(xml_score(xml_note()))
|
|
path = root / 'fixture.json'
|
|
path.write_text(json.dumps(source))
|
|
with self.assertRaisesRegex(EngravingError, 'cannot load bundled'):
|
|
engrave_tree(root, root / 'missing-fonts')
|
|
self.assertEqual(json.loads(path.read_text()), source)
|
|
def test_blank_and_wrong_sized_pages_are_rejected(self):
|
|
from PIL import Image
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
path = Path(temporary) / 'page.png'
|
|
for size in ((976, 484), (100, 100)):
|
|
Image.new('RGB', size, 'white').save(path)
|
|
with self.assertRaises(EngravingError):
|
|
check_page(path)
|
|
def test_audit_rejects_an_engraver_pitch_change(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
source, output = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
|
|
source.write_text(xml_score(xml_note()))
|
|
output.write_text(xml_score(xml_note('D')))
|
|
with self.assertRaisesRegex(ValueError, 'pitch letter'):
|
|
audit_roundtrip(source, output)
|
|
def test_audit_rejects_staff_details_and_barline_changes(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
source, output = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
|
|
document = parse(xml_score(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
|
|
write_musicxml(source, original)
|
|
write_musicxml(output, document)
|
|
with self.assertRaisesRegex(ValueError, 'engraver changed musical content'):
|
|
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 = parse(xml_score(''.join(xml_note(duration=2, kind='quarter', extra=modulation) for _ in range(6)), divisions=3))
|
|
span = {'id': 'tuplet-1', 'voice': '1', 'start': notes(source)[0]['id'], 'end': 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 notes(source)[:3]:
|
|
note['tuplet_ids'] = ['tuplet-1']
|
|
changed = copy.deepcopy(source)
|
|
changed['staves'][0]['tuplets'][0].update(start=notes(changed)[3]['id'], end=notes(changed)[5]['id'])
|
|
for index, note in enumerate(notes(changed)):
|
|
note['tuplet_ids'] = ['tuplet-1'] if index >= 3 else []
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
before, after = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
|
|
write_musicxml(before, source)
|
|
write_musicxml(after, changed)
|
|
with self.assertRaisesRegex(ValueError, 'members'):
|
|
audit_roundtrip(before, after)
|
|
def test_bundle_validation_checks_pages_and_rejects_symlink_escape(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
assets = root / 'assets'
|
|
assets.mkdir()
|
|
data = Path('tests/fixtures/engraving.png').read_bytes()
|
|
page = assets / 'page.png'
|
|
page.write_bytes(data)
|
|
document = load_musicxml(Path('tests/fixtures/engraving.xml'))
|
|
bundle = {'music': document, 'presentation': {'engine': ENGINE_NAME, 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
|
|
path = assets / 'score.json'
|
|
path.write_text(json.dumps(bundle))
|
|
self.assertEqual(load_json(path), document)
|
|
page.write_bytes(data + b'corrupt')
|
|
with self.assertRaisesRegex(ValueError, 'checksum'):
|
|
load_json(path)
|
|
page.unlink()
|
|
outside = root / 'outside.png'
|
|
outside.write_bytes(data)
|
|
page.symlink_to(outside)
|
|
with self.assertRaisesRegex(ValueError, 'escapes'):
|
|
load_json(path)
|
|
def test_python_engraver_visual_golden_and_determinism(self):
|
|
from PIL import Image, ImageChops
|
|
from generator.score import write_json
|
|
document = load_musicxml(Path('tests/fixtures/engraving.xml'))
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
before = None
|
|
for _ in range(2):
|
|
write_json(root / 'fixture.json', document)
|
|
engrave_tree(root, 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 Image.open(page) as actual, Image.open('tests/fixtures/engraving-python.png') as expected:
|
|
self.assertIsNone(ImageChops.difference(actual.convert('RGB'), expected.convert('RGB')).getbbox())
|
|
if before is not None:
|
|
self.assertEqual(bundle, before)
|
|
before = bundle
|