|
|
|
@@ -11,6 +11,7 @@ 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
|
|
|
|
@@ -54,6 +55,14 @@ def source_ideas(analysis):
|
|
|
|
|
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
|
|
|
|
@@ -62,6 +71,7 @@ class Phrases:
|
|
|
|
|
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
|
|
|
|
@@ -72,9 +82,14 @@ class Phrases:
|
|
|
|
|
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)
|
|
|
|
|
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()
|
|
|
|
@@ -118,31 +133,181 @@ class Phrases:
|
|
|
|
|
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):
|
|
|
|
|
@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 p in self.pitches(start,end):
|
|
|
|
|
if abs(p.number()-near)>12:
|
|
|
|
|
continue
|
|
|
|
|
note = self.note(start,end,p,'arrival' if structural else 'motion')
|
|
|
|
|
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 p.number()%12 in self.harmony.members(start,end):
|
|
|
|
|
if not structural or pitch.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():
|
|
|
|
|
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((dataclasses.replace(note,role='color'),(color,)))
|
|
|
|
|
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:
|
|
|
|
@@ -161,53 +326,94 @@ class Phrases:
|
|
|
|
|
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."""
|
|
|
|
|
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 None
|
|
|
|
|
return ()
|
|
|
|
|
initial = incoming.pitch.number() if incoming else center
|
|
|
|
|
if incoming and mode in ('connect','arpeggio'):
|
|
|
|
|
direction = 1 if contour[-1]>=contour[0] else -1
|
|
|
|
|
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()))[:3]
|
|
|
|
|
states = [(0,(n,),c,n.pitch.number()) for n,c in starts]
|
|
|
|
|
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:
|
|
|
|
|
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:
|
|
|
|
|
if not span or not span.scale:
|
|
|
|
|
continue
|
|
|
|
|
wanted = target.number()
|
|
|
|
|
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):
|
|
|
|
|
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):
|
|
|
|
|
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 self.harmony.at(a).roles.basic and not support:
|
|
|
|
|
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 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]))):
|
|
|
|
|
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:
|
|
|
|
|
return notes,contracts
|
|
|
|
|
return None
|
|
|
|
|
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 if mode=='answer' else 1
|
|
|
|
|
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 ()
|
|
|
|
@@ -223,6 +429,34 @@ class Phrases:
|
|
|
|
|
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
|
|
|
|
@@ -234,86 +468,125 @@ class Phrases:
|
|
|
|
|
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))
|
|
|
|
|
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 = [],[],[]
|
|
|
|
|
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
|
|
|
|
|
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))
|
|
|
|
|
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:
|
|
|
|
|
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):
|
|
|
|
|
# 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 and notes:
|
|
|
|
|
notes,contracts = self.expression(tuple(notes),tuple(contracts),signature_notes,start,variant)
|
|
|
|
|
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=self.center)
|
|
|
|
|
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:
|
|
|
|
|
groups = generator.solo.groups.describe(pattern,origin=start,source=purpose)
|
|
|
|
|
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))
|
|
|
|
|
# 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):
|
|
|
|
|