This commit is contained in:
khodowany
2026-10-04 18:48:02 -05:00
parent 68df7292ee
commit 0e68f5364b
58 changed files with 1416 additions and 7567 deletions
+1 -175
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import dataclasses
from fractions import Fraction as F
from generator import harmony as chord_model, model, pitch as spelling, vocabulary
from generator.solo import types, drafts, enclosures, placement, policy
from generator.solo import types
def quality(chord):
@@ -158,177 +158,3 @@ def validate_frontier(notes,contracts,harmony,until,complete):
if incomplete:pending.append(c)
approved,error=validate(tuple(sorted(future.values(),key=lambda n:(n.start,n.event_id))),contracts,harmony)
return {key:c for key,c in approved.items() if key in actual},tuple(pending),error
def proposals(phrase,harmony):
draft=phrase.draft
if not draft or any(n.continuation for n in draft.notes):return
notes=types.sounding_notes(draft.notes)
for target,role in enclosures.declared_targets(phrase,harmony):
for rule in vocabulary.data('lead_ins')['patterns']:
start=target.start-F(rule['gap'])-sum((F(d) for d in rule['durations']),F())
if start<phrase.plan.start or not enclosures.editable(draft,start,target.start):continue
cursor=start;events=[]
for i,(step,duration) in enumerate(zip(rule['semitones'],rule['durations'])):
events.append(types.Note(cursor,cursor+F(duration),chromatic(target.pitch,step),target.sources,rule['kind'],event_id=target.event_id+':'+rule['id']+':'+str(i)))
cursor+=F(duration)
actual=enclosures.replace_window(notes,start,target.start,tuple(events))
if actual is None:continue
prior=next((n for n in reversed(actual) if n.end<=start),None)
contract=types.ExpressionContract(target.event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(events),target,harmony.at(target.start).chord,preparation=prior)
yield actual,contract
for i,n in enumerate(notes[:-1]):
target=notes[i+1]
if not enclosures.editable(draft,n.start,n.end):continue
span=harmony.at(n.start)
if not span or not span.scale:continue
for rule in vocabulary.data('harmony')['colors']:
if not eligible_color(rule,span,harmony) or n.end-n.start>F(rule['max_beats']):continue
p=degree_pitch(chord_model.parse_symbol(span.chord),rule['degree'],rule['alter'],n.pitch.number())
if abs(p.number()-n.pitch.number())>4 or p==n.pitch:continue
color=dataclasses.replace(n,pitch=p,role='color')
resolution=target if rule.get('resolve') else color
contract=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(color,),resolution,harmony.at(resolution.start).chord)
yield (*notes[:i],color,*notes[i+1:]),contract
for rule in vocabulary.data('lead_ins')['blues']:
if i==0 or n.end-n.start>F(1,2):continue
p=degree_pitch(chord_model.parse_symbol(span.chord),rule['degree'],rule['alter'],n.pitch.number())
changed=dataclasses.replace(n,pitch=p,role='blues')
yield (*notes[:i],changed,*notes[i+1:]),types.ExpressionContract(n.event_id+':'+rule['id'],'blues',rule['id'],(changed,),target,harmony.at(target.start).chord,preparation=notes[i-1])
for i,target in enumerate(notes):
if i==0 or target.start not in {s.start for s in harmony.spans}:continue
start=target.start-F(1,2)
if not enclosures.editable(draft,start,target.start):continue
n=dataclasses.replace(target,start=start,role='anticipation')
actual=enclosures.replace_window(notes,start,target.start,())
if actual:
actual=tuple(n if a.event_id==target.event_id else a for a in actual)
yield actual,types.ExpressionContract(target.event_id+':anticipate','anticipation','early_chord_tone',(n,),n,harmony.at(target.start).chord,boundary=target.start)
for i,(before,target) in enumerate(zip(notes,notes[1:])):
boundary=target.start
if before.end!=boundary or boundary not in {s.start for s in harmony.spans}:continue
if not enclosures.editable(draft,before.start,target.end):continue
for rule in vocabulary.data('harmony').get('suspensions',[]):
delay=min(F(1,2),F(rule['max_delay']))
if target.end<=boundary+delay:continue
held=dataclasses.replace(before,end=boundary+delay,role='suspension')
resolved=dataclasses.replace(target,start=boundary+delay)
yield (*notes[:i],held,resolved,*notes[i+2:]),types.ExpressionContract(before.event_id+':suspend',
'suspension',rule['id'],(held,),resolved,harmony.at(boundary).chord,boundary=boundary)
def joint_candidates(phrases,incoming,harmony,tempo,context,*,bar=F(4)):
"""Expression alternatives compete BEFORE complete-phrase selection.
This rewrites provisional gestures, never the selected chorus. The direct
realization remains available and every alternative uses the same evaluator.
"""
from generator.solo import realization, sequences, phrasing, groups
result=list(phrases)
trace=context.diagnostics.setdefault('expression_operations',{})
for phrase in phrases:
proposed={}
for actual,c in proposals(phrase,harmony):
counts=trace.setdefault(c.kind,dict(proposed=0,valid=0,retained=0,rejected_by_rule=0))
counts['proposed']+=1
old_pattern=tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in phrase.draft.notes)
new_pattern=tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in actual)
if groups.valid(old_pattern) and not groups.valid(new_pattern):
counts['rejected_by_rule']+=1;continue
if not drafts.contract_valid(phrase.draft,actual)[0]:
counts['rejected_by_rule']+=1;continue
checked=harmony.evaluate(actual,until=phrase.plan.end,complete=True,expressions=(*phrase.draft.expressions,c))
if not checked.valid:
counts['rejected_by_rule']+=1;continue
counts['valid']+=1
prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None)
join=abs(prior.pitch.number()-c.events[0].pitch.number()) if prior else 0
rank=max(0,join-5)+bool(prior and prior.pitch==c.events[0].pitch)
proposed.setdefault(c.kind,[]).append((rank,actual,c))
# One representative per treatment kind, then a bounded second sweep.
options=sequences.diverse([v for values in proposed.values() for v in values],
lambda v:(v[0],policy.stable_rank(phrase.plan.seed,v[2].id)),lambda v:v[2].kind,8)
for _,actual,c in options:
transforms=tuple(dataclasses.replace(t,
events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end))
for t in phrase.draft.transformations)
changed=dataclasses.replace(phrase.draft,id=phrase.draft.id+':'+c.rule,
notes=actual,expressions=(*phrase.draft.expressions,c),transformations=transforms)
candidate=realization.checked_phrase(phrase.plan,changed,actual,incoming.treatments,incoming,harmony,tempo,phrase.edits)
if candidate is None:continue
trace[c.kind]['retained']+=1
state=dataclasses.replace(phrase.state,notes=candidate.state.notes,last=candidate.state.last,
motifs=candidate.state.motifs,travel=candidate.state.travel)
history=incoming.history
for t in transforms:
ns=tuple(n for n in actual if n.event_id in t.events)
if ns:history=phrasing.remember(history,phrasing.heard(ns,t.operation,bar=bar),bar)
state=dataclasses.replace(state,history=history)
kept_routes=tuple(r for r in phrase.routes if all(any((n.event_id,n.start,n.end,n.pitch)==
(a.event_id,a.start,a.end,a.pitch) for a in actual) for n in r.notes))
original=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes))
result.append(dataclasses.replace(candidate,state=state,initial=phrase.initial,
refined=phrase.refined,before_rhythm=phrase.before_rhythm,before_enclosure=original,routes=kept_routes))
return result
def refine(solo,enabled=True):
from generator.solo import harmonic
harmony=harmonic.Harmony(solo.choices,solo.profile);result=[];history=[];attempts=0
seed=vocabulary.role(solo.part+'_solo').get('choice_seed',solo.part)
for phrase in solo.phrases:
draft=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes))
phrase=dataclasses.replace(phrase,draft=draft,before_enclosure=draft)
options=[(0.,phrase,None)]
if enabled:
original=draft.notes
for actual,c in proposals(phrase,harmony):
attempts+=1
if not drafts.contract_valid(draft,actual)[0]:continue
if not harmony.evaluate(actual,until=phrase.plan.end,complete=True,expressions=(c,)).valid:continue
if not harmonic.anchors_valid(phrase.anchors,actual,phrase.plan.end,complete=True,closure=phrase.plan.closure)[0]:continue
placed=placement.place(actual,solo.tempo,profile=solo.profile)
if not placed:continue
# Context, repetition, and motion determine utility; no chromatic count target.
prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None)
join=abs(prior.pitch.number()-c.events[0].pitch.number()) if prior else 0
duplicated=bool(prior and prior.pitch==c.events[0].pitch)
destination=c.target.pitch.number()
old_approach=next((n for n in reversed(original) if n.end<=c.target.start),None)
prepares=bool(old_approach and abs(old_approach.pitch.number()-destination)>2 and join<=3)
recent=sum(kind==c.kind for kind in history[-2:])
cost=.08*max(0,join-3)+.18*duplicated+.12*recent+.3*(enclosures.movement(actual)-enclosures.movement(original))-.12*prepares
if c.kind=='anticipation':cost-=.05
decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in actual if d.start<=n.start<d.end)) for d in phrase.decisions)
if tuple(n for d in decisions for n in d.notes)!=actual:continue
# Rebase editable development spans onto the completed gesture. The
# feature contract is unchanged, but stale event IDs/rhythm would make
# reports falsely describe notes that the expression replaced.
transforms=tuple(dataclasses.replace(t,
events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end))
for t in draft.transformations)
changed=dataclasses.replace(draft,notes=actual,expressions=(*phrase.draft.expressions,c),transformations=transforms)
routes=tuple(r for r in phrase.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in actual) for n in r.notes))
options.append((cost,dataclasses.replace(phrase,draft=changed,decisions=decisions,routes=routes),c))
_,chosen,c=min(options,key=lambda v:(round(v[0],8),policy.stable_rank(seed,(phrase.plan.id,v[2]))))
if c:history.append(c.kind)
result.append(chosen)
raw=tuple(n for p in result for d in p.decisions for n in d.notes)
placed=placement.place(raw,solo.tempo,profile=solo.profile)
if raw and placed is None:raise ValueError('Expression realization has no complete guitar path')
by_id={n.event_id:n for n in placed[1]} if placed else {}
final=[]
for p in result:
decisions=tuple(dataclasses.replace(d,notes=tuple(by_id[n.event_id] for n in d.notes)) for d in p.decisions)
notes=tuple(n for d in decisions for n in d.notes)
state=dataclasses.replace(p.state,notes=notes,last=notes[-1] if notes else p.state.last) if p.state else None
# A legal timing revision preserves the complete route with its new times.
routes=[]
for route in p.routes:
if all(n.event_id in by_id and by_id[n.event_id].pitch==n.pitch for n in route.notes):
route_notes=tuple(by_id[n.event_id] for n in route.notes)
routes.append(dataclasses.replace(route,notes=route_notes,destination=route_notes[-1]))
final.append(dataclasses.replace(p,decisions=decisions,draft=dataclasses.replace(p.draft,notes=notes),state=state,routes=tuple(routes)))
return dataclasses.replace(solo,phrases=tuple(final),diagnostics={**solo.diagnostics,'expression_candidates':attempts,'expressions_selected':history,'enclosures_enabled':enabled})