"""Learned inference and explicit authored reuse have different contracts.""" import copy import dataclasses import json from pathlib import Path from fractions import Fraction as F import tempfile import unittest from unittest.mock import patch from generator import production,parts,musicxml,train,training_io,vocabulary from generator.solo import authored,learned,composer,decoder,harmonic,analysis,export from tests.solo_fixtures import song from tests.test_musicxml import xml_score,xml_note ROOT=Path(__file__).resolve().parents[1] def music(events):return tuple((F(n['start']),F(n['end']),F(n['sounding_midi'])) for n in events) class TrainingAndRepertoire(unittest.TestCase): @classmethod def setUpClass(cls): cls.examples,cls.provenance=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=learned.fit(self.examples,{}) swapped=[dict(e,role='electric' if e['role']=='acoustic' else 'acoustic') for e in reversed(self.examples)] self.assertEqual(baseline,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,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=parts.prepare_document(musicxml.load_musicxml(ROOT/'data/training'/entry['source'])) source=production.SourceAnalysis.read(doc) with patch('generator.solo.composer.compose',side_effect=AssertionError('authored pair must not be predicted')): result=production.generate_role(source,part+'_solo',ROOT/'data') path=Path(directory)/(part+'.xml');export.write_role(result,path) actual=training_io.read(path,part);expected=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=parts.prepare_document(musicxml.load_musicxml(ROOT/'data/training'/entry['source'])) source=production.SourceAnalysis.read(doc) part='acoustic' if entry['slot']==1 else 'electric' self.assertIsNotNone(authored.lookup(source,part,ROOT/'data')) doc['metadata'].update(title='Renamed standard',composer='Display only') self.assertEqual(source,production.SourceAnalysis.read(doc)) doc['staves'][0]['measures'][0]['voices'][0]['notes'][0]['pitches'][0]['octave']+=1 self.assertIsNone(authored.lookup(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=Path(directory);source=root/'new-standard.xml';solo=root/'interpretation.xml' content=xml_score('Cmajor'+xml_note('E',octave=5)) source.write_text(content);solo.write_text(content) manifest=root/'data/training/manifest.json' train.register_example(manifest,source,solo,1,'guitar-written') examples,_=train.load_examples(manifest) self.assertEqual(len(examples),1) doc=parts.prepare_document(musicxml.load_musicxml(root/'data/songs'/source.name)) context=production.SourceAnalysis.read(doc) self.assertIsNotNone(authored.lookup(context,'acoustic',root/'data')) self.assertIsNone(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'):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=Path(directory);source=root/'song.xml';solo=root/'solo.xml' source.write_text(xml_score(xml_note()));solo.write_text(xml_score(xml_note(duration=2,kind='half'))) manifest=root/'data/training/manifest.json' with self.assertRaisesRegex(ValueError,'timing differs'):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'):learned.Model.load('/missing-model-file') def test_decoder_runs_without_training_files_and_is_deterministic(self): source=song(count=1,chords=('C7',));a=analysis.analyze(source) predictor=learned.Model.load(ROOT/'data/models/solo.json') with vocabulary.using(vocabulary.load(),'acoustic_solo',{'attempts':2}): original=Path.open def guarded(path,*args,**kwargs): self.assertNotIn('training',str(path));return original(path,*args,**kwargs) with patch.object(Path,'open',guarded): first=composer.compose(a,source['staves'][0]['measures'],'acoustic',F(4),learned_model=predictor) second=composer.compose(a,source['staves'][0]['measures'],'acoustic',F(4),learned_model=predictor) self.assertEqual(first.notes(),second.notes());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=song(count=1,chords=('C7',));a=analysis.analyze(source) from generator.harmonic_time import read h=harmonic.Harmony(read(source['staves'][0]));predictor=learned.Model.load(ROOT/'data/models/solo.json') engine=decoder.Decoder(a,h,predictor,'acoustic',F(),F(4)) notes=(engine.note(F(),F(1),60),engine.note(F(1),F(2),64)) events=tuple((n.start,n.end,n.pitch.number()) for n in notes) combined=engine.extend(decoder.State(),(events,notes,())) split=engine.extend(engine.extend(decoder.State(),(events[:1],notes[:1],())),(events[1:],notes[1:],())) self.assertEqual(combined,split) def test_source_features_include_actual_melodic_intervals(self): source=song(count=1,chords=('C7',));a=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(learned.source_feature(a,F(1)),learned.source_feature(changed,F(1))) def test_generated_long_nonchord_notes_still_need_support(self): from tests.solo_fixtures import choices,note h=harmonic.Harmony(choices(('C7',))) self.assertTrue(h.evaluate((note(0,2,'e'),)).valid) self.assertFalse(h.evaluate((note(0,2,'f'),)).valid) self.assertTrue(h.evaluate((note(0,F(1,2),'g'),note(F(1,2),1,'f'),note(1,2,'e'))).valid) def test_held_chord_member_can_become_a_supported_color(self): from tests.solo_fixtures import choices source=song(count=2,chords=('C','Dm'));a=analysis.analyze(source) h=harmonic.Harmony(choices(('C','Dm'))) engine=decoder.Decoder(a,h,learned.Model.load(ROOT/'data/models/solo.json'),'acoustic',F(),F(8)) n=engine.note(F(3),F(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)