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'{root}{kind}{offset}'
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('', '' + xml_note() + '' + xml_harmony(kind='none') + xml_note() + '' + xml_note() + '')
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', '', False)):
source = parse(xml_score(xml_harmony() + xml_harmony('F') + xml_note(duration=duration, kind=kind, extra='' 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') + '' + xml_note(duration=3, kind='half', extra='')))
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('