Files
product-vamp/generator/solo/enclosures.py
T
khodowany 04c6ea2e00 Soloing
2026-10-03 12:44:53 -05:00

205 lines
12 KiB
Python

"""Bounded target-driven ornaments after strict thematic pair selection.
The main tune and its home identity remain strictly mapped. Every exception is
an atomic upper/lower/target gesture, never a replacement policy for single notes.
"""
from __future__ import annotations
import dataclasses
import math
import time
from fractions import Fraction as F
import generator.model
from generator.solo import types, harmonic, placement, policy, drafts, routes, phrasing
def mapped_pitch(number, start, end, harmony):
if number%12 not in types.permitted(harmony.choices,start,end):return None
span=harmony.at(start)
return types.scale_pitch(span.scale,types.scale_index(span.scale,number))
def approach(target, harmony):
"""Spelling follows the sounding collection, or explicitly spells resolution."""
t=target.start; number=target.pitch.number()
upper=next((p for n in range(number+1,number+4) if (p:=mapped_pitch(n,t-1,t-F(1,2),harmony))),None)
if upper is None:return None
lower=mapped_pitch(number-1,t-F(1,2),t,harmony)
if lower is None:
if target.pitch.alter>0:
lower=dataclasses.replace(target.pitch,alter=target.pitch.alter-1)
else:
letters='cdefgab'; index=letters.index(target.pitch.letter)
letter=letters[(index-1)%7]; octave=target.pitch.octave-(index==0)
alter=number-1-generator.model.NATURAL[letter]-12*(octave+1)
lower=types.Pitch(letter,F(alter),octave)
if not all(placement.positions(p.number(),harmony.profile) for p in (upper,lower)):return None
identity=target.event_id+':enclosure'
return (types.Note(t-1,t-F(1,2),upper,target.sources,'enclosure_upper',event_id=identity+':upper'),
types.Note(t-F(1,2),t,lower,target.sources,'enclosure_lower',event_id=identity+':lower'))
def declared_targets(phrase,harmony):
"""Declare eligible existing stops before proposing their ornaments."""
if not phrase.draft:return ()
terminal={a.pitch.number():a for a in phrase.anchors if a.role=='terminal'}
values=[]
held=phrase.draft.notes[0].event_id if phrase.draft.notes and phrase.draft.notes[0].continuation else None
for note in drafts.local_sounding(phrase.draft.notes):
if note.event_id==held:continue
if note.continuation or note.role=='quote' or note.end-note.start<1:continue
if note.pitch.number()%12 not in harmony.members(note.start,note.end):continue
anchor=terminal.get(note.pitch.number())
role='terminal' if anchor and anchor.earliest<=note.start<=anchor.latest else 'intermediate'
rank=(role!='terminal',phrasing.destination_cost(note.pitch,note.start,note.end,harmony,terminal=role=='terminal'),-(note.end-note.start),note.start)
values.append((rank,note,role))
return tuple((n,role) for _,n,role in sorted(values)[:policy.DEFAULT.enclosure_targets])
def replace_window(notes,start,end,replacement):
"""Only rewrite continuously sounding material; preserve meaningful space."""
cursor=start;prefix=[];suffix=[]
for note in notes:
if note.end<=start:prefix.append(note);continue
if note.start>=end:suffix.append(note);continue
if note.start>cursor:return None
cursor=max(cursor,note.end)
if note.start<start:prefix.append(dataclasses.replace(note,end=start))
if note.end>end:return None # never clip a pre-existing target sustain
if cursor<end:return None
return (*prefix,*replacement,*suffix)
def editable(draft,start,end):
ids=set((*draft.structural,*draft.protected,*(e for f in draft.features for e in f.events)))
return not any(n.start<end and n.end>start and (n.event_id in ids or n.role=='quote') for n in draft.notes) and not any(t.start<end and t.end>start for t in draft.sustained_tails)
def movement(notes):
return sum(max(0,abs(b.pitch.number()-a.pitch.number())-3)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1)
def candidates(phrase,harmony,library,tempo,rejections):
draft=phrase.draft
if not draft:return ()
if draft.notes and draft.notes[0].continuation:
# This local budget does not reopen a committed preceding phrase. Never
# invent a new attack by detaching its already-sounding entrance.
rejections.append({'target':draft.notes[0].event_id,'reason':'cross-phrase sustain retained; no local rewrite of its context'})
return ()
notes=types.sounding_notes(draft.notes);result=[]
for target,role in declared_targets(phrase,harmony):
start=target.start-1
if start<phrase.plan.start or not editable(draft,start,target.start):
rejections.append({'target':target.event_id,'reason':'protected material or no editable approach window'});continue
pair=approach(target,harmony)
if not pair:
rejections.append({'target':target.event_id,'reason':'no mapped upper neighbor or playable approach'});continue
upper,lower=pair
contract=types.EnclosureContract(target.event_id+':enclosure',upper,lower,target,harmony.at(target.start).chord,role,
types.SustainedTail(target.start,target.end,target.pitch.number()))
proposals=[(start,pair,None)]
# Reopen up to two beats before the approach as a whole in-map route. Keep
# the incoming attack and fixed upper endpoint, not independently chosen steps.
for first in reversed(notes):
if not start-2<=first.start<start or first.end>start or not editable(draft,first.end,target.start):continue
templates=tuple(n for n in notes if first.start<=n.start<start)
if not templates or any(a.end!=b.start for a,b in zip(templates,templates[1:])) or templates[-1].end!=start:continue
for route in routes.candidates((*templates,upper),{upper.event_id},harmony,library,(41,76),'mapped approach into enclosure')[:policy.DEFAULT.enclosure_routes]:
proposals.append((first.start,(*route.notes,lower),route))
break
accepted=0
for begin,replacement,route in proposals[:1+policy.DEFAULT.enclosure_routes]:
actual=replace_window(notes,begin,target.start,replacement)
if actual is None:continue
variants=[(actual,contract)]
upper_index=next((i for i,n in enumerate(actual) if n.event_id==upper.event_id),0)
if upper_index:
prior=actual[upper_index-1]
if prior.end==upper.start and prior.pitch==upper.pitch and editable(draft,prior.start,upper.start):
shared=dataclasses.replace(prior,end=upper.end)
combined=(*actual[:upper_index-1],shared,*actual[upper_index+1:])
variants.append((combined,dataclasses.replace(contract,upper=shared,approach_start=upper.start)))
for actual,actual_contract in variants:
valid,error=drafts.contract_valid(draft,actual)
if not valid:continue
checked=harmony.evaluate(actual,phrase.state.treatments if phrase.state else (),until=phrase.plan.end,complete=True,enclosures=(actual_contract,))
if not checked.valid:continue
valid,error=harmonic.anchors_valid(phrase.anchors,actual,phrase.plan.end,complete=True,closure=phrase.plan.closure)
if not valid:continue
placed=placement.place(actual,tempo,profile=harmony.profile)
if placed is None:continue
old_travel=placement.place(notes,tempo,profile=harmony.profile)[0]
delta=.6*(movement(actual)-movement(notes))+.025*(placed[0]-old_travel)/max(1,len(notes))/2000
density=4*len(actual)/float(phrase.plan.end-phrase.plan.start)
before_density=4*len(notes)/float(phrase.plan.end-phrase.plan.start)
repeated=sum(a.pitch.number()==b.pitch.number() and a.end==b.start for a,b in zip(actual,actual[1:]))
old_repeated=sum(a.pitch.number()==b.pitch.number() and a.end==b.start for a,b in zip(notes,notes[1:]))
delta+=.15*max(0,repeated-old_repeated)
delta+=.1*(max(0,density-6)-max(0,before_density-6))
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,transformations=transforms,enclosures=(actual_contract,))
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
retained=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))
result.append((delta,dataclasses.replace(phrase,draft=changed,decisions=decisions,before_enclosure=draft,
routes=(*retained,*((route,) if route else ())))))
accepted+=1
if not accepted:rejections.append({'target':target.event_id,'reason':'no valid complete enclosure within local route budget'})
return tuple(result)
def strict(solo):
"""Recover the exact selected pre-enclosure notes, including their placement."""
phrases=[]
for phrase in solo.phrases:
draft=phrase.before_enclosure
if not draft:phrases.append(phrase);continue
decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in draft.notes if d.start<=n.start<d.end)) for d in phrase.decisions)
state=dataclasses.replace(phrase.state,notes=draft.notes,last=draft.notes[-1] if draft.notes else phrase.state.last) if phrase.state else None
phrases.append(dataclasses.replace(phrase,draft=draft,decisions=decisions,state=state))
return dataclasses.replace(solo,phrases=tuple(phrases),diagnostics={**solo.diagnostics,'enclosures_enabled':False})
def refine(solo,library,enabled=True):
started=time.monotonic();harmony=harmonic.Harmony(solo.choices,solo.profile);rejections=[]
baseline=[]
for phrase in solo.phrases:
draft=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes)) if phrase.draft else None
baseline.append(dataclasses.replace(phrase,draft=draft,before_enclosure=draft))
baseline=tuple(baseline)
if not enabled:
return dataclasses.replace(solo,phrases=baseline,diagnostics={**solo.diagnostics,'enclosures_enabled':False,'enclosure_seconds':time.monotonic()-started})
maximum=math.ceil(policy.DEFAULT.enclosure_max_per_32_bars*float(solo.end)/128)
low,high=2*float(solo.end)/128,3*float(solo.end)/128
states=[(0.,(),())];options_count=0
def sparse(count):return .10*max(0,low-count)+.12*max(0,count-high)
for phrase in baseline:
options=((0.,phrase),*candidates(phrase,harmony,library,solo.tempo,rejections));options_count+=len(options)-1
following=[]
for cost,chosen,arrivals in states:
for delta,option in options:
enclosure=option.draft.enclosures[0] if option.draft and option.draft.enclosures else None
if enclosure and len(arrivals)>=maximum:continue
spacing=.08*max(0,1-float(enclosure.target.start-arrivals[-1][0])/16) if enclosure and arrivals else 0.
repeated=.06*sum(pc==enclosure.target.pitch.number()%12 for _,pc in arrivals) if enclosure else 0.
history=(*arrivals,(enclosure.target.start,enclosure.target.pitch.number()%12)) if enclosure else arrivals
following.append((cost+delta+spacing+repeated,(*chosen,option),history))
states=sorted(following,key=lambda x:(x[0]+sparse(len(x[2])),policy.stable_rank('enclosure-selection',x[2])))[:policy.DEFAULT.enclosure_frontier]
_,phrases,arrivals=min(states,key=lambda x:(x[0]+sparse(len(x[2])),policy.stable_rank('enclosure-selection',x[2])))
# Resolve the complete guitar path once, including both sides of phrase joins.
raw=tuple(n for p in phrases for d in p.decisions for n in d.notes)
placed=placement.place(raw,solo.tempo,profile=harmony.profile)
if placed is None:raise ValueError('selected enclosure solo has no complete guitar path')
by_id={n.event_id:n for n in placed[1]};final=[]
for phrase in phrases:
decisions=tuple(dataclasses.replace(d,notes=tuple(by_id[n.event_id] for n in d.notes)) for d in phrase.decisions)
notes=tuple(n for d in decisions for n in d.notes)
state=dataclasses.replace(phrase.state,notes=notes,last=notes[-1] if notes else phrase.state.last) if phrase.state else None
final.append(dataclasses.replace(phrase,decisions=decisions,draft=dataclasses.replace(phrase.draft,notes=notes) if phrase.draft else None,state=state))
diagnostics={**solo.diagnostics,'enclosures_enabled':True,'enclosure_seconds':time.monotonic()-started,
'enclosure_candidates':options_count,'enclosure_rejections':rejections,'enclosures_selected':len(arrivals)}
return dataclasses.replace(solo,phrases=tuple(final),diagnostics=diagnostics)