This commit is contained in:
khodowany
2026-10-04 22:29:06 -05:00
parent 0e68f5364b
commit ef62af892b
40 changed files with 1754 additions and 789 deletions
+8 -7
View File
@@ -6,15 +6,16 @@ from generator import harmony as chords, vocabulary
from generator.scales import timeline, selection
from generator.solo import types, profiles, harmonic, construction, placement, policy
def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,expressions_enabled=True):
def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,expressions_enabled=True,start=F()):
began=time.monotonic()
choices=selection.select(timeline.read({'measures':measures},initial=initial),timeline.Solo(F(),end,part.capitalize()+' Solo'),scales)
choices=selection.select(timeline.read({'measures':measures},initial=initial),timeline.Solo(F(),end-start,part.capitalize()+' Solo'),scales)
choices=tuple(dataclasses.replace(c,span=dataclasses.replace(c.span,start=c.span.start+start,end=c.span.end+start)) for c in choices)
for c in choices:
if c.span.chord and c.scale is None:raise ValueError(f'{part} solo, source measure {c.span.source_measure}, beat {c.span.beat}, {chords.symbol(c.span.chord)}: No exact match within the permitted catalog')
profile=profiles.for_part(part,analysis);harmony=harmonic.Harmony(choices,profile)
source=tuple((n.start,n.end,n.pitch) for n in analysis.melody)
seed=vocabulary.role().get('choice_seed') or policy.stable_rank(part,(source,tuple((c.span.start,c.span.end,c.span.chord) for c in choices),vocabulary.role(part+'_solo'),policy.DEFAULT.composition_version))
builder=construction.Builder(analysis,measures,harmony,sequences,rhythms,end,seed,expressions_enabled)
builder=construction.Builder(analysis,measures,harmony,sequences,rhythms,end,seed,expressions_enabled,start=start)
phrases=builder.compose()
# Resolve hand-position transitions over the selected whole melody. This
# cannot reselect pitches, rhythm or another independently generated part.
@@ -23,22 +24,22 @@ def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,e
if placed is None:raise construction.SearchExhausted('selected melody has no complete legal guitar path')
by_id={n.event_id:n for n in placed[1]}
phrases=tuple(dataclasses.replace(p,notes=tuple(by_id[n.event_id] for n in p.notes)) for p in phrases)
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'complete_phrases','version':policy.DEFAULT.composition_version,
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'joint_actions','version':policy.DEFAULT.composition_version,
'construction_seconds':time.monotonic()-began,**builder.timings,'operation_coverage':{k:dict(v) for k,v in builder.coverage.items()},
'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile)
'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile,start)
validate(result)
return result
def validate(solo):
previous=None
for note in solo.notes():
if note.start<0 or note.end>solo.end or note.end<=note.start or previous and previous.end>note.start:raise ValueError('overlapping/out-of-bounds sounding event')
if note.start<solo.start or note.end>solo.end or note.end<=note.start or previous and previous.end>note.start:raise ValueError('overlapping/out-of-bounds sounding event')
if note.position not in placement.positions(note.pitch.number(),solo.profile):raise ValueError('invalid guitar placement')
previous=note
expressions=tuple(c for p in solo.phrases for c in p.expressions)
checked=harmonic.Harmony(solo.choices).evaluate(solo.notes(),until=solo.end,complete=True,expressions=expressions)
if not checked.valid:raise ValueError(checked.error)
cursor=F()
cursor=solo.start
for p in solo.phrases:
if p.start!=cursor or p.end<=p.start:raise ValueError('phrase coverage gap or overlap')
cursor=p.end
+130 -217
View File
@@ -1,230 +1,143 @@
"""Construct complete phrases directly from harmonic/rhythmic purposes.
"""Bounded search of joint musical actions; one normal production path.
The head is optional material, never a compulsory prefix or rhythmic template.
No reference score, completed part, or previous published output enters here.
Selection compares complete actions by cost per quarter. Actions are indivisible;
cross-bar paths and resolution contracts never restart at search boundaries.
"""
import dataclasses
import time
from __future__ import annotations
from collections import Counter
import dataclasses
from fractions import Fraction as F
from generator import vocabulary, harmony as chords, model
from generator.solo import types, expressions, groups, routes, rhythm, scoring, placement, policy
import time
from generator import vocabulary
from generator.solo import types, groups, operators, selection, sequences, scoring
class SearchExhausted(ValueError):pass
def choose(values,seed,domain):
return min(values,key=lambda v:policy.stable_rank(seed,(domain,v)))
@dataclasses.dataclass(frozen=True)
class State:
total: F = F()
recent: tuple = ()
home: tuple = ()
last_kind: str = ''
parent: object = None
action: object = None
evaluation: object = None
tie: tuple = ()
pending: tuple = ()
alternatives: tuple = ()
class Builder:
def __init__(self,analysis,measures,harmony,sequences,rhythms,end,seed,enabled=True):
self.analysis=analysis;self.harmony=harmony;self.end=end;self.seed=seed;self.enabled=enabled
self.rules=vocabulary.data('solo');self.budgets=self.rules['search'];self.routes=routes.Routes(harmony,sequences)
self.rhythms=rhythm.Rhythms(rhythms,analysis.tempo);self.windows=[];cursor=F()
for m in measures:
length=model.fraction(m['duration']);self.windows.append((cursor,cursor+length,m['time_signature']));cursor+=length
self.coverage={a:Counter() for a in self.rules['actions']};self.failures=Counter();self.cache={};self.decisions=[];self.timings=Counter()
def __init__(self,analysis,measures,harmony,sequences,rhythms,end,seed,enabled=True,*,start=F()):
self.analysis=analysis;self.harmony=harmony;self.start=start;self.end=end
self.operators=operators.Operators(harmony,analysis,end,start,enabled,sequences,rhythms)
self.selector=selection.Selector(harmony,analysis,harmony.profile.reference or 60)
self.rules=vocabulary.data('solo')['operators'];self.decisions=[];self.failures=Counter();self.coverage={};self.timings=Counter()
def at(self,time):return next((m for m in self.windows if m[0]<=time<m[1]),self.windows[-1])
def candidates(self,at,state):
candidates=self.operators.at(at)
if state.home and state.last_kind!='return':
candidates+=self.operators.related(at,state.home,'return')+self.operators.related(at,state.home,'answer')
evaluated=[]
for a in candidates:
if state.pending and self.advance(state,None,a) is None:continue
cost=self.selector.cost(a,state.recent,state.home,state.last_kind)
evaluated.append((cost,a))
# Rank complete actions by cost per quarter before bounded repair.
# Accumulated prefix totals are diagnostic, not a global search objective.
retained=sequences.diverse(evaluated,
lambda v:(v[0],v[1].key),
lambda v:(v[1].kind,v[1].end-v[1].start),self.rules['per_state'])
for _,a in retained:self.operators.coverage['retained:'+a.kind]+=1
return tuple((self.selector.evaluate(a,state.recent,state.home,state.last_kind),a) for _,a in retained)
def actions(self,history):
result=['arpeggio','scalar','neighbor']
if history:
result+=['develop','answer']
if len(history)>1 and history[-1].purpose!='return':result.append('return')
if self.analysis.melody and len(history)>2:result.append('head')
preferred=vocabulary.role().get('preferred_actions',[])
return sorted(result,key=lambda a:(a not in preferred,preferred.index(a) if a in preferred else 0,a))
def propose(self,start,history,variant,batch=0):
incoming=next((p.notes[-1] for p in reversed(history) if p.notes),None)
home=next((p for p in history if len(p.notes)>=4),None)
seed=policy.stable_rank(self.seed,('phrase',start,tuple(p.id for p in history[-2:]),variant,batch))
purpose=self.actions(history)[variant%len(self.actions(history))]
self.coverage[purpose]['proposed']+=1
_,_,meter=self.at(start);bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
span=self.harmony.at(start)
if not span or not span.scale:return None
limit=min(self.end,start+self.rules['max_phrase_bars']*bar)
# Source harmony boundaries and available time supply opportunities, not mandatory closes.
opportunities=sorted({min(self.end,start+2*bar),*(s.start for s in self.harmony.spans if start+2<=s.start<limit),*(s.end for s in self.harmony.spans if start+2<=s.end<limit),self.end if self.end<=limit else limit})
boundary=choose(opportunities,seed,'destination time')
ending=choose(('open','settle','complete'),seed,'ending')
if boundary==self.end:ending='handoff'
elif history and history[-1].ending=='open':ending=choose(('settle','complete'),seed,'complete continuation')
offset=choose(tuple(map(F,self.rules['arrival_offsets'])) if self.enabled else (F(),F(1,2)),seed,'arrival')
arrival=boundary+offset
if ending=='open':arrival=min(boundary,self.end)-F(1,2)
if ending=='handoff':arrival=self.end-choose((F(2),F(5,2),F(3)),seed,'handoff hold')
arrival=max(start+(F(1) if ending=='handoff' else F(3,2)),min(arrival,self.end-F(1,2)))
if arrival<=start:return None
anticipated=arrival<boundary and ending!='open' and any(s.start==boundary for s in self.harmony.spans) and boundary-arrival<=F(vocabulary.data('harmony')['anticipation_max'])
harmonic_time=boundary if anticipated else arrival
target_span=self.harmony.at(harmonic_time)
if not target_span or not target_span.scale:return None
hold=F(1,2) if ending=='open' else choose(tuple(map(F,self.rules['holds'])),seed,'hold')
if ending in ('complete','handoff'):hold=max(F(2),hold)
release=min(self.end,arrival+hold)
if anticipated and release<=boundary:return None
if ending=='handoff':release=self.end
breath=F() if ending=='open' else choose(tuple(map(F,self.rules['breaths'])),seed,'breath')
finish=min(self.end,release+breath)
if 0<self.end-finish<3:return None
if finish==self.end and ending!='open':
if release-arrival<min(F(2),self.end-start-1):return None
ending='handoff'
center=self.harmony.profile.reference or 60
# A coherent excursion and return; no per-note octave folding or string quota.
shift=choose(tuple(vocabulary.role().get('register_alternatives',(-7,0,7))),seed,'register')
if incoming and incoming.pitch.number()>center+7:shift=min(0,shift)
if incoming and incoming.pitch.number()<center-7:shift=max(0,shift)
goal=center+shift
permitted=types.permitted(self.harmony.choices,harmonic_time,release)
members=self.harmony.members(harmonic_time,release)
targets=[types.scale_pitch(target_span.scale,i) for i in range(-21,22)]
targets=[p for p in targets if p.number()%12 in permitted and (ending=='open' or p.number()%12 in members) and self.harmony.profile.permits(p.number()) and abs(p.number()-goal)<=7]
if not targets:return None
# Choose among actual chord degrees and registers, without always picking the nearest fifth/root.
target_pitch=choose(targets,seed,'destination pitch')
target=types.Note(arrival,release,target_pitch,role='arrival' if ending!='open' else 'motion',event_id=seed[:16]+':target')
contracts=[]
if anticipated:contracts.append(types.ExpressionContract(seed[:16]+':anticipation','anticipation','early_chord_tone',(target,),target,target_span.chord,boundary=boundary))
lead=();movement_end=arrival
# Shape-changing expression is planned before either rhythm or route is realized.
treatment=choose(('direct','approach','color','blues','suspension'),seed,'expression') if self.enabled and ending!='open' else 'direct'
if treatment=='approach':
rule=choose(vocabulary.data('lead_ins')['patterns'],seed,'approach rule')
movement_end=arrival-F(rule['gap'])-sum(map(F,rule['durations']),F());cursor=movement_end;events=[]
for i,(step,duration) in enumerate(zip(rule['semitones'],rule['durations'])):
events.append(types.Note(cursor,cursor+F(duration),expressions.chromatic(target_pitch,step),role=rule['kind'],event_id=seed[:16]+':approach:'+str(i)));cursor+=F(duration)
lead=tuple(events)
contracts.append(types.ExpressionContract(seed[:16]+':approach',rule['kind'],rule['id'],lead,target,target_span.chord,boundary=boundary if anticipated else None))
elif treatment=='color' and not anticipated:
region=self.harmony.at(arrival-1)
eligible=[r for r in vocabulary.data('harmony')['colors'] if region and expressions.eligible_color(r,region,self.harmony)]
if eligible:
rule=choose(eligible,seed,'color rule');p=expressions.degree_pitch(chords.parse_symbol(region.chord),rule['degree'],rule['alter'],target_pitch.number())
if not rule.get('resolve') and choose((True,False),seed,'sustained color'):
target=dataclasses.replace(target,pitch=p,role='color');target_pitch=p
contracts.append(types.ExpressionContract(seed[:16]+':color','color',rule['id'],(target,),target,target_span.chord))
else:
color=types.Note(arrival-1,arrival,p,role='color',event_id=seed[:16]+':color')
if rule.get('resolve') and not 0<abs(target_pitch.number()-p.number())<=2:return None
lead=(color,);movement_end=color.start
contracts.append(types.ExpressionContract(seed[:16]+':color','color',rule['id'],lead,target if rule.get('resolve') else color,target_span.chord if rule.get('resolve') else region.chord))
elif treatment=='blues' and not anticipated:
eligible=[r for r in vocabulary.data('lead_ins')['blues'] if expressions.degree_pitch(chords.parse_symbol(target_span.chord),r['target_degree'],0,target_pitch.number())==target_pitch]
if eligible:
rule=choose(eligible,seed,'blues rule');p=expressions.degree_pitch(chords.parse_symbol(target_span.chord),rule['degree'],rule['alter'],target_pitch.number())
lead=(types.Note(arrival-F(1,2),arrival,p,role='blues',event_id=seed[:16]+':blues'),);movement_end=lead[0].start
contracts.append(types.ExpressionContract(seed[:16]+':blues','blues',rule['id'],lead,target,target_span.chord))
elif treatment=='suspension' and offset>0 and arrival==boundary+offset:
previous=self.harmony.at(boundary-F(1,2));rule=vocabulary.data('harmony')['suspensions'][0]
if previous and previous.scale:
candidates=[p for p in self.routes.pitches(boundary-F(1,2),arrival) if p.number()%12 in previous.roles.basic and target_pitch.number()-p.number() in rule['steps']]
if candidates:
p=choose(candidates,seed,'suspension pitch');lead=(types.Note(boundary-F(1,2),arrival,p,role='suspension',event_id=seed[:16]+':suspension'),);movement_end=lead[0].start
contracts.append(types.ExpressionContract(seed[:16]+':suspension','suspension',rule['id'],lead,target,target_span.chord,boundary=boundary))
entrance=F() if history and history[-1].ending=='open' else choose(tuple(map(F,self.rules['entrances'])),seed,'entrance')
entry=start+entrance
if movement_end-entry<F(1):return None
hint=home.notes[:4] if home else ()
if purpose=='head':
candidates=[self.analysis.melody[i:i+4] for i in range(max(0,len(self.analysis.melody)-3))]
candidates=[ns for ns in candidates if ns[-1].end-ns[0].start<=movement_end-entry]
if not candidates:return None
hint=choose(candidates,seed,'head reference')
rhythm_purpose=purpose if purpose in ('arpeggio','scalar','neighbor','develop','answer') else 'develop'
rhythm_began=time.monotonic()
timing,rhythm_groups,sources=self.rhythms.realize(entry,movement_end,rhythm_purpose,seed,meter)
self.timings['rhythm_seconds']+=time.monotonic()-rhythm_began
if not timing:return None
if purpose in ('return','head'):
if len(hint)<3:return None
duration=hint[-1].end-hint[0].start
if entry+duration>movement_end:return None
pattern=tuple((entry+n.start-hint[0].start,entry+n.end-hint[0].start) for n in hint)
tail,tail_groups,tail_sources=self.rhythms.realize(entry+duration,movement_end,'develop',seed,meter)
timing=(*pattern,*tail)
if not groups.valid(tuple((a-entry,b-entry) for a,b in timing)):return None
rhythm_groups=(*groups.describe(tuple((a-entry,b-entry) for a,b in pattern),origin=entry,source='heard_theme' if purpose=='return' else 'head'),*tail_groups)
sources=(purpose,*tail_sources)
self.coverage[purpose]['rhythm_valid']+=1
first_span=self.harmony.at(entry)
starting=[p for p in self.routes.pitches(*timing[0]) if p.number()%12 in first_span.roles.basic and abs(p.number()-(incoming.pitch.number() if incoming else goal))<=7]
if not starting:return None
first=choose(starting,seed,'entry pitch')
notes=tuple(types.Note(a,b,first,role=purpose,event_id=seed[:16]+':'+str(i)) for i,(a,b) in enumerate(timing))
all_notes=(*notes,*lead,target)
fixed={0,*range(len(notes),len(all_notes))}
direction=choose(self.rules['directions'],seed,'contour')
route_began=time.monotonic()
paths=self.routes.realize(all_notes,fixed,purpose,direction,seed,goal,incoming,hint)
self.timings['route_seconds']+=time.monotonic()-route_began
self.coverage[purpose]['complete_routes']+=len(paths)
for actual in paths:
bound=[]
for c in contracts:
prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None)
bound.append(dataclasses.replace(c,preparation=prior if c.kind in ('connection','blues') else c.preparation))
validation_began=time.monotonic()
checked=self.harmony.evaluate(actual,until=finish,complete=True,expressions=tuple(bound))
self.timings['harmonic_seconds']+=time.monotonic()-validation_began
if not checked.valid:self.failures[checked.error.split(':')[-1]]+=1;continue
anchor=None if ending=='open' else types.Anchor(seed[:16]+':destination',arrival,arrival,target_pitch,members|frozenset({target_pitch.number()%12}),self.harmony.profile.register,release-arrival,'terminal' if ending in ('complete','handoff') else 'intermediate',target_span.chord,False,release,ending)
phrase=types.Phrase(seed[:16],start,finish,actual,purpose,direction,ending,anchor,tuple(bound),rhythm_groups,home.id if home else seed[:16],sources=sources)
evaluation_began=time.monotonic()
evaluation=scoring.evaluate(phrase,incoming,history,center)
self.timings['evaluation_seconds']+=time.monotonic()-evaluation_began
self.coverage[purpose]['valid']+=1
return dataclasses.replace(phrase,score=evaluation.total,evaluation=evaluation)
return None
def candidates(self,start,history):
key=(start,len(history),next(((p.id,p.notes) for p in history if len(p.notes)>=4),None),tuple((p.id,p.purpose,p.ending,p.notes,p.expressions) for p in history[-4:]))
if key in self.cache:return self.cache[key]
span=self.harmony.at(start)
if not span or not span.scale:
finish=min(self.end,span.end if span else self.end)
result=(types.Phrase('space:'+str(start),start,finish,(),'space','none','open'),)
self.cache[key]=result;return result
for action in self.actions(history):self.coverage[action]['eligible_contexts']+=1
proposals=[]
for batch in range(self.budgets['replans']+1):
for variant in range(self.budgets['proposals']):
p=self.propose(start,history,variant,batch)
if p:proposals.append(p)
if proposals:break
if not proposals:raise SearchExhausted(f'at beat {start}: no complete phrase within deterministic budget; rejections {self.failures.most_common(3)}; not proof of harmonic incompatibility')
from generator.solo.sequences import diverse
finals=diverse(proposals,lambda p:(p.score,policy.stable_rank(self.seed,p.id)),lambda p:(p.purpose,p.ending,p.direction),self.budgets['finalists'])
placed=[];incoming=next((p.notes[-1] for p in reversed(history) if p.notes),None)
for p in finals:
self.coverage[p.purpose]['finalists']+=1
placement_began=time.monotonic()
options=placement.alternatives(p.notes,self.analysis.tempo,incoming,self.budgets['fingerings'],self.harmony.profile)
self.timings['placement_seconds']+=time.monotonic()-placement_began
if options:
# Physical economy selects a fingering of this same melody, not the tune itself.
placed.append(dataclasses.replace(p,notes=options[0][1]));self.coverage[p.purpose]['placed']+=1
self.cache[key]=tuple(placed)
return self.cache[key]
def advance(self,state,evaluation,action):
for contract in state.pending:
target=contract.target
if action.start<=target.start<action.end:
# Reject a missed committed attack/hold before revalidating all the
# harmonic relationships. Partial target evidence remains possible.
if not any(n.event_id==target.event_id and n.start==target.start
and n.end==min(action.end,target.end) and n.pitch==target.pitch for n in action.notes):return None
obligations=(*state.pending,*action.contracts);pending=()
if not state.pending:
pending=tuple(c for c in action.contracts if c.target.end>action.end)
elif obligations:
heard={n.event_id:n for c in state.pending for n in c.events if n.start<action.start}
heard.update({n.event_id:n for n in action.notes})
check=self.harmony.evaluate(tuple(sorted(heard.values(),key=lambda n:n.start)),
expressions=obligations,until=action.end,complete=action.end==self.end)
if not check.valid:return None
pending=check.pending
recent=(*state.recent,*action.notes)[-8:];home=state.home
if not home and len(recent)>=4:
proposed=recent[:4]
if proposed[-1].end-proposed[0].start<=8:home=proposed
return State(state.total+(evaluation.total if evaluation else 0),recent,home,action.kind,state,action,evaluation,(),pending)
def compose(self):
history=[];start=F()
while start<self.end:
options=self.candidates(start,tuple(history));ranked=[]
for p in options[:self.budgets['frontier']]:
future=()
if p.end<self.end:
try:future=self.candidates(p.end,(*history,p))
except SearchExhausted:continue
if not future:continue
next_score=min((f.score for f in future),default=p.score)
total=(p.score+next_score)/2
ranked.append((total,policy.stable_rank(self.seed,p.id),p))
if p.purpose in self.coverage:self.coverage[p.purpose]['lookahead_valid']+=1
if not ranked:raise SearchExhausted(f'at beat {start}: no legal following phrase within lookahead budget')
_,_,chosen=min(ranked,key=lambda v:v[:2]);history.append(chosen);start=chosen.end
if chosen.purpose in self.coverage:self.coverage[chosen.purpose]['selected']+=1
self.decisions.append({'start':str(chosen.start),'selected':chosen.id,'candidates':[{'id':p.id,'purpose':p.purpose,'end':str(p.end),'evaluation':dataclasses.asdict(p.evaluation) if p.evaluation else {},'lookahead_total':next((v[0] for v in ranked if v[2].id==p.id),None)} for p in options]})
return tuple(history)
self.decisions=[];began=time.monotonic();backtracks=0
# Compare full actions by cost per exact quarter, with a bounded repair
# frontier. This is a local policy, not a claim of globally optimal melody.
stack=[(self.start,State(),None)];rankings={}
while stack:
at,state,iterator=stack[-1]
if at==self.end:final=state;break
if iterator is None:
options=sorted(self.candidates(at,state),key=lambda v:(v[0].total,v[1].key))
rankings[id(state)]=tuple({'kind':a.kind,'start':str(a.start),'end':str(a.end),
'notes':[(str(n.start),str(n.end),n.pitch.number()) for n in a.notes],
'evaluation':dataclasses.asdict(e)} for e,a in options[:4])
iterator=iter(options);stack[-1]=(at,state,iterator)
for evaluation,action in iterator:
next_state=self.advance(state,evaluation,action)
if next_state is not None:
next_state=dataclasses.replace(next_state,alternatives=rankings[id(state)])
stack.append((action.end,next_state,None));break
else:
stack.pop();backtracks+=1
if backtracks>self.rules['backtracks']:raise SearchExhausted(f'at {at}: action repair budget exhausted')
else:raise SearchExhausted('no complete action path')
self.timings['backtracks']=backtracks
self.timings['action_search_seconds']=time.monotonic()-began
path=[];state=final
while state.action:
path.append(state);state=state.parent
path.reverse();phrases=[];pending=[]
self.selected_actions=tuple(s.action for s in path)
for state in path:
action=state.action;pending.append(state)
self.operators.coverage['selected:'+action.kind]+=1
self.decisions.append({'start':str(action.start),'end':str(action.end),'selected':action.kind,
'evaluation':dataclasses.asdict(state.evaluation),'prefix_total':state.total,'alternatives':state.alternatives})
# Reporting groups the selected thought; it does not insert events.
held=bool(action.notes and action.notes[-1].end-action.notes[-1].start>=2)
breath=not action.notes and action.end-action.start>=1
if held or breath or action.end==self.end:
phrases.append(self.phrase(pending,len(phrases)));pending=[]
for name,count in self.operators.coverage.items():
stage,kind=name.split(':',1);self.coverage.setdefault(kind,{})[stage]=count
self.timings['selected_cost']=final.total
return tuple(phrases)
def phrase(self,states,index):
actions=tuple(s.action for s in states);ns=tuple(n for a in actions for n in a.notes)
start,end=actions[0].start,actions[-1].end
last=ns[-1] if ns else None
held=last and last.end-last.start>=2
ending='handoff' if end==self.end else 'settle' if held else 'open'
anchor=None
if held:
region=self.harmony.at(last.start)
anchor=types.Anchor(f'gesture_{index}:arrival',last.start,last.start,last.pitch,
frozenset({last.pitch.number()%12}),self.harmony.profile.register,last.end-last.start,
'terminal' if ending=='handoff' else 'intermediate',region.chord,False,last.end,ending)
raw=Counter();contributions=Counter();weights={}
for s in states:
raw.update(s.evaluation.raw);contributions.update(s.evaluation.contributions);weights.update(s.evaluation.weights)
evaluation=scoring.Evaluation(selection.VERSION,True,dict(raw),weights,dict(contributions),sum(s.evaluation.total for s in states),
{'span':str(end-start),'attacks':len(ns),'ticks_per_quarter':12})
direction='up' if len(ns)>1 and ns[-1].pitch.number()>ns[0].pitch.number() else 'down'
rhythm=tuple(g for a in actions for g in groups.describe(tuple((n.start-a.start,n.end-a.start) for n in a.notes),origin=a.start,source=a.kind))
return types.Phrase(f'gesture_{index}',start,end,ns,actions[0].kind,direction,ending,anchor,
tuple(c for a in actions for c in a.contracts),rhythm,'home',evaluation.total,evaluation,tuple(a.source or a.kind for a in actions))
+3 -1
View File
@@ -53,6 +53,8 @@ def write_role(result, path):
part = role.split('_')[0]
document = result['document']
first, count = region(document, part)
leadin=int(result.get('details',{}).get('allocation',{}).get('lead_in_measures',0))
first-=leadin;count+=leadin
selected = excerpt(document, first, count)
numbers = [m['number'] for m in selected['staves'][0]['measures']]
for i, measure in enumerate(selected['staves'][0]['measures'], 1):
@@ -60,7 +62,7 @@ def write_role(result, path):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
musicxml_writer.write_musicxml(path, selected, tablature=True)
return {'role': role, 'file': path.name, 'form_start_measure': 1,
return {'role': role, 'file': path.name, 'form_start_measure': 1+leadin,
'measure_count': count, 'arrangement_measures': numbers,
'arrangement_start_beat': str(sum((model.fraction(m['duration']) for m in document['staves'][0]['measures'][:first]), F())),
'pitch_convention': 'guitar-written; explicit octave transposition -1',
+65 -17
View File
@@ -1,10 +1,23 @@
"""Typed, target-driven harmonic expression over a strictly mapped theme."""
from __future__ import annotations
import dataclasses
import functools
from fractions import Fraction as F
from generator import harmony as chord_model, model, pitch as spelling, vocabulary
from generator.solo import types
@functools.lru_cache(maxsize=512)
def parsed_chord(symbol):
"""Private read-only chord analysis shared by pure expression operations."""
return chord_model.parse_symbol(symbol)
@functools.lru_cache(maxsize=512)
def degree_spelling(letter,accidental,degree,alter):
root={'letter':letter,'alter':model.rational(accidental),'octave':4}
p=spelling.shift_pitch(root,degree-1,chord_model.degree_semitones(degree,alter))
return types.Pitch(p['letter'],model.alteration(p['alter']),p['octave'])
def quality(chord):
degrees=chord_model.degrees(chord)
@@ -15,16 +28,15 @@ def quality(chord):
def degree_pitch(chord,degree,alter,near):
root={**chord['root'],'octave':4}
p=spelling.shift_pitch(root,degree-1,chord_model.degree_semitones(degree,alter))
value=types.Pitch(p['letter'],model.alteration(p['alter']),p['octave'])
value=degree_spelling(chord['root']['letter'],model.alteration(chord['root']['alter']),degree,alter)
return dataclasses.replace(value,octave=value.octave+round((near-value.number())/12))
@functools.lru_cache(maxsize=1024)
def chromatic(target,delta):
# Chromatic neighbors are spelled relative to their exact destination.
if delta in (-2,-1,1,2):
step=1 if delta>0 else -1
if delta in (-3,-2,-1,1,2,3):
step=(1 if delta>0 else -1)*(2 if abs(delta)==3 else 1)
root={'letter':target.letter,'alter':model.rational(target.alter),'octave':target.octave}
p=spelling.shift_pitch(root,step,delta)
return types.Pitch(p['letter'],model.alteration(p['alter']),p['octave'])
@@ -32,7 +44,14 @@ def chromatic(target,delta):
def eligible_color(rule,span,harmony):
chord=chord_model.parse_symbol(span.chord)
key=(tuple(rule['qualities']),rule['degree'],rule['alter'],rule.get('function'),span)
cache=harmony.color_eligibility
if key not in cache:cache[key]=_eligible_color(rule,span,harmony)
return cache[key]
def _eligible_color(rule,span,harmony):
chord=parsed_chord(span.chord)
degrees=chord_model.degrees(chord)
if quality(chord) not in rule['qualities']:return False
degree=rule['degree']; simple=(degree-1)%7+1
@@ -41,9 +60,10 @@ def eligible_color(rule,span,harmony):
if degree in degrees and degrees[degree]!=rule['alter']:return False
following=next((s for s in harmony.spans if s.start>=span.end and s.chord!=span.chord and s.scale),None)
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))%12
next_chord=chord_model.parse_symbol(following.chord) if following else None
next_chord=parsed_chord(following.chord) if following else None
next_root=int(model.NATURAL[next_chord['root']['letter']]+model.alteration(next_chord['root']['alter']))%12 if next_chord else None
function=rule.get('function')
if function=='predominant_to_dominant' and not (next_chord and quality(next_chord)=='dominant' and (next_root-root)%12==5):return False
if function=='resolving_dominant' and (next_root is None or (root-next_root)%12!=7):return False
if function=='tonic_or_predominant':
tonic=model.parse_pitch_name(span.scale.root)
@@ -70,27 +90,43 @@ def validate(notes,contracts,harmony):
if not rule or len(c.events)!=len(rule['semitones']):return {},c.id+': unknown approach formula'
arrival=c.boundary if c.boundary is not None else target.start
if c.boundary is not None and not any(a.kind=='anticipation' and a.target==target and a.boundary==c.boundary for a in contracts):return {},c.id+': future target lacks anticipation contract'
if target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,arrival,target.end):return {},c.id+': invalid destination hold'
gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F())
planned_color=any(a.kind=='color' and a.target==target and a.events==(target,) for a in contracts)
if not planned_color and (target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,arrival,target.end)):return {},c.id+': invalid destination hold'
gaps=tuple(map(F,rule.get('internal_gaps',['0']*(len(c.events)-1))))
if len(gaps)!=len(c.events)-1 or any(g<0 for g in gaps):return {},c.id+': invalid internal approach gaps'
gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F())-sum(gaps,F())
if target.start-cursor>F(vocabulary.data('lead_ins')['max_approach']):return {},c.id+': approach too long'
for n,step,duration in zip(c.events,rule['semitones'],rule['durations']):
for i,(n,step,duration) in enumerate(zip(c.events,rule['semitones'],rule['durations'])):
if n.start!=cursor or n.end!=cursor+F(duration) or n.pitch.number()!=target.pitch.number()+step:return {},c.id+': wrong register, interval, or timing'
cursor=n.end
cursor=n.end+(gaps[i] if i<len(gaps) else 0)
for index in rule.get('mapped_indices',[]):
n=c.events[index]
if n.pitch.number()%12 not in types.permitted(harmony.choices,n.start,n.end):return {},c.id+': declared diatonic neighbor is outside the mapped collection'
if any(n.start<c.target.start and n.end>c.events[0].start and n.event_id not in {e.event_id for e in c.events} for n in notes):return {},c.id+': interrupted approach'
if c.kind=='connection' and (not c.preparation or c.preparation.end!=c.events[0].start or abs(c.preparation.pitch.number()-c.events[0].pitch.number())>2):return {},c.id+': chromatic connection lacks its incoming motion'
elif c.kind=='delayed_approach':
rule=vocabulary.data('lead_ins').get('delayed_approach',{})
if c.rule!=rule.get('id') or len(c.events)!=1:return {},c.id+': unknown delayed approach'
n,=c.events
if n.end-n.start>F(rule['max_note']) or not n.end<=target.start<=n.start+F(rule['deadline']):return {},c.id+': expired delayed approach'
if target.pitch.number()-n.pitch.number() not in rule['steps']:return {},c.id+': wrong delayed target register'
if target.pitch.number()%12 not in harmony.members(target.start,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,target.start,target.end):return {},c.id+': invalid delayed destination'
elif c.kind=='color':
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
if not rule:return {},c.id+': unknown color policy'
colored=False
for n in c.events:
if n.end-n.start>F(rule['max_beats']):return {},c.id+': excessive color duration'
for region,a,b in harmony.segments(n):
if n.pitch.number()%12 in region.roles.basic:continue
if not region.scale or not eligible_color(rule,region,harmony):return {},c.id+': incompatible color harmony/function'
p=degree_pitch(chord_model.parse_symbol(region.chord),rule['degree'],rule['alter'],n.pitch.number())
if p!=n.pitch:return {},c.id+': color degree/spelling mismatch'
p=degree_pitch(parsed_chord(region.chord),rule['degree'],rule['alter'],n.pitch.number())
# The rule retains the functional degree/spelling. Encoded
# enharmonic notation must not change sounding harmonic validity.
if p.number()!=n.pitch.number():return {},c.id+': color degree/register mismatch'
colored=True
if not rule['outside'] and n.pitch.number()%12 not in region.scale.pitches:return {},c.id+': color requires mapped collection'
if not colored:return {},c.id+': no interval expresses the declared color'
last=c.events[-1]
if rule.get('resolve'):
if target.event_id==last.event_id or not 0<=target.start-last.end<=F(vocabulary.data('harmony')['resolution_gap']) or not 0<abs(target.pitch.number()-last.pitch.number())<=2 or target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': unresolved color'
@@ -105,17 +141,29 @@ def validate(notes,contracts,harmony):
return {},c.id+': missed suspension resolution'
if target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': invalid suspension target'
if held.pitch.number()%12 not in types.permitted(harmony.choices,held.start,held.end):return {},c.id+': suspension outside mapped collections'
elif c.kind=='anticipated_color':
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
if not rule or len(c.events)!=1 or c.boundary!=span.start:return {},c.id+': unsupported anticipated color'
n,=c.events
if not n.start<c.boundary<n.end or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipated color interval'
arrived=dataclasses.replace(n,start=c.boundary)
target=arrived if c.target==n else c.target
inner=types.ExpressionContract(c.id+':arrived','color',rule['id'],(arrived,),target,span.chord)
_,error=validate((arrived,*((target,) if target!=arrived else ())), (inner,),harmony)
if error:return {},error
elif c.kind=='anticipation':
if c.rule!='early_chord_tone' or len(c.events)!=1 or c.target!=c.events[0] or span.start!=c.boundary:
if c.rule not in ('early_chord_tone','early_reattack') or len(c.events)!=1 or span.start!=c.boundary:
return {},c.id+': unknown anticipation or non-boundary target'
n,=c.events
if c.boundary is None or not n.start<c.boundary<n.end or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipation interval'
if n.pitch.number()%12 not in harmony.members(c.boundary,n.end) or n.pitch.number()%12 not in types.permitted(harmony.choices,c.boundary,n.end):return {},c.id+': invalid future destination'
if c.boundary is None or not n.start<c.boundary or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipation interval'
if c.rule=='early_chord_tone' and (n!=target or not c.boundary<n.end):return {},c.id+': invalid held anticipation'
if c.rule=='early_reattack' and (n.end!=c.boundary or target.start!=c.boundary or target.event_id==n.event_id or target.pitch.number()!=n.pitch.number()):return {},c.id+': invalid anticipated reattack'
if target.pitch.number()%12 not in harmony.members(c.boundary,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,c.boundary,target.end):return {},c.id+': invalid future destination'
elif c.kind=='blues':
if len(c.events)!=1:return {},c.id+': blues treatment requires one inflected event'
rule=next((r for r in vocabulary.data('lead_ins')['blues'] if r['id']==c.rule),None)
n,=c.events
chord=chord_model.parse_symbol(span.chord)
chord=parsed_chord(span.chord)
if not rule or n.end-n.start>F(1,2) or n.end!=target.start or target.pitch.number()%12 not in span.roles.basic:return {},c.id+': unresolved blues inflection'
expected=degree_pitch(chord,rule['degree'],rule['alter'],n.pitch.number())
resolved=degree_pitch(chord,rule['target_degree'],0,target.pitch.number())
+1
View File
@@ -58,6 +58,7 @@ class Harmony:
self._segments = {}
self._members = {}
self.evaluations = {}
self.color_eligibility = {}
def at(self, time):
if time not in self._at:
self._at[time] = next((s for s in self.spans if s.start <= time < s.end), None)
+296
View File
@@ -0,0 +1,296 @@
"""Shared, enumerable musical actions for composition and derivation checking.
An action owns its complete rhythm and path. A rest is an action; an ordinary
attack is not an implied cadence. Compound actions retain approach targets and
whole tuplet groups. This module has no training/reference-file interface.
"""
from __future__ import annotations
import dataclasses
import functools
from fractions import Fraction as F
from collections import Counter
from generator import harmony as chords, vocabulary
from generator.solo import types, expressions, groups, rhythm
@dataclasses.dataclass(frozen=True)
class Action:
start: F
end: F
notes: tuple
kind: str
contracts: tuple = ()
source: str = ''
@functools.cached_property
def key(self):
return (self.start,self.end,self.kind,tuple((n.start,n.end,n.pitch.number()) for n in self.notes),
tuple((c.rule,str(c.boundary),c.target.start,c.target.end,c.target.pitch.number()) for c in self.contracts))
class Operators:
def __init__(self,harmony,analysis,end,start=F(),enabled=True,sequences=None,rhythms=None):
self.harmony=harmony;self.analysis=analysis;self.start=start;self.end=end;self.enabled=enabled
self.rules=vocabulary.data('solo')['operators'];self.cache={};self.pitch_cache={};self.coverage=Counter()
self.ordinary_cache={}
self.sequences=sequences
self.patterns=[]
if rhythms is not None:
library=rhythm.Rhythms(rhythms,analysis.tempo)
# Purposeful curated groups are peers; exhaustive catalogs contribute
# representatives, not a probability proportional to XML file counts.
candidates=library.groups
for meter,values in library.catalog.items():
if any(b-a==F(meter.split('/')[0])*4/int(meter.split('/')[1]) for a,b in zip(analysis.boundaries,analysis.boundaries[1:])):
candidates+=list(values)
seen=set()
for name,pattern,length,_ in candidates:
if 2<=len(pattern)<=4 and length<=4 and (pattern,length) not in seen:
seen.add((pattern,length));self.patterns.append((name,pattern,length))
def pitches(self,time):
span=self.harmony.at(time)
if not span or not span.scale:return ()
if span.scale not in self.pitch_cache:
self.pitch_cache[span.scale]=tuple(p for i in range(-28,29)
if self.harmony.profile.permits((p:=types.scale_pitch(span.scale,i)).number()))
return self.pitch_cache[span.scale]
def note(self,start,end,pitch,role):
# Identity follows exact musical content, not an output filename or hash.
return types.Note(start,end,pitch,role=role,event_id=f'{start}:{end}:{pitch.number()}')
def degree_pitches(self,chord,rule):
spelling=expressions.degree_pitch(chord,rule['degree'],rule['alter'],60)
# Enumerate octaves, not repeated rounding from C-octave centers. Halfway
# pitches (e.g. F#) otherwise disappear because round uses even ties.
return tuple(p for octave in range(1,6)
if self.harmony.profile.permits((p:=dataclasses.replace(spelling,octave=octave)).number()))
def _accepted(self,action):
self.coverage['proposed:'+action.kind]+=1
if action.end>self.end or action.end<=action.start:
self.coverage['rejected_by_allocation:'+action.kind]+=1;return False
if any(not self.harmony.profile.permits(n.pitch.number()) for n in action.notes):
self.coverage['rejected_by_physical_domain:'+action.kind]+=1;return False
if not groups.valid(tuple((n.start-action.start,n.end-action.start) for n in action.notes)):
self.coverage['rejected_by_rhythm:'+action.kind]+=1;return False
if action.contracts:
valid=self.harmony.evaluate(action.notes,expressions=action.contracts,complete=False,until=action.end).valid
else:
valid=True
for n in action.notes:
key=(n.start,n.end,n.pitch.number())
if key not in self.ordinary_cache:self.ordinary_cache[key]=self.harmony.evaluate((n,),complete=True,until=n.end).valid
if not self.ordinary_cache[key]:valid=False;break
if not valid:
self.coverage['rejected_by_rule:'+action.kind]+=1
return False
self.coverage['valid:'+action.kind]+=1
return True
def _color(self,n,span):
for rule in vocabulary.data('harmony')['colors']:
if rule.get('resolve') or not expressions.eligible_color(rule,span,self.harmony):continue
expected=expressions.degree_pitch(expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],n.pitch.number())
if expected.number()!=n.pitch.number():continue
c=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(n,),n,span.chord)
yield Action(n.start,n.end,(n,),'color',(c,),rule['id'])
def at(self,time,eligible=None,first=None):
"""Unpruned legal actions. Incoming-state ranking is a separate operation."""
if eligible is None and first is None and time in self.cache:return self.cache[time]
span=self.harmony.at(time);values=[]
if not span or not span.scale:
end=min(self.end,span.end if span else self.end)
result=(Action(time,end,(),'space'),)
if eligible is not None:return tuple(a for a in result if eligible(a))
self.cache[time]=result;return result
def add(action):
if eligible is not None and not eligible(action):return
if self._accepted(action):values.append(action)
def entry(a,b,pitch):
return first is None or first==(a,b,pitch.number())
durations=tuple(map(F,self.rules['durations']))
if self.analysis.tempo>vocabulary.data('roles')['difficulty']['sixteenth_max_bpm']:
durations=tuple(d for d in durations if d>=F(1,2))
for duration in durations:
if time+duration>self.end:continue
if (time*(4 if duration==F(1,4) else 2)).denominator==1:
add(Action(time,time+duration,(),'rest'))
for pitch in self.pitches(time):
if not entry(time,time+duration,pitch):continue
n=self.note(time,time+duration,pitch,'motion')
if pitch.number()%12 in types.permitted(self.harmony.choices,time,n.end):
add(Action(time,n.end,(n,),'tone'))
if self.enabled:
delayed=vocabulary.data('lead_ins').get('delayed_approach')
if delayed:
for delay in map(F,delayed['arrivals']):
arrival=time+delay
for hold in map(F,delayed['holds']):
if arrival+hold>self.end:continue
region=self.harmony.at(arrival)
if not region:continue
for p in self.pitches(arrival):
if p.number()%12 not in self.harmony.members(arrival,arrival+hold):continue
target=self.note(arrival,arrival+hold,p,'arrival')
for step in delayed['steps']:
n=self.note(time,time+F(delayed['max_note']),expressions.chromatic(p,-step),'delayed_approach')
if not entry(n.start,n.end,n.pitch):continue
if n.pitch.number()%12 in types.permitted(self.harmony.choices,n.start,n.end):continue
c=types.ExpressionContract(n.event_id+':delayed','delayed_approach',delayed['id'],(n,),target,region.chord)
add(Action(time,n.end,(n,),'delayed_approach',(c,),delayed['id']))
# Optional colors retain functional degree identity independently of
# the reference/exporter's enharmonic notation. No scale is widened.
for rule in vocabulary.data('harmony')['colors']:
if not expressions.eligible_color(rule,span,self.harmony):continue
for p in self.degree_pitches(expressions.parsed_chord(span.chord),rule):
if not self.harmony.profile.permits(p.number()):continue
for duration in durations:
if duration>F(rule['max_beats']) or time+duration>self.end:continue
if not entry(time,time+duration,p):continue
n=self.note(time,time+duration,p,'color')
if not rule.get('resolve'):
c=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(n,),n,span.chord)
add(Action(time,n.end,(n,),'color',(c,),rule['id']))
else:
for gap in (F(),F(1,2),F(1)):
for q in self.pitches(n.end+gap):
if not 0<abs(q.number()-p.number())<=2:continue
for hold in map(F,self.rules['approach_holds']):
target=self.note(n.end+gap,n.end+gap+hold,q,'arrival')
target_span=self.harmony.at(target.start)
if not target_span or target.end>self.end:continue
c=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(n,),target,target_span.chord)
add(Action(time,n.end,(n,),'color_resolution',(c,),rule['id']))
for boundary in (s.start for s in self.harmony.spans if time<s.start<=time+F(vocabulary.data('harmony')['anticipation_max'])):
region=self.harmony.at(boundary)
for hold in durations:
for p in self.pitches(boundary):
n=self.note(time,boundary,p,'anticipation')
if not entry(n.start,n.end,p):continue
target=self.note(boundary,boundary+hold,p,'arrival')
c=types.ExpressionContract(n.event_id+':anticipation','anticipation','early_reattack',(n,),target,region.chord,boundary=boundary)
add(Action(time,target.end,(n,target),'anticipation',(c,),c.rule))
for duration in durations:
if time+duration<=boundary:continue
for p in self.pitches(boundary):
if not entry(time,time+duration,p):continue
n=self.note(time,time+duration,p,'anticipation')
c=types.ExpressionContract(n.event_id+':anticipation','anticipation','early_chord_tone',(n,),n,region.chord,boundary=boundary)
add(Action(time,n.end,(n,),'anticipation',(c,),c.rule))
for rule in vocabulary.data('harmony')['colors']:
if not expressions.eligible_color(rule,region,self.harmony):continue
for p in self.degree_pitches(expressions.parsed_chord(region.chord),rule):
if not entry(time,time+duration,p):continue
n=self.note(time,time+duration,p,'anticipated_color')
if not rule.get('resolve'):
c=types.ExpressionContract(n.event_id+':anticipated_color','anticipated_color',rule['id'],(n,),n,region.chord,boundary=boundary)
add(Action(time,n.end,(n,),'anticipated_color',(c,),rule['id']))
else:
for q in self.pitches(n.end):
if not 0<abs(q.number()-p.number())<=2:continue
for hold in map(F,self.rules['approach_holds']):
target=self.note(n.end,n.end+hold,q,'arrival')
c=types.ExpressionContract(n.event_id+':anticipated_color','anticipated_color',rule['id'],(n,),target,region.chord,boundary=boundary)
add(Action(time,target.end,(n,target),'anticipated_color',(c,),rule['id']))
for rule in vocabulary.data('lead_ins')['patterns']:
if rule.get('difficulty') and self.analysis.tempo>vocabulary.data('roles')['difficulty'][rule['difficulty']+'_max_bpm']:continue
# A connection's preceding event is explicit in the action. It cannot
# obtain a fictitious preparation from a label or from a future note.
if rule['kind']=='connection':
for preparation in map(F,self.rules['connection_preparations']):
if first is not None and (first[0]!=time or first[1]!=time+preparation):continue
arrival=time+preparation+sum(map(F,rule['durations']),F())+F(rule['gap'])
for hold in map(F,self.rules['approach_holds']):
if arrival+hold>self.end:continue
for p in self.pitches(arrival):
region=self.harmony.at(arrival)
target=self.note(arrival,arrival+hold,p,'arrival')
cursor=time+preparation;notes=[]
for step,length in zip(rule['semitones'],map(F,rule['durations'])):
notes.append(self.note(cursor,cursor+length,expressions.chromatic(p,step),'connection'));cursor+=length
for prep_pitch in self.pitches(time):
if not entry(time,time+preparation,prep_pitch) or not 0<abs(prep_pitch.number()-notes[0].pitch.number())<=2:continue
prep=self.note(time,time+preparation,prep_pitch,'preparation')
c=types.ExpressionContract(prep.event_id+':'+rule['id'],'connection',rule['id'],tuple(notes),target,region.chord,preparation=prep)
add(Action(time,target.end,(prep,*notes,target),'connection',(c,),rule['id']))
continue
gaps=tuple(map(F,rule.get('internal_gaps',[])))
arrival=time+sum(map(F,rule['durations']),F())+sum(gaps,F())+F(rule['gap'])
for hold in map(F,self.rules['approach_holds']):
for p in self.pitches(arrival):
if not entry(time,time+F(rule['durations'][0]),expressions.chromatic(p,rule['semitones'][0])):continue
region=self.harmony.at(arrival)
if not region:continue
target=self.note(arrival,arrival+hold,p,'arrival')
cursor=time;notes=[]
for i,(step,length) in enumerate(zip(rule['semitones'],map(F,rule['durations']))):
notes.append(self.note(cursor,cursor+length,expressions.chromatic(p,step),rule['kind']));cursor+=length+(gaps[i] if i<len(gaps) else 0)
c=types.ExpressionContract(notes[0].event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(notes),target,region.chord)
add(Action(time,target.end,(*notes,target),rule['kind'],(c,),rule['id']))
for colored in self._color(target,region):
add(Action(time,target.end,(*notes,target),rule['kind'],(c,*colored.contracts),rule['id']))
for upcoming in self.harmony.spans:
if not arrival<upcoming.start<target.end or upcoming.start-arrival>F(vocabulary.data('harmony')['anticipation_max']):continue
anticipation=types.ExpressionContract(target.event_id+':early','anticipation','early_chord_tone',(target,),target,upcoming.chord,boundary=upcoming.start)
approach=dataclasses.replace(c,chord=upcoming.chord,boundary=upcoming.start)
add(Action(time,target.end,(*notes,target),rule['kind'],(approach,anticipation),rule['id']))
# Complete directional groups use exact scale indexing at each harmonic
# time. A changed collection retains actual pitches, never degree labels.
lengths_list=[(F(1,2),)*2,(F(1,2),)*4,(F(1),F(1,2),F(1,2)),(F(1,2),F(1),F(1,2))]
difficulty=vocabulary.data('roles')['difficulty']
if self.analysis.tempo<=difficulty['triplet_max_bpm']:lengths_list.append((F(1,3),)*3)
if self.analysis.tempo<=difficulty['sixteenth_max_bpm']:lengths_list.extend(((F(1,4),)*4,(F(1,4),)*2))
patterns=list(self.patterns)
for lengths in lengths_list:
cursor=F();pattern=[]
for d in lengths:pattern.append((cursor,cursor+d));cursor+=d
patterns.append(('complete_subdivision',tuple(pattern),cursor))
for name,pattern,length in patterns:
if time+length>self.end:continue
if first is not None and (time+pattern[0][0],time+pattern[0][1])!=first[:2]:continue
for stride in (-2,-1,1,2):
# Catalog licenses relative paths, but never sets family probability.
if self.sequences and not any(s.steps==(stride,)*(len(pattern)-1) for s in self.sequences.paths.get(len(pattern),())):continue
for initial in self.pitches(time):
if not entry(time+pattern[0][0],time+pattern[0][1],initial):continue
cursor=time;p=initial;notes=[]
for index,(a,b) in enumerate(pattern):
cursor=time+a
region=self.harmony.at(cursor)
if not region or not region.scale:break
if index:
try:p=types.scale_pitch(region.scale,types.scale_index(region.scale,p.number())+stride)
except ValueError:break
notes.append(self.note(cursor,time+b,p,'scalar' if abs(stride)==1 else 'arpeggio'))
if len(notes)==len(pattern):add(Action(time,time+length,tuple(notes),'scalar' if abs(stride)==1 else 'arpeggio',source=f'{name}:degree_stride:{stride}'))
# Equivalent musical actions need one stable derivation, not extra votes.
unique={}
for a in values:unique.setdefault(a.key,a)
result=tuple(unique[k] for k in sorted(unique))
if eligible is not None or first is not None:return result
if len(self.cache)>=32:self.cache.pop(next(iter(self.cache)))
if len(self.ordinary_cache)>8192:self.ordinary_cache.clear()
self.cache[time]=result
return result
def related(self,time,home,kind):
"""Construct a heard-theme return or answer as a whole group, never a prefix."""
if len(home)<3:return ()
pattern=tuple((n.start-home[0].start,n.end-home[0].start) for n in home)
if not groups.valid(pattern):return ()
values=[]
for p in self.pitches(time):
notes=[]
for n,(a,b) in zip(home,pattern):
number=p.number()+(n.pitch.number()-home[0].pitch.number())*(1 if kind=='return' else -1)
q=next((q for q in self.pitches(time+a) if q.number()==number),None)
if q is None:break
notes.append(self.note(time+a,time+b,q,kind))
if len(notes)==len(home):
action=Action(time,time+pattern[-1][1],tuple(notes),kind,source='realized_theme')
if self._accepted(action):values.append(action)
return tuple(values)
+1 -1
View File
@@ -4,7 +4,7 @@ import dataclasses, hashlib
class Policy:
long_note: float = 2.
stopping_note: float = 1.
composition_version: str = 'phrase-composer-1'
composition_version: str = 'joint-actions-1'
DEFAULT=Policy()
def stable_rank(seed, value):
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
+5 -3
View File
@@ -25,16 +25,18 @@ def structure(solo):
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})
ns=solo.notes()
duration=solo.end-solo.start
return {'schema':'composer-report-3','selection_trace':plain({k:v for k,v in solo.diagnostics.items() if not k.endswith('_seconds')}),
'instrument_profile':plain(solo.profile),'phrases':phrases,'expressions':plain(contracts),'fretboard':fretboard_report(solo),
'phrasing':{'endings':endings,'attacks':len(ns),'attacks_per_four_beats':4*len(ns)/float(solo.end) if solo.end else 0,
'occupancy':sum(float(n.end-n.start) for n in ns)/float(solo.end) if solo.end else 0},
'phrasing':{'endings':endings,'attacks':len(ns),'allocation_beats':str(duration),
'attacks_per_four_beats':4*len(ns)/float(duration) if duration else 0,
'occupancy':sum(float(n.end-n.start) for n in ns)/float(duration) if duration else 0},
'harmonic_roles':harmony.describe(ns,expressions=contracts),'harmonic_valid':checked.valid,'harmonic_error':checked.error,
'harmonic_exposure':checked.exposure,'unresolved_treatments':plain(checked.pending)}
def describe(solo,scales,measure_offset=0,time_offset=0,*,detail=None):
detail=detail or structure(solo)
lines=[f'Independent complete-phrase solo: {solo.part}', 'Times are exact quarter beats from the solo start; pitches sound one octave below guitar notation.',
lines=[f'Independent joint-action solo: {solo.part}', 'Times are exact quarter beats from the chorus start; negative times are allocated lead-ins. Pitches sound one octave below guitar notation.',
'Approximate fingering costs are not a biomechanical guarantee.',json.dumps(detail['phrasing'])]
for p in solo.phrases:
lines.append(f'{p.id}: {p.start}–{p.end}; {p.purpose}, {p.direction}, {p.ending}; theme {p.theme}; score {p.score:.4f}')
+1 -30
View File
@@ -1,7 +1,7 @@
"""Complete generated rhythm groups, indexed once, independent of source rhythms."""
from fractions import Fraction as F
from generator import vocabulary
from generator.solo import groups, policy
from generator.solo import groups
class Rhythms:
def __init__(self, library, tempo):
@@ -31,32 +31,3 @@ class Rhythms:
representatives.setdefault(descriptor,(item.id,pattern,bar.length,('develop','answer')))
self.catalog[meter]=tuple(representatives.values())
self.cache={}
def realize(self,start,end,purpose,seed,meter):
key=(start,end,purpose,seed,meter)
if key in self.cache:return self.cache[key]
cursor=start;notes=[];sources=[];chosen=[];home=None;uses=0
while cursor<end:
available=[g for g in self.groups if purpose in g[3] and g[2]<=end-cursor]
# Catalog is a separate family, not thousands of duplicate votes.
if purpose in ('develop','answer'):
available += sorted((g for g in self.catalog.get(meter,()) if g[2]<=end-cursor),key=lambda g:policy.stable_rank(seed,('catalog',cursor,g[0])))[:1]
available=[g for g in available if ((end-cursor-g[2])*2).denominator==1]
if not available:return (),(),()
# Prefer complete multi-event groups; single values finish an exact remainder.
large=[g for g in available if g[2]>=1]
if home is not None and uses<2 and home in available:
selected=home
else:
# Establish one rhythmic idea before reshaping it. A complete phrase
# is not a fresh unrelated catalog draw at every short boundary.
selected=min(large or available,key=lambda g:policy.stable_rank(seed,('rhythm',len(chosen)//4,g[0])))
home=selected;uses=0
uses+=1
name,pattern,length,_=selected
events=tuple((cursor+a,cursor+b) for a,b in pattern)
notes.extend(events);sources.append(name)
chosen.extend(groups.describe(pattern,origin=cursor,source=name))
cursor+=length
result=(tuple(notes),tuple(chosen),tuple(sources));self.cache[key]=result
return result
-99
View File
@@ -1,99 +0,0 @@
"""Complete pitch paths for joint phrase proposals, with explicit structural motion."""
import dataclasses
from fractions import Fraction as F
from generator import vocabulary
from generator.solo import types, policy, sequences
class Routes:
def __init__(self,harmony,library):
self.harmony=harmony;self.library=library;self.pitch_cache={};self.cache={}
# Every short relative path is reachable by attack count and exact displacement.
self.index={}
self.steps=frozenset(s.steps[0] for s in library.paths.get(2,()))
for values in library.paths.values():
for value in values:self.index.setdefault((len(value.steps)+1,value.offsets()[-1]),[]).append(value)
def pitches(self,start,end):
key=(start,end)
if key not in self.pitch_cache:
span=self.harmony.at(start)
allowed=types.permitted(self.harmony.choices,start,end)
if end-start>=2:allowed &= self.harmony.members(start,end)| (span.roles.specified if span else frozenset())
self.pitch_cache[key]=tuple(types.scale_pitch(span.scale,i) for i in range(-28,29)
if types.scale_pitch(span.scale,i).number()%12 in allowed and self.harmony.profile.permits(types.scale_pitch(span.scale,i).number())) if span and span.scale else ()
return self.pitch_cache[key]
def realize(self,notes,fixed,purpose,direction,seed,center,incoming=None,hint=()):
"""Only completed paths leave this function; internal frontier cuts have no cadence."""
if not notes:return ()
# Identical musical work is cached without event/provenance IDs.
key=(tuple((n.start,n.end,n.pitch if i in fixed else None) for i,n in enumerate(notes)),tuple(sorted(fixed)),purpose,direction,seed,center,incoming.pitch if incoming else None,tuple((n.start,n.end,n.pitch) for n in hint))
if key in self.cache:
return tuple(tuple(dataclasses.replace(n,pitch=p) for n,p in zip(notes,path)) for path in self.cache[key])
width=vocabulary.data('solo')['search']['route_frontier'];states=[(0.,())];count=len(notes)
for i,n in enumerate(notes):
options=(n.pitch,) if i in fixed else self.pitches(n.start,n.end)
region=self.harmony.at(n.start)
pitch_order={p:policy.stable_rank(seed,('pitch',i,p)) for p in options}
following=[]
for cost,path in states:
prior=path[-1] if path else incoming.pitch if incoming else None
phase=i/max(1,count-1)
sign=1 if direction=='up' or direction=='arch' and phase<.5 or direction=='valley' and phase>=.5 else -1
target=next((notes[j].pitch for j in sorted(fixed) if j>i),None)
choices=[]
for pitch in options:
number=pitch.number();motion=number-prior.number() if prior else 0
if not self.harmony.profile.permits(number) or prior and abs(motion)>12:continue
if prior and i not in fixed:
try:
displacement=types.scale_index(region.scale,number)-types.scale_index(region.scale,prior.number())
if displacement and displacement not in self.steps:continue
except ValueError:
pass # Changed collection: retain the actual incoming pitch.
local=max(0,abs(number-center)-10)/12
if i not in fixed:
local+=max(0,abs(motion)-7)/3
if prior:
span=region
chordal=number%12 in span.roles.basic
# Arpeggios expand during the gesture; the last portion connects to its goal.
if purpose=='arpeggio' and phase<.65:local+=(0 if chordal else 1)+(.4 if abs(motion)<3 else 0)
elif purpose=='neighbor':local+=max(0,abs(motion)-2)/2
else:local+=max(0,abs(motion)-3)/3
desired=sign
if hint and 0<i<len(hint):
step=hint[i].pitch.number()-hint[i-1].pitch.number()
if purpose=='answer':step=-step
desired=(step>0)-(step<0)
if purpose in ('return','head') and ((motion>0)-(motion<0))!=desired:continue
local+=abs(abs(motion)-abs(step))/12
if purpose=='neighbor':desired=sign if i%2 else -sign
local+=.35 if motion*desired<0 else .2 if motion==0 else 0
if len(path)>=2 and path[-2]==prior==pitch:local+=1
if target and phase>.6:
remaining=max(1,next(j for j in sorted(fixed) if j>i)-i)
local+=max(0,abs(target.number()-number)-remaining*2)/3
choices.append((local,pitch))
# Preserve distinct register/motion alternatives, not merely two nearest notes.
for local,pitch in sorted(choices,key=lambda v:(v[0],pitch_order[v[1]]))[:width]:
following.append((cost+local,(*path,pitch)))
if not following:return ()
states=sequences.diverse(following,lambda v:(round(v[0],8),policy.stable_rank(seed,('path',v[1]))),
lambda v:((v[1][-1].number()-v[1][0].number()>0),v[1][-1].number()),width)
# Where applicable, add complete catalog routes as peers of the contextual route.
if 2<=count<=4 and fixed=={0,count-1}:
span=self.harmony.at(notes[0].start)
if span and span.scale and all(self.harmony.at(n.start).scale==span.scale for n in notes):
try:
origin=types.scale_index(span.scale,notes[0].pitch.number());displacement=types.scale_index(span.scale,notes[-1].pitch.number())-origin
for route in self.index.get((count,displacement),())[:32]:
path=tuple(types.scale_pitch(span.scale,origin+x) for x in route.offsets())
if all(p in self.pitches(n.start,n.end) for p,n in zip(path,notes)):states=(*states,(0.,path))
except ValueError:pass
paths=[]
for _,path in sorted(states,key=lambda v:(v[0],policy.stable_rank(seed,v[1]))):
if path not in paths:paths.append(path)
if len(paths)==2:break
self.cache[key]=tuple(paths)
return tuple(tuple(dataclasses.replace(n,pitch=p) for n,p in zip(notes,path)) for path in paths)
+2 -37
View File
@@ -1,8 +1,6 @@
"""One authoritative evaluation of completed phrases, never guitar travel."""
"""The authoritative action-evaluation record, independent of guitar travel."""
from dataclasses import dataclass
from fractions import Fraction as F
from generator import vocabulary
from generator.solo import types
@dataclass(frozen=True)
class Evaluation:
@@ -11,38 +9,5 @@ class Evaluation:
raw: dict
weights: dict
contributions: dict
total: float
total: F
denominators: dict
def signature(notes):
ns=types.sounding_notes(notes)
if not ns:return ()
origin=ns[0].start;pitch=ns[0].pitch.number()
return tuple((n.start-origin,n.end-n.start,n.pitch.number()-pitch) for n in ns)
def evaluate(phrase,incoming,history,center,weights=None):
ns=types.sounding_notes(phrase.notes); span=float(phrase.end-phrase.start)
weights=dict(vocabulary.data('solo')['weights'] if weights is None else weights)
if set(weights)!={'purpose','continuity','repetition','register'}:raise ValueError('Unknown musical contribution')
if not ns:
valid=phrase.purpose=='space';raw=dict.fromkeys(weights,0.)
else:
valid=True
movement=[b.pitch.number()-a.pitch.number() for a,b in zip(ns,ns[1:])]
# Declared expansion permits arpeggio leaps; avoid unprepared octave flailing.
purpose=sum(max(0,abs(v)-7)/12 for v in movement)/max(1,len(movement))
oscillation=sum(a==-b==c and a!=0 for a,b,c in zip(movement,movement[1:],movement[2:]))
purpose+=max(0,oscillation-1)/max(1,len(movement))
if phrase.destination:
prior=next((n for n in reversed(ns) if n.end<=phrase.destination.earliest),None)
if prior:purpose+=max(0,abs(prior.pitch.number()-phrase.destination.pitch.number())-3)/12
continuity=max(0,abs(ns[0].pitch.number()-incoming.pitch.number())-5)/12 if incoming else 0.
current=signature(ns[:6]); repetition=0.
for age,old in enumerate(reversed(history[-4:])):
if len(current)>=3 and current==signature(old.notes[:6]):repetition+=1/(age+1)
if phrase.purpose=='return':repetition=max(0,repetition-.5)
duration=sum(float(n.end-n.start) for n in ns)
register=sum(float(n.end-n.start)*max(0,abs(n.pitch.number()-center)-10)/12 for n in ns)/max(duration,.001)
raw={'purpose':min(1,purpose),'continuity':min(1,continuity),'repetition':min(1,repetition),'register':min(1,register)}
contributions={k:raw[k]*weights[k] for k in weights}
return Evaluation('phrase-musical-1',valid,raw,weights,contributions,sum(contributions.values()) if valid else float('inf'),{'span':span,'attacks':len(ns),'sounding':sum(float(n.end-n.start) for n in ns)})
+154
View File
@@ -0,0 +1,154 @@
"""Interpretable shared action ranking; no song identity or absolute bar features.
Complete actions compete by exact cost per quarter, with bounded backtracking.
The final report exposes the exact same contributions used by search.
Role-specific learned integer coefficients can augment these general priors.
"""
from collections import Counter
from fractions import Fraction as F
import dataclasses
from generator import vocabulary, harmony as chords, model
from generator.solo import expressions, scoring, types
VERSION='action-selection-1'
DEFAULT={'excessive_leap':12,'register_distance':1,'oscillation':6,'repeated_attack':3,
'unprepared_space':12,'rest_after_rest':24,'source_relation':-2,'theme_relation':-4,
'sustained_arrival':-1,'directed_motion':-1}
def validate_weights(weights):
durations={str(F(i,4)).replace('/','_') for i in range(1,17)}|{'1_3','2_3'}
kinds={'tone','color','color_resolution','anticipation','anticipated_color','approach','enclosure','connection','delayed_approach','space','rest','scalar','arpeggio','return','answer'}
allowed={f'kind/{k}' for k in kinds}|{f'duration/{d}' for d in durations}
allowed|={f'harmony/{q}/{p}/{d}' for q in ('major','minor','dominant','half_diminished','other') for p in range(12) for d in durations}
allowed|={f'motion/{p}/{d}' for p in range(-12,13) for d in durations}
allowed|={f'metric/{m}/{d}' for m in ('barline','beat','eighth','subdivision') for d in durations}
allowed|={f'register/{i}' for i in range(-4,5)}
allowed|={f'obligation/step/{i}' for i in range(-12,13)}
allowed|={f'obligation/delay/{str(F(i,4)).replace("/","_")}' for i in range(1,21)}
allowed|={f'obligation/hold/{d}' for d in durations}
allowed|={f'continuation/{a}/{b}' for a in ('up','down','repeat') for b in ('up','down','repeat')}
allowed|={'source/rhythm_match','theme/realized_identity',*(f'space/{s}' for s in ('unharmonized','breath','after_arrival','delayed_entrance'))}
for key,value in weights.items():
if key not in allowed or not isinstance(value,int) or isinstance(value,bool):
raise ValueError('Unsupported fitted feature (only shared musical categories): '+key)
if any(c not in 'abcdefghijklmnopqrstuvwxyz_0123456789/-' for c in key):
raise ValueError('Noncanonical fitted feature: '+key)
return dict(weights)
def signature(notes):
if not notes:return ()
first=notes[0]
return tuple((n.start-first.start,n.end-n.start,n.pitch.number()-first.pitch.number()) for n in notes)
class Selector:
def __init__(self,harmony,analysis,center,weights=None):
self.harmony=harmony;self.analysis=analysis;self.center=center
self.weights=validate_weights(weights if weights is not None else vocabulary.role().get('selection_weights',{}))
self.source_intervals=Counter(b.pitch.number()-a.pitch.number() for a,b in zip(analysis.melody,analysis.melody[1:]))
self.source_pairs=Counter((b.pitch.number()-a.pitch.number(),c.pitch.number()-b.pitch.number())
for a,b,c in zip(analysis.melody,analysis.melody[1:],analysis.melody[2:]))
self.source_rhythms=Counter(n.end-n.start for n in analysis.melody)
self.static={}
def base(self,action):
if action.key in self.static:return self.static[action.key]
values=Counter();prior=Counter();ticks=int((action.end-action.start)*12)
values['kind/'+action.kind]=ticks
for contract in action.contracts:
if contract.target.start>=action.end and contract.events:
# Pending destinations must be visible to selection. Identical heard
# attacks with different future obligations are different actions.
last=contract.events[-1];target=contract.target
values[f'obligation/step/{max(-12,min(12,target.pitch.number()-last.pitch.number()))}']+=ticks
values['obligation/delay/'+str(target.start-last.start).replace('/','_')]+=ticks
values['obligation/hold/'+str(target.end-target.start).replace('/','_')]+=ticks
if not action.notes:
values['space/'+('unharmonized' if action.kind=='space' else 'breath')]=ticks
for n in action.notes:
duration=n.end-n.start;length=int(duration*12)
label=str(duration).replace('/','_')
values['duration/'+label]+=length
beat=(n.start%1)
metric='barline' if n.start+self.analysis.body_offset in self.analysis.boundaries else 'beat' if beat==0 else 'eighth' if beat==F(1,2) else 'subdivision'
values['metric/'+metric+'/'+label]+=length
band=max(-4,min(4,(n.pitch.number()-self.center+6)//12))
values[f'register/{band}']+=length
region=self.harmony.at(n.start)
chord=expressions.parsed_chord(region.chord)
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))
degree=(n.pitch.number()-root)%12
values[f'harmony/{expressions.quality(chord)}/{degree}/{label}']+=length
prior['register_distance']+=length*max(0,abs(n.pitch.number()-self.center)-7)
if duration>=2 and n.pitch.number()%12 in self.harmony.members(n.start,n.end):
prior['sustained_arrival']+=length
values['source/rhythm_match']+=length if duration in self.source_rhythms else 0
self.static[action.key]=(values,prior)
if len(self.static)>8192:self.static.pop(next(iter(self.static)))
return values,prior
def features(self,action,recent=(),home=(),previous_kind=''):
if any(n.continuation for n in action.notes):action=dataclasses.replace(action,notes=types.sounding_notes(action.notes))
static,base=self.base(action);values=Counter(static);prior=Counter(base)
if not action.notes:
if action.kind=='space':return values,prior
ticks=int((action.end-action.start)*12)
if previous_kind in ('rest','space'):prior['rest_after_rest']+=ticks
elif recent and recent[-1].end-recent[-1].start>=F(3,2):values['space/after_arrival']+=ticks
elif recent:prior['unprepared_space']+=ticks
else:values['space/delayed_entrance']+=ticks
return values,prior
previous=recent[-1] if recent else None
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(recent,recent[1:])]
for n in action.notes:
length=int((n.end-n.start)*12)
if previous:
delta=n.pitch.number()-previous.pitch.number()
token=max(-12,min(12,delta));label=str(n.end-n.start).replace('/','_')
values[f'motion/{token}/{label}']+=length
if intervals:
# Direction changes are contextual, not a blanket leap prohibition.
sign=lambda x: 'up' if x>0 else 'down' if x<0 else 'repeat'
values[f'continuation/{sign(intervals[-1])}/{sign(delta)}']+=length
if len(intervals)>=2 and intervals[-2]==-intervals[-1]==delta and delta:prior['oscillation']+=length
prior['excessive_leap']+=length*max(0,abs(delta)-7)
prior['repeated_attack']+=length if delta==0 and n.start==previous.end else 0
# Ordered relationships, not the set of available intervals. Weight
# by sounding duration so subdivisions cannot multiply a reward.
if intervals and (intervals[-1],delta) in self.source_pairs:prior['source_relation']+=length
if 0<abs(delta)<=2 and intervals and delta*intervals[-1]>0:prior['directed_motion']+=length
intervals.append(delta)
previous=n
if action.kind=='return' and len(home)>=3:
if signature(action.notes)==signature(home):
recently_heard=any(signature(recent[i:i+len(home)])==signature(home)
for i in range(max(0,len(recent)-len(home)+1)))
if not recently_heard:prior['theme_relation']+=int((action.end-action.start)*12)
if recent and home:
joined=(*recent,*action.notes)
count=min(len(home),len(joined))
if count>=3 and signature(joined[-count:])==signature(home[:count]):
values['theme/realized_identity']+=12
return values,prior
def evaluate(self,action,recent=(),home=(),previous_kind=''):
features,prior=self.features(action,recent,home,previous_kind)
contributions={'prior/'+k:v*DEFAULT[k] for k,v in prior.items() if v}
contributions.update({k:v*self.weights.get(k,0) for k,v in features.items() if v})
weights={'prior/'+k:v for k,v in DEFAULT.items()}|self.weights
raw={'prior/'+k:v for k,v in prior.items()}|dict(features)
span=action.end-action.start
contributions={k:F(v)/span for k,v in contributions.items()}
raw={k:F(v)/span for k,v in raw.items()}
total=sum(contributions.values(),F())
return scoring.Evaluation(VERSION,True,raw,weights,contributions,total,
{'span':str(action.end-action.start),'ticks_per_quarter':12,'attacks':len(types.sounding_notes(action.notes))})
def cost(self,action,recent=(),home=(),previous_kind=''):
"""Identical selection total without serializing every rejected breakdown."""
features,prior=self.features(action,recent,home,previous_kind)
numerator=sum(v*DEFAULT[k] for k,v in prior.items())
numerator+=sum(v*self.weights.get(k,0) for k,v in features.items())
return F(numerator)/(action.end-action.start)
+1
View File
@@ -172,5 +172,6 @@ class Solo:
end: fractions.Fraction
diagnostics: dict = dataclasses.field(default_factory=dict, compare=False)
profile: generator.solo.profiles.Instrument | None = None
start: fractions.Fraction = fractions.Fraction()
def notes(self):
return sounding_notes(n for p in self.phrases for n in p.notes)