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
+104 -130
View File
@@ -2,163 +2,137 @@
import copy
import dataclasses
import json
from fractions import Fraction as F
from pathlib import Path
import fractions
import pathlib
import tempfile
from types import SimpleNamespace
import types
import unittest
from unittest.mock import patch
import unittest.mock
import contextlib
import io
from tests import solo_compare
reference_review = solo_compare
from generator import vocabulary, production
from generator.solo import export, harmonic, notation, placement, types
from tests.solo_fixtures import song
from tests.solo_fixtures import note
from tests.solo_fixtures import choices
from tests import solo_fixtures
import tests.solo_compare
import generator.score
import generator.training_io
import generator.vocabulary
import generator.production
import generator.solo.export
import generator.solo.harmonic
import generator.solo.notation
import generator.solo.placement
import generator.solo.types
import tests.solo_fixtures
import tests.solo_fixtures
import tests.solo_fixtures
import tests.solo_fixtures
def xml(notes, *, transpose=False, leadin=''):
return '<score-partwise version="4.0"><part-list><score-part id="P"><part-name>Vocal</part-name></score-part></part-list><part id="P">'+leadin+'<measure number="1"><attributes><divisions>6</divisions><key><fifths>0</fifths></key><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>G</sign><line>2</line></clef>'+('<transpose><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>' if transpose else '')+'</attributes><harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>'+notes+'</measure></part></score-partwise>'
return '<score-partwise version="4.0"><part-list><score-part id="P"><part-name>Vocal</part-name></score-part></part-list><part id="P">' + leadin + '<measure number="1"><attributes><divisions>6</divisions><key><fifths>0</fifths></key><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>G</sign><line>2</line></clef>' + ('<transpose><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>' if transpose else '') + '</attributes><harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>' + notes + '</measure></part></score-partwise>'
def pitched(duration, tie='', octave=5):
return f'<note><pitch><step>C</step><octave>{octave}</octave></pitch><duration>{duration}</duration>{tie}<type>quarter</type></note>'
class ComparisonTests(unittest.TestCase):
def test_comparison_separates_pitch_timing_and_sounding_duration(self):
events=lambda pitch,end: [dict(start='0',end=end,pitch=pitch)]
a=events(60,'2')
octave=solo_compare.relationships(a,events(72,'2'))
self.assertEqual(octave['pitch_sequence_edit_distance'],1)
self.assertEqual(octave['timed_attack_edit_distance'],0)
self.assertEqual(octave['same_sounding_pitch_beats'],'0')
self.assertEqual(octave['same_pitch_class_beats'],'2')
shorter=solo_compare.relationships(a,events(60,'1'))
self.assertEqual(shorter['pitch_sequence_edit_distance'],0)
self.assertEqual(shorter['timed_attack_edit_distance'],1)
self.assertEqual(shorter['same_sounding_pitch_beats'],'1')
self.assertEqual(shorter['either_sounding_beats'],'2')
def events(pitch, end):
return [dict(start='0', end=end, pitch=pitch)]
a = events(60, '2')
octave = tests.solo_compare.relationships(a, events(72, '2'))
self.assertEqual(octave['pitch_sequence_edit_distance'], 1)
self.assertEqual(octave['timed_attack_edit_distance'], 0)
self.assertEqual(octave['same_sounding_pitch_beats'], '0')
self.assertEqual(octave['same_pitch_class_beats'], '2')
shorter = tests.solo_compare.relationships(a, events(60, '1'))
self.assertEqual(shorter['pitch_sequence_edit_distance'], 0)
self.assertEqual(shorter['timed_attack_edit_distance'], 1)
self.assertEqual(shorter['same_sounding_pitch_beats'], '1')
self.assertEqual(shorter['either_sounding_beats'], '2')
def test_exact_cli_is_a_real_exit_gate(self):
args=['solo_compare','--generated-dir','unused','--output','unused','--reference','fixture/acoustic=unused','--require-exact']
review={'exact_musical_match':False,'exact_matching_attacks':0,'differences':[{}],'rest_difference':True}
with patch('sys.argv',args),patch.object(solo_compare,'run',return_value={'solos':{'fixture/acoustic':review}}),contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(solo_compare.main(),1)
review.update(exact_musical_match=True,differences=[],rest_difference=False)
self.assertEqual(solo_compare.main(),0)
with patch('sys.argv',args[:-3]+['--require-exact']),contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as error:solo_compare.main()
self.assertEqual(error.exception.code,2)
args = ['solo_compare', '--generated-dir', 'unused', '--output', 'unused', '--reference', 'fixture/acoustic=unused', '--require-exact']
review = {'exact_musical_match': False, 'exact_matching_attacks': 0, 'differences': [{}], 'rest_difference': True}
with unittest.mock.patch('sys.argv', args), unittest.mock.patch.object(tests.solo_compare, 'run', return_value={'solos': {'fixture/acoustic': review}}), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(tests.solo_compare.main(), 1)
review.update(exact_musical_match=True, differences=[], rest_difference=False)
self.assertEqual(tests.solo_compare.main(), 0)
with unittest.mock.patch('sys.argv', args[:-3] + ['--require-exact']), contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as error:
tests.solo_compare.main()
self.assertEqual(error.exception.code, 2)
def test_note_container_does_not_turn_color_label_into_fingering(self):
with tempfile.TemporaryDirectory() as root:
note_xml=pitched(24).replace('</note>','<notations><technical><fingering>6</fingering></technical></notations></note>')
r=self.review(root,'annotation.xml',xml(note_xml),form_start=1)
self.assertEqual(r['annotations'][0]['text'],'6')
self.assertEqual(r['annotations'][0]['container'],'notations/technical/fingering')
note_xml = pitched(24).replace('</note>', '<notations><technical><fingering>6</fingering></technical></notations></note>')
r = self.review(root, 'annotation.xml', xml(note_xml), form_start=1)
self.assertEqual(r['annotations'][0]['text'], '6')
self.assertEqual(r['annotations'][0]['container'], 'notations/technical/fingering')
self.assertIsNone(r['notes'][0]['tablature'])
def review(self,directory,name,content,**kwargs):
path=Path(directory)/name;path.write_text(content)
return reference_review.read(path,'fixture/acoustic',**kwargs)
def review(self, directory, name, content, **kwargs):
path = pathlib.Path(directory) / name
path.write_text(content)
return generator.training_io.read(path, 'fixture/acoustic', **kwargs)
def test_ties_not_reattacks_and_one_guitar_transposition(self):
with tempfile.TemporaryDirectory() as root:
a=self.review(root,'actual.xml',xml(pitched(24),transpose=True),form_start=1)
b=self.review(root,'reference.xml',xml(pitched(12,'<tie type="start"/>')+pitched(12,'<tie type="stop"/>')),form_start=1,pitch_convention='guitar-written')
self.assertTrue(solo_compare.compare(a,b)['exact_musical_match'])
self.assertEqual(a['sounding_events'][0]['sounding_midi'],'60')
repeated=self.review(root,'repeat.xml',xml(pitched(12)+pitched(12)),form_start=1,pitch_convention='guitar-written')
self.assertFalse(solo_compare.compare(a,repeated)['exact_musical_match'])
a = self.review(root, 'actual.xml', xml(pitched(24), transpose=True), form_start=1)
b = self.review(root, 'reference.xml', xml(pitched(12, '<tie type="start"/>') + pitched(12, '<tie type="stop"/>')), form_start=1, pitch_convention='guitar-written')
self.assertTrue(tests.solo_compare.compare(a, b)['exact_musical_match'])
self.assertEqual(a['sounding_events'][0]['sounding_midi'], '60')
repeated = self.review(root, 'repeat.xml', xml(pitched(12) + pitched(12)), form_start=1, pitch_convention='guitar-written')
self.assertFalse(tests.solo_compare.compare(a, repeated)['exact_musical_match'])
def test_leadin_is_reported_without_harmony_or_alignment_padding(self):
with tempfile.TemporaryDirectory() as root:
prelude='<measure number="0"><attributes><divisions>6</divisions></attributes>'+pitched(6)+'<note><rest/><duration>18</duration></note></measure>'
a=self.review(root,'a.xml',xml(pitched(24),transpose=True),form_start=1)
b=self.review(root,'b.xml',xml(pitched(24),leadin=prelude),form_start=2,pitch_convention='guitar-written')
result=solo_compare.compare(a,b)
self.assertEqual(result['differences'][0]['time'],'-4')
self.assertEqual(result['exact_matching_attacks'],1)
self.assertIn('uncertainty',b['notes'][0]['harmonic_spans'][0])
prelude = '<measure number="0"><attributes><divisions>6</divisions></attributes>' + pitched(6) + '<note><rest/><duration>18</duration></note></measure>'
a = self.review(root, 'a.xml', xml(pitched(24), transpose=True), form_start=1)
b = self.review(root, 'b.xml', xml(pitched(24), leadin=prelude), form_start=2, pitch_convention='guitar-written')
result = tests.solo_compare.compare(a, b)
self.assertEqual(result['differences'][0]['time'], '-4')
self.assertEqual(result['exact_matching_attacks'], 1)
self.assertIn('uncertainty', b['notes'][0]['harmonic_spans'][0])
def test_unplayable_pitch_is_reported_not_folded(self):
with tempfile.TemporaryDirectory() as root:
r=self.review(root,'high.xml',xml(pitched(24,octave=7)),form_start=1,pitch_convention='guitar-written')
checked=solo_compare.playability(r)
self.assertEqual(checked['conflicts'][0]['sounding_midi'],84)
self.assertEqual(r['notes'][0]['pitch']['octave'],7)
def test_silent_reference_leadin_remains_in_audition_clock(self):
with tempfile.TemporaryDirectory() as root:
prelude='<measure number="0"><attributes><divisions>6</divisions></attributes><note><rest/><duration>24</duration></note></measure>'
a=self.review(root,'actual.xml',xml(pitched(24),transpose=True),form_start=1)
b=self.review(root,'reference.xml',xml(pitched(24),leadin=prelude),form_start=2,pitch_convention='guitar-written')
self.assertEqual(solo_compare.audition_bounds(a,b),(F(-4),F(4)))
def test_fixed_backing_includes_the_production_leadin(self):
import array
from generator import playback, score
document=song(count=2,chords=('CM7',))
document['staves'][0]['measures'][1]['section_label']='Acoustic Solo'
with tempfile.TemporaryDirectory() as root:
prelude='<measure number="0"><attributes><divisions>6</divisions></attributes>'+pitched(24)+'</measure>'
actual=self.review(root,'actual.xml',xml(pitched(24),leadin=prelude),form_start=2,pitch_convention='guitar-written')
with patch.object(score,'load_json',return_value=document),patch.object(solo_compare,'render_events',return_value=array.array('f',[0]*10)) as render,patch.object(playback,'write_pcm'):
solo_compare.audition_pair(actual,actual,'fixture/acoustic',root,Path(root),{})
# Backing's preceding bar starts at the same audition clock as the lead.
self.assertEqual(render.call_args_list[0].args[0][0][0],0)
self.assertTrue(any(start>=4 for start,end,pitch in render.call_args_list[0].args[0]))
r = self.review(root, 'high.xml', xml(pitched(24, octave=7)), form_start=1, pitch_convention='guitar-written')
checked = tests.solo_compare.playability(r)
self.assertEqual(checked['conflicts'][0]['sounding_midi'], 84)
self.assertEqual(r['notes'][0]['pitch']['octave'], 7)
def test_triplet_time_and_annotations_are_literal(self):
with tempfile.TemporaryDirectory() as root:
triplet=pitched(2).replace('<type>quarter</type>','<type>eighth</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>')
r=self.review(root,'thirds.xml',xml('<direction><direction-type><words>Lead in</words></direction-type></direction>'+triplet*3+'<note><rest/><duration>18</duration></note>'),form_start=1)
self.assertEqual([n['start'] for n in r['sounding_events']],['0','1/3','2/3'])
self.assertEqual(r['annotations'][0]['text'],'Lead in')
triplet = pitched(2).replace('<type>quarter</type>', '<type>eighth</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>')
r = self.review(root, 'thirds.xml', xml('<direction><direction-type><words>Lead in</words></direction-type></direction>' + triplet * 3 + '<note><rest/><duration>18</duration></note>'), form_start=1)
self.assertEqual([n['start'] for n in r['sounding_events']], ['0', '1/3', '2/3'])
self.assertEqual(r['annotations'][0]['text'], 'Lead in')
def test_export_roundtrip_preserves_actual_sounding_events_and_tab(self):
document=song(count=2,chords=('CM7',))
raw=(note(0,F(1,3),'c'),note(F(1,3),F(2,3),'d'),note(F(2,3),1,'e'),note(F(7,2),5,'g'),note(5,6,'g'))
placed=placement.place(raw,F(120))[1]
notation.replace_region(document,0,notation.measures(document['staves'][0]['measures'],placed,'export'))
document['staves'][0]['measures'][0]['section_label']='Acoustic Solo'
before=copy.deepcopy(document)
document = tests.solo_fixtures.song(count=2, chords=('CM7',))
raw = (tests.solo_fixtures.note(0, fractions.Fraction(1, 3), 'c'), tests.solo_fixtures.note(fractions.Fraction(1, 3), fractions.Fraction(2, 3), 'd'), tests.solo_fixtures.note(fractions.Fraction(2, 3), 1, 'e'), tests.solo_fixtures.note(fractions.Fraction(7, 2), 5, 'g'), tests.solo_fixtures.note(5, 6, 'g'))
placed = generator.solo.placement.place(raw, fractions.Fraction(120))[1]
generator.solo.notation.replace_region(document, 0, generator.solo.notation.measures(document['staves'][0]['measures'], placed, 'export'))
document['staves'][0]['measures'][0]['section_label'] = 'Acoustic Solo'
before = copy.deepcopy(document)
with tempfile.TemporaryDirectory() as root:
path=Path(root)/'fixture-solo1.xml'
info=export.write_role({'role':'acoustic_solo','document':document,'trace':{'source':'fixture'}},path)
loaded=reference_review.read(path,'fixture/acoustic',1)
sounded=[(F(n['start']),F(n['end']),F(n['sounding_midi'])) for n in loaded['sounding_events']]
self.assertEqual([(n.start,n.end,n.pitch.number()) for n in raw],sounded)
from generator import score
tab=lambda d:[p['tablature'] for m in d['staves'][0]['measures'] for n in score.measure_notes(m) for p in n['pitches']]
self.assertEqual(tab(document),[n['tablature'] for n in loaded['notes'] if n['pitch']])
self.assertEqual(info['role'],'acoustic_solo')
self.assertEqual(before,document)
path = pathlib.Path(root) / 'fixture-solo1.xml'
info = generator.solo.export.write_role({'role': 'acoustic_solo', 'document': document, 'trace': {'source': 'fixture'}}, path)
loaded = generator.training_io.read(path, 'fixture/acoustic', 1)
sounded = [(fractions.Fraction(n['start']), fractions.Fraction(n['end']), fractions.Fraction(n['sounding_midi'])) for n in loaded['sounding_events']]
self.assertEqual([(n.start, n.end, n.pitch.number()) for n in raw], sounded)
def tab(d):
return [p['tablature'] for m in d['staves'][0]['measures'] for n in generator.score.measure_notes(m) for p in n['pitches']]
self.assertEqual(tab(document), [n['tablature'] for n in loaded['notes'] if n['pitch']])
self.assertEqual(info['role'], 'acoustic_solo')
self.assertEqual(before, document)
def test_export_manifests_are_not_discovered_as_runtime_scores(self):
from generator import score
document=song(count=1,chords=('CM7',))
document = tests.solo_fixtures.song(count=1, chords=('CM7',))
with tempfile.TemporaryDirectory() as root:
root=Path(root);(root/'analysis').mkdir()
roles={}
for part in ('acoustic','electric'):
doc=copy.deepcopy(document)
doc['staves'][0]['measures'][0]['section_label']=part.capitalize()+' Solo'
roles[part+'_solo']={'role':part+'_solo','document':doc,'trace':{'source':'synthetic'}}
(root/'analysis'/'fixture.roles').write_text(json.dumps(roles))
score.write_json(root/'score.json',document)
export.export_tree(root)
self.assertEqual(score.count_json_files(root),1)
score.validate_generated_tree(root)
self.assertTrue((root/'solo_xml'/'fixture-solo1.xml').is_file())
self.assertTrue((root/'solo_xml'/'fixture-solo2.xml').is_file())
root = pathlib.Path(root)
(root / 'analysis').mkdir()
roles = {}
for part in ('acoustic', 'electric'):
doc = copy.deepcopy(document)
doc['staves'][0]['measures'][0]['section_label'] = part.capitalize() + ' Solo'
roles[part + '_solo'] = {'role': part + '_solo', 'document': doc, 'trace': {'source': 'synthetic'}}
(root / 'analysis' / 'fixture.roles').write_text(json.dumps(roles))
generator.score.write_json(root / 'score.json', document)
generator.solo.export.export_tree(root)
self.assertEqual(generator.score.count_json_files(root), 1)
generator.score.validate_generated_tree(root)
self.assertTrue((root / 'solo_xml' / 'fixture-solo1.xml').is_file())
self.assertTrue((root / 'solo_xml' / 'fixture-solo2.xml').is_file())
if __name__=='__main__':unittest.main()
if __name__ == '__main__':
unittest.main()