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('