161 lines
10 KiB
Python
161 lines
10 KiB
Python
"""Learned inference and explicit authored reuse have different contracts."""
|
|
import copy
|
|
import dataclasses
|
|
import json
|
|
import pathlib
|
|
import fractions
|
|
import tempfile
|
|
import unittest
|
|
import unittest.mock
|
|
|
|
import generator.production
|
|
import generator.parts
|
|
import generator.musicxml
|
|
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.solo.analysis
|
|
import generator.solo.export
|
|
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_exports_exact_sounding_music_for_every_registered_pair(self):
|
|
manifest = json.loads((ROOT / 'data/training/manifest.json').read_text())
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
for entry in manifest['examples']:
|
|
part = 'acoustic' if entry['slot'] == 1 else 'electric'
|
|
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source']))
|
|
source = generator.production.SourceAnalysis.read(doc)
|
|
with unittest.mock.patch('generator.solo.composer.compose', side_effect=AssertionError('authored pair must not be predicted')):
|
|
result = generator.production.generate_role(source, part + '_solo', ROOT / 'data')
|
|
path = pathlib.Path(directory) / (part + '.xml')
|
|
generator.solo.export.write_role(result, path)
|
|
actual = generator.training_io.read(path, part)
|
|
expected = generator.training_io.read(ROOT / 'data/training' / entry['solo'], part, pitch_convention=entry['pitch_convention'])
|
|
self.assertEqual(music(actual['sounding_events']), music(expected['sounding_events']), entry['id'])
|
|
self.assertEqual([(m['start'], m['duration']) for m in actual['measures']], [(m['start'], m['duration']) for m in expected['measures']])
|
|
self.assertEqual(actual['discrepancies'], [])
|
|
self.assertTrue(all(n['legal_positions_at_sounding_pitch'] for n in actual['notes'] if n['pitch']))
|
|
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.production.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.production.SourceAnalysis.read(doc))
|
|
doc['staves'][0]['measures'][0]['voices'][0]['notes'][0]['pitches'][0]['octave'] += 1
|
|
self.assertIsNone(generator.solo.authored.lookup(generator.production.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.production.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.solo.analysis.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.solo.analysis.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.solo.analysis.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.solo.analysis.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)
|