Files
product-vamp/generator/solo/expressions.py
T
khodowany 68df7292ee Cleanup
2026-10-04 17:14:13 -05:00

335 lines
22 KiB
Python

"""Typed, target-driven harmonic expression over a strictly mapped theme."""
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
def quality(chord):
degrees=chord_model.degrees(chord)
if degrees.get(3)==0 and degrees.get(7)==-1:return 'dominant'
if degrees.get(3)==-1:return 'half_diminished' if degrees.get(5)==-1 else 'minor'
if degrees.get(3)==0 and degrees.get(5)==0:return 'major'
return 'other'
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'])
return dataclasses.replace(value,octave=value.octave+round((near-value.number())/12))
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
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'])
raise ValueError('unsupported approach interval')
def eligible_color(rule,span,harmony):
chord=chord_model.parse_symbol(span.chord)
degrees=chord_model.degrees(chord)
if quality(chord) not in rule['qualities']:return False
degree=rule['degree']; simple=(degree-1)%7+1
# Explicit alterations and omissions take precedence over optional colors.
if any((d['value']-1)%7+1==simple and (d['type']=='subtract' or model.alteration(d['alter'])!=rule['alter']) for d in chord.get('degrees',[])):return False
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_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=='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)
tonic_pc=int(model.NATURAL[tonic['letter']]+model.alteration(tonic['alter']))%12
if root!=tonic_pc and not (next_chord and quality(next_chord)=='dominant' and (next_root-root)%12==5):return False
return True
def validate(notes,contracts,harmony):
"""Return event permissions only after checking complete actual gestures."""
actual={n.event_id:n for n in notes};approved={}
def same(expected):
n=actual.get(expected.event_id)
return n and (n.start,n.end,n.pitch)==(expected.start,expected.end,expected.pitch)
for c in contracts:
if not c.events or not all(same(n) for n in (*c.events,c.target)):
return {},c.id+': missing or changed expression event/target'
if c.preparation and not same(c.preparation):return {},c.id+': changed preparation'
if any(n.event_id in approved for n in c.events):return {},c.id+': overlapping expression permissions'
target=c.target; span=harmony.at(c.boundary if c.boundary is not None else target.start)
if not span or not span.scale or span.chord!=c.chord:return {},c.id+': wrong target harmony'
if c.kind in ('approach','enclosure','connection'):
rule=next((r for r in vocabulary.data('lead_ins')['patterns'] if r['id']==c.rule and r['kind']==c.kind),None)
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())
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']):
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
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=='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'
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 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'
if not rule['outside'] and n.pitch.number()%12 not in region.scale.pitches:return {},c.id+': color requires mapped collection'
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'
elif c.kind=='suspension':
rule=next((r for r in vocabulary.data('harmony').get('suspensions',[]) if r['id']==c.rule),None)
if not rule or len(c.events)!=1 or c.boundary is None:return {},c.id+': unsupported suspension'
held,=c.events
if not held.start<=c.boundary-F(rule['preparation'])<c.boundary<held.end:
return {},c.id+': insufficient sounding preparation'
if held.pitch.number()%12 not in harmony.members(held.start,c.boundary):return {},c.id+': nonchordal preparation'
if held.end-c.boundary>F(rule['max_delay']) or target.start!=held.end or target.pitch.number()-held.pitch.number() not in rule['steps']:
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=='anticipation':
if c.rule!='early_chord_tone' or len(c.events)!=1 or c.target!=c.events[0] 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'
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)
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())
if n.pitch!=expected or target.pitch!=resolved or target.pitch.number()-n.pitch.number()!=1:return {},c.id+': unsupported blues destination'
if not c.preparation or c.preparation.end!=n.start or abs(c.preparation.pitch.number()-n.pitch.number())>2:return {},c.id+': unprepared blues gesture'
else:return {},c.id+': unknown expression kind'
for n in c.events:
covered=n.start
for region,a,b in harmony.segments(n):
if a!=covered or not region.scale:return {},c.id+': missing harmony'
covered=b
if covered!=n.end:return {},c.id+': incomplete harmonic coverage'
approved[n.event_id]=c
return approved,''
def validate_frontier(notes,contracts,harmony,until,complete):
"""Validate heard prefixes and bounded future obligations separately.
Future contract events are used ONLY to establish rule feasibility. They do
not count as heard resolution; the contract stays pending until its entire
target hold is present. Missing past events and expired deadlines fail.
"""
if complete:
approved,error=validate(notes,contracts,harmony)
return approved,(),error
actual={n.event_id:n for n in notes};future=dict(actual);pending=[]
for c in contracts:
required=tuple(dict.fromkeys((*c.events,c.target,*((c.preparation,) if c.preparation else ()))))
incomplete=False
for expected in required:
heard=actual.get(expected.event_id)
if expected.start>=until:
future[expected.event_id]=expected;incomplete=True
elif heard and heard.pitch==expected.pitch and heard.start==expected.start and heard.end==min(until,expected.end):
future[expected.event_id]=expected
incomplete |= heard.end<expected.end
elif not heard or (heard.start,heard.end,heard.pitch)!=(expected.start,expected.end,expected.pitch):
return {},(),c.id+': missing/changed event before resolution deadline'
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})