115 lines
7.6 KiB
Python
115 lines
7.6 KiB
Python
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_catalog'), 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')
|