@@ -152,6 +152,15 @@ void write_text(std::filesystem::path const &path, std::string const &text) {
|
||||
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(runtime_config_uses_headless_and_ignores_unknown_fields) {
|
||||
auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({"runtime":{"headless":true,"obsolete_setting":"ignored"},"audio":{"enabled":false}})json"));
|
||||
BOOST_REQUIRE(parsed);
|
||||
BOOST_TEST(parsed->runtime.headless);
|
||||
BOOST_TEST(!parsed->audio.enabled);
|
||||
auto invalid = vamp_zero::parse_config(boost::json::parse(R"json({"runtime":{"headless":"yes"}})json"));
|
||||
BOOST_TEST(!invalid);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) {
|
||||
auto defaults = vamp_zero::parse_config(boost::json::parse(R"json({})json"));
|
||||
BOOST_REQUIRE(defaults);
|
||||
|
||||
@@ -18,7 +18,7 @@ class CatalogueTests(unittest.TestCase):
|
||||
self.assertEqual([p.name for p in sources], [f'{i:03}.xml' for i in range(1, 367)])
|
||||
expected = {Path(path.stem) / (part + '.json') for path in sources for part in PART_TYPES}
|
||||
self.assertEqual(len(expected), 1098)
|
||||
with tempfile.TemporaryDirectory() as temporary, patch('generator.practice.generate.practice_document', side_effect=lambda document, part: document):
|
||||
with tempfile.TemporaryDirectory() as temporary, patch('generator.practice.generate._practice_document', side_effect=lambda document, part: document):
|
||||
output = Path(temporary)
|
||||
self.assertEqual(generate_practice_scores(source, output), len(expected))
|
||||
self.assertEqual({p.relative_to(output) for p in output.rglob('*.json')}, expected)
|
||||
|
||||
@@ -30,6 +30,45 @@ class EngravingTests(unittest.TestCase):
|
||||
actual = rasterize_page(layout.pages[0], self.fonts)
|
||||
with Image.open('tests/fixtures/notation-python.png') as expected:
|
||||
self.assertIsNone(ImageChops.difference(actual, expected.convert('RGB')).getbbox())
|
||||
def test_accidental_work_is_bounded_by_local_event_count(self):
|
||||
class Unscannable(list):
|
||||
def __iter__(self):
|
||||
raise AssertionError('whole-score event scan')
|
||||
class Counted(list):
|
||||
visited = 0
|
||||
def __iter__(self):
|
||||
for value in super().__iter__():
|
||||
Counted.visited += 1
|
||||
yield value
|
||||
for count in (100, 200, 400):
|
||||
content = ''.join(xml_note(duration=1, kind='quarter', alter=i % 2) for i in range(4))
|
||||
extra = ''.join(f'<measure number="{i + 1}">{content}</measure>' for i in range(1, count))
|
||||
document = parse(xml_score(content).replace('</part></score-partwise>', extra + '</part></score-partwise>'))
|
||||
index = ScoreIndex(document)
|
||||
expected = index.accidentals
|
||||
index.events = Unscannable(index.events)
|
||||
index.events_by_onset = {time: Counted(events) for time, events in index.events_by_onset.items()}
|
||||
Counted.visited = 0
|
||||
self.assertEqual(index.accidental_state(), expected)
|
||||
self.assertEqual(Counted.visited, count * 4)
|
||||
def test_indexed_columns_preserve_sustains_and_timed_annotations(self):
|
||||
from generator.engraving.layout import column
|
||||
class Unscannable(list):
|
||||
def __iter__(self):
|
||||
raise AssertionError('whole-score scan from column layout')
|
||||
content = xml_note(extra='<voice>held</voice>') + '<backup><duration>4</duration></backup>'
|
||||
content += ''.join(xml_note('G', 1, 'quarter', extra='<voice>moving</voice>') for _ in range(4))
|
||||
content += '<harmony><root><root-step>F</root-step></root><kind>major</kind><offset>-2</offset></harmony>'
|
||||
content += '<direction><direction-type><metronome><beat-unit>quarter</beat-unit><per-minute>90</per-minute></metronome></direction-type><offset>-2</offset></direction>'
|
||||
index = ScoreIndex(parse(xml_score(content)))
|
||||
expected = column(index, Fraction(2), Fraction(2), Fraction(4), self.fonts, Style())
|
||||
index.events = Unscannable(index.events)
|
||||
index.measures = Unscannable(index.measures)
|
||||
actual = column(index, Fraction(2), Fraction(2), Fraction(4), self.fonts, Style())
|
||||
self.assertEqual(actual, expected)
|
||||
self.assertEqual(len(index.events_at(Fraction(2), include_sustained=True)), 2)
|
||||
self.assertTrue(any(p.role == 'harmony' for p in actual[0]))
|
||||
self.assertEqual(len(index.events_at(Fraction(4), include_sustained=True)), 0)
|
||||
def test_every_written_note_and_rest_value(self):
|
||||
for kind in NOTE_TYPES:
|
||||
for dots in (0, 1, 3):
|
||||
|
||||
@@ -13,6 +13,12 @@ from generator.engraving.cache import renderer_fingerprint, restore_pages, score
|
||||
from tests.test_musicxml import parse, xml_note, xml_score
|
||||
|
||||
class EngravingCacheTests(unittest.TestCase):
|
||||
def test_ci_snapshot_tracks_all_generator_inputs_and_keeps_fallback(self):
|
||||
workflow = Path('.gitea/workflows/ci.yml').read_text()
|
||||
self.assertIn("hashFiles('generator/**/*.py', 'generator/*.py', 'data/**/*.xml', 'data/*.xml')", workflow)
|
||||
self.assertIn('restore-keys: product-vamp-pages-v1-${{ runner.os }}-', workflow)
|
||||
self.assertIn("if: steps.engraving-cache.outputs.cache-hit != 'true'", workflow)
|
||||
self.assertIn('key: ${{ steps.engraving-cache.outputs.cache-primary-key }}', workflow)
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
|
||||
@@ -132,7 +132,7 @@ auto fixture_config(std::filesystem::path const &root) -> vamp_zero::config {
|
||||
auto config_file(std::filesystem::path const &root, vamp_zero::config const &config) -> std::string {
|
||||
auto json = boost::json::object{
|
||||
{"modulus", modulus::to_json(config.modulus)},
|
||||
{"runtime", {{"assets_path", config.runtime.assets_path}, {"heartbeat_path", config.runtime.heartbeat_path}, {"hardware", "real"}, {"headless", false}, {"exit_after_startup", config.runtime.exit_after_startup}}},
|
||||
{"runtime", {{"assets_path", config.runtime.assets_path}, {"heartbeat_path", config.runtime.heartbeat_path}, {"headless", false}, {"exit_after_startup", config.runtime.exit_after_startup}}},
|
||||
{"database", {{"path", config.database.path}}},
|
||||
{"control", {{"endpoint", ligo::to_json(config.control.endpoint)}, {"zero_ip", config.control.zero_ip}}},
|
||||
{"audio", {{"enabled", true}, {"playback_enabled", true}, {"metronome_enabled", true}}},
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import argparse
|
||||
import copy
|
||||
from fractions import Fraction
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from generator.__main__ import generate_assets
|
||||
from generator.arrange.generate import _arrange_document, arrange_document, generate_performance_scores
|
||||
from generator.model import fraction, rational, validate_document
|
||||
from generator.musicxml import load_musicxml
|
||||
from generator.parts import PART_TYPES, generated_voice, prepare_document
|
||||
from generator.pitch import concert_document
|
||||
from generator.playback import compile_timeline
|
||||
from generator.practice.generate import _practice_document, practice_document
|
||||
from tests.test_musicxml import notes, parse, xml_note, xml_score
|
||||
|
||||
def xml_harmony(root='C', kind='major', offset=0):
|
||||
return f'<harmony><root><root-step>{root}</root-step></root><kind>{kind}</kind><offset>{offset}</offset></harmony>'
|
||||
|
||||
class PartGenerationTests(unittest.TestCase):
|
||||
def test_accompaniment_ignores_melody_attacks_and_sustains_to_changes(self):
|
||||
source = parse(xml_score(xml_harmony() + xml_harmony('F', offset=3) + ''.join(xml_note(duration=1, kind='eighth') for _ in range(8)), divisions=2))
|
||||
for result in (practice_document(source, 'acoustic'), arrange_document(source, 'electric')):
|
||||
validate_document(result)
|
||||
for measure in result['staves'][0]['measures']:
|
||||
events = measure['voices'][0]['notes']
|
||||
if measure.get('section_label') in ('Rhythm Lead-in', 'Guitar Melody', 'Guitar Solo'):
|
||||
continue
|
||||
self.assertEqual([fraction(n['onset']) for n in events], [0, Fraction(3, 2)])
|
||||
self.assertEqual([fraction(n['duration']) for n in events], [Fraction(3, 2), Fraction(5, 2)])
|
||||
self.assertTrue(all(n['pitches'] for n in events))
|
||||
self.assertTrue(compile_timeline(result)['events'])
|
||||
def test_harmony_carry_and_explicit_silence(self):
|
||||
content = xml_harmony(offset=1) + xml_harmony(kind='none', offset=2) + xml_harmony('F', offset=3) + xml_note()
|
||||
source = xml_score(content).replace('</measure></part>', '</measure><measure number="2">' + xml_note() + '</measure><measure number="3">' + xml_harmony(kind='none') + xml_note() + '</measure><measure number="4">' + xml_note() + '</measure></part>')
|
||||
result = practice_document(parse(source), 'acoustic')
|
||||
self.assertEqual([n['kind'] for n in notes(result)], ['rest', 'note', 'rest', 'note'])
|
||||
self.assertEqual([fraction(n['onset']) for n in notes(result)], [0, 1, 2, 3])
|
||||
self.assertEqual([n['kind'] for n in notes(result, 1)], ['note'])
|
||||
self.assertEqual(fraction(notes(result, 1)[0]['duration']), 4)
|
||||
self.assertEqual([n['kind'] for n in notes(result, 2)], ['rest'])
|
||||
self.assertEqual([n['kind'] for n in notes(result, 3)], ['rest'])
|
||||
def test_accompaniment_handles_pickups_meters_and_coincident_markers(self):
|
||||
for duration, kind, attributes, implicit in ((1, 'quarter', '', True), (3, 'half', '<time><beats>3</beats><beat-type>4</beat-type></time>', False)):
|
||||
source = parse(xml_score(xml_harmony() + xml_harmony('F') + xml_note(duration=duration, kind=kind, extra='<dot/>' if duration == 3 else ''), attributes, implicit=implicit))
|
||||
measure = source['staves'][0]['measures'][0]
|
||||
events = generated_voice(measure, 'accompaniment', None)
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(fraction(events[0]['duration']), duration)
|
||||
self.assertEqual(events[0]['_harmony']['root']['letter'], 'f')
|
||||
source = parse(xml_score(xml_harmony() + xml_note(duration=1, kind='quarter') + '<attributes><time><beats>6</beats><beat-type>8</beat-type></time></attributes>' + xml_note(duration=3, kind='half', extra='<dot/>')))
|
||||
self.assertEqual(len(generated_voice(source['staves'][0]['measures'][0], 'accompaniment', None)), 1)
|
||||
def test_exercise_006_has_accompaniment_in_every_measure(self):
|
||||
result = practice_document(load_musicxml(Path('data/practice/006.xml')), 'acoustic')
|
||||
self.assertEqual(len(result['staves'][0]['measures']), 12)
|
||||
for measure in result['staves'][0]['measures']:
|
||||
self.assertTrue(any(n['pitches'] for v in measure['voices'] for n in v['notes']), measure['id'])
|
||||
validate_document(result)
|
||||
self.assertTrue(compile_timeline(result)['events'])
|
||||
def test_prepared_document_and_parts_have_independent_ownership(self):
|
||||
source = parse(xml_score(xml_harmony() + xml_note()))
|
||||
source['staves'][0]['octave_shifts'] = [{'id': 'octave', 'start': rational(0), 'end': rational(4), 'octaves': -1}]
|
||||
original = copy.deepcopy(source)
|
||||
for builder, wrapper, practice in ((_practice_document, practice_document, True), (_arrange_document, arrange_document, False)):
|
||||
prepared = prepare_document(source, practice=practice)
|
||||
before = copy.deepcopy(prepared)
|
||||
parts = [builder(prepared, part) for part in PART_TYPES]
|
||||
self.assertEqual(prepared, before)
|
||||
self.assertEqual(parts, [wrapper(source, part) for part in PART_TYPES])
|
||||
vocal = copy.deepcopy(parts[2])
|
||||
parts[1]['metadata']['title'] = 'changed'
|
||||
notes(parts[1])[0]['pitches'].clear()
|
||||
if parts[1]['staves'][0]['octave_shifts']:
|
||||
parts[1]['staves'][0]['octave_shifts'][0]['octaves'] = 9
|
||||
self.assertEqual(parts[2], vocal)
|
||||
self.assertEqual(prepared, before)
|
||||
self.assertEqual(source, original)
|
||||
def test_song_slug_collisions_fail_before_writes(self):
|
||||
for names in (('one-two', 'one_two'), ('Song', 'song'), ('!!!', '???')):
|
||||
with self.subTest(names=names), tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source, output = root / 'songs', root / 'output'
|
||||
source.mkdir()
|
||||
output.mkdir()
|
||||
(output / 'sentinel').write_text('preserved')
|
||||
for name in names:
|
||||
(source / (name + '.xml')).write_text(xml_score(xml_note()))
|
||||
with self.assertRaisesRegex(ValueError, 'conflicting output path') as raised:
|
||||
generate_performance_scores(source, output)
|
||||
for name in names:
|
||||
self.assertIn(str(source / (name + '.xml')), str(raised.exception))
|
||||
self.assertEqual([p.name for p in output.iterdir()], ['sentinel'])
|
||||
def test_pipeline_parses_and_normalizes_each_source_once(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
for category, name in (('songs', 'song'), ('practice', '001')):
|
||||
(root / 'data' / category).mkdir(parents=True)
|
||||
(root / 'data' / category / (name + '.xml')).write_text(xml_score(xml_harmony() + xml_note()))
|
||||
args = argparse.Namespace(data_dir=root / 'data', output_dir=root / 'output', static_assets_dir=Path('package/assets'), check=False)
|
||||
with patch('generator.arrange.generate.load_musicxml', wraps=load_musicxml) as song_load, patch('generator.practice.generate.load_musicxml', wraps=load_musicxml) as practice_load, patch('generator.parts.concert_document', wraps=concert_document) as normalize, patch('generator.__main__.engrave_tree'), patch('generator.__main__.build_catalogue'), patch('generator.__main__.compile_tree'):
|
||||
generate_assets(args)
|
||||
song_load.assert_called_once()
|
||||
practice_load.assert_called_once()
|
||||
self.assertEqual(normalize.call_count, 2)
|
||||
original = {p.relative_to(args.output_dir): p.read_bytes() for p in args.output_dir.rglob('*') if p.is_file()}
|
||||
(root / 'data/practice/002.xml').write_text('<broken')
|
||||
with self.assertRaises(ValueError):
|
||||
generate_assets(args)
|
||||
self.assertEqual(original, {p.relative_to(args.output_dir): p.read_bytes() for p in args.output_dir.rglob('*') if p.is_file()})
|
||||
self.assertEqual(len(list(args.output_dir.rglob('*.json'))), 6)
|
||||
self.assertEqual(json.loads((args.output_dir / 'songs/song-vocal.json').read_text())['metadata']['title'], 'Fixture')
|
||||
@@ -10,6 +10,23 @@ from generator.playback import PlaybackUnavailable, compile_timeline, generate_t
|
||||
from tests.test_musicxml import notes, parse, xml_note, xml_score
|
||||
|
||||
class PlaybackTests(unittest.TestCase):
|
||||
def test_grace_steals_accumulate_at_both_ends_of_a_donor(self):
|
||||
grace = '<note><grace {}/><pitch><step>{}</step><octave>4</octave></pitch><type>eighth</type></note>'
|
||||
content = grace.format('steal-time-following="25"', 'D') + xml_note(duration=2, kind='half') + grace.format('steal-time-previous="25"', 'E') + xml_note('F', 2, 'half')
|
||||
result = compile_timeline(parse(xml_score(content)))
|
||||
self.assertEqual([(fraction(e['pitch']), fraction(e['onset']), fraction(e['duration'])) for e in result['events']], [(62, 0, Fraction(1, 2)), (60, Fraction(1, 2), 1), (64, Fraction(3, 2), Fraction(1, 2)), (65, 2, 2)])
|
||||
for percent in (50, 60):
|
||||
with self.subTest(percent=percent), self.assertRaisesRegex(PlaybackUnavailable, 'grace timing consumes'):
|
||||
compile_timeline(parse(xml_score(content.replace('"25"', f'"{percent}"'))))
|
||||
chord = content.replace(xml_note(duration=2, kind='half'), xml_note(duration=2, kind='half') + xml_note('G', 1, 'quarter', prefix='<chord/>'))
|
||||
with self.assertRaisesRegex(PlaybackUnavailable, 'chord tone'):
|
||||
compile_timeline(parse(xml_score(chord)))
|
||||
def test_grace_previous_and_make_time_preserve_their_timing(self):
|
||||
for mode, expected, total in [('steal-time-previous="25"', [(0, Fraction(3, 2)), (Fraction(3, 2), Fraction(1, 2)), (2, 2)], 4), ('make-time="1"', [(0, 2), (2, 1), (3, 2)], 5)]:
|
||||
grace = f'<note><grace {mode}/><pitch><step>D</step><octave>4</octave></pitch><type>eighth</type></note>'
|
||||
result = compile_timeline(parse(xml_score(xml_note(duration=2, kind='half') + grace + xml_note('E', 2, 'half'))))
|
||||
self.assertEqual([(fraction(e['onset']), fraction(e['duration'])) for e in result['events']], expected)
|
||||
self.assertEqual(fraction(result['duration']), total)
|
||||
def test_chords_and_trailing_rest(self):
|
||||
document = parse(xml_score(xml_note(duration=2, kind='half') + xml_note('E', 1, 'quarter', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>'))
|
||||
result = compile_timeline(document)
|
||||
|
||||
Reference in New Issue
Block a user