"""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('Cmajor' + 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)