Files
product-vamp/tests/test_solo_comparison.py
T
khodowany 82349cf897
ci / checks (push) Failing after 23m37s
Playback
2026-10-05 23:41:11 -05:00

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