Sepearate Generation

This commit is contained in:
khodowany
2026-10-04 09:05:25 -05:00
parent e8ac7b5edd
commit 8fdd736e13
56 changed files with 1739 additions and 341 deletions
+196
View File
@@ -0,0 +1,196 @@
"""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()