Files
product-vamp/tests/test_learned_solos.py
T
khodowany bc7baea7c4
ci / checks (push) Failing after 23m22s
Cleanup
2026-10-08 02:13:45 -05:00

178 lines
11 KiB
Python

"""Learned inference and explicit authored reuse have different contracts."""
import generator.composition
import copy
import dataclasses
import json
import pathlib
import fractions
import tempfile
import unittest
import unittest.mock
import generator.parts
import generator.musicxml
import generator.model
import generator.score
import generator.train
import generator.training_io
import generator.vocabulary
import generator.solo.authored
import generator.solo.learned
import generator.solo.composer
import generator.solo.decoder
import generator.solo.harmonic
import generator.source
import tests.score_events
import tests.solo_fixtures
import tests.test_musicxml
import generator.harmonic_time
ROOT = pathlib.Path(__file__).resolve().parents[1]
def music(events): return tuple((fractions.Fraction(n['start']), fractions.Fraction(n['end']), fractions.Fraction(n['sounding_midi'])) for n in events)
class TrainingAndRepertoire(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.examples, cls.provenance = generator.train.load_examples(ROOT / 'data/training/manifest.json')
def test_all_four_examples_and_intentional_pickup_forms(self):
self.assertEqual(len(self.examples), 4)
self.assertEqual(sorted(p['measures'] for p in self.provenance['examples']), [32, 32, 33, 33])
self.assertEqual(sorted(p['pickup_beats'] for p in self.provenance['examples']), ['0', '0', '4', '4'])
def test_training_is_pooled_and_deterministic(self):
baseline = generator.solo.learned.fit(self.examples, {})
swapped = [dict(e, role='electric' if e['role'] == 'acoustic' else 'acoustic') for e in reversed(self.examples)]
self.assertEqual(baseline, generator.solo.learned.fit(swapped, {}))
self.assertNotIn('acoustic', json.dumps(baseline))
self.assertNotIn('electric', json.dumps(baseline))
frozen = json.loads((ROOT / 'data/models/solo.json').read_text())
self.assertEqual(frozen, generator.solo.learned.fit(self.examples, self.provenance))
def test_repertoire_preserves_exact_sounding_music_for_every_registered_pair(self):
manifest = json.loads((ROOT / 'data/training/manifest.json').read_text())
for entry in manifest['examples']:
with self.subTest(pair=entry['id']):
part = 'acoustic' if entry['slot'] == 1 else 'electric'
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source']))
source = generator.source.SourceAnalysis.read(doc)
with unittest.mock.patch('generator.solo.composer.compose', side_effect=AssertionError('authored pair must not be predicted')):
result = generator.composition.generate_role(source, part + '_solo', ROOT / 'data')
measures = result['document']['staves'][0]['measures']
first = next(i for i, m in enumerate(measures) if m.get('section_label') == part.capitalize() + ' Solo')
last = next((i for i in range(first + 1, len(measures)) if measures[i].get('section_label')), len(measures))
first -= result['details']['allocation']['lead_in_measures']
offset = sum((generator.model.fraction(m['duration']) for m in measures[:first]), fractions.Fraction())
timeline = tests.score_events.timeline(result['document'])
actual = tuple((generator.model.fraction(n['onset']) - offset,
generator.model.fraction(n['onset']) + generator.model.fraction(n['duration']) - offset,
generator.model.fraction(n['pitch'])) for n in timeline['events'])
expected = generator.training_io.read(ROOT / 'data/training' / entry['solo'], part, pitch_convention=entry['pitch_convention'])
self.assertEqual(actual, music(expected['sounding_events']))
cursor, spans = fractions.Fraction(), []
for measure in measures[first:last]:
duration = generator.model.fraction(measure['duration'])
spans.append((str(cursor), str(duration)))
cursor += duration
for note in generator.score.measure_notes(measure):
for pitch in note['pitches']:
position = pitch['tablature']
self.assertIn(position['string'], range(1, 7))
self.assertIn(position['fret'], range(1, 13))
self.assertEqual(spans, [(m['start'], m['duration']) for m in expected['measures']])
self.assertEqual(expected['discrepancies'], [])
def test_lookup_uses_source_music_and_slot_not_title_or_path(self):
entry = json.loads((ROOT / 'data/training/manifest.json').read_text())['examples'][0]
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source']))
source = generator.source.SourceAnalysis.read(doc)
part = 'acoustic' if entry['slot'] == 1 else 'electric'
self.assertIsNotNone(generator.solo.authored.lookup(source, part, ROOT / 'data'))
doc['metadata'].update(title='Renamed standard', composer='Display only')
self.assertEqual(source, generator.source.SourceAnalysis.read(doc))
doc['staves'][0]['measures'][0]['voices'][0]['notes'][0]['pitches'][0]['octave'] += 1
self.assertIsNone(generator.solo.authored.lookup(generator.source.SourceAnalysis.read(doc), part, ROOT / 'data'))
def test_adding_a_new_pair_requires_no_code_or_existing_song_names(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
source = root / 'new-standard.xml'
solo = root / 'interpretation.xml'
content = tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note('E', octave=5))
source.write_text(content)
solo.write_text(content)
manifest = root / 'data/training/manifest.json'
generator.train.register_example(manifest, source, solo, 1, 'guitar-written')
examples, _ = generator.train.load_examples(manifest)
self.assertEqual(len(examples), 1)
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(root / 'data/songs' / source.name))
context = generator.source.SourceAnalysis.read(doc)
self.assertIsNotNone(generator.solo.authored.lookup(context, 'acoustic', root / 'data'))
self.assertIsNone(generator.solo.authored.lookup(context, 'electric', root / 'data'))
self.assertEqual(solo.read_bytes(), (manifest.parent / solo.name).read_bytes())
before = manifest.read_bytes()
with self.assertRaisesRegex(ValueError, 'already registered'):
generator.train.register_example(manifest, source, solo, 1, 'guitar-written')
self.assertEqual(manifest.read_bytes(), before)
def test_incorrect_form_registration_rolls_back(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
source = root / 'song.xml'
solo = root / 'solo.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
solo.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half')))
manifest = root / 'data/training/manifest.json'
with self.assertRaisesRegex(ValueError, 'timing differs'):
generator.train.register_example(manifest, source, solo, 2, 'guitar-written')
self.assertFalse(manifest.exists())
self.assertFalse((root / 'data/songs/song.xml').exists())
self.assertFalse((root / 'data/training/solo.xml').exists())
class LearnedInference(unittest.TestCase):
def test_model_is_required_without_repertoire(self):
with self.assertRaisesRegex(ValueError, 'run python -m generator.train'):
generator.solo.learned.Model.load('/missing-model-file')
def test_decoder_runs_without_training_files_and_is_deterministic(self):
source = tests.solo_fixtures.song(count=1, chords=('C7',))
a = generator.source.analyze(source)
predictor = generator.solo.learned.Model.load(ROOT / 'data/models/solo.json')
with generator.vocabulary.using(generator.vocabulary.load(), 'acoustic_solo', {'attempts': 2}):
original = pathlib.Path.open
def guarded(path, *args, **kwargs):
self.assertNotIn('training', str(path))
return original(path, *args, **kwargs)
with unittest.mock.patch.object(pathlib.Path, 'open', guarded):
first = generator.solo.composer.compose(a, source['staves'][0]['measures'], 'acoustic', fractions.Fraction(4), learned_model=predictor)
second = generator.solo.composer.compose(a, source['staves'][0]['measures'], 'acoustic', fractions.Fraction(4), learned_model=predictor)
self.assertEqual(first.notes(), second.notes())
generator.solo.composer.validate(first)
self.assertTrue(first.notes())
self.assertEqual(first.diagnostics['construction'], 'learned_joint_events')
def test_event_score_does_not_depend_on_action_grouping(self):
source = tests.solo_fixtures.song(count=1, chords=('C7',))
a = generator.source.analyze(source)
h = generator.solo.harmonic.Harmony(generator.harmonic_time.read(source['staves'][0]))
predictor = generator.solo.learned.Model.load(ROOT / 'data/models/solo.json')
engine = generator.solo.decoder.Decoder(a, h, predictor, 'acoustic', fractions.Fraction(), fractions.Fraction(4))
notes = (engine.note(fractions.Fraction(), fractions.Fraction(1), 60), engine.note(fractions.Fraction(1), fractions.Fraction(2), 64))
events = tuple((n.start, n.end, n.pitch.number()) for n in notes)
combined = engine.extend(generator.solo.decoder.State(), (events, notes, ()))
split = engine.extend(engine.extend(generator.solo.decoder.State(), (events[:1], notes[:1], ())), (events[1:], notes[1:], ()))
self.assertEqual(combined, split)
def test_source_features_include_actual_melodic_intervals(self):
source = tests.solo_fixtures.song(count=1, chords=('C7',))
a = generator.source.analyze(source)
changed = dataclasses.replace(a, melody=tuple(dataclasses.replace(n, pitch=dataclasses.replace(n.pitch, octave=n.pitch.octave + (i == 1))) for i, n in enumerate(a.melody)))
self.assertNotEqual(generator.solo.learned.source_feature(a, fractions.Fraction(1)), generator.solo.learned.source_feature(changed, fractions.Fraction(1)))
def test_generated_long_nonchord_notes_still_need_support(self):
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7',)))
self.assertTrue(h.evaluate((tests.solo_fixtures.note(0, 2, 'e'),)).valid)
self.assertFalse(h.evaluate((tests.solo_fixtures.note(0, 2, 'f'),)).valid)
self.assertTrue(h.evaluate((tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'g'), tests.solo_fixtures.note(fractions.Fraction(1, 2), 1, 'f'), tests.solo_fixtures.note(1, 2, 'e'))).valid)
def test_held_chord_member_can_become_a_supported_color(self):
source = tests.solo_fixtures.song(count=2, chords=('C', 'Dm'))
a = generator.source.analyze(source)
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C', 'Dm')))
engine = generator.solo.decoder.Decoder(a, h, generator.solo.learned.Model.load(ROOT / 'data/models/solo.json'), 'acoustic', fractions.Fraction(), fractions.Fraction(8))
n = engine.note(fractions.Fraction(3), fractions.Fraction(6), 64) # E: C's third becomes D minor's ninth.
support = engine.permission(n)
self.assertIsNotNone(support)
self.assertEqual(support[0].rule, 'minor_ninth')
self.assertTrue(h.evaluate((n,), complete=True, expressions=support).valid)