"""Joint rhythmic and harmonic actions, constructed before pitch-route search. Rules describe musical relationships, never reference-score phrases. Contracts are bound to the selected whole route and validated before a candidate survives. """ from __future__ import annotations import dataclasses from fractions import Fraction as F from generator import vocabulary, harmony as chords from generator.solo import types, expressions, groups, sequences, policy @dataclasses.dataclass(frozen=True) class Proposal: notes: tuple contracts: tuple purpose: str def bind(self, notes): actual={n.event_id:n for n in notes} return tuple(dataclasses.replace(c,events=tuple(actual[n.event_id] for n in c.events), target=actual[c.target.event_id],preparation=actual[c.preparation.event_id] if c.preparation else None) for c in self.contracts) def replace(notes, start, end, events): # Provisional rhythm is editable, but a sliced tuplet is not an explanation. before=tuple(dataclasses.replace(n,end=start) if n.end>start else n for n in notes if n.startend) actual=(*before,*events,*after) if any(n.end<=n.start for n in actual):return () origin=notes[0].start if not groups.valid(tuple((n.start-origin,n.end-origin) for n in actual)):return () return actual def proposals(notes,harmony,start,finish,ending=None): """Plan future arrivals, colors and approaches together with exact rhythm.""" rules=vocabulary.data('melodic_vocabulary')['gesture_rules'] fixed_end=ending.earliest if ending else finish for rule in rules: if rule['kind']=='harmonic_arrival': for span in harmony.spans: boundary=span.start prior=harmony.at(boundary-F(1,100)) if not span.scale or not prior or not prior.scale or prior.chord==span.chord:continue if not start+1min(fixed_end,span.end) or end<=boundary:continue breath=min(F(rule['release']),fixed_end-end) near=next((n.pitch.number() for n in notes if n.start<=arrival=fixed_end or n.end-n.start<1:continue span=harmony.at(n.start) if not span or not span.scale:continue for color in vocabulary.data('harmony')['colors']: if n.end-n.start>F(color['max_beats']) or not expressions.eligible_color(color,span,harmony):continue pitch=expressions.degree_pitch(chords.parse_symbol(span.chord),color['degree'],color['alter'],n.pitch.number()) if not harmony.profile.permits(pitch.number()) or pitch==n.pitch:continue changed=dataclasses.replace(n,pitch=pitch,role='color') actual=(*notes[:i],changed,*notes[i+1:]);target=changed if color.get('resolve'): if i+1==len(notes):continue next_note=notes[i+1];future=harmony.at(next_note.start) if not future or not future.scale:continue targets=[types.scale_pitch(future.scale,j) for j in range(-21,29)] targets=[p for p in targets if 0=ending.earliest:continue events=[] for j,(step,duration) in enumerate(zip(rule['semitones'],rule['durations'])): events.append(types.Note(cursor,cursor+F(duration),expressions.chromatic(target.pitch,step),target.sources,rule['kind'],event_id=target.event_id+':'+rule['id']+':'+str(j))) cursor+=F(duration) actual=replace(proposal.notes,events[0].start,target.start,tuple(events)) if not actual:continue if any(n.event_id not in {a.event_id for a in actual} for c in proposal.contracts for n in (*c.events,c.target)):continue prior=next((n for n in reversed(actual) if n.end<=events[0].start),None) c=types.ExpressionContract(target.event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(events),target,span.chord, boundary=anticipated.boundary if anticipated else None,preparation=prior if rule['kind']=='connection' else None) yield Proposal(actual,(*proposal.contracts,c),proposal.purpose+' + '+rule['kind']) def select(notes,harmony,start,finish,ending=None,limit=4): # Provenance differs across candidate histories; musical work does not. # This cache belongs to one immutable Harmony and includes all timing, # pitches, roles, continuation flags, profile and ending constraints. canonical=tuple(dataclasses.replace(n,sources=(),event_id=f'@{i}@') for i,n in enumerate(notes)) key=(canonical,start,finish,ending,limit,harmony.profile) if not hasattr(harmony,'gesture_cache'):harmony.gesture_cache={} if key not in harmony.gesture_cache: harmony.gesture_cache[key]=_select(canonical,harmony,start,finish,ending,limit) if len(harmony.gesture_cache)>4096:harmony.gesture_cache.pop(next(iter(harmony.gesture_cache))) def identity(value): for i,n in enumerate(notes):value=value.replace(f'@{i}@',n.event_id) return value def restore(n): i=int(n.event_id.split('@')[1]) return dataclasses.replace(n,event_id=identity(n.event_id),sources=notes[i].sources) return tuple(Proposal(tuple(map(restore,p.notes)),tuple(dataclasses.replace(c,id=identity(c.id), events=tuple(map(restore,c.events)),target=restore(c.target), preparation=restore(c.preparation) if c.preparation else None) for c in p.contracts),p.purpose) for p in harmony.gesture_cache[key]) def _select(notes,harmony,start,finish,ending=None,limit=4): plain=Proposal(notes,(),'direct') base=sequences.diverse(proposals(notes,harmony,start,finish,ending), lambda p:(abs(len(p.notes)-len(notes)),tuple((n.start,n.end,n.pitch.number()) for n in p.notes)), lambda p:(p.purpose,tuple(c.kind for c in p.contracts)),8) values=(*base,*approach(plain,harmony,start,ending),*(p for b in base if b.purpose.startswith('harmonic') for p in approach(b,harmony,start,ending))) # Complete structural choices compete, not one replacement at each attack. return sequences.diverse(values,lambda p:(abs(len(p.notes)-len(notes)),policy.stable_rank('joint-purpose',tuple((n.start,n.end,n.pitch) for n in p.notes))), lambda p:tuple(c.kind for c in p.contracts) or (p.purpose,),limit)