Cleanup
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
"""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.start<start)
|
||||
after=tuple(dataclasses.replace(n,start=end,event_id=n.event_id+':resumed') if n.start<end else n for n in notes if n.end>end)
|
||||
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+1<boundary<fixed_end:continue
|
||||
for offset in map(F,rule['arrival_offsets']):
|
||||
arrival=boundary+offset
|
||||
if arrival<start+1:continue
|
||||
for hold in map(F,rule['holds']):
|
||||
end=arrival+hold
|
||||
if end>min(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<n.end),notes[-1].pitch.number())
|
||||
members=harmony.members(boundary if offset<0 else arrival,end)&types.permitted(harmony.choices,boundary if offset<0 else arrival,end)
|
||||
candidates=tuple(types.scale_pitch(span.scale,i) for i in range(-21,29))
|
||||
candidates=tuple(p for p in candidates if p.number()%12 in members and harmony.profile.permits(p.number()))
|
||||
# Third/seventh, root/fifth and register alternatives remain real
|
||||
# competitors; no target name or song position determines a winner.
|
||||
targets=sequences.diverse(candidates,lambda p:(abs(p.number()-near),p.number()),
|
||||
lambda p:next((d for pc,d in span.roles.degrees if pc==p.number()%12),()),3)
|
||||
for pitch in targets:
|
||||
target=types.Note(arrival,end,pitch,notes[0].sources,'arrival',event_id=notes[0].event_id+f':{rule["id"]}:{arrival}:{pitch.number()}')
|
||||
actual=replace(notes,arrival,end+breath,(target,))
|
||||
if len(actual)<2:continue
|
||||
contracts=()
|
||||
if offset<0:
|
||||
contracts=(types.ExpressionContract(target.event_id,'anticipation','early_chord_tone',(target,),target,span.chord,boundary=boundary),)
|
||||
yield Proposal(actual,contracts,rule['id']+':'+str(offset))
|
||||
elif rule['kind']=='sustained_color':
|
||||
for i,n in enumerate(notes):
|
||||
if n.start>=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<abs(p.number()-pitch.number())<=2 and p.number()%12 in harmony.members(next_note.start,next_note.end) and harmony.profile.permits(p.number())]
|
||||
if not targets:continue
|
||||
target=dataclasses.replace(next_note,pitch=min(targets,key=lambda p:(abs(p.number()-next_note.pitch.number()),p.number())))
|
||||
actual=(*notes[:i],changed,target,*notes[i+2:])
|
||||
c=types.ExpressionContract(changed.event_id+':'+color['id'],'color',color['id'],(changed,),target,span.chord)
|
||||
yield Proposal(actual,(c,),rule['id'])
|
||||
|
||||
|
||||
def approach(proposal,harmony,start,ending=None):
|
||||
"""An approach may share its target with an anticipated destination."""
|
||||
for i,target in enumerate(proposal.notes):
|
||||
if target.end-target.start<1:continue
|
||||
anticipated=next((c for c in proposal.contracts if c.kind=='anticipation' and c.target.event_id==target.event_id),None)
|
||||
time=anticipated.boundary if anticipated else target.start
|
||||
if target.pitch.number()%12 not in harmony.members(time,target.end):continue
|
||||
span=harmony.at(time)
|
||||
for rule in vocabulary.data('lead_ins')['patterns']:
|
||||
arrival=target.start;cursor=arrival-F(rule['gap'])-sum(map(F,rule['durations']),F())
|
||||
if cursor<start+F(1,2):continue
|
||||
if ending and cursor>=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)
|
||||
Reference in New Issue
Block a user