Files
product-vamp/generator/solo/gestures.py
T
khodowany 68df7292ee Cleanup
2026-10-04 17:14:13 -05:00

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)