197 lines
11 KiB
Python
197 lines
11 KiB
Python
"""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, themes
|
|
from tests.test_solo import song
|
|
from tests.test_solo_passages import choices
|
|
from tests.test_solo_development 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('<not-a-production-source/>')
|
|
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]['draft']['notes'],b['details']['phrases'][0]['draft']['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[0] for c in vocabulary.rhythm_cells(180)}
|
|
slow={c[0] 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()
|