Files
product-vamp/tests/test_solo_comparison.py
T
2026-10-05 16:40:22 -05:00

165 lines
9.8 KiB
Python

"""Independent musical fixtures; the four optional user scores are never read."""
import copy
import dataclasses
import json
from fractions import Fraction as F
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
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
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>'
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 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)
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')
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 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'])
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])
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]))
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')
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)
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)
def test_export_manifests_are_not_discovered_as_runtime_scores(self):
from generator import score
document=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())
if __name__=='__main__':unittest.main()