Simplification

This commit is contained in:
khodowany
2026-10-05 17:24:16 -05:00
parent bcaf2f1403
commit db7966a430
12 changed files with 639 additions and 123 deletions
+2 -1
View File
@@ -27,6 +27,7 @@ def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,e
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'source_melodic_phrases','version':policy.DEFAULT.composition_version,
'construction_seconds':time.monotonic()-began,**builder.timings,'operation_coverage':{k:dict(v) for k,v in builder.coverage.items()},
'whole_stream_diagnostic':dataclasses.asdict(builder.winner_evaluation),
'opening_candidates':builder.opening_candidates,
'frontiers':builder.frontiers,'completed_candidates':[{'rank':i+1,'evaluation':{'selection_total': str(s.total / (end-start)), 'phrases': [dataclasses.asdict(p.evaluation) for p in s.phrases]},'events':[(str(n.start),str(n.end),n.pitch.number()) for n in s.notes]} for i,s in enumerate(builder.completed)],'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile,start)
validate(result)
return result
@@ -44,6 +45,6 @@ def validate(solo):
for p in solo.phrases:
if p.start!=cursor or p.end<=p.start:raise ValueError('phrase coverage gap or overlap')
cursor=p.end
valid,error=harmonic.anchors_valid((p.destination,) if p.destination else (),p.notes,p.end,complete=True,closure='closed' if p.ending in ('complete','handoff') else 'open')
valid,error=harmonic.anchors_valid(p.arrivals or ((p.destination,) if p.destination else ()),p.notes,p.end,complete=True,closure='closed' if p.ending in ('complete','handoff') else 'open')
if not valid:raise ValueError(error)
if cursor!=solo.end:raise ValueError('incomplete solo duration')
+22 -1
View File
@@ -33,6 +33,7 @@ class Builder:
self.rules = generator.vocabulary.data('solo')['search']
self.timings = {}; self.frontiers = []; self.coverage = collections.defaultdict(collections.Counter); self.decisions = []
self.failures = self.phrases.rejections
self.opening_candidates = []
def candidates(self,start,end,state):
incoming = state.notes[-1] if state.notes else None
@@ -51,8 +52,18 @@ class Builder:
def rank(self,state):
return state.total,generator.solo.policy.stable_rank(self.seed,tuple((n.start,n.end,n.pitch.number()) for n in state.notes))
@staticmethod
def character(state):
phrase = state.phrases[-1]
ns = phrase.notes
# Diversity describes the realized phrase, not its constructor label.
return (tuple((n.start-phrase.start,n.end-n.start) for n in ns[:4]),
tuple((b.pitch.number()>a.pitch.number())-(b.pitch.number()<a.pitch.number()) for a,b in zip(ns[:3],ns[1:4])),
tuple((a.earliest%1,a.effect) for a in phrase.arrivals))
def compose(self):
began = time.monotonic(); self.frontiers = []; self.decisions = []
openings = []
states = (State(),); start = self.start
while start<self.end:
end = self.phrases.horizon(start)
@@ -63,15 +74,25 @@ class Builder:
successors.append(State((*state.phrases,phrase),state.home or (introduced if start>=0 and len(introduced)>=3 else ()),total))
if not successors:
raise SearchExhausted(f'No valid complete phrase at {start}–{end}; failures: {dict(self.failures)}')
if start>=0 and not openings:
openings = [s.phrases[-1] for s in successors]
successors.sort(key=self.rank)
states = tuple(generator.solo.sequences.diverse(successors,self.rank,
lambda s:(s.phrases[-1].purpose,s.phrases[-1].direction,s.phrases[-1].ending),self.rules['histories']))
self.character,self.rules['histories']))
for state in states:
self.coverage[state.phrases[-1].purpose]['retained_histories'] += 1
self.frontiers.append({'start':str(start),'end':str(end),'completed':len(successors),'retained':len(states),'best':str(min(s.total for s in states))})
start = end
self.completed = tuple(sorted(states,key=self.rank))
winner = self.completed[0]
selected_opening = next((p for p in winner.phrases if p.start>=0),None)
if selected_opening is not None:
remaining = [p for p in openings if p.notes!=selected_opening.notes]
examples = (selected_opening,*generator.solo.sequences.diverse(remaining,
lambda p:(p.evaluation.total,p.id),lambda p:self.character(State((p,))),3))
self.opening_candidates = [{'selected_in_final_solo':p is selected_opening,'start':str(p.start),'end':str(p.end),
'purpose':p.purpose,'ending':p.ending,'evaluation':dataclasses.asdict(p.evaluation),
'events':[(str(n.start),str(n.end),n.pitch.number()) for n in p.notes]} for p in examples]
self.timings['phrase_search_seconds'] = time.monotonic()-began
self.winner_evaluation = generator.solo.selection.evaluate(winner.notes,self.start,self.end,self.harmony,
home=winner.home,contracts=tuple(c for p in winner.phrases for c in p.expressions),center=self.phrases.center)
+9
View File
@@ -61,6 +61,15 @@ def signature(notes):
for i,n in enumerate(notes))
def descriptor(pattern, duration):
"""Retain ordered attacks, durations and space, including a trailing breath.
Absolute placement belongs to the caller. Equal counts or Boolean labels
do not make two different rhythmic sentences interchangeable.
"""
return (duration, tuple(pattern))
def broaden(pattern, arrival, release):
"""Reopen an editable binary tail; never slice a tuplet or inherit its phase."""
if (arrival*2).denominator!=1 or (release*2).denominator!=1:return ()
+371 -98
View File
@@ -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):
+1 -1
View File
@@ -4,7 +4,7 @@ import dataclasses, hashlib
class Policy:
long_note: float = 2.
stopping_note: float = 1.
composition_version: str = 'melodic-phrases-1'
composition_version: str = 'directed-phrases-2'
DEFAULT=Policy()
def stable_rank(seed, value):
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
+2 -1
View File
@@ -20,7 +20,8 @@ def structure(solo):
completed=anchor is None or harmonic.anchor_note(anchor,p.notes) is not None
entry={'id':p.id,'start':str(p.start),'end':str(p.end),'purpose':p.purpose,'direction':p.direction,'ending':p.ending,
'theme':p.theme,'notes':plain(p.notes),'rhythm_groups':plain(p.groups),'sources':p.sources,
'anchors':[{**plain(anchor),'pitch':anchor.pitch.label(),'completed':completed}] if anchor else [],
'anchors':[{**plain(a),'pitch':a.pitch.label(),'completed':harmonic.anchor_note(a,p.notes) is not None} for a in p.arrivals or ((anchor,) if anchor else ())],
'identifying_events':plain(p.identity),
'ending_completed':completed,'evaluation':plain(p.evaluation),'score':p.score,'expressions':plain(p.expressions)}
phrases.append(entry)
if anchor:endings.append({'phrase':p.id,'effect':p.ending,'arrival':str(anchor.earliest),'pitch':anchor.pitch.label(),'required_beats':str(anchor.duration),'completed':completed})
+1 -1
View File
@@ -27,6 +27,6 @@ class Rhythms:
pattern=tuple((e.onset,e.onset+e.duration) for e in bar.events if e.sounding)
if any(e.continuation for e in bar.events) or not pattern or not groups.valid(pattern):continue
if any(b-a<F(1,2) for a,b in pattern):continue
descriptor=(len(pattern),pattern[0][0]>0,any(a[1]<b[0] for a,b in zip(pattern,pattern[1:])),len(set(b-a for a,b in pattern)))
descriptor=groups.descriptor(pattern,bar.length)
representatives.setdefault(descriptor,(item.id,pattern,bar.length,('develop','answer')))
self.catalog[meter]=tuple(representatives.values())
+69 -16
View File
@@ -1,12 +1,17 @@
"""One complete-event evaluator. No learned or constructor-family rewards."""
"""One complete-event evaluator, including the declared musical relationships.
The inputs are realized notes and independently planned obligations. Action
counts and constructor-family labels never award musical credit.
"""
import collections
import fractions
import generator.solo.harmonic
import generator.solo.scoring
import generator.solo.types
VERSION = 'melodic-phrases-1'
VERSION = 'directed-phrases-2'
WEIGHTS = {'continuity': 2, 'repetition': 3, 'interruption': 2, 'activity': 2,
'harmony': 2, 'register': 1, 'identity': 3}
'harmony': 2, 'register': 1, 'identity': 3, 'arrival': 2}
def signature(notes):
@@ -16,34 +21,82 @@ def signature(notes):
return tuple((n.start-notes[0].start, n.end-n.start, n.pitch.number()-notes[0].pitch.number()) for n in notes)
def evaluate(notes, start, end, harmony, *, incoming=(), home=(), purpose='develop', contracts=(), center=60):
def theme_distance(home, sample, purpose):
if not home or purpose not in ('return','develop','answer','extend'):
return fractions.Fraction()
# Returns preserve the whole introduced signature; development preserves
# its selected opening intervals, allowing the rhythm and conclusion to change.
count = len(home) if purpose=='return' else min(3,len(home))
if count<3 or len(sample)<count:
return fractions.Fraction(2)
a,b = signature(home[:count]),signature(sample[:count])
intervals = [(x[i][2]-x[i-1][2]) for x in (a,b) for i in range(1,count)]
left,right = intervals[:count-1],intervals[count-1:]
pitch = sum(min(fractions.Fraction(1),fractions.Fraction(abs(x-y),4)) for x,y in zip(left,right))/(count-1)
rhythm = sum((x[0],x[1])!=(y[0],y[1]) for x,y in zip(a,b))/fractions.Fraction(count) if purpose=='return' else 0
return pitch+rhythm
def figures(notes):
return tuple(signature(notes[i:i+4]) for i in range(max(0,len(notes)-3)))
def evaluate(notes, start, end, harmony, *, incoming=(), home=(), purpose='develop', contracts=(), center=60,
destinations=(), history=(), identity=()):
notes = generator.solo.types.sounding_notes(notes)
duration = end-start
if duration <= 0:
raise ValueError('Evaluation requires a positive span')
checked = harmony.evaluate(notes, expressions=contracts, until=end, complete=True)
anchors_valid,_ = generator.solo.harmonic.anchors_valid(destinations,notes,end,complete=True,closure='closed')
sounding = sum((n.end-n.start for n in notes), fractions.Fraction())
pitches = [n.pitch.number() for n in notes]
joined = [n.pitch.number() for n in incoming[-1:]]+pitches
intervals = [b-a for a,b in zip(joined, joined[1:])]
continuity = sum(max(0, abs(d)-7) for d in intervals)/max(1,len(intervals))
continuity = fractions.Fraction(sum(max(0, abs(d)-7) for d in intervals),max(1,len(intervals)))
oscillation = sum(a == -b == c and a != 0 for a,b,c in zip(intervals,intervals[1:],intervals[2:]))
figures = collections.Counter(tuple(pitches[i:i+4]) for i in range(max(0,len(pitches)-3)))
repetition = (oscillation+sum(a==b for a,b in zip(pitches,pitches[1:]))+sum(max(0,n-2) for n in figures.values()))/max(1,len(notes))
interruption = sum(a.end<b.start and a.end-a.start<1 and b.end-b.start<1 for a,b in zip(notes,notes[1:]))/max(1,len(notes))
patterns = collections.Counter(figures(notes))
recent = collections.Counter(f for p in history[-3:] for f in figures(generator.solo.types.sounding_notes(p.notes)))
# A single clear return is allowed. Repeating the same figure several times
# still costs something; a "return" label is not unlimited immunity.
overuse = sum(max(0,n+recent[f]-2) for f,n in patterns.items())
repetition = fractions.Fraction(oscillation+sum(a==b for a,b in zip(pitches,pitches[1:]))+overuse,max(1,len(notes)))
arrival_ids = {n.event_id for a in destinations if (n:=generator.solo.harmonic.anchor_note(a,notes))}
interruption = fractions.Fraction(sum(a.end<b.start and a.end-a.start<1 and b.end-b.start<1
and a.event_id not in arrival_ids for a,b in zip(notes,notes[1:])),max(1,len(notes)))
density = fractions.Fraction(len(notes)*4,1)/duration
active = any(s.scale and s.start<end and s.end>start for s in harmony.spans)
activity = max(0,density-7)+(2 if active and not notes else 0)
if notes and sounding<duration/3:
activity += (duration/3-sounding)/duration
identity = 0
if home and purpose == 'return':
sample = notes[:len(home)]
identity = 2 if len(sample)!=len(home) else sum((a[0],a[1])!=(b[0],b[1]) or (a[2]>0)!=(b[2]>0) for a,b in zip(signature(home),signature(sample)))/len(home)
register = sum(float(n.end-n.start)*max(0,abs(n.pitch.number()-center)-10) for n in notes)/max(1,float(sounding))
# Unplanned broad notes in the moving portion have a different function from
# a fulfilled arrival. This does not penalize the required held destination.
motion = [n for n in notes if n.event_id not in arrival_ids and n.role!='quote']
activity += sum((max(0,n.end-n.start-2) for n in motion),fractions.Fraction())/duration
identity_cost = theme_distance(home,identity or notes,purpose)
register = sum(( (n.end-n.start)*max(0,abs(n.pitch.number()-center)-10) for n in notes),fractions.Fraction())/max(1,sounding)
arrival_cost = fractions.Fraction()
for anchor in destinations:
target = generator.solo.harmonic.anchor_note(anchor,notes)
if target is None:
arrival_cost += 2
continue
before = [n for n in notes if n.end<=target.start][-3:]
if before:
distances = [abs(n.pitch.number()-target.pitch.number()) for n in before]+[0]
# A contour may turn. What matters here is whether the final approach
# prepares its already selected destination, rather than jumping to it.
arrival_cost += fractions.Fraction(max(0,distances[-2]-2),6)
arrival_cost += fractions.Fraction(sum(max(0,b-a) for a,b in zip(distances,distances[1:])),12)
if before[-1].end<target.start:
supported_gap = any(c.target.event_id==target.event_id and c.events[-1].end==before[-1].end for c in contracts)
if not supported_gap:
arrival_cost += min(1,target.start-before[-1].end)
arrival_cost /= max(1,len(destinations))
values = dict(continuity=continuity,repetition=repetition,interruption=interruption,
activity=activity,harmony=checked.exposure,register=register,identity=identity)
activity=activity,harmony=checked.exposure,register=register,identity=identity_cost,arrival=arrival_cost)
raw = {k:fractions.Fraction(str(v)).limit_denominator(1000000) for k,v in values.items()}
contributions = {k:v*WEIGHTS[k] for k,v in raw.items()}
return generator.solo.scoring.Evaluation(VERSION,checked.valid,raw,dict(WEIGHTS),contributions,
sum(contributions.values(),fractions.Fraction()),{'span':str(duration),'attacks':len(notes),'sounding':str(sounding)})
return generator.solo.scoring.Evaluation(VERSION,checked.valid and anchors_valid,raw,dict(WEIGHTS),contributions,
sum(contributions.values(),fractions.Fraction()),{'span':str(duration),'attacks':len(notes),'sounding':str(sounding),
'destinations':len(destinations),'identity_events':len(identity or notes[:len(home)]),'history_phrases':min(3,len(history))})
+2
View File
@@ -162,6 +162,8 @@ class Phrase:
score: float = 0.
evaluation: object = None
sources: tuple[str, ...] = ()
arrivals: tuple[Anchor, ...] = ()
identity: tuple[Note, ...] = ()
@dataclasses.dataclass(frozen=True)
class Solo: