150 lines
8.8 KiB
Python
150 lines
8.8 KiB
Python
"""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)
|