142 lines
9.8 KiB
Python
142 lines
9.8 KiB
Python
import argparse
|
|
import json
|
|
import unittest
|
|
from pathlib import Path
|
|
import tempfile
|
|
from unittest.mock import patch
|
|
|
|
from generator.model import fraction, validate_document
|
|
from generator.musicxml import MusicXMLError
|
|
from generator.playback import compile_timeline
|
|
from generator.repeats import expand_document, performed_order
|
|
from tests.test_musicxml import parse, xml_note, xml_score
|
|
|
|
def score_bars(bars):
|
|
return xml_score(bars[0]).replace('</measure></part>', '</measure>' + ''.join(f'<measure number="{i + 2}">{bar}</measure>' for i, bar in enumerate(bars[1:])) + '</part>')
|
|
|
|
def boundary(direction, times=None):
|
|
side = 'left' if direction == 'forward' else 'right'
|
|
count = f' times="{times}"' if times else ''
|
|
return f'<barline location="{side}"><repeat direction="{direction}"{count}/></barline>'
|
|
|
|
def ending(numbers, action, repeat=False):
|
|
side = 'left' if action == 'start' else 'right'
|
|
return f'<barline location="{side}"><ending number="{numbers}" type="{action}"/>' + ('<repeat direction="backward"/>' if repeat else '') + '</barline>'
|
|
|
|
class RepeatTests(unittest.TestCase):
|
|
def test_repeat_count_and_implicit_start(self):
|
|
source = parse(score_bars([xml_note(), xml_note('D') + boundary('backward', 3)]))
|
|
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1] * 3)
|
|
expanded = expand_document(source)
|
|
validate_document(expanded)
|
|
self.assertEqual(len(compile_timeline(source)['events']), 6)
|
|
def test_combined_endings(self):
|
|
source = parse(score_bars([boundary('forward') + xml_note(), ending('1,2', 'start') + xml_note('D') + ending('1,2', 'stop', True), ending('3', 'start') + xml_note('E') + ending('3', 'discontinue')]))
|
|
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1, 0, 1, 0, 2])
|
|
self.assertIn('endings', source['staves'][0]['measures'][1])
|
|
self.assertNotIn('endings', expand_document(source)['staves'][0]['measures'][1])
|
|
def test_nested_repeat(self):
|
|
source = parse(score_bars([boundary('forward') + xml_note(), boundary('forward') + xml_note('D') + boundary('backward'), xml_note('E') + boundary('backward')]))
|
|
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1, 1, 2] * 2)
|
|
def test_sequential_and_expansion_limit(self):
|
|
source = parse(score_bars([xml_note() + boundary('backward'), boundary('forward') + xml_note('D') + boundary('backward', 3)]))
|
|
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 0, 1, 1, 1])
|
|
with self.assertRaisesRegex(MusicXMLError, '10000'):
|
|
parse(score_bars([boundary('forward') + xml_note(), xml_note() + boundary('backward', 10000)]))
|
|
def test_bad_endings_and_open_repeat(self):
|
|
for content in (boundary('forward') + xml_note(), ending('1', 'start') + xml_note(), ending('1', 'start') + xml_note() + ending('1', 'stop')):
|
|
with self.assertRaises(MusicXMLError):
|
|
parse(xml_score(content))
|
|
def test_ties_reidentify_each_pass(self):
|
|
source = parse(score_bars([boundary('forward') + xml_note(extra='<tie type="start"/>'), xml_note(extra='<tie type="stop"/>') + boundary('backward')]))
|
|
validate_document(expand_document(source))
|
|
self.assertEqual(len(compile_timeline(source)['events']), 2)
|
|
def test_ties_across_repeat_jump_and_unreachable_pass(self):
|
|
first = xml_note(extra='<tie type="stop" time-only="2"/>')
|
|
last = xml_note(extra='<tie type="start" time-only="1"/>')
|
|
source = parse(score_bars([boundary('forward') + first, last + boundary('backward')]))
|
|
self.assertEqual([fraction(e['duration']) for e in compile_timeline(source)['events']], [4, 8, 4])
|
|
for bad in (first.replace('time-only="2"', ''), first.replace('time-only="2"', 'time-only="3"')):
|
|
with self.assertRaisesRegex(MusicXMLError, 'tie'):
|
|
parse(score_bars([boundary('forward') + bad, last + boundary('backward')]))
|
|
def test_printed_tie_does_not_create_sound_tie(self):
|
|
source = parse(score_bars([boundary('forward') + xml_note(extra='<notations><tied type="start"/></notations>'), xml_note(extra='<notations><tied type="stop"/></notations>') + boundary('backward')]))
|
|
self.assertEqual(len(compile_timeline(source)['events']), 4)
|
|
def test_ties_into_both_endings(self):
|
|
source = parse(score_bars([boundary('forward') + xml_note(extra='<tie type="start"/>'), ending('1', 'start') + xml_note(extra='<tie type="stop"/>') + ending('1', 'stop', True), ending('2', 'start') + xml_note(extra='<tie type="stop"/>') + ending('2', 'discontinue')]))
|
|
self.assertEqual([fraction(e['duration']) for e in compile_timeline(source)['events']], [8, 8])
|
|
def test_cross_measure_tuplets_and_tremolos_remap(self):
|
|
extra = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
|
|
bars = [boundary('forward'), '']
|
|
for index in range(3):
|
|
bars[0] += xml_note(duration=4, kind='half', extra=extra + ('<notations><tuplet type="start"/></notations>' if index == 0 else ''))
|
|
bars[1] += xml_note(duration=4, kind='half', extra=extra + ('<notations><tuplet type="stop"/></notations>' if index == 2 else ''))
|
|
bars[1] += boundary('backward')
|
|
source = parse(score_bars(bars).replace('<divisions>1</divisions>', '<divisions>3</divisions>'))
|
|
expanded = expand_document(source)
|
|
validate_document(expanded)
|
|
self.assertEqual(len(expanded['staves'][0]['tuplets']), 2)
|
|
modulation = '<time-modification><actual-notes>2</actual-notes><normal-notes>1</normal-notes></time-modification>'
|
|
bars = [xml_note(step, kind='breve', extra=modulation + f'<notations><ornaments><tremolo type="{action}">3</tremolo></ornaments></notations>') for step, action in [('C', 'start'), ('G', 'stop')]]
|
|
source = parse(score_bars([boundary('forward') + bars[0], bars[1] + boundary('backward')]))
|
|
validate_document(expand_document(source))
|
|
self.assertEqual(len(compile_timeline(source)['events']), 128)
|
|
def test_no_repeat_is_unchanged(self):
|
|
source = parse(xml_score(xml_note()))
|
|
self.assertEqual(source, expand_document(source))
|
|
def test_roundtrip_and_engraving(self):
|
|
from generator.musicxml_writer import write_musicxml
|
|
from generator.musicxml import load_musicxml
|
|
from generator.engraving import Fonts, Style, layout_score
|
|
source = parse(score_bars([boundary('forward') + xml_note(), ending('1', 'start') + xml_note('D') + ending('1', 'stop', True), ending('2', 'start') + xml_note('E') + ending('2', 'discontinue')]))
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / 'repeat.xml'
|
|
write_musicxml(path, source)
|
|
restored = load_musicxml(path)
|
|
self.assertEqual(performed_order(source['staves'][0]['measures']), performed_order(restored['staves'][0]['measures']))
|
|
layout = layout_score(source, Style(), Fonts(Path('package/assets/fonts/petaluma')))
|
|
items = [p for page in layout.pages for p in page.primitives]
|
|
self.assertEqual(sum(p.role == 'repeat-dot' for p in items), 4)
|
|
self.assertEqual([p.value for p in items if p.role == 'ending-label'], ['1.', '2.'])
|
|
def test_printed_tie_continues_across_system_boundary(self):
|
|
from generator.engraving import Fonts, Style
|
|
from generator.engraving.layout import make_system
|
|
from generator.engraving.notation import ScoreIndex
|
|
source = parse(score_bars([boundary('forward') + xml_note(extra='<notations><tied type="start"/></notations>'), xml_note(extra='<notations><tied type="stop"/></notations>') + boundary('backward')]))
|
|
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))
|
|
def test_build_pipeline_keeps_compact_practice_and_compiles_performed_order(self):
|
|
from generator.__main__ import generate_assets
|
|
from generator.arrange.generate import arrange_document
|
|
text = score_bars([boundary('forward') + xml_note(), xml_note('D') + boundary('backward')])
|
|
source = parse(text)
|
|
self.assertEqual(arrange_document(source, 'vocal'), arrange_document(expand_document(source), 'vocal'))
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
data = root / 'data'
|
|
lesson = data / 'practice/melody/lesson'
|
|
lesson.mkdir(parents=True)
|
|
(data / 'songs').mkdir()
|
|
(lesson / 'repeat.xml').write_text(text)
|
|
output = root / 'assets'
|
|
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False)
|
|
generate_assets(args)
|
|
bundle = json.loads((output / 'practice/melody/lesson/repeat/c.json').read_text())
|
|
self.assertEqual(len(bundle['music']['staves'][0]['measures']), 2)
|
|
self.assertEqual(len(bundle['playback']['events']), 4)
|
|
self.assertEqual(fraction(bundle['playback']['duration']), 16)
|
|
before = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
|
|
generate_assets(args)
|
|
self.assertEqual(before, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
|
with patch('generator.__main__.compile_tree', side_effect=ValueError('invalid generated audio')):
|
|
with self.assertRaisesRegex(ValueError, 'audio'):
|
|
generate_assets(args)
|
|
self.assertEqual(before, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
|
def test_alternate_ending_does_not_inherit_skipped_key_change(self):
|
|
source = parse(score_bars([boundary('forward') + xml_note(), ending('1', 'start') + '<attributes><key><fifths>2</fifths></key></attributes>' + xml_note('D') + ending('1', 'stop', True), ending('2', 'start') + xml_note('E') + ending('2', 'discontinue')]))
|
|
expanded = expand_document(source)
|
|
self.assertEqual([m['key']['fifths'] for m in expanded['staves'][0]['measures']], [0, 2, 0, 0])
|