178 lines
10 KiB
Python
178 lines
10 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 audition import reference_review, solo_compare
|
|
from generator import vocabulary, production
|
|
from generator.solo import export, gesture_rhythm, harmonic, notation, placement, routes, themes, types
|
|
from tests.test_solo import song
|
|
from tests.test_solo_development import note
|
|
from tests.test_solo_passages 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 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_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())
|
|
|
|
|
|
class MusicalSelectionTests(unittest.TestCase):
|
|
def test_cell_purpose_survives_loading_and_filters_eligibility(self):
|
|
cells=tuple(vocabulary.rhythm_cells(120,'arrival'))
|
|
self.assertTrue(cells)
|
|
self.assertTrue(all(c.purpose=='arrival' for c in cells))
|
|
self.assertFalse(any(c.id=='short_answer' for c in cells))
|
|
|
|
def test_connecting_rhythm_prefers_groups_to_isolated_notes(self):
|
|
joined=((F(),F(1,2)),(F(1,2),F(3,2)),(F(3,2),F(2)))
|
|
fragmented=((F(),F(1,2)),(F(1),F(3,2)),(F(2),F(5,2)))
|
|
self.assertLess(themes.rhythm_cost(joined,F(4),'connect'),themes.rhythm_cost(fragmented,F(4),'connect'))
|
|
|
|
def test_complete_signature_boundary_can_cross_barline(self):
|
|
home=SimpleNamespace(signature=(note(0,2),note(2,5)))
|
|
source=SimpleNamespace(end=F(24));ending=SimpleNamespace(earliest=F(21))
|
|
self.assertEqual(themes.unit_end(source,F(8),home,ending,F(4)),13)
|
|
|
|
def test_arpeggio_is_not_charged_for_each_nonstep_interval(self):
|
|
chord=(note(0,F(1,2),'c'),note(F(1,2),1,'e'),note(1,F(3,2),'g'))
|
|
steps=(note(0,F(1,2),'c'),note(F(1,2),1,'d'),note(1,F(3,2),'e'))
|
|
self.assertEqual(routes.path_cost(chord),routes.path_cost(steps))
|
|
h=harmonic.Harmony(choices(('CM7',)))
|
|
self.assertEqual(routes.path_family(chord,h)[0],'arpeggio')
|
|
self.assertEqual(routes.path_family(steps,h)[0],'mapped')
|
|
run=tuple(note(i,F(i+1),p) for i,p in enumerate(('c','d','e','f','g','a')))
|
|
self.assertEqual(routes.path_cost(run),0)
|
|
oscillation=tuple(note(i,F(i+1),p) for i,p in enumerate(('c','d','c','d','c')))
|
|
self.assertGreater(routes.path_cost(oscillation),0)
|
|
|
|
def test_data_driven_arpeggio_reaches_exact_destination_as_one_route(self):
|
|
h=harmonic.Harmony(choices(('CM7',)))
|
|
notes=(note(0,F(1,2),'c',event_id='first'),note(F(1,2),1,'c',event_id='middle'),note(1,F(3,2),'g',event_id='target'))
|
|
paths=routes.candidates(notes,{'target'},h,solo_fixtures.library(),(41,76))
|
|
authored=[r for r in paths if any(s.startswith('chord-path:') for s in r.sources)]
|
|
self.assertTrue(authored)
|
|
self.assertTrue(any([n.pitch.number() for n in r.notes]==[60,64,67] for r in authored))
|
|
self.assertTrue(all(r.notes[-1].pitch==notes[-1].pitch for r in paths))
|
|
self.assertTrue(all(h.evaluate(r.notes).valid for r in paths))
|
|
|
|
def test_solo_vocabulary_changes_do_not_change_accompaniment_inputs(self):
|
|
resources=vocabulary.load()
|
|
changed=copy.deepcopy(resources)
|
|
changed['melodic_vocabulary']['cells'].append({'id':'independent-test'})
|
|
for role in ('acoustic_accompaniment','electric_accompaniment','electric_melody','vocal_melody'):
|
|
self.assertEqual(production.dependencies(role,resources),production.dependencies(role,changed))
|
|
self.assertNotEqual(production.dependencies('acoustic_solo',resources),production.dependencies('acoustic_solo',changed))
|
|
|
|
def test_cross_bar_arrival_cell_remains_one_sounding_duration(self):
|
|
cell=next(c for c in vocabulary.rhythm_cells(120,'arrival') if c.id=='anticipated_sustain')
|
|
self.assertTrue(any(a<4<b for a,b in cell.pattern))
|
|
|
|
def test_shared_provisional_arrival_preserves_terminal_hold(self):
|
|
a=note(0,1,'c',event_id='a');b=note(1,2,'c',event_id='b');c=note(2,4,'e',event_id='c')
|
|
t=lambda id,start,end,operation,events:types.Transformation(id,'source','home',operation,F(start),F(end),events,(),preserves=())
|
|
draft=types.MelodicDraft('draft',F(),F(4),(a,b,c),(t('a',0,1,'introduce',('a',)),t('b',1,4,'continue',('b','c'))),(),(),(),None,'continue')
|
|
h=harmonic.Harmony(choices(('CM7',)))
|
|
revised,edits=gesture_rhythm.shared_arrival(draft,h)
|
|
self.assertEqual(len(revised.notes),2)
|
|
self.assertEqual(revised.notes[0].start,0)
|
|
self.assertEqual(revised.notes[-1],c)
|
|
self.assertEqual(edits[0].kind,'shared_arrival')
|
|
self.assertEqual(gesture_rhythm.revise(draft,h)[1][0].kind,'shared_arrival')
|
|
protected=dataclasses.replace(draft,protected=('a',))
|
|
self.assertEqual(gesture_rhythm.shared_arrival(protected,h)[0],protected)
|
|
|
|
|
|
if __name__=='__main__':unittest.main()
|