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

657 lines
38 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.sequences
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])
@dataclasses.dataclass(frozen=True)
class Arrival:
note: generator.solo.types.Note
anchor: generator.solo.types.Anchor
contracts: tuple = ()
resume: fractions.Fraction = fractions.Fraction()
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.authored_ids = frozenset(v[0] for v in self.patterns)
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 = generator.solo.groups.descriptor(pattern,length)
representatives.setdefault(descriptor,(name,pattern,length,purposes))
self.patterns += tuple(representatives.values())
self.patterns = tuple(dict((generator.solo.groups.descriptor(p,l),(name,p,l,roles))
for name,p,l,roles in self.patterns).values())
self.patterns_by_length = {}
for value in self.patterns:
self.patterns_by_length.setdefault(value[2],[]).append(value)
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=4096)
def event_options(self,start,end,structural,enabled):
"""Validate the immutable harmonic interval once, not at every near pitch."""
result = []
span = self.harmony.at(start)
if not span or not span.scale:
return ()
for pitch in self.pitches(start,end):
note = self.note(start,end,pitch,'arrival' if structural else 'motion')
if self.harmony.evaluate((note,),complete=True).valid:
if not structural or pitch.number()%12 in self.harmony.members(start,end):
result.append((note,()))
if 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
low,high = self.harmony.profile.register
for near in range(low,high+12,12):
pitch = generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],near)
if not self.harmony.profile.permits(pitch.number()):
continue
note = self.note(start,end,pitch,'color')
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((note,(color,)))
return tuple(dict.fromkeys(result))
def options(self,start,end,near,structural=False):
return tuple(v for v in self.event_options(start,end,structural,self.enabled) if abs(v[0].pitch.number()-near)<=12)
def arrival_options(self,onset,release,harmonic_time,center,effect,resume=None):
"""Choose the sounding target before realizing its incoming gesture."""
region = self.harmony.at(harmonic_time)
if not region or not region.scale or not onset<release:
return ()
result = []
if onset<harmonic_time:
if not self.enabled or region.start!=harmonic_time or not onset<harmonic_time<release:
return ()
# A future member need not be legal in the preceding collection. Only
# the bounded, validated anticipation authorizes that preceding interval.
candidates = [(p,()) for p in self.pitches(harmonic_time,release)
if p.number()%12 in self.harmony.members(harmonic_time,release)]
else:
candidates = [(n.pitch,cs) for n,cs in self.options(onset,release,center,True)]
for pitch,support in candidates:
if abs(pitch.number()-center)>10:
continue
note = self.note(onset,release,pitch,'arrival')
if onset<harmonic_time:
support = (generator.solo.types.ExpressionContract(note.event_id+':early','anticipation',
'early_chord_tone',(note,),note,region.chord,boundary=harmonic_time),)
elif support:
note = support[0].target
if not self.harmony.evaluate((note,),expressions=support,complete=True).valid:
continue
anchor = generator.solo.types.Anchor('arrival:'+str(onset),onset,onset,pitch,
frozenset({pitch.number()%12}),self.harmony.profile.register,release-onset,
'terminal' if effect in ('complete','handoff') else 'intermediate',region.chord,
False,release,effect)
result.append(Arrival(note,anchor,support,release if resume is None else resume))
return tuple(result)
def cadence(self,time):
current = self.harmony.at(time)
previous = self.harmony.at(time-fractions.Fraction(1,8))
if not current or not previous or not current.scale or not previous.scale or current.chord==previous.chord:
return False
a,b = (generator.solo.expressions.parsed_chord(s.chord) for s in (previous,current))
root = lambda c:int(generator.model.NATURAL[c['root']['letter']]+generator.model.alteration(c['root']['alter']))%12
return (generator.solo.expressions.quality(a)=='dominant' and
generator.solo.expressions.quality(b)!='dominant' and (root(a)-root(b))%12==7)
def destination_choice(self,options,center,domain,key):
# Each eligible harmonic degree has an octave realization close to the
# phrase's chosen hand/register region. Register exploration belongs to
# the phrase choice, not an unrelated octave lottery at every arrival.
members = {}
for value in options:
pc = value.note.pitch.number()%12
rank = (abs(value.note.pitch.number()-center),value.note.pitch.number())
if pc not in members or rank<members[pc][0]:
members[pc] = (rank,value)
return self.choose(tuple(v for _,v in sorted(members.values(),key=lambda v:v[0])),domain,key)
def destinations(self,start,end,center,variant):
"""Small connected alternatives in harmonic time, independent of a body."""
hold,breath = self.choose(self.arrivals,'arrival',(start,variant))
close = end==self.end or self.choose((True,False),'closure',(start,variant))
result = []
if close and end-start>=3:
release = end-breath
base = max(start+1,release-hold)
times = [(base,base)]
# Near a harmonic change compare anticipation, on-time and delayed
# entry, rather than retiming an already selected strict melody.
for span in self.harmony.spans:
if start+2<=span.start<release-1 and abs(span.start-base)<=2:
for offset in (fractions.Fraction(-1,2),fractions.Fraction(),fractions.Fraction(1,2)):
onset = span.start+offset
if start+1<=onset<release-1:
times.append((onset,span.start if offset<0 else onset))
onset,when = self.choose(tuple(dict.fromkeys(times)),'arrival_time',(start,variant))
effect = 'handoff' if end==self.end else 'complete' if any(self.cadence(s.start) for s in self.harmony.spans if onset<=s.start<release) or self.cadence(when) else 'settle'
options = self.arrival_options(onset,release,when,center,effect,end)
if not options:
return None
result.append(self.destination_choice(options,center,'destination',(start,variant)))
limit = result[0].note.start if result else end
regions = [s for i,s in enumerate(self.harmony.spans) if s.scale and i and
self.harmony.spans[i-1].chord!=s.chord and start+3<=s.start<limit-3]
if regions:
# Local repose is optional; it is not closure at every chord/cell.
region = self.choose((*regions,None),'local_settle',(start,variant))
if region:
offset = self.choose((fractions.Fraction(-1,2),fractions.Fraction(),fractions.Fraction(1,2)), 'local_time',(start,variant))
onset = region.start+offset
duration,space = self.choose(self.arrivals,'local_duration',(start,variant))
release = min(region.end,region.start+duration,limit-1)
resume = min(release+space,limit-1)
local_center = round((center+result[0].note.pitch.number())/2) if result else center
options = self.arrival_options(onset,release,region.start if offset<0 else onset,local_center,'settle',resume)
if options:
result.insert(0,self.destination_choice(options,local_center,'local_destination',(start,variant)))
return tuple(result)
@functools.lru_cache(maxsize=128)
def developments(self,motif,purpose):
if not motif:
return ()
if purpose=='return':
return (motif,)
values = []
# The characteristic pitch relationships can survive rhythmic recasting.
# Source holds remain source music; they do not become obligatory holds
# at the start of every generated development.
cursor = fractions.Fraction(); compact = []
for i,(a,b) in enumerate(motif.rhythm):
gap = a-motif.rhythm[i-1][1] if i else a
cursor += min(gap,fractions.Fraction(1,2))
length = min(b-a,fractions.Fraction(1))
compact.append((cursor,cursor+length)); cursor += length
if generator.solo.groups.valid(tuple(compact)):
values.append(Idea(tuple(compact),motif.intervals,motif.source+':rephrase'))
if purpose=='extend':
# Reopen the provisional ending and continue its motion. This is an
# edit to the existing idea, not a finished idea followed by a new one.
a,b = compact[-1]
stop = a+min(b-a,fractions.Fraction(1,2))
rhythm = (*compact[:-1],(a,stop),(stop,stop+fractions.Fraction(1,2)),(stop+fractions.Fraction(1,2),stop+1))
step = 2 if motif.intervals[-1]>=motif.intervals[-2] else -2
values.append(Idea(rhythm,(*motif.intervals,motif.intervals[-1]+step,motif.intervals[-1]+2*step),motif.source+':extend'))
for name,pattern,length,roles in self.patterns:
if (len(pattern)==len(motif.intervals) and 1<=length<=4 and
set(roles)&{'entrance','continue','connect','develop','answer','scalar'}):
values.append(Idea(pattern,motif.intervals,motif.source+':rhythm:'+name))
# A full rhythmic recurrence is still a legitimate candidate, not a duty.
values.append(motif)
return tuple(dict(((v.rhythm,v.intervals),v) for v in values).values())
@functools.lru_cache(maxsize=1024)
def rhythm_candidates(self,cursor,remaining,approaching,fast_allowed):
desired_roles = {'connect','continue','scalar'} if approaching else {'continue','connect','develop','answer','scalar','arpeggio'}
eligible = []
for length,values in sorted(self.patterns_by_length.items()):
if not min(remaining,2)<=length<=remaining:
continue
for value in values:
name,pattern,length,roles = value
fast = any((b-a).denominator not in (1,2) for a,b in pattern)
if (set(roles)&desired_roles and (not fast or fast_allowed) and
generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in pattern))):
eligible.append(value)
return tuple(eligible)
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,target=None):
values = self.routes(rhythm,contour,incoming,center,mode,degree_path=degree_path,target=target,limit=1)
return values[0] if values else None
def routes(self,rhythm,contour,incoming,center,mode='develop',*,degree_path=False,target=None,limit=3):
return self._routes(rhythm,contour,incoming,center,mode,degree_path,target,limit,self.enabled)
@functools.lru_cache(maxsize=1024)
def _routes(self,rhythm,contour,incoming,center,mode,degree_path,target,limit,enabled):
"""Keep complete paths toward a known future event, not one next step."""
if not rhythm:
return ()
initial = incoming.pitch.number() if incoming else center
if incoming and mode in ('connect','arpeggio'):
direction = (1 if target.pitch.number()>=initial else -1) if target else (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()))[:6]
states = [(abs(n.pitch.number()-initial)/4,(n,),c,n.pitch.number()) for n,c in starts]
def finish_distance(note):
if target is None:
return 0
distance = abs(note.pitch.number()-target.pitch.number())
return max(0,distance-2)*2+int(distance==0 and note.end==target.start)*2
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)]
span = self.harmony.at(a)
if degree_path:
if not span or not span.scale:
continue
if target is not None:
# Use octave-aware indices when both pitches belong to this map.
# Across maps the target remains an actual sounding pitch.
try:
displacement = generator.solo.types.scale_index(span.scale,target.pitch.number())-generator.solo.types.scale_index(span.scale,notes[-1].pitch.number())
step = max(-2,min(2,round(displacement/(len(rhythm)-index+1))))
except ValueError:
step = 1 if target.pitch.number()>notes[-1].pitch.number() else -1
pitch = self.scale_step(span.scale,notes[-1].pitch.number(),step)
else:
pitch = self.scale_step(span.scale,notes[-1].pitch.number(),expected)
if pitch is None:
continue
wanted = pitch.number()
expected = wanted-notes[-1].pitch.number()
for note,support in self.options(a,b,wanted):
delta = note.pitch.number()-notes[-1].pitch.number()
if degree_path:
if target is None and note.pitch.number()!=wanted:
continue
if target is not None:
try:
step = generator.solo.types.scale_index(span.scale,note.pitch.number())-generator.solo.types.scale_index(span.scale,notes[-1].pitch.number())
if abs(step)>2:
continue
except ValueError:
if abs(delta)>3:
continue
if mode=='arpeggio' and (note.pitch.number()%12 not in span.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 span.roles.basic and not support:
error += 2
next_states.append((cost+error,(*notes,note),(*contracts,*support),origin))
if not next_states:
return ()
def rank(state):
# Admissible reachability pressure uses the actual remaining attacks;
# a partial short route is never compared to a complete long route.
distance = max(0,abs(state[1][-1].pitch.number()-target.pitch.number())-3*(len(rhythm)-index)) if target else 0
return state[0]+distance,tuple(n.pitch.number() for n in state[1])
states = generator.solo.sequences.diverse(next_states,rank,
lambda s:(s[1][0].pitch.number(),s[1][-1].pitch.number()),8)
values = []
for cost,notes,contracts,_ in sorted(states,key=lambda s:(s[0]+finish_distance(s[1][-1]),tuple(n.pitch.number() for n in s[1]))):
if self.harmony.evaluate(notes,expressions=contracts,complete=True).valid:
values.append((notes,contracts))
return tuple(values[:limit])
def contour(self,count,direction,mode,motif,time):
if mode in ('statement','return','develop','answer') and motif:
values = motif.intervals
sign = 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 approach(self,arrival,start,variant):
"""Compose a complete optional lead-in before choosing the body rhythm."""
if not self.enabled:
return (),()
family = self.choose(('direct','approach','enclosure'),'approach_family',(start,variant,arrival.note.start))
if family=='direct':
return (),()
rules = [r for r in generator.vocabulary.data('lead_ins')['patterns'] if r['kind']==family and
(not r.get('difficulty') or self.analysis.tempo<=generator.vocabulary.data('roles')['difficulty'][r['difficulty']+'_max_bpm'])]
for rule in sorted(rules,key=lambda r:generator.solo.policy.stable_rank(self.seed+'lead_in',(start,variant,r['id']))):
gaps = tuple(map(fractions.Fraction,rule.get('internal_gaps',[])))
cursor = arrival.note.start-sum(map(fractions.Fraction,rule['durations']),fractions.Fraction())-sum(gaps,fractions.Fraction())-fractions.Fraction(rule['gap'])
if cursor<start+1:
continue
ns = []
for i,(step,length) in enumerate(zip(rule['semitones'],rule['durations'])):
pitch = generator.solo.expressions.chromatic(arrival.note.pitch,step)
ns.append(self.note(cursor,cursor+fractions.Fraction(length),pitch,family))
cursor = ns[-1].end+(gaps[i] if i<len(gaps) else 0)
if any(not self.harmony.profile.permits(n.pitch.number()) for n in ns):
continue
early = next((c.boundary for c in arrival.contracts if c.kind=='anticipation'),None)
c = generator.solo.types.ExpressionContract(arrival.note.event_id+':'+rule['id'],family,rule['id'],tuple(ns),
arrival.note,arrival.anchor.chord,boundary=early)
if self.harmony.evaluate((*ns,arrival.note),expressions=(*arrival.contracts,c),complete=True).valid:
return tuple(ns),(c,)
return (),()
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 purpose=='answer' and home:
movement = home[-1].pitch.number()-home[0].pitch.number()
center -= 3 if movement>0 else -3 if movement<0 else 0
if incoming:
center = max(incoming.pitch.number()-7,min(incoming.pitch.number()+7,center))
destinations = self.destinations(start,end,center,variant)
if destinations is None:
self.rejections['destination_feasibility'] += 1; return None
ending = destinations[-1].anchor.effect if destinations and destinations[-1].resume==end else 'open'
terminal = destinations[-1].anchor if ending!='open' else None
developments = self.developments(motif,purpose)
notes,contracts,sources,rhythm_groups = [],[],[],[]
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
landmarks = (*destinations,*((None,) if not destinations or destinations[-1].resume<end else ()))
for landmark in landmarks:
approach,support = self.approach(landmark,cursor,variant) if landmark else ((),())
stop = approach[0].start if approach else landmark.note.start if landmark else end
target = approach[0] if approach else landmark.note if landmark else None
while cursor<stop:
span = self.harmony.at(cursor)
if not span or not span.scale:
cursor = min(stop,span.end if span else stop); continue
remaining = stop-cursor
choices = [v for v in developments if v.length<=remaining and generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in v.rhythm))]
if group_index==0 and choices:
motif = self.choose(choices,'theme_rhythm',(start,variant))
pattern,length,source = motif.rhythm,motif.length,motif.source
mode = purpose if purpose!='extend' else 'develop'
else:
# Select complete rhythmic groups by their musical function, keeping
# ordered timing diversity until pitches and destination are known.
eligible = self.rhythm_candidates(cursor,remaining,bool(target and remaining<=4),
fast_groups<generator.vocabulary.data('solo')['subdivision_groups'])
if eligible:
# Families have equal standing; the combinatorial catalog's file
# count never determines how often its material is requested.
families = {}
for value in eligible:
family = 'catalog' if value[0] not in self.authored_ids else 'authored'
families.setdefault(family,[]).append(value)
family = self.choose(tuple(families),'rhythm_family',(start,variant,group_index))
source,pattern,length,_ = self.choose(families[family],'rhythm',(start,variant,group_index))
else:
# An explicit moving primitive, never an arbitrary long hold used
# to fill a leftover span. Tuplets may only enter as whole groups.
length = min(fractions.Fraction(1,2),remaining)
pattern,source = ((fractions.Fraction(),length),),'eighth_continuation'
if not generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in pattern)):
self.rejections['subdivision_remainder'] += 1; return None
mode = 'connect' if target and remaining<=4 else self.choose(('connect','develop','arpeggio'),'movement',(start,variant,group_index))
rhythm = tuple((cursor+a,cursor+b) for a,b in pattern)
previous = notes[-1] if notes else incoming
direction = 1 if target and target.pitch.number()> (previous.pitch.number() if previous else center) else -1 if target else self.choose((-1,1),'direction',(start,variant))
desired = self.contour(len(pattern),direction,mode,motif,cursor)
# Only the group immediately reaching a target must finish its approach.
# Earlier material sees the future register but can retain its identity.
next_target = target if cursor+length==stop else None
local_center = round((center+target.pitch.number())/2) if target else center
routes = self.routes(rhythm,desired,previous,local_center,mode,degree_path=mode=='connect',target=next_target)
if not routes:
self.rejections['route'] += 1; return None
ns,cs = routes[variant%len(routes)]
if not signature_notes and group_index==0 and len(ns)>=3:
signature_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)
rhythm_groups.append(generator.solo.groups.RhythmGroup(cursor,cursor+length,'gesture',rhythm,
cursor%1,source+':'+mode))
cursor += length; group_index += 1
if landmark:
if approach and notes and notes[-1].end==approach[0].start and notes[-1].pitch==approach[0].pitch:
# Recompose the join as a direct route in another candidate; never
# keep an accidental duplicated entrance because the label is valid.
self.rejections['duplicated_approach_entrance'] += 1; return None
notes.extend(approach); contracts.extend(support)
note = landmark.note
if notes and not approach and not landmark.contracts and notes[-1].end==note.start and notes[-1].pitch==note.pitch:
before = notes[-1]
used = {n.event_id for c in contracts for n in (*c.events,c.target)}
held = dataclasses.replace(note,start=before.start,event_id=note.event_id+':carried')
if before not in signature_notes and before.event_id not in used and self.harmony.evaluate((held,),complete=True).valid:
notes.pop(); note=held
notes.append(note); contracts.extend(landmark.contracts)
rhythm_groups.append(generator.solo.groups.RhythmGroup(
approach[0].start if approach else note.start,landmark.resume,'arrival',
tuple((n.start,n.end) for n in (*approach,note)),note.start%1,landmark.anchor.effect))
sources.append('destination:'+landmark.anchor.effect)
cursor = landmark.resume
if not notes:
return None
if self.enabled and variant%3:
# Only genuinely local refinements remain here. Every committed
# arrival and the treatment's identifying events are protected.
protected = (*signature_notes,*(n for n in notes if any(n.start<=d.note.start and n.end>=d.note.end and n.pitch==d.note.pitch for d in destinations)))
notes,contracts = self.expression(tuple(notes),tuple(contracts),protected,start,variant)
checked = self.harmony.evaluate(notes,expressions=contracts,until=end,complete=True)
if not checked.valid:
self.rejections[checked.error] += 1; return None
anchors = tuple(d.anchor for d in destinations)
evaluation = generator.solo.selection.evaluate(notes,start,end,self.harmony,incoming=(incoming,) if incoming else (),
home=home,purpose=purpose,contracts=contracts,center=center,destinations=anchors,history=history,identity=signature_notes)
if not evaluation.valid:
self.rejections['arrival_contract'] += 1; return None
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:
generator.solo.groups.describe(pattern,origin=start,source=purpose)
except ValueError:
self.rejections['rhythm_group'] += 1; return None
# Refine diagnostics against the chosen sounding events. Local expression
# and shared arrival sustains must not leave stale pre-edit rhythm reports.
rhythm_groups = tuple(dataclasses.replace(g,events=tuple((n.start,n.end) for n in notes
if g.start<=n.start<g.end)) for g in rhythm_groups)
direction = 'up' if notes[-1].pitch.number()>notes[0].pitch.number() else 'down' if notes[-1].pitch.number()<notes[0].pitch.number() else 'returning'
phrase = generator.solo.types.Phrase(key,start,end,tuple(notes),purpose,direction,ending,
terminal,tuple(contracts),rhythm_groups,'home' if home else key,float(evaluation.total),evaluation,tuple(sources),anchors,signature_notes)
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