Files
product-vamp/generator/solo/composer.py
T
2026-10-05 19:44:33 -05:00

58 lines
3.6 KiB
Python

"""The sole production solo composer: frozen learned model plus music validity."""
import dataclasses
import time
from fractions import Fraction as F
from pathlib import Path
from generator import harmonic_time, vocabulary
from generator.solo import types, profiles, harmonic, placement, learned, decoder, groups
def compose(analysis,measures,part,end,*,learned_model=None,initial=None,expressions_enabled=True,start=F()):
began=time.monotonic()
spans=tuple(dataclasses.replace(s,start=s.start+start,end=s.end+start) for s in harmonic_time.read({'measures':measures},initial=initial))
profile=profiles.for_part(part,analysis);harmony=harmonic.Harmony(spans,profile)
model=learned_model or learned.Model.load(Path(__file__).resolve().parents[2]/'data/models/solo.json')
settings=vocabulary.role(part+'_solo')
builder=decoder.Decoder(analysis,harmony,model,part,start,end,expressions_enabled=expressions_enabled,
attempts=settings.get('attempts',4),seed=settings.get('choice_seed',''))
selected=builder.compose();decoded=time.monotonic()
placed=placement.place(selected.notes,analysis.tempo,profile=profile)
if placed is None:raise decoder.SearchExhausted('learned solo has no complete legal guitar path')
# Phrase labels describe heard rests/arrivals. They do not impose a new
# endpoint or reset the learned history at barlines or arbitrary windows.
notes=placed[1];ends=[n.end for i,n in enumerate(notes[:-1]) if notes[i+1].start-n.end>=1]
boundaries=[start,*[t for t in ends if start<t<end],end];phrases=[]
home=tuple(n for n in notes if n.start<(ends[0] if ends else min(end,start+8)))
for i,(a,b) in enumerate(zip(boundaries,boundaries[1:])):
ns=tuple(n for n in notes if a<=n.start<b)
cs=tuple(c for c in selected.contracts if a<=c.target.start<b)
ending='handoff' if b==end else 'settle'
phrases.append(types.Phrase(f'learned_{i+1}',a,b,ns,'learned_sequence','learned',ending,
expressions=cs,groups=groups.describe(tuple((n.start,n.end) for n in ns)),
theme='introduced' if i==0 else 'conditional_history',identity=home if i==0 else ()))
result=types.Solo(part,spans,tuple(phrases),analysis.tempo,end,
{'construction':'learned_joint_events','version':learned.VERSION,'decode_seconds':decoded-began,
'placement_seconds':time.monotonic()-decoded,'selection_total':selected.cost,
'objective':'seeded sampling from validated learned joint-event likelihoods; score is diagnostic, not a complete-solo ranking',
'budget':{'attempts':builder.attempts},
'counts':dict(builder.counts),'frontiers':builder.trace,'completed_candidates':[
{'rank':i+1,'cost':s.cost,'events':[(str(a),str(b),p) for a,b,p in s.events]} for i,s in enumerate(builder.completed)],
'expressions_enabled':expressions_enabled},profile,start)
validate(result);return result
def validate(solo):
previous=None
for n in solo.notes():
if n.start<solo.start or n.end>solo.end or n.end<=n.start or previous and previous.end>n.start:raise ValueError('overlapping/out-of-bounds sounding event')
if n.position not in placement.positions(n.pitch.number(),solo.profile):raise ValueError('invalid guitar placement')
previous=n
checked=harmonic.Harmony(solo.harmony).evaluate(solo.notes(),until=solo.end,complete=True,
expressions=tuple(c for p in solo.phrases for c in p.expressions))
if not checked.valid:raise ValueError(checked.error)
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
if cursor!=solo.end:raise ValueError('incomplete solo duration')