"""Dependency and music contracts use synthetic sources, never audition scores.""" import concurrent.futures import copy import contextlib import dataclasses import json import multiprocessing from pathlib import Path import tempfile import unittest from fractions import Fraction as F from unittest.mock import patch from generator import production, model, vocabulary from generator.solo import expressions, types, harmonic, placement, notation, composer from tests.solo_fixtures import song from tests.solo_fixtures import choices from tests.solo_fixtures import note def music(result): return result['document'] class IndependentRoles(unittest.TestCase): @classmethod def setUpClass(cls): cls.source=production.SourceAnalysis.read(song(count=1,chords=('CM7',))) cls.root=Path('data').resolve() def test_alone_order_parallel_and_assembly(self): serial={role:production.generate_role(self.source,role,self.root) for role in production.ROLES} with concurrent.futures.ProcessPoolExecutor(max_workers=2,mp_context=multiprocessing.get_context('spawn')) as pool: jobs={role:pool.submit(production._job,(self.source,role,self.root,{},None)) for role in reversed(production.ROLES)} parallel={role:future.result() for role,future in jobs.items()} for role in production.ROLES:self.assertEqual(music(serial[role]),music(parallel[role]),role) for doc in production.assemble(self.source,serial).values():model.validate_document(doc) def test_role_setting_and_cache_are_independent(self): with tempfile.TemporaryDirectory() as directory: a=production.generate_role(self.source,'acoustic_solo',self.root,cache_dir=directory) b=production.generate_role(self.source,'electric_solo',self.root,{'seed':'changed'},directory) again=production.generate_role(self.source,'acoustic_solo',self.root,cache_dir=directory) clean=production.generate_role(self.source,'acoustic_solo',self.root) self.assertTrue(again['cache_hit']);self.assertEqual(music(a),music(again));self.assertEqual(music(a),music(clean)) self.assertEqual(json.dumps(music(a)),json.dumps(music(again))) self.assertNotEqual(a['trace']['cache_key'],b['trace']['cache_key']) def test_optional_inputs_cannot_be_opened_by_composition(self): original=Path.open def guarded(path,*args,**kwargs): self.assertNotIn('audition',str(path));self.assertNotIn('reference',str(path));self.assertNotIn('generated_assets',str(path)) return original(path,*args,**kwargs) with patch.object(Path,'open',guarded): result=production.generate_role(self.source,'acoustic_solo',self.root) self.assertNotIn('reference',json.dumps(result['trace'])) def test_titles_and_export_ids_do_not_supply_musical_identity(self): document=song(count=1,chords=('CM7',));other=copy.deepcopy(document) other['metadata'].update(title='A completely different title',composer='Different') for m in other['staves'][0]['measures']:m['id']='exporter_123';m['number']='99' self.assertEqual(production.SourceAnalysis.read(document),production.SourceAnalysis.read(other)) def test_reference_presence_and_changes_do_not_change_clean_music(self): # No repository reference is a fixture or prerequisite. These deliberately # invalid optional files test the absence of a discovery/fallback branch. with tempfile.TemporaryDirectory() as directory, contextlib.chdir(directory): reference_dir=Path(directory)/'build';reference_dir.mkdir() references=[reference_dir/(song+'-solo-'+str(index)+suffix+'.xml') for song in ('all-of-me','autumn-leaves') for index in (1,2) for suffix in ('','(4)')] before=production.generate_role(self.source,'electric_solo',self.root) for reference in references:reference.write_text('') present=production.generate_role(self.source,'electric_solo',self.root) for reference in references:reference.write_text('changed optional target, still not MusicXML') changed=production.generate_role(self.source,'electric_solo',self.root) for reference in references:reference.unlink() absent=production.generate_role(self.source,'electric_solo',self.root) self.assertTrue(all(music(before)==music(v) for v in (present,changed,absent))) def test_original_melody_changes_the_theme_with_unchanged_harmony(self): source=song(count=4,chords=('CM7',)) changed=copy.deepcopy(source) for m in changed['staves'][0]['measures']: for v in m['voices']: for n in v['notes']: for p in n['pitches']:p['letter']={'c':'e','d':'f','e':'g','f':'a'}[p['letter']] a=production.generate_role(production.SourceAnalysis.read(source),'acoustic_solo',self.root) b=production.generate_role(production.SourceAnalysis.read(changed),'acoustic_solo',self.root) self.assertNotEqual(a['details']['phrases'][0]['notes'],b['details']['phrases'][0]['notes']) def test_source_tuplet_lanes_survive_assembly(self): serial={r:production.generate_role(self.source,r,self.root) for r in production.ROLES} # Exercise a different original lane name, as in the imported Autumn head. doc=serial['electric_melody']['document'];staff=doc['staves'][0] raw=tuple(note(F(i,3),F(i+1,3),'c',event_id=str(i)) for i in range(3)) placed=placement.place(raw,F(120))[1] index=next(i for i,m in enumerate(staff['measures']) if m.get('section_label')=='Guitar Melody') replacement=notation.measures(staff['measures'][index:index+1],placed,'source_triplet') notation.replace_region(doc,index,replacement) for m in staff['measures']: for v in m['voices']: v['id']='voice_source' for n in v['notes']:n['voice']='voice_source' for t in staff['tuplets']:t['voice']='voice_source' model.validate_document(doc) model.validate_document(production.assemble(self.source,serial)['electric']) def test_sparse_parts_reuse_the_canonical_form(self): result=production.generate_role(production.SourceAnalysis.read(song(count=4,chords=('Dm7','G7','CM7'))),'acoustic_accompaniment',self.root) measures=result['document']['staves'][0]['measures'] def signature(m): return [(n['onset'],n['duration'],[(model.pitch_number(p),p['tablature']) for p in n['pitches']]) for v in m['voices'] for n in v['notes']] self.assertEqual([signature(m) for m in measures[4:8]],[signature(m) for m in measures[8:12]]) self.assertTrue(all(len(n['pitches'])<=3 for m in measures for v in m['voices'] for n in v['notes'])) class TypedExpressionContracts(unittest.TestCase): def setUp(self): self.h=harmonic.Harmony(choices(('C7','FM7'))) def make(self,rule='upper_lower'): target=note(2,4,'e',event_id='target') upper=note(1,F(3,2),'f',event_id='upper') lower=dataclasses.replace(note(F(3,2),2,'d',event_id='lower'),pitch=types.Pitch('d',F(1),4)) c=types.ExpressionContract('gesture','enclosure',rule,(upper,lower),target,'C7') return (upper,lower,target),c def test_complete_enclosure_and_orphans(self): notes,c=self.make() self.assertTrue(self.h.evaluate(notes,expressions=(c,)).valid) self.assertFalse(self.h.evaluate(notes).valid) self.assertFalse(self.h.evaluate(notes[:-1],expressions=(c,)).valid) changed=(*notes[:-1],dataclasses.replace(notes[-1],pitch=dataclasses.replace(notes[-1].pitch,octave=5))) self.assertFalse(self.h.evaluate(changed,expressions=(c,)).valid) def test_anticipation_validates_actual_future_harmony(self): n=note(F(7,2),6,'a',event_id='early') c=types.ExpressionContract('anticipation','anticipation','early_chord_tone',(n,),n,'FM7',boundary=F(4)) self.assertTrue(self.h.evaluate((n,),expressions=(c,)).valid) self.assertFalse(self.h.evaluate((n,)).valid) self.assertFalse(self.h.evaluate((n,),expressions=(dataclasses.replace(c,boundary=F(3)),)).valid) def test_optional_color_is_not_a_written_chord_requirement(self): n=note(0,2,'d',event_id='ninth') c=types.ExpressionContract('color','color','dominant_ninth',(n,),n,'C7') self.assertTrue(self.h.evaluate((n,),expressions=(c,)).valid) self.assertFalse(self.h.evaluate((n,)).valid) self.assertNotIn(n.pitch.number()%12,self.h.spans[0].roles.basic) self.assertNotIn(n.pitch.number()%12,self.h.spans[0].roles.specified) def test_labels_do_not_authorize_unresolved_notes(self): n=dataclasses.replace(note(0,2,'c',event_id='bad'),pitch=types.Pitch('c',F(1),4),role='color') self.assertFalse(self.h.evaluate((n,)).valid) c=types.ExpressionContract('bad','color','anything',(n,),n,'C7') self.assertFalse(self.h.evaluate((n,),expressions=(c,)).valid) def test_delayed_approach_allows_only_the_declared_rest(self): lower=dataclasses.replace(note(1,F(3,2),'d',event_id='lower'),pitch=types.Pitch('d',F(1),4)) target=note(2,4,'e',event_id='target') c=types.ExpressionContract('delay','approach','delayed_below',(lower,),target,'C7') self.assertTrue(self.h.evaluate((lower,target),expressions=(c,)).valid) wrong=dataclasses.replace(c,rule='below') self.assertFalse(self.h.evaluate((lower,target),expressions=(wrong,)).valid) def test_altered_dominant_color_requires_function_and_resolution(self): color=dataclasses.replace(note(0,1,'d',event_id='color'),pitch=types.Pitch('d',F(-1),4)) target=note(1,2,'c',event_id='target') c=types.ExpressionContract('flat9','color','dominant_flat_ninth',(color,),target,'C7') self.assertTrue(self.h.evaluate((color,target),expressions=(c,)).valid) unresolved=harmonic.Harmony(choices(('C7','Dm7'))) self.assertFalse(unresolved.evaluate((color,target),expressions=(c,)).valid) self.assertFalse(self.h.evaluate((color,),expressions=(c,)).valid) def test_mapped_semitone_needs_no_expression_exception(self): from tests import scale_fixtures h=harmonic.Harmony(choices(('A7','Dm7'),scale=scale_fixtures.library().scale('harmonic_minor',1,'D'))) a=dataclasses.replace(note(0,F(1,2),'c',event_id='csharp'),pitch=types.Pitch('c',F(1),4)) b=note(F(1,2),1,'d',event_id='d') self.assertTrue(h.evaluate((a,b)).valid) def test_ties_and_barlines_do_not_restart_triplets(self): raw=tuple(note(F(7,2)+F(i,3),F(7,2)+F(i+1,3),'c',event_id=str(i)) for i in range(3)) placed=placement.place(raw,F(120))[1] doc=song(count=2,chords=('CM7',));measures=notation.measures(doc['staves'][0]['measures'],placed,'triplet') spans=notation.tuplets(measures) self.assertEqual(len(spans),1) self.assertTrue(any('tie_from' in p for m in measures for v in m['voices'] for n in v['notes'] for p in n['pitches'])) def test_authored_fast_cells_respect_tempo(self): fast={c.id for c in vocabulary.rhythm_cells(180)} slow={c.id for c in vocabulary.rhythm_cells(100)} self.assertNotIn('sixteenth_pickup',fast);self.assertNotIn('triplet_turn',fast) self.assertIn('sixteenth_pickup',slow);self.assertIn('triplet_turn',slow) if __name__=='__main__':unittest.main()