Playback
ci / checks (push) Failing after 23m37s

This commit is contained in:
khodowany
2026-10-05 23:41:11 -05:00
parent ffdbe48258
commit 82349cf897
104 changed files with 4095 additions and 4354 deletions
+144 -140
View File
@@ -5,189 +5,193 @@ import contextlib
import dataclasses
import json
import multiprocessing
from pathlib import Path
import pathlib
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
import fractions
import unittest.mock
import generator.production
import generator.model
import generator.vocabulary
import generator.solo.expressions
import generator.solo.types
import generator.solo.harmonic
import generator.solo.placement
import generator.solo.notation
import generator.solo.composer
import tests.solo_fixtures
import tests.solo_fixtures
import tests.solo_fixtures
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()
cls.source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=1, chords=('CM7',)))
cls.root = pathlib.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)
serial = {role: generator.production.generate_role(self.source, role, self.root) for role in generator.production.ROLES}
with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool:
jobs = {role: pool.submit(generator.production._job, (self.source, role, self.root, {}, None)) for role in reversed(generator.production.ROLES)}
parallel = {role: future.result() for role, future in jobs.items()}
for role in generator.production.ROLES:
self.assertEqual(music(serial[role]), music(parallel[role]), role)
for doc in generator.production.assemble(self.source, serial).values():
generator.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'])
a = generator.production.generate_role(self.source, 'acoustic_solo', self.root, cache_dir=directory)
b = generator.production.generate_role(self.source, 'electric_solo', self.root, {'seed': 'changed'}, directory)
again = generator.production.generate_role(self.source, 'acoustic_solo', self.root, cache_dir=directory)
clean = generator.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']))
original = pathlib.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 unittest.mock.patch.object(pathlib.Path, 'open', guarded):
result = generator.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))
document = tests.solo_fixtures.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(generator.production.SourceAnalysis.read(document), generator.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)))
reference_dir = pathlib.Path(directory) / 'build'
reference_dir.mkdir()
references = [reference_dir / (source_name + '-solo-' + str(index) + suffix + '.xml')
for source_name in ('all-of-me', 'autumn-leaves') for index in (1, 2) for suffix in ('', '(4)')]
before = generator.production.generate_role(self.source, 'electric_solo', self.root)
for reference in references:
reference.write_text('<not-a-production-source/>')
present = generator.production.generate_role(self.source, 'electric_solo', self.root)
for reference in references:
reference.write_text('changed optional target, still not MusicXML')
changed = generator.production.generate_role(self.source, 'electric_solo', self.root)
for reference in references:
reference.unlink()
absent = generator.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)
source = tests.solo_fixtures.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'])
for p in n['pitches']:
p['letter'] = {'c': 'e', 'd': 'f', 'e': 'g', 'f': 'a'}[p['letter']]
a = generator.production.generate_role(generator.production.SourceAnalysis.read(source), 'acoustic_solo', self.root)
b = generator.production.generate_role(generator.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}
serial = {r: generator.production.generate_role(self.source, r, self.root) for r in generator.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)
doc = serial['electric_melody']['document']
staff = doc['staves'][0]
raw = tuple(tests.solo_fixtures.note(fractions.Fraction(i, 3), fractions.Fraction(i + 1, 3), 'c', event_id=str(i)) for i in range(3))
placed = generator.solo.placement.place(raw, fractions.Fraction(120))[1]
index = next(i for i, m in enumerate(staff['measures']) if m.get('section_label') == 'Guitar Melody')
replacement = generator.solo.notation.measures(staff['measures'][index:index + 1], placed, 'source_triplet')
generator.solo.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'])
v['id'] = 'voice_source'
for n in v['notes']:
n['voice'] = 'voice_source'
for t in staff['tuplets']:
t['voice'] = 'voice_source'
generator.model.validate_document(doc)
generator.model.validate_document(generator.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']
result = generator.production.generate_role(generator.production.SourceAnalysis.read(tests.solo_fixtures.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']))
return [(n['onset'], n['duration'], [(generator.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
self.h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7', 'FM7')))
def make(self, rule='upper_lower'):
target = tests.solo_fixtures.note(2, 4, 'e', event_id='target')
upper = tests.solo_fixtures.note(1, fractions.Fraction(3, 2), 'f', event_id='upper')
lower = dataclasses.replace(tests.solo_fixtures.note(fractions.Fraction(3, 2), 2, 'd', event_id='lower'), pitch=generator.solo.types.Pitch('d', fractions.Fraction(1), 4))
c = generator.solo.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)
notes, c = self.make()
self.assertTrue(self.h.evaluate(notes, expressions=(c,)).valid)
self.assertTrue(self.h.evaluate(notes).valid)
self.assertFalse(self.h.evaluate(notes,infer=False).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)
self.assertFalse(self.h.evaluate(notes, infer=False).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)
n = tests.solo_fixtures.note(fractions.Fraction(7, 2), 6, 'a', event_id='early')
c = generator.solo.types.ExpressionContract('anticipation', 'anticipation', 'early_chord_tone', (n,), n, 'FM7', boundary=fractions.Fraction(4))
self.assertTrue(self.h.evaluate((n,), expressions=(c,)).valid)
self.assertTrue(self.h.evaluate((n,)).valid)
self.assertFalse(self.h.evaluate((n,),infer=False).valid)
self.assertFalse(self.h.evaluate((n,),expressions=(dataclasses.replace(c,boundary=F(3)),)).valid)
self.assertFalse(self.h.evaluate((n,), infer=False).valid)
self.assertFalse(self.h.evaluate((n,), expressions=(dataclasses.replace(c, boundary=fractions.Fraction(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)
n = tests.solo_fixtures.note(0, 2, 'd', event_id='ninth')
c = generator.solo.types.ExpressionContract('color', 'color', 'dominant_ninth', (n,), n, 'C7')
self.assertTrue(self.h.evaluate((n,), expressions=(c,)).valid)
self.assertTrue(self.h.evaluate((n,)).valid)
self.assertFalse(self.h.evaluate((n,),infer=False).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)
self.assertFalse(self.h.evaluate((n,), infer=False).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,'f',event_id='bad'),pitch=types.Pitch('f',F(1),4),role='color')
n = dataclasses.replace(tests.solo_fixtures.note(0, 2, 'f', event_id='bad'), pitch=generator.solo.types.Pitch('f', fractions.Fraction(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)
c = generator.solo.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)
lower = dataclasses.replace(tests.solo_fixtures.note(1, fractions.Fraction(3, 2), 'd', event_id='lower'), pitch=generator.solo.types.Pitch('d', fractions.Fraction(1), 4))
target = tests.solo_fixtures.note(2, 4, 'e', event_id='target')
c = generator.solo.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)
color = dataclasses.replace(tests.solo_fixtures.note(0, 1, 'd', event_id='color'), pitch=generator.solo.types.Pitch('d', fractions.Fraction(-1), 4))
target = tests.solo_fixtures.note(1, 2, 'c', event_id='target')
c = generator.solo.types.ExpressionContract('flat9', 'color', 'dominant_flat_ninth', (color,), target, 'C7')
self.assertTrue(self.h.evaluate((color, target), expressions=(c,)).valid)
unresolved = generator.solo.harmonic.Harmony(tests.solo_fixtures.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):
h=harmonic.Harmony(choices(('A7','Dm7')))
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)
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('A7', 'Dm7')))
a = dataclasses.replace(tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'c', event_id='csharp'), pitch=generator.solo.types.Pitch('c', fractions.Fraction(1), 4))
b = tests.solo_fixtures.note(fractions.Fraction(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)
raw = tuple(tests.solo_fixtures.note(fractions.Fraction(7, 2) + fractions.Fraction(i, 3), fractions.Fraction(7, 2) + fractions.Fraction(i + 1, 3), 'c', event_id=str(i)) for i in range(3))
placed = generator.solo.placement.place(raw, fractions.Fraction(120))[1]
doc = tests.solo_fixtures.song(count=2, chords=('CM7',))
measures = generator.solo.notation.measures(doc['staves'][0]['measures'], placed, 'triplet')
spans = generator.solo.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']))
if __name__=='__main__':unittest.main()
if __name__ == '__main__':
unittest.main()