Sepearate Generation

This commit is contained in:
khodowany
2026-10-04 09:05:25 -05:00
parent e8ac7b5edd
commit 8fdd736e13
56 changed files with 1739 additions and 341 deletions
+223
View File
@@ -0,0 +1,223 @@
"""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.kind=='anticipation' 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'
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 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
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 is last 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=='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 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)
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=(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})