Files
product-vamp/tests/test_repeats.py
T
khodowany 80873a0896
ci / checks (push) Has been cancelled
Tone Generation
2026-09-18 01:17:36 -05:00

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])