Lower edit distance means fewer insertions, deletions or substitutions on that axis. Sounding agreement is measured in quarter-note beats, without octave shifting or fitted alignment. These do not measure musical quality.
') selected=collections.Counter(c['kind'] for c in r['production_phrases'].get('expressions',[])) - rows.append('Selected production treatment contracts: '+html.escape(str(dict(selected)))+'. Registered authored music is preserved without inventing replacement contracts.
') + rows.append('Validated production treatments: '+html.escape(str(dict(selected)))+'. Registered authored music is preserved; harmonic relationships are recognized from notes and chords without requiring explanatory annotations.
') if r.get('reference_events_after_production_end'): rows.append('Reference material extends beyond this exported production allocation: '+str(len(r['reference_events_after_production_end']))+' events. The reference and source timing remain unchanged.
') funnel=r['production_phrases'].get('selection_trace',{}).get('operation_coverage',{}) diff --git a/tests/test_harmonic_recognition.py b/tests/test_harmonic_recognition.py new file mode 100644 index 0000000..1918ddc --- /dev/null +++ b/tests/test_harmonic_recognition.py @@ -0,0 +1,163 @@ +"""Contextual musical validity needs notes and chords, not author annotations.""" +import dataclasses +import json +from fractions import Fraction as F +from pathlib import Path +import unittest + +from generator import train,training_io,vocabulary,harmonic_time,harmony as chords +from generator.solo import harmonic,types,decoder,learned,analysis,expressions +from tests.solo_fixtures import choices,note,song + +ROOT=Path(__file__).resolve().parents[1] + + +class ContextualHarmony(unittest.TestCase): + def test_approved_corpus_passes_without_annotations_or_authored_privileges(self): + examples,_=train.load_examples(ROOT/'data/training/manifest.json') + manifest=json.loads((ROOT/'data/training/manifest.json').read_text()) + from generator import musicxml,parts + from generator.solo.authored import sounding + for entry in manifest['examples']: + raw=training_io.read(ROOT/'data/training'/entry['solo'],'',pitch_convention=entry['pitch_convention']) + self.assertEqual(raw['annotations'],[]) + doc=parts.prepare_document(musicxml.load_musicxml(ROOT/'data/training'/entry['source'])) + h=harmonic.Harmony(harmonic_time.solo_source(doc['staves'][0])) + ns=tuple(dataclasses.replace(n,role='ordinary',sources=(),event_id='') for n in sounding(raw)) + checked=h.evaluate(ns,complete=True) + self.assertTrue(checked.valid,(entry['id'],checked.error)) + self.assertTrue(checked.contracts) + self.assertEqual(len(examples),4) + + def test_passing_to_nonchord_scale_note(self): + h=harmonic.Harmony(choices(('G6',))) + notes=(note(0,F(1,2),'g'),note(F(1,2),1,'g',alter=1),note(1,2,'a')) + result=h.evaluate(notes) + self.assertTrue(result.valid,result.error) + self.assertEqual(result.contracts[0].kind,'passing') + self.assertFalse(h.evaluate(notes,infer=False).valid) + self.assertFalse(h.evaluate(notes[:-1]).valid) + leap=(*notes[:2],dataclasses.replace(notes[2],pitch=types.Pitch('b',F(),4))) + self.assertFalse(h.evaluate(leap).valid) + long=(notes[0],dataclasses.replace(notes[1],end=F(3,2)),dataclasses.replace(notes[2],start=F(3,2))) + self.assertFalse(h.evaluate(long).valid) + + def test_passing_contract_requires_its_actual_preparation(self): + h=harmonic.Harmony(choices(('G6',))) + a=note(0,F(1,2),'g',event_id='a');b=note(F(1,2),1,'g',alter=1,event_id='b');c=note(1,2,'a',event_id='c') + contract=types.ExpressionContract('p','passing','chromatic_passing',(b,),c,'GM6',preparation=a) + self.assertTrue(h.evaluate((a,b,c),expressions=(contract,)).valid) + changed=dataclasses.replace(a,end=F(1,4)) + wrong=dataclasses.replace(contract,preparation=changed) + self.assertFalse(h.evaluate((changed,b,c),expressions=(wrong,)).valid) + high=dataclasses.replace(c,pitch=dataclasses.replace(c.pitch,octave=5)) + self.assertFalse(h.evaluate((a,b,high),expressions=(dataclasses.replace(contract,target=high),)).valid) + + def test_color_resolution_can_have_intervening_valid_motion(self): + h=harmonic.Harmony(choices(('C7',))) + notes=(note(0,F(3,2),'f',alter=1),note(F(3,2),2,'a'),note(2,4,'g')) + checked=h.evaluate(notes) + self.assertTrue(checked.valid,checked.error) + self.assertEqual(checked.contracts[0].rule,'lydian_color') + self.assertFalse(h.evaluate(notes[:2]).valid) + bad_mid=dataclasses.replace(notes[1],pitch=types.Pitch('g',F(1),5)) + self.assertFalse(h.evaluate((notes[0],bad_mid,notes[2])).valid) + + def test_anticipated_stable_and_resolving_colors(self): + h=harmonic.Harmony(choices(('Bbdim7','Am7'))) + n=note(F(7,2),6,'d') + result=h.evaluate((n,)) + self.assertTrue(result.valid,result.error) + self.assertEqual(result.contracts[0].kind,'anticipated_color') + self.assertFalse(h.evaluate((dataclasses.replace(n,start=F(2)),)).valid) + h=harmonic.Harmony(choices(('Gm7','C7'))) + color=note(F(7,2),F(11,2),'f',alter=1) + target=note(F(11,2),7,'g') + self.assertTrue(h.evaluate((color,target)).valid) + self.assertFalse(h.evaluate((color,)).valid) + flat=dataclasses.replace(color,pitch=types.Pitch('g',F(-1),4)) + self.assertTrue(h.evaluate((flat,target)).valid) + + def test_repeated_harmony_cannot_reset_color_duration(self): + h=harmonic.Harmony(choices(('C7','C7'))) + color=note(3,6,'f',alter=1);target=note(6,7,'g') + self.assertFalse(h.evaluate((color,target)).valid) + + def test_held_color_is_independent_of_notation_partition(self): + h=harmonic.Harmony(choices(('Bbdim7','Am7'))) + whole=note(F(7,2),6,'d') + split=(dataclasses.replace(whole,end=F(4)),dataclasses.replace(whole,start=F(4),continuation=True)) + self.assertEqual(h.evaluate((whole,)),h.evaluate(split)) + self.assertEqual(h.describe((whole,)),h.describe(split)) + + def test_unsupported_notes_are_not_authorized_by_labels(self): + h=harmonic.Harmony(choices(('C7',))) + for role in ('ordinary','authored','quote','color','Lead in'): + self.assertFalse(h.evaluate((note(0,3,'c',alter=1,role=role),)).valid) + missing=harmonic.Harmony(()) + self.assertFalse(missing.evaluate((note(0,1,'c'),)).valid) + + def test_fully_expired_partial_treatments_remain_invalid(self): + h=harmonic.Harmony(choices(('C7',))) + notes=(note(0,F(1,2),'d',alter=1,event_id='a'),note(F(1,2),2,'e',event_id='b')) + contract=types.ExpressionContract('a','approach','below',(notes[0],),notes[1],'C7') + partial=h.evaluate(notes[:1],until=F(1,2),expressions=(contract,)) + self.assertTrue(partial.valid) + self.assertEqual(len(partial.pending),1) + self.assertFalse(h.evaluate(notes[:1],until=F(2),expressions=(contract,)).valid) + + def test_pickup_uses_source_form_context_without_overriding_written_harmony(self): + doc=song(count=3,chords=('C','Dm','G7'));staff=doc['staves'][0] + pickup=staff['measures'][0];pickup['implicit']=True;pickup['harmonies']=[] + self.assertEqual(chords.symbol(harmonic_time.solo_source(staff)[0].chord),'G7') + self.assertIsNone(harmonic_time.read(staff)[0].chord) + pickup['harmonies']=[dict(chords.parse_symbol('F'),onset={'numerator':0,'denominator':1})] + self.assertEqual(chords.symbol(harmonic_time.solo_source(staff)[0].chord),'FM') + pickup['harmonies']=[{'kind':'none','onset':{'numerator':0,'denominator':1}}] + self.assertIsNone(harmonic_time.solo_source(staff)[0].chord) + pickup['harmonies']=[];pickup['implicit']=False + self.assertIsNone(harmonic_time.solo_source(staff)[0].chord) + + +class PredictionReachability(unittest.TestCase): + def engine(self,chords): + doc=song(count=2,chords=chords) + return decoder.Decoder(analysis.analyze(doc),harmonic.Harmony(choices(chords)), + learned.Model.load(ROOT/'data/models/solo.json'),'acoustic',F(),F(8)) + + def test_joint_resolved_color_reaches_the_real_action_builder(self): + d=self.engine(('C7','FM7')) + n=d.note(F(),F(3,2),66) + actions=list(d.resolutions(n)) + self.assertTrue(any([p for _,_,p in events]==[66,69,67] for events,_,_ in actions)) + for _,notes,contracts in actions: + self.assertTrue(d.harmony.evaluate(notes,expressions=contracts,infer=False).valid) + + def test_anticipated_color_is_a_normal_single_event_option(self): + d=self.engine(('Bbdim7','Am7'));n=d.note(F(7,2),F(6),62) + support=d.permission(n) + self.assertTrue(support) + self.assertEqual(support[0].kind,'anticipated_color') + d.expressions_enabled=False;d._permissions.clear() + self.assertIsNone(d.permission(n)) + + def test_delayed_action_retains_space_and_intervening_motion(self): + d=self.engine(('C7','FM7'));n=d.note(F(),F(1,2),63) + actions=list(d.resolutions(n)) + self.assertTrue(any(c[0].kind=='delayed_approach' and any(p is None for _,_,p in events) for events,_,c in actions)) + self.assertTrue(any(c[0].kind=='delayed_approach' and len(notes)==3 for _,notes,c in actions)) + + def test_passing_action_uses_incoming_state(self): + d=self.engine(('G6','G6'));first=d.note(F(),F(1,2),67) + state=d.extend(decoder.State(),(((first.start,first.end,67),),(first,),())) + actions=[a for a in d.actions(F(1,2)) if a[2] and a[2][0].kind=='passing' + and a[0]==((F(1,2),F(1),68),(F(1),F(2),69))] + self.assertTrue(actions) + self.assertIsNotNone(d.extend(state,actions[0])) + self.assertIsNone(d.extend(decoder.State(),actions[0])) + wrong=d.note(F(),F(1,2),64) + prior=d.extend(decoder.State(),(((wrong.start,wrong.end,64),),(wrong,),())) + self.assertIsNone(d.extend(prior,actions[0])) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_independent_generation.py b/tests/test_independent_generation.py index bccbc7b..bd6c95b 100644 --- a/tests/test_independent_generation.py +++ b/tests/test_independent_generation.py @@ -128,7 +128,8 @@ class TypedExpressionContracts(unittest.TestCase): 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.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) @@ -137,19 +138,21 @@ class TypedExpressionContracts(unittest.TestCase): 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.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) 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.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) 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') + n=dataclasses.replace(note(0,2,'f',event_id='bad'),pitch=types.Pitch('f',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) diff --git a/tests/test_rhythm_contracts.py b/tests/test_rhythm_contracts.py index 39649e6..a761bd1 100644 --- a/tests/test_rhythm_contracts.py +++ b/tests/test_rhythm_contracts.py @@ -66,7 +66,8 @@ class PreparedSuspensions(unittest.TestCase): target = note(F(9, 2), 6, 'e', event_id='resolution') contract = types.ExpressionContract('prepared', 'suspension', 'prepared_step_down', (held,), target, 'C7', boundary=F(4)) self.assertTrue(h.evaluate((held, target), expressions=(contract,)).valid) - self.assertFalse(h.evaluate((held, target)).valid) + self.assertTrue(h.evaluate((held, target)).valid) + self.assertFalse(h.evaluate((held, target),infer=False).valid) changed = dataclasses.replace(held, start=F(15, 4)) self.assertFalse(h.evaluate((changed, target), expressions=(dataclasses.replace(contract, events=(changed,)),)).valid) self.assertFalse(h.evaluate((held,), expressions=(contract,)).valid)