384 lines
22 KiB
Python
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
|