Files
product-vamp/generator/solo/phrases.py
T
2026-10-05 16:40:22 -05:00

384 lines
22 KiB
Python

"""Source-related melodic sentences realized as whole pitch/rhythm routes.
Original song analysis and general vocabulary are the only musical inputs.
"""
import collections
import dataclasses
import fractions
import functools
import generator.model
import generator.solo.expressions
import generator.solo.groups
import generator.solo.policy
import generator.solo.rhythm
import generator.solo.selection
import generator.solo.types
import generator.vocabulary
@dataclasses.dataclass(frozen=True)
class Idea:
rhythm: tuple
intervals: tuple
source: str
@property
def length(self):
return self.rhythm[-1][1]
def idea(notes, source):
first = notes[0]
return Idea(tuple((n.start-first.start,n.end-first.start) for n in notes),
tuple(n.pitch.number()-first.pitch.number() for n in notes),source)
def source_ideas(analysis):
found = {}
for i in range(len(analysis.melody)):
for count in (3,4,5,6):
notes = analysis.melody[i:i+count]
if len(notes)!=count or not 2<=notes[-1].end-notes[0].start<=6:
continue
value = idea(notes,'head:'+str(notes[0].start))
if not generator.solo.groups.valid(value.rhythm):
continue
key = (value.rhythm,value.intervals)
old,number = found.get(key,(value,0)); found[key] = (old,number+1)
def rank(row):
value,count = row
intervals = [b-a for a,b in zip(value.intervals,value.intervals[1:])]
turns = sum(a*b<0 for a,b in zip(intervals,intervals[1:]))
durations = len({b-a for a,b in value.rhythm})
return (-min(count,3),-durations,-min(turns,2),-min(7,max(value.intervals)-min(value.intervals)),value.source)
return tuple(v for v,_ in sorted(found.values(),key=rank)[:12])
class Phrases:
def __init__(self,analysis,harmony,sequences,rhythms,start,end,seed,enabled):
self.analysis,self.harmony = analysis,harmony
self.start,self.end,self.seed,self.enabled = start,end,seed,enabled
self.sequences = sequences
self.ideas = source_ideas(analysis)
library = generator.solo.rhythm.Rhythms(rhythms,analysis.tempo)
self.patterns = tuple(library.groups)
self.arrivals = tuple(sorted({(cell.pattern[-1][1]-cell.pattern[-1][0],cell.duration-cell.pattern[-1][1])
for cell in generator.vocabulary.rhythm_cells(analysis.tempo,'arrival') if cell.pattern and cell.pattern[-1][1]-cell.pattern[-1][0]>=1}))
# Catalog families contribute one peer per rhythmic function, not one
# vote per file. In particular the large 5/4 family cannot dominate 4/4.
representatives = {}
lengths = {b-a for a,b in zip(analysis.boundaries,analysis.boundaries[1:])}
for values in library.catalog.values():
for name,pattern,length,purposes in values:
if length not in lengths or not 2<=len(pattern)<=6:
continue
descriptor = (any(a[1]<b[0] for a,b in zip(pattern,pattern[1:])),any(a%1 for a,b in pattern),len({b-a for a,b in pattern})>1)
representatives.setdefault(descriptor,(name,pattern,length,purposes))
self.patterns += tuple(representatives.values())
self.center = harmony.profile.reference or 60
self.rejections = collections.Counter()
self.proposals = collections.Counter()
def choose(self,values,domain,key):
return min(values,key=lambda x:generator.solo.policy.stable_rank(self.seed+domain,(key,x)))
def horizon(self,start):
region = self.harmony.at(start)
if not region or not region.scale:
return min(self.end,region.end if region else self.end)
gap = next((s.start for s in self.harmony.spans if start<s.start<self.end and not s.scale),self.end)
boundaries = [t-self.analysis.body_offset for t in self.analysis.boundaries if start<t-self.analysis.body_offset<=self.end]
if not boundaries:
return self.end
if start<0:
return min(self.end,fractions.Fraction())
candidates = boundaries[1:8] or boundaries
def cost(end):
previous = self.harmony.at(end-fractions.Fraction(1,8))
before = self.harmony.at(previous.start-fractions.Fraction(1,8)) if previous else None
cadence = False
if previous and before and previous.scale and before.scale:
a = generator.solo.expressions.parsed_chord(before.chord)
b = generator.solo.expressions.parsed_chord(previous.chord)
root = lambda c:int(generator.model.NATURAL[c['root']['letter']]+generator.model.alteration(c['root']['alter']))%12
cadence = generator.solo.expressions.quality(a)=='dominant' and (root(a)-root(b))%12==7
breath = any(n.end==end+self.analysis.body_offset and (i+1==len(self.analysis.melody) or self.analysis.melody[i+1].start>n.end) for i,n in enumerate(self.analysis.melody))
return (abs(boundaries.index(end)+1-4)-2*int(cadence)-int(breath),end)
return min(gap,min(candidates,key=cost))
@functools.lru_cache(maxsize=8192)
def pitches(self,start,end):
span = self.harmony.at(start)
if not span or not span.scale:
return ()
permitted = self.harmony.permitted(start,end)
return tuple(p for index in range(-28,29) if self.harmony.profile.permits((p:=generator.solo.types.scale_pitch(span.scale,index)).number()) and p.number()%12 in permitted)
@staticmethod
def note(start,end,pitch,role):
return generator.solo.types.Note(start,end,pitch,role=role,event_id=f'{start}:{end}:{pitch.number()}')
@functools.lru_cache(maxsize=16384)
def options(self,start,end,near,structural=False):
result = []
span = self.harmony.at(start)
if not span or not span.scale:
return ()
for p in self.pitches(start,end):
if abs(p.number()-near)>12:
continue
note = self.note(start,end,p,'arrival' if structural else 'motion')
if self.harmony.evaluate((note,),complete=True).valid:
if not structural or p.number()%12 in self.harmony.members(start,end):
result.append((note,()))
if self.enabled and end-start>=1:
for rule in generator.vocabulary.data('harmony')['colors']:
if rule.get('resolve') or not generator.solo.expressions.eligible_color(rule,span,self.harmony):
continue
pitch = generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],p.number())
if pitch.number()!=p.number():
continue
color = generator.solo.types.ExpressionContract(note.event_id+':'+rule['id'],'color',rule['id'],(note,),note,span.chord)
if self.harmony.evaluate((note,),expressions=(color,),complete=True).valid:
result.append((dataclasses.replace(note,role='color'),(color,)))
return tuple(result)
def scale_step(self,scale,number,step):
"""Continue from sounding pitch, never transfer a degree between maps."""
try:
index = generator.solo.types.scale_index(scale,number)
except ValueError:
# The preceding attack may belong to the previous collection. Its
# pitch stays untouched; enter the new one in the requested direction.
if not step:
return None
entries = [generator.solo.types.scale_pitch(scale,i) for i in range(-28,29)]
entries = [p for p in entries if (p.number()-number)*step>0]
if not entries:
return None
nearest = min(entries,key=lambda p:abs(p.number()-number))
index = generator.solo.types.scale_index(scale,nearest.number())
step -= 1 if step>0 else -1
return generator.solo.types.scale_pitch(scale,index+step)
def route(self,rhythm,contour,incoming,center,mode='develop',*,degree_path=False):
"""DP compares completed paths; no single route step is committed alone."""
if not rhythm:
return None
initial = incoming.pitch.number() if incoming else center
if incoming and mode in ('connect','arpeggio'):
direction = 1 if contour[-1]>=contour[0] else -1
center = initial+direction*2
options = self.options(*rhythm[0],center)
if mode=='arpeggio':
options = tuple(v for v in options if v[0].pitch.number()%12 in self.harmony.at(rhythm[0][0]).roles.basic)
starts = sorted(options,key=lambda v:(abs(v[0].pitch.number()-center)+max(0,abs(v[0].pitch.number()-initial)-5),v[0].pitch.number()))[:3]
states = [(0,(n,),c,n.pitch.number()) for n,c in starts]
for index,(a,b) in enumerate(rhythm[1:],1):
next_states = []
for cost,notes,contracts,origin in states:
wanted = origin+contour[min(index,len(contour)-1)]
expected = contour[min(index,len(contour)-1)]-contour[min(index-1,len(contour)-1)]
if degree_path:
span = self.harmony.at(a)
target = self.scale_step(span.scale,notes[-1].pitch.number(),expected) if span and span.scale else None
if target is None:
continue
wanted = target.number()
expected = wanted-notes[-1].pitch.number()
for note,support in self.options(a,b,wanted):
if degree_path and note.pitch.number()!=wanted:
continue
delta = note.pitch.number()-notes[-1].pitch.number()
if mode=='arpeggio' and (note.pitch.number()%12 not in self.harmony.at(a).roles.basic or delta==0):
continue
error = abs(note.pitch.number()-wanted)+3*int(delta==0 and expected!=0)+max(0,abs(delta)-7)*2
if b-a>=1 and note.pitch.number()%12 not in self.harmony.at(a).roles.basic and not support:
error += 2
next_states.append((cost+error,(*notes,note),(*contracts,*support),origin))
if not next_states:
return None
states = sorted(next_states,key=lambda s:(s[0],tuple(n.pitch.number() for n in s[1])))[:8]
for _,notes,contracts,_ in sorted(states,key=lambda s:(s[0],tuple(n.pitch.number() for n in s[1]))):
if self.harmony.evaluate(notes,expressions=contracts,complete=True).valid:
return notes,contracts
return None
def contour(self,count,direction,mode,motif,time):
if mode in ('statement','return','develop','answer') and motif:
values = motif.intervals
sign = -1 if mode=='answer' else 1
# Explicit reduction/extension; never cycle a route to fit its rhythm.
return tuple(sign*values[i] if i<len(values) else sign*values[-1]+direction*2*(i-len(values)+1) for i in range(count))
paths = self.sequences.connecting(count) if self.sequences else ()
paths = [p for p in paths if max(map(abs,p.steps))<=(3 if mode=='arpeggio' else 2) and (p.offsets()[-1]>0)==(direction>0) and sum(a*b<0 for a,b in zip(p.steps,p.steps[1:]))<=1]
span = self.harmony.at(time)
if paths and span and span.scale:
path = min(paths,key=lambda p:(abs(abs(p.offsets()[-1])-(count-1)),p.rank()))
if mode=='connect':
return path.offsets()
base = generator.solo.types.scale_pitch(span.scale,0)
return tuple(generator.solo.types.scale_pitch(span.scale,i).number()-base.number() for i in path.offsets())
if mode=='connect':
return tuple(direction*i for i in range(count))
return tuple(direction*i*(3 if mode=='arpeggio' else 2) for i in range(count))
def make(self,start,end,incoming,home,history,variant):
if not any(s.scale and s.start<end and s.end>start for s in self.harmony.spans):
self.proposals['space'] += 1
evaluation = generator.solo.selection.evaluate((),start,end,self.harmony)
return generator.solo.types.Phrase('space:'+str(start),start,end,(),'space','level','open',evaluation=evaluation),()
motif = idea(home,'realized_theme') if home else self.choose(self.ideas,'source',variant) if self.ideas else None
purpose = 'statement' if not home else self.choose(('return','develop','answer','extend'),'purpose',(start,variant))
if purpose=='return' and history and history[-1].purpose=='return':
purpose = 'develop'
self.proposals[purpose] += 1
center = self.center+self.choose((-5,0,5),'register',(start,variant))
if incoming:
center = max(incoming.pitch.number()-7,min(incoming.pitch.number()+7,center))
direction = self.choose((-1,1),'direction',(start,variant))
hold,release = self.choose(self.arrivals,'arrival',(start,variant))
close = end==self.end or self.choose((True,True,False),'closure',(start,variant))
arrival = max(start,end-release-hold) if close and end-start>=4 else end
notes,contracts,sources = [],[],[]
cursor = start
if start>=0 and purpose!='return':
cursor += self.choose((fractions.Fraction(),fractions.Fraction(1,2),fractions.Fraction(1)),'entrance',(start,variant))
signature_notes = (); group_index = 0; fast_groups = 0
while cursor<arrival:
span = self.harmony.at(cursor)
if not span or not span.scale:
cursor = min(arrival,span.end if span else arrival); continue
remaining = arrival-cursor
if group_index==0 and motif and motif.length<=remaining:
pattern,length,source = motif.rhythm,motif.length,motif.source
mode = purpose if purpose!='extend' else 'develop'
else:
def suitable(value):
_,pattern,length,purposes = value
fast = any((b-a).denominator not in (1,2) for a,b in pattern)
return (length<=remaining and length>=min(remaining,1)
and bool(set(purposes)&({'continue','connect','develop','answer','scalar','arpeggio'}|({'entrance'} if group_index==0 else set())))
and (not fast or fast_groups<generator.vocabulary.data('solo')['subdivision_groups'])
and generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in pattern)))
eligible = [v for v in self.patterns if suitable(v)]
if eligible:
source,pattern,length,_ = self.choose(eligible,'rhythm',(start,variant,group_index))
else:
length = remaining; pattern,source = ((fractions.Fraction(),length),),'broadening'
mode = self.choose(('connect','develop','arpeggio'),'movement',(start,variant,group_index))
rhythm = tuple((cursor+a,cursor+b) for a,b in pattern)
desired = self.contour(len(pattern),direction,mode,motif,cursor)
phase = float((cursor-start)/max(1,end-start))
local_center = center+direction*round(5*(1-abs(2*phase-1)))
realized = self.route(rhythm,desired,notes[-1] if notes else incoming,local_center,mode,degree_path=mode=='connect')
if realized is None:
self.rejections['route'] += 1; return None
ns,cs = realized
if not signature_notes and len((*notes,*ns))>=3:
signature_notes = tuple((*notes,*ns))
fast_groups += int(any((b-a).denominator not in (1,2) for a,b in pattern))
notes.extend(ns); contracts.extend(cs); sources.append(source)
cursor += length; group_index += 1
anchor = None; ending = 'open'
if arrival<end:
sounding_end = end-release
previous = notes[-1].pitch.number() if notes else center
options = self.options(arrival,sounding_end,previous,True)
if not options:
self.rejections['arrival'] += 1; return None
def destination_cost(value):
note,cs = value
degrees = dict(self.harmony.at(arrival).roles.degrees).get(note.pitch.number()%12,())
return (abs(note.pitch.number()-previous)+abs(note.pitch.number()-center)/3-int(bool(set(degrees)&{3,7})),note.pitch.number())
target,support = sorted(options,key=destination_cost)[variant%min(3,len(options))]
notes.append(target); contracts.extend(support)
ending = 'handoff' if end==self.end else 'complete' if release else 'settle'
anchor = generator.solo.types.Anchor('arrival:'+str(arrival),arrival,arrival,target.pitch,frozenset({target.pitch.number()%12}),
self.harmony.profile.register,sounding_end-arrival,'terminal' if ending in ('complete','handoff') else 'intermediate',
self.harmony.at(arrival).chord,False,sounding_end,ending)
if not notes and any(s.scale and s.start<end and s.end>start for s in self.harmony.spans):
return None
if self.enabled and variant%3 and notes:
notes,contracts = self.expression(tuple(notes),tuple(contracts),signature_notes,start,variant)
checked = self.harmony.evaluate(notes,expressions=contracts,until=end,complete=True)
if not checked.valid:
self.rejections[checked.error] += 1; return None
evaluation = generator.solo.selection.evaluate(notes,start,end,self.harmony,incoming=(incoming,) if incoming else (),
home=home,purpose=purpose,contracts=contracts,center=self.center)
key = generator.solo.policy.stable_rank(self.seed,(start,end,variant))[:12]
pattern = tuple((n.start-start,n.end-start) for n in notes)
try:
groups = generator.solo.groups.describe(pattern,origin=start,source=purpose)
except ValueError:
self.rejections['rhythm_group'] += 1; return None
phrase = generator.solo.types.Phrase(key,start,end,tuple(notes),purpose,'up' if direction>0 else 'down',ending,
anchor,tuple(contracts),groups,'home' if home else key,float(evaluation.total),evaluation,tuple(sources))
return phrase,signature_notes
def expression(self,notes,contracts,protected,start,variant):
protected_ids = {n.event_id for n in protected}
used = {n.event_id for c in contracts for n in (*c.events,c.target)}
targets = [n for n in notes if n.event_id not in used|protected_ids and n.end-n.start>=1 and n.pitch.number()%12 in self.harmony.members(n.start,n.end)]
if not targets:
return notes,contracts
target = self.choose(targets,'expression_target',(start,variant))
previous = next((n for n in reversed(notes) if n.end<=target.start),None)
region = self.harmony.at(target.start)
editable = previous and previous.event_id not in used|protected_ids and previous.end==target.start
if editable and region.start==target.start and target is not notes[-1] and target.end-target.start>=fractions.Fraction(3,2):
for rule in generator.vocabulary.data('harmony').get('suspensions',[]):
if target.pitch.number()-previous.pitch.number() not in rule['steps']:
continue
held = dataclasses.replace(previous,end=target.start+fractions.Fraction(1,2),event_id=previous.event_id+':held',role='suspension')
resolved = dataclasses.replace(target,start=held.end,event_id=target.event_id+':resolved')
c = generator.solo.types.ExpressionContract(held.event_id,'suspension',rule['id'],(held,),resolved,region.chord,boundary=region.start)
revised = tuple(held if n is previous else resolved if n is target else n for n in notes)
if self.harmony.evaluate(revised,expressions=(*contracts,c),complete=True).valid:
return revised,(*contracts,c)
# Anticipation changes the actual rhythm before phrase selection. The
# original committed arrival interval remains sounding in its entirety.
if editable and region.start==target.start and previous.end-previous.start>=1:
early = dataclasses.replace(target,start=target.start-fractions.Fraction(1,2),event_id=target.event_id+':early')
before = dataclasses.replace(previous,end=early.start,event_id=previous.event_id+':short')
contract = generator.solo.types.ExpressionContract(early.event_id,'anticipation','early_chord_tone',(early,),early,region.chord,boundary=region.start)
revised = tuple(early if n is target else before if n is previous else n for n in notes)
if self.harmony.evaluate(revised,expressions=(*contracts,contract),complete=True).valid:
return revised,(*contracts,contract)
# Altered colors resolve to an existing destination; the rule, not the
# annotation or accidental, authorizes their complete exposed interval.
if editable and variant%2 and previous.end-previous.start<=1:
span = self.harmony.at(previous.start)
for rule in generator.vocabulary.data('harmony')['colors']:
if not rule.get('resolve') or not generator.solo.expressions.eligible_color(rule,span,self.harmony):
continue
pitch = generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],target.pitch.number())
color = dataclasses.replace(previous,pitch=pitch,event_id=previous.event_id+':color',role='color')
c = generator.solo.types.ExpressionContract(color.event_id,'color',rule['id'],(color,),target,region.chord)
revised = tuple(color if n is previous else n for n in notes)
if self.harmony.profile.permits(pitch.number()) and self.harmony.evaluate(revised,expressions=(*contracts,c),complete=True).valid:
return revised,(*contracts,c)
rules = [r for r in generator.vocabulary.data('lead_ins')['patterns'] if r['kind'] in ('approach','enclosure') and (not r.get('difficulty') or self.analysis.tempo<=generator.vocabulary.data('roles')['difficulty'][r['difficulty']+'_max_bpm'])]
rules = sorted(rules,key=lambda r:generator.solo.policy.stable_rank(self.seed+'expression',(start,variant,r['id'])))
for rule in rules:
gaps = tuple(map(fractions.Fraction,rule.get('internal_gaps',[])))
cursor = target.start-sum(map(fractions.Fraction,rule['durations']),fractions.Fraction())-sum(gaps,fractions.Fraction())-fractions.Fraction(rule['gap'])
affected = [n for n in notes if n.start<target.start and n.end>cursor]
if cursor<start or any(n.event_id in used|protected_ids or n.start<cursor for n in affected):
continue
gesture = []
for i,(delta,length) in enumerate(zip(rule['semitones'],rule['durations'])):
pitch = generator.solo.expressions.chromatic(target.pitch,delta)
gesture.append(self.note(cursor,cursor+fractions.Fraction(length),pitch,rule['kind']))
cursor += fractions.Fraction(length)+(gaps[i] if i<len(gaps) else 0)
if any(not self.harmony.profile.permits(n.pitch.number()) for n in gesture):
continue
before = next((n for n in reversed(notes) if n.end<=gesture[0].start),None)
if before and (abs(before.pitch.number()-gesture[0].pitch.number())>7 or before.end==gesture[0].start and before.pitch==gesture[0].pitch):
continue
revised = tuple(sorted((*[n for n in notes if n not in affected],*gesture),key=lambda n:n.start))
contract = generator.solo.types.ExpressionContract(target.event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(gesture),target,self.harmony.at(target.start).chord)
if self.harmony.evaluate(revised,expressions=(*contracts,contract),complete=True).valid:
return revised,(*contracts,contract)
return notes,contracts