139 lines
9.0 KiB
Python
139 lines
9.0 KiB
Python
"""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 '<score-partwise version="4.0"><part-list><score-part id="P"><part-name>Vocal</part-name></score-part></part-list><part id="P">' + leadin + '<measure number="1"><attributes><divisions>6</divisions><key><fifths>0</fifths></key><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>G</sign><line>2</line></clef>' + ('<transpose><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>' if transpose else '') + '</attributes><harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>' + notes + '</measure></part></score-partwise>'
|
|
|
|
def pitched(duration, tie='', octave=5):
|
|
return f'<note><pitch><step>C</step><octave>{octave}</octave></pitch><duration>{duration}</duration>{tie}<type>quarter</type></note>'
|
|
|
|
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('</note>', '<notations><technical><fingering>6</fingering></technical></notations></note>')
|
|
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, '<tie type="start"/>') + pitched(12, '<tie type="stop"/>')), 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 = '<measure number="0"><attributes><divisions>6</divisions></attributes>' + pitched(6) + '<note><rest/><duration>18</duration></note></measure>'
|
|
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('<type>quarter</type>', '<type>eighth</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>')
|
|
r = self.review(root, 'thirds.xml', xml('<direction><direction-type><words>Lead in</words></direction-type></direction>' + triplet * 3 + '<note><rest/><duration>18</duration></note>'), 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()
|