"""Independent musical fixtures; the four optional user scores are never read.""" import copy import dataclasses import json import fractions import pathlib import tempfile import types import unittest import unittest.mock import contextlib import io import tests.solo_compare import generator.score import generator.training_io import generator.vocabulary import generator.production import generator.solo.export import generator.solo.harmonic import generator.solo.notation import generator.solo.placement import generator.solo.types import tests.solo_fixtures import tests.solo_fixtures import tests.solo_fixtures import tests.solo_fixtures def xml(notes, *, transpose=False, leadin=''): return 'Vocal' + leadin + '60G2' + ('0-1' if transpose else '') + 'Cmajor-seventh' + notes + '' def pitched(duration, tie='', octave=5): return f'C{octave}{duration}{tie}quarter' class ComparisonTests(unittest.TestCase): def test_comparison_separates_pitch_timing_and_sounding_duration(self): def events(pitch, end): return [dict(start='0', end=end, pitch=pitch)] a = events(60, '2') octave = tests.solo_compare.relationships(a, events(72, '2')) self.assertEqual(octave['pitch_sequence_edit_distance'], 1) self.assertEqual(octave['timed_attack_edit_distance'], 0) self.assertEqual(octave['same_sounding_pitch_beats'], '0') self.assertEqual(octave['same_pitch_class_beats'], '2') shorter = tests.solo_compare.relationships(a, events(60, '1')) self.assertEqual(shorter['pitch_sequence_edit_distance'], 0) self.assertEqual(shorter['timed_attack_edit_distance'], 1) self.assertEqual(shorter['same_sounding_pitch_beats'], '1') self.assertEqual(shorter['either_sounding_beats'], '2') def test_exact_cli_is_a_real_exit_gate(self): args = ['solo_compare', '--generated-dir', 'unused', '--output', 'unused', '--reference', 'fixture/acoustic=unused', '--require-exact'] review = {'exact_musical_match': False, 'exact_matching_attacks': 0, 'differences': [{}], 'rest_difference': True} with unittest.mock.patch('sys.argv', args), unittest.mock.patch.object(tests.solo_compare, 'run', return_value={'solos': {'fixture/acoustic': review}}), contextlib.redirect_stdout(io.StringIO()): self.assertEqual(tests.solo_compare.main(), 1) review.update(exact_musical_match=True, differences=[], rest_difference=False) self.assertEqual(tests.solo_compare.main(), 0) with unittest.mock.patch('sys.argv', args[:-3] + ['--require-exact']), contextlib.redirect_stderr(io.StringIO()): with self.assertRaises(SystemExit) as error: tests.solo_compare.main() self.assertEqual(error.exception.code, 2) def test_note_container_does_not_turn_color_label_into_fingering(self): with tempfile.TemporaryDirectory() as root: note_xml = pitched(24).replace('', '6') r = self.review(root, 'annotation.xml', xml(note_xml), form_start=1) self.assertEqual(r['annotations'][0]['text'], '6') self.assertEqual(r['annotations'][0]['container'], 'notations/technical/fingering') self.assertIsNone(r['notes'][0]['tablature']) def review(self, directory, name, content, **kwargs): path = pathlib.Path(directory) / name path.write_text(content) return generator.training_io.read(path, 'fixture/acoustic', **kwargs) def test_ties_not_reattacks_and_one_guitar_transposition(self): with tempfile.TemporaryDirectory() as root: a = self.review(root, 'actual.xml', xml(pitched(24), transpose=True), form_start=1) b = self.review(root, 'reference.xml', xml(pitched(12, '') + pitched(12, '')), form_start=1, pitch_convention='guitar-written') self.assertTrue(tests.solo_compare.compare(a, b)['exact_musical_match']) self.assertEqual(a['sounding_events'][0]['sounding_midi'], '60') repeated = self.review(root, 'repeat.xml', xml(pitched(12) + pitched(12)), form_start=1, pitch_convention='guitar-written') self.assertFalse(tests.solo_compare.compare(a, repeated)['exact_musical_match']) def test_leadin_is_reported_without_harmony_or_alignment_padding(self): with tempfile.TemporaryDirectory() as root: prelude = '6' + pitched(6) + '18' a = self.review(root, 'a.xml', xml(pitched(24), transpose=True), form_start=1) b = self.review(root, 'b.xml', xml(pitched(24), leadin=prelude), form_start=2, pitch_convention='guitar-written') result = tests.solo_compare.compare(a, b) self.assertEqual(result['differences'][0]['time'], '-4') self.assertEqual(result['exact_matching_attacks'], 1) self.assertIn('uncertainty', b['notes'][0]['harmonic_spans'][0]) def test_unplayable_pitch_is_reported_not_folded(self): with tempfile.TemporaryDirectory() as root: r = self.review(root, 'high.xml', xml(pitched(24, octave=7)), form_start=1, pitch_convention='guitar-written') checked = tests.solo_compare.playability(r) self.assertEqual(checked['conflicts'][0]['sounding_midi'], 84) self.assertEqual(r['notes'][0]['pitch']['octave'], 7) def test_triplet_time_and_annotations_are_literal(self): with tempfile.TemporaryDirectory() as root: triplet = pitched(2).replace('quarter', 'eighth32') r = self.review(root, 'thirds.xml', xml('Lead in' + triplet * 3 + '18'), form_start=1) self.assertEqual([n['start'] for n in r['sounding_events']], ['0', '1/3', '2/3']) self.assertEqual(r['annotations'][0]['text'], 'Lead in') def test_export_roundtrip_preserves_actual_sounding_events_and_tab(self): document = tests.solo_fixtures.song(count=2, chords=('CM7',)) raw = (tests.solo_fixtures.note(0, fractions.Fraction(1, 3), 'c'), tests.solo_fixtures.note(fractions.Fraction(1, 3), fractions.Fraction(2, 3), 'd'), tests.solo_fixtures.note(fractions.Fraction(2, 3), 1, 'e'), tests.solo_fixtures.note(fractions.Fraction(7, 2), 5, 'g'), tests.solo_fixtures.note(5, 6, 'g')) placed = generator.solo.placement.place(raw, fractions.Fraction(120))[1] generator.solo.notation.replace_region(document, 0, generator.solo.notation.measures(document['staves'][0]['measures'], placed, 'export')) document['staves'][0]['measures'][0]['section_label'] = 'Acoustic Solo' before = copy.deepcopy(document) with tempfile.TemporaryDirectory() as root: path = pathlib.Path(root) / 'fixture-solo1.xml' info = generator.solo.export.write_role({'role': 'acoustic_solo', 'document': document, 'trace': {'source': 'fixture'}}, path) loaded = generator.training_io.read(path, 'fixture/acoustic', 1) sounded = [(fractions.Fraction(n['start']), fractions.Fraction(n['end']), fractions.Fraction(n['sounding_midi'])) for n in loaded['sounding_events']] self.assertEqual([(n.start, n.end, n.pitch.number()) for n in raw], sounded) def tab(d): return [p['tablature'] for m in d['staves'][0]['measures'] for n in generator.score.measure_notes(m) for p in n['pitches']] self.assertEqual(tab(document), [n['tablature'] for n in loaded['notes'] if n['pitch']]) self.assertEqual(info['role'], 'acoustic_solo') self.assertEqual(before, document) def test_export_manifests_are_not_discovered_as_runtime_scores(self): document = tests.solo_fixtures.song(count=1, chords=('CM7',)) with tempfile.TemporaryDirectory() as root: root = pathlib.Path(root) (root / 'analysis').mkdir() roles = {} for part in ('acoustic', 'electric'): doc = copy.deepcopy(document) doc['staves'][0]['measures'][0]['section_label'] = part.capitalize() + ' Solo' roles[part + '_solo'] = {'role': part + '_solo', 'document': doc, 'trace': {'source': 'synthetic'}} (root / 'analysis' / 'fixture.roles').write_text(json.dumps(roles)) generator.score.write_json(root / 'score.json', document) generator.solo.export.export_tree(root) self.assertEqual(generator.score.count_json_files(root), 1) generator.score.validate_generated_tree(root) self.assertTrue((root / 'solo_xml' / 'fixture-solo1.xml').is_file()) self.assertTrue((root / 'solo_xml' / 'fixture-solo2.xml').is_file()) if __name__ == '__main__': unittest.main()