568 lines
37 KiB
Python
568 lines
37 KiB
Python
"""Thematic transformations used by the existing complete-draft composer.
|
|
|
|
Source features initialize a heard theme. Subsequent complete routes develop
|
|
that realization, conditioned on the actual incoming musical state.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
from fractions import Fraction as F
|
|
|
|
import generator.model
|
|
import generator.harmony
|
|
import generator.vocabulary
|
|
from generator.solo import types, policy, sequences, routes, features as references, similarity, phrasing, profiles, groups as rhythm_groups
|
|
|
|
|
|
def source_notes(analysis):
|
|
return tuple(dataclasses.replace(n,start=max(F(),n.start-analysis.body_offset),end=n.end-analysis.body_offset,
|
|
event_id=n.sources[0],position=None) for n in analysis.melody if n.end>analysis.body_offset)
|
|
|
|
|
|
|
|
def silence(source,incoming,part,harmony,tempo,seed):
|
|
from generator.solo import realization
|
|
motif=types.Motif('intentional_space',(),source.end-source.start,(),'explicit missing harmony')
|
|
plan=types.PhrasePlan(part+'_'+source.id,source.start,source.end,motif,'space','thematic_recomposition',None,harmony.profile.register,F(),F(),
|
|
(types.ActivitySpan(source.start,source.end,'space',F(),F()),),passage_id=source.id,passage_start=source.start,passage_end=source.end)
|
|
draft=types.MelodicDraft(plan.id,source.start,source.end,(),(),(),(),(),incoming.home,'space',source_phrase=source,source_reference=incoming.source_reference)
|
|
phrase=realization.checked_phrase(plan,draft,(),incoming.treatments,incoming,harmony,tempo)
|
|
if phrase is None:return ()
|
|
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks,
|
|
history=incoming.history,returns=incoming.returns,closures=incoming.closures,register_peak=incoming.register_peak)
|
|
original=dataclasses.replace(draft,notes=source.notes)
|
|
return (dataclasses.replace(phrase,state=state,initial=original,refined=draft,score=0.),)
|
|
|
|
|
|
def short_fragment(source,incoming,part,harmony,tempo,seed,final):
|
|
"""An explicitly reduced pickup around missing harmony; not a new theme."""
|
|
from generator.solo import leads,realization
|
|
reference=incoming.home.notes if incoming.home else source.notes
|
|
if not reference:return ()
|
|
template=dataclasses.replace(reference[0],start=source.start,end=source.end,position=None,continuation=False,event_id=source.id+':reduction')
|
|
result=[]
|
|
for pitch in pitches(harmony,template,incoming.last.pitch.number() if incoming.last else template.pitch.number(),members=True):
|
|
note=dataclasses.replace(template,pitch=pitch)
|
|
motif=types.Motif('reduction',reference,source.end-source.start,(),'explicit short fragment; no claim of full motif fidelity')
|
|
anchor=types.Anchor(note.event_id+':arrival',note.start,note.start,pitch,harmony.members(note.start,note.end),harmony.profile.register,note.end-note.start,'terminal' if final else 'intermediate',harmony.at(note.start).chord,hold_until=note.end)
|
|
transform=types.Transformation(note.event_id,reference[0].event_id,incoming.home.id if incoming.home else '', 'fragment',note.start,note.end,(note.event_id,),((F(),note.end-note.start),),preserves=())
|
|
draft=types.MelodicDraft(part+'_'+note.event_id,source.start,source.end,(note,),(transform,),(note.event_id,),(),(anchor,),incoming.home,'fragment',source_phrase=source,source_reference=incoming.source_reference)
|
|
plan=types.PhrasePlan(draft.id,source.start,source.end,motif,'fragment','thematic_recomposition',None,harmony.profile.register,F(5,4),F(),ending='sustain' if final else 'connected',closure='closed' if final else 'open',passage_id=source.id,passage_start=source.start,passage_end=source.end)
|
|
phrase=realization.checked_phrase(plan,draft,(note,),incoming.treatments,incoming,harmony,tempo)
|
|
if phrase:
|
|
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks,
|
|
history=incoming.history,returns=incoming.returns,closures=incoming.closures,register_peak=incoming.register_peak)
|
|
result.append(dataclasses.replace(phrase,state=state,initial=draft,refined=draft))
|
|
return tuple(result)
|
|
|
|
|
|
def seeds(analysis, bar, seed):
|
|
melody=tuple(dataclasses.replace(n,start=n.start-analysis.body_offset,end=n.end-analysis.body_offset,
|
|
event_id=n.sources[0]) for n in analysis.melody)
|
|
groups=[]
|
|
for i in range(len(melody)):
|
|
for count in ((len(melody),) if len(melody)<3 else (3,4,5,6,7,8)):
|
|
group=melody[i:i+count]
|
|
if len(group)!=count or group[-1].end-group[0].start>max(2*bar,F(3)):
|
|
continue
|
|
local=tuple(dataclasses.replace(n,start=n.start-group[0].start,end=n.end-group[0].start) for n in group)
|
|
parent=next((s.id for s in analysis.source_phrases if s.start<=max(0,group[0].start)<s.end),'pickup')
|
|
for treatment in generator.vocabulary.role().get('preferred_features',('rhythmic_seed','contour_seed','opening_fragment','characteristic_interval')):
|
|
identity=policy.stable_rank(seed,(group,treatment))[:12]
|
|
groups.append(types.SourceReference(identity,parent,group[0].start,group[-1].end,local,treatment))
|
|
def rank(ref):
|
|
rhythm=set(rhythm_groups.signature(ref.notes))
|
|
intervals={b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])}
|
|
preferred=generator.vocabulary.role().get('preferred_features',[])
|
|
preference=preferred.index(ref.treatment) if ref.treatment in preferred else 0
|
|
turn=any(a*b<0 for a,b in zip(ref.notes and tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])),tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:]))[1:]))
|
|
return (-min(4,len(intervals))-.3*turn+.15*preference,-min(3,len(rhythm)),ref.id)
|
|
# Preserve different contracts first. Combining treatment with form position
|
|
# created many categories of the preferred treatment and could crowd every
|
|
# rhythm/contour seed out before a single pitch was constructed.
|
|
return sequences.diverse(groups,rank,lambda r:r.treatment,policy.DEFAULT.theme_seed_limit)
|
|
|
|
|
|
def feature(reference, actual, *, treatment=None):
|
|
treatment=treatment or reference.treatment
|
|
required={'rhythmic_seed':('rhythm',),'contour_seed':('contour',),
|
|
'opening_fragment':('rhythm','pitches'),'characteristic_interval':('exact_intervals',),
|
|
'repeat_with_harmonic_adaptation':('rhythm','contour'),'answer':('contour',),
|
|
'extend':('contour',),'fragment':('rhythm',),'callback':('rhythm','pitches'),
|
|
'return':('rhythm','intervals'), 'develop_rhythm':('rhythm',),
|
|
'develop_contour':('intervals',), 'continue':(), 'connect':(), 'extension':('contour',)}[treatment]
|
|
original=reference.notes
|
|
authored=next((r for r in generator.vocabulary.data('grammar')['transformations'] if r['id']==treatment),None)
|
|
if authored:required=tuple(authored['preserve'])
|
|
origin=original[0].start
|
|
octave=12*round((actual[0].pitch.number()-original[0].pitch.number())/12)
|
|
return types.IdentifyingFeature(reference.id+':'+treatment,tuple(n.event_id for n in actual),
|
|
tuple((n.start-origin,n.end-n.start) for n in original),
|
|
tuple(b.pitch.number()-a.pitch.number() for a,b in zip(original,original[1:])),reference.id,required,
|
|
tuple(n.pitch.number()+octave for n in original))
|
|
|
|
|
|
def rhythmic_purpose(operation, terminal=False):
|
|
if terminal:return 'arrival'
|
|
if operation in ('introduce','callback','return','repeat_with_harmonic_adaptation'):return 'entrance'
|
|
if operation in ('answer','fragment'):return 'answer'
|
|
return 'connect' if operation in ('continue','connect') else 'continue'
|
|
|
|
|
|
def rhythm_cost(pattern,length,purpose):
|
|
"""Evaluate a rhythmic job, not resemblance to any target score or density."""
|
|
if not pattern:return float('inf')
|
|
gaps=[b[0]-a[1] for a,b in zip(pattern,pattern[1:])]
|
|
isolated=sum(b-a<=F(1,2) and prev[1]<a and b<nxt[0]
|
|
for prev,(a,b),nxt in zip(pattern,pattern[1:],pattern[2:]))
|
|
values={b-a for a,b in pattern}
|
|
# Equal eighths can be a single directional run. Uniformity alone is not a
|
|
# musical defect; aimless long-value packets are addressed by construction.
|
|
uniform=.15 if len(pattern)>=4 and len(values)==1 and next(iter(values))>=1 else 0.
|
|
if purpose in ('continue','connect'):
|
|
# Silence may articulate an idea; disconnected tiny islands do not carry
|
|
# unfinished motion. No minimum occupancy or chorus attack quota.
|
|
return isolated+.25*float(sum(gaps,F())/length)+uniform
|
|
if purpose=='arrival':
|
|
return isolated+float(max(F(),F(1)- (pattern[-1][1]-pattern[-1][0])))/2+uniform
|
|
return isolated+uniform
|
|
|
|
|
|
def rhythm_windows(library,meter,length,context,purpose='continue'):
|
|
from generator.solo import drafts
|
|
tempo=getattr(context,'tempo',120)
|
|
key=('theme-rhythm',id(library),meter,length,tempo,purpose)
|
|
if key not in context.pools:
|
|
patterns={}
|
|
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
|
if length>bar:
|
|
# The two-bar catalog contains quarter-note masks. Include combinations
|
|
# of the richer one-bar material as complete timing alternatives, rather
|
|
# than accidentally excluding eighths/dotted values by snippet length.
|
|
# Pitch paths and feature contracts span the assembled rhythm's seams.
|
|
split=bar
|
|
for left,left_id in rhythm_windows(library,meter,split,context,'continue' if purpose=='arrival' else purpose):
|
|
for right,right_id in rhythm_windows(library,meter,length-split,context,purpose):
|
|
combined=(*left,*((a+split,b+split) for a,b in right))
|
|
if len(combined)<=16:patterns.setdefault(combined,left_id+' + '+right_id)
|
|
for identifier,pattern,total in drafts.catalog(library,meter):
|
|
if total<length:continue
|
|
for offset in sorted({F(),*(a for a,_ in pattern)}):
|
|
if offset+length>total or any(a<offset<b or a<offset+length<b for a,b in pattern):continue
|
|
selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length)
|
|
if selected and len(selected)<=16 and rhythm_groups.valid(selected):patterns.setdefault(selected,identifier)
|
|
# Authored cells can start late and continue over computational barlines.
|
|
for cell in generator.vocabulary.rhythm_cells(tempo,purpose):
|
|
identifier,pattern,total=cell.id,cell.pattern,cell.duration
|
|
if total>length:continue
|
|
for offset in ((F(),) if cell.entry=='pickup' else (F(),F(1,2),max(F(),length-total))):
|
|
if offset+total>length:continue
|
|
head=tuple((a+offset,b+offset) for a,b in pattern)
|
|
# Keep representative compatible continuations; never let dictionary
|
|
# insertion order secretly choose the only tail for an authored cell.
|
|
tails=sequences.diverse(tuple(patterns),lambda p:(rhythm_cost(p,length,purpose),policy.stable_rank('rhythm-tail',p)),
|
|
lambda p:(len(p),p[-1][1]-p[-1][0]),2) or ((),)
|
|
for tail in tails:
|
|
if any(a<offset+total<b for a,b in tail):continue
|
|
continuation=tuple((a,b) for a,b in tail if a>=offset+total)
|
|
if cell.exit=='continue' and offset+total<length and (not continuation or continuation[0][0]!=offset+total):continue
|
|
combined=(*head,*continuation)
|
|
if 2<=len(combined)<=16 and rhythm_groups.valid(combined):patterns.setdefault(combined,'authored:'+identifier)
|
|
context.pools[key]=sequences.diverse(tuple(patterns.items()),
|
|
lambda x:(rhythm_cost(x[0],length,purpose)+(0 if x[1].startswith('authored:') else .05),policy.stable_rank('rhythm-window',x[0])),
|
|
lambda x:(x[1].startswith('authored:'),len(x[0]),x[0][0][0],any(a[1]!=b[0] for a,b in zip(x[0],x[0][1:]))),8)
|
|
return context.pools[key]
|
|
|
|
|
|
def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operation='introduce', terminal=False, ending=None):
|
|
length=finish-start; values={}
|
|
purpose=rhythmic_purpose(operation,terminal)
|
|
if home and operation in ('repeat_with_harmonic_adaptation','return'):
|
|
pattern=tuple((n.start,n.end) for n in home.notes if n.end<=length)
|
|
if pattern:values[pattern]='heard-theme'
|
|
prefix=tuple((n.start,n.end) for n in ref.notes)
|
|
fixed_rhythm=ref.treatment in ('rhythmic_seed','opening_fragment','callback') or operation in ('fragment','repeat_with_harmonic_adaptation','return','develop_rhythm')
|
|
if fixed_rhythm:
|
|
duration=prefix[-1][1]
|
|
if duration==length:values[prefix]=ref.id
|
|
if duration<length:
|
|
for tail,label in rhythm_windows(rhythms,meter,length-duration,context,purpose):
|
|
values[(*prefix,*((a+duration,b+duration) for a,b in tail))]=ref.id+' + '+label
|
|
else:
|
|
values.update(rhythm_windows(rhythms,meter,length,context,purpose))
|
|
if home and operation=='answer':
|
|
for p,label in rhythm_windows(rhythms,meter,length,context,purpose):values.setdefault(p,label)
|
|
if not fixed_rhythm:
|
|
# Develop the heard timing, preserving total duration. Eighth/quarter/dotted
|
|
# alternatives are actual contracts, not failed copies of a rhythmic seed.
|
|
for original,label in tuple(values.items()):
|
|
movement=generator.vocabulary.data('melodic_vocabulary')['motion_subdivision']
|
|
for i,(a,b) in enumerate(original[:-1]):
|
|
pulse=F(movement['pulse'])
|
|
if b-a<F(movement['minimum_value']) or (a/pulse).denominator!=1 or ((b-a)/pulse).denominator!=1:continue
|
|
moving=tuple((a+j*pulse,a+(j+1)*pulse) for j in range(int((b-a)/pulse)))
|
|
revised=(*original[:i],*moving,*original[i+1:])
|
|
if len(revised)<=movement['max_attacks']:values[revised]=label+'; moving continuation'
|
|
for i,(a,b) in enumerate(original):
|
|
if b-a==1:
|
|
values[(*original[:i],(a,a+F(1,2)),(a+F(1,2),b),*original[i+1:])]=label+'; diminution'
|
|
for i,((a,b),(c,d)) in enumerate(zip(original,original[1:])):
|
|
if b!=c or any((v*2).denominator!=1 for v in (a,b,c,d)):continue
|
|
for split in (a+F(1,2),a+F(3,2),d-F(1,2)):
|
|
if a<split<d:
|
|
values[(*original[:i],(a,split),(split,d),*original[i+2:])]=label+'; rhythmic alteration'
|
|
if ending:
|
|
arrivals={}
|
|
arrival=ending.earliest-start
|
|
for original,label in values.items():
|
|
try: revised=rhythm_groups.broaden(original,arrival,ending.hold_until-start)
|
|
except ValueError: revised=()
|
|
if revised and len(revised)>=len(ref.notes):
|
|
arrivals[revised]=label+'; planned sounding destination'
|
|
values=arrivals
|
|
values={p:label for p,label in values.items() if rhythm_groups.valid(p) or fixed_rhythm and p==prefix}
|
|
return sequences.diverse(tuple(values.items()),lambda x:(rhythm_cost(x[0],length,purpose),policy.stable_rank(ref.id,x[0])),
|
|
lambda x:(len(x[0]),tuple(b-a for a,b in x[0][:3]),x[0][0][0]),policy.DEFAULT.theme_rhythm_alternatives)
|
|
|
|
|
|
def pitches(harmony,note,base,*,members=False,limit=6,incoming=None):
|
|
from generator.solo.leads import available
|
|
domain=available(harmony,note.start,note.end,members=members or note.end-note.start>=2)
|
|
if members:
|
|
return phrasing.destination_options(domain,lambda p:(phrasing.destination_cost(p,note.start,note.end,harmony,incoming,base),p.number()),
|
|
lambda p:p,lambda p:note.start,harmony,limit)
|
|
return sequences.diverse(domain,lambda p:(abs(p.number()-base),p.number()),
|
|
lambda p:((p.number()-base>0)-(p.number()-base<0),p.number()//12),limit)
|
|
|
|
|
|
def relative_steps(notes,harmony):
|
|
result=[]
|
|
for a,b in zip(notes,notes[1:]):
|
|
scale=harmony.at(b.start).scale
|
|
try:step=types.scale_index(scale,b.pitch.number())-types.scale_index(scale,a.pitch.number())
|
|
except ValueError:
|
|
lo,hi=sorted((a.pitch.number(),b.pitch.number()))
|
|
intervening=range(lo+1,hi+1) if b.pitch.number()>a.pitch.number() else range(lo,hi)
|
|
count=sum(p%12 in scale.pitches for p in intervening)
|
|
step=count if b.pitch.number()>a.pitch.number() else -count
|
|
result.append(step)
|
|
return tuple(result)
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class Unit:
|
|
notes: tuple[types.Note,...]
|
|
feature: types.IdentifyingFeature
|
|
reference: types.SourceReference
|
|
operation: str
|
|
anchor: types.Anchor
|
|
score: float
|
|
source: str
|
|
routes: tuple[types.RouteCandidate,...] = ()
|
|
start: F = F()
|
|
end: F = F()
|
|
expressions: tuple[types.ExpressionContract,...] = ()
|
|
|
|
|
|
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None,allow_expressions=True):
|
|
"""Joint whole-gesture pitch/rhythm actions, conditioned on the actual entry.
|
|
|
|
A complete route is proposed before thematic evidence is checked. No motif
|
|
prefix is spliced to an independently cheapest suffix. Ordinary search ends
|
|
carry no chord-tone obligation; long-note and destination contracts still do.
|
|
"""
|
|
from generator.solo import leads
|
|
key=('joint-gesture-1',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,
|
|
home,operation,ending,goal,batch,allow_expressions,getattr(context,'expressions_enabled',True),id(harmony),id(library),id(rhythms))
|
|
if key in context.results:
|
|
context.diagnostics['cache_hits']+=1;return context.results[key]
|
|
trace=context.diagnostics.setdefault('operations',{}).setdefault(operation,
|
|
dict(eligible=0,proposed=0,valid=0,retained=0,jobs=0,pruned_jobs=0,rejected_by_rule=0))
|
|
trace['eligible']+=1
|
|
require_feature=operation not in ('continue','connect')
|
|
count=len(ref.notes) if require_feature else 0
|
|
jobs=[]
|
|
for pattern,label in patterns(ref,start,finish,rhythms,meter,context,home=home,operation=operation,terminal=terminal,ending=ending):
|
|
if len(pattern)<max(2,count) or len(pattern)>16:continue
|
|
provenance=tuple(dict.fromkeys(s for n in ref.notes for s in n.sources))
|
|
templates=tuple(types.Note(start+a,start+b,ref.notes[0].pitch,provenance,
|
|
'theme' if require_feature else 'connection',event_id=f'{ref.id}:{operation}:{start}:{i}') for i,(a,b) in enumerate(pattern))
|
|
base=incoming.last.pitch.number() if incoming.last else harmony.profile.reference or ref.notes[0].pitch.number()
|
|
firsts=pitches(harmony,templates[0],base,limit=6)
|
|
if incoming.last is None:
|
|
offsets=generator.vocabulary.role().get('register_alternatives',[-7,0,7])
|
|
firsts=tuple(dict.fromkeys(p for offset in offsets for p in pitches(harmony,templates[0],base+offset,limit=2)))
|
|
targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,
|
|
members=False,limit=policy.DEFAULT.theme_destinations,incoming=incoming)
|
|
for first in firsts:
|
|
for target in targets:
|
|
motion=ref.notes[-1].pitch.number()-ref.notes[0].pitch.number()
|
|
if operation=='answer':motion=-motion
|
|
# Rank purposeful direction separately from entry continuity. No reward
|
|
# for minimizing every interval or for repeatedly visiting the midpoint.
|
|
rank=max(0,abs(first.number()-base)-7)/12
|
|
rank+=.1*min(1,abs((target.number()-first.number())-motion)/12)
|
|
jobs.append((rank,templates,label,first,target))
|
|
selected=sequences.diverse(jobs,lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4]))),
|
|
lambda j:(tuple(n.end-n.start for n in j[1]),j[3].number()//12,
|
|
(j[4].number()>j[3].number())-(j[4].number()<j[3].number())),policy.DEFAULT.theme_route_jobs*(batch+1))
|
|
trace['jobs']+=len(jobs);trace['pruned_jobs']+=len(jobs)-len(selected)
|
|
result=[]
|
|
from generator.solo import gestures
|
|
scheduled=[]
|
|
for rank,templates,label,first,target in selected:
|
|
timed=(dataclasses.replace(templates[0],pitch=first),*templates[1:-1],dataclasses.replace(templates[-1],pitch=target))
|
|
scheduled.append((rank,gestures.Proposal(timed,(),'direct'),label))
|
|
if allow_expressions and getattr(context,'expressions_enabled',True):
|
|
for rank,plain,label in tuple(scheduled[:4]):
|
|
scheduled.extend((rank,p,label+'; '+p.purpose) for p in gestures.select(plain.notes,harmony,start,finish,ending,limit=4))
|
|
funnel=context.diagnostics.setdefault('joint_gestures',{})
|
|
def record(purpose,stage,count=1):
|
|
values=funnel.setdefault(purpose,dict(proposed=0,retained_jobs=0,valid_routes=0,retained_units=0))
|
|
values[stage]+=count
|
|
for _,p,_ in scheduled:record(p.purpose,'proposed')
|
|
scheduled=sequences.diverse(scheduled,lambda v:(v[0],policy.stable_rank(ref.id,(v[1].purpose,tuple((n.start,n.end,n.pitch) for n in v[1].notes)))),
|
|
lambda v:tuple(c.kind for c in v[1].contracts) or ('direct',),policy.DEFAULT.theme_route_jobs*(batch+1))
|
|
for _,proposal,label in scheduled:
|
|
record(proposal.purpose,'retained_jobs')
|
|
timed=proposal.notes;first=timed[0].pitch;target=timed[-1].pitch;templates=timed
|
|
if not max(2,count)<=len(timed)<=16:continue
|
|
fixed={timed[-1].event_id}|{n.event_id for n in timed if n.role=='arrival'}
|
|
proposed=list(routes.candidates(timed,fixed,harmony,library,harmony.profile.register,
|
|
operation+' '+ref.id,purpose=rhythmic_purpose(operation,terminal),expressions=proposal.contracts))
|
|
# A substantial literal return is a musical action, not merely the hope
|
|
# that a general interval search will accidentally reproduce the theme.
|
|
if proposal.purpose=='direct' and require_feature and len(templates)==len(ref.notes):
|
|
delta=first.number()-ref.notes[0].pitch.number()
|
|
actual=[]
|
|
for template,n in zip(templates,ref.notes):
|
|
options=leads.available(harmony,template.start,template.end,members=template.end-template.start>=2)
|
|
p=next((p for p in options if p.number()==n.pitch.number()+delta),None)
|
|
if p is None:break
|
|
actual.append(dataclasses.replace(template,pitch=p))
|
|
if len(actual)==len(templates) and actual[-1].pitch==target:
|
|
proposed.append(types.RouteCandidate('theme:'+ref.id,tuple(actual),actual[-1],(),operation,(ref.id,)))
|
|
for route in proposed:
|
|
trace['proposed']+=1
|
|
actual=route.notes
|
|
contract=feature(ref,actual[:count],treatment=operation if operation!='introduce' else None) if count else None
|
|
if contract and references.feature_error(contract,actual):
|
|
trace['rejected_by_rule']+=1;continue
|
|
contracts=proposal.bind(actual)
|
|
checked=harmony.evaluate(actual,until=finish,complete=True,expressions=contracts)
|
|
if not checked.valid:trace['rejected_by_rule']+=1;continue
|
|
trace['valid']+=1
|
|
record(proposal.purpose,'valid_routes')
|
|
entry=max(0,abs(first.number()-incoming.last.pitch.number())-7)/12 if incoming.last else 0.
|
|
score=2*checked.exposure+entry+.1*routes.path_cost(actual)
|
|
score+=.25*rhythm_cost(tuple((n.start-start,n.end-start) for n in actual),finish-start,rhythmic_purpose(operation,terminal))
|
|
result.append(Unit(actual,contract,ref,operation,ending,score,label,(route,),start,finish,contracts))
|
|
unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result}
|
|
chosen=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)),
|
|
lambda u:(routes.path_family(u.notes,harmony),u.notes[0].pitch.number()//12,
|
|
tuple(n.end-n.start for n in u.notes),tuple(c.kind for c in u.expressions)),policy.DEFAULT.theme_unit_alternatives)
|
|
trace['retained']+=len(chosen)
|
|
for u in chosen:
|
|
record(u.source.split('; ')[-1] if '; ' in u.source else 'direct','retained_units')
|
|
context.diagnostics['generated_candidates']+=len(chosen)
|
|
context.results[key]=chosen
|
|
return chosen
|
|
|
|
|
|
def home_reference(home,operation):
|
|
# A return refers to the opening the listener heard, not whichever later
|
|
# three-note turn happens to rank best. Development may use its smaller core.
|
|
notes = home.signature or home.notes[:min(4, len(home.notes))]
|
|
if operation not in ('return', 'repeat_with_harmonic_adaptation'):
|
|
notes = notes[:3]
|
|
origin=notes[0].start
|
|
notes=tuple(dataclasses.replace(n,start=n.start-origin,end=n.end-origin) for n in notes)
|
|
treatment='return' if operation=='return' else 'rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','fragment','develop_rhythm') else 'contour_seed'
|
|
return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes,treatment)
|
|
|
|
|
|
def make_home(unit,harmony,start=None,end=None):
|
|
from generator.solo.realization import make_identity
|
|
start=unit.notes[0].start if start is None else start
|
|
identity=policy.stable_rank('solo-theme',(unit.reference.id,unit.notes))[:16]
|
|
home=make_identity('solo_theme_'+identity,unit.reference.id,unit.notes,start,unit.notes[-1].end if end is None else end,'introduce',harmony)
|
|
return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony),signature=home.notes[:len(unit.reference.notes)])
|
|
|
|
|
|
def operation_choices(home,incoming,cursor,phrase_start,closing):
|
|
available=generator.vocabulary.data('grammar')['operations']
|
|
selected=[]
|
|
for rule in available:
|
|
op=rule['id']
|
|
if rule.get('entry_only') and cursor!=phrase_start:continue
|
|
if rule.get('after_statement') and not home:continue
|
|
if rule.get('needs_recent') and not incoming.notes:continue
|
|
selected.append(op)
|
|
return tuple(selected)
|
|
|
|
|
|
def unit_end(source,cursor,home,ending,bar):
|
|
"""Bound search by time without cutting the heard signature at a barline."""
|
|
length=2*bar
|
|
if home and home.signature:
|
|
length=home.signature[-1].end
|
|
while 0<length<bar:
|
|
length+=home.signature[-1].end
|
|
length=min(2*bar,max(bar,length))
|
|
finish=min(source.end,cursor+length)
|
|
if source.end-finish<bar+2 or ending and ending.earliest<finish:
|
|
finish=source.end
|
|
return finish
|
|
|
|
|
|
def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False,solo_end=None):
|
|
from generator.solo import leads,realization
|
|
if source.end-source.start<2:
|
|
return short_fragment(source,incoming,part,harmony,tempo,seed,final)
|
|
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
|
solo_end=solo_end or max(c.span.end for c in harmony.choices)
|
|
head=source_notes(analysis)
|
|
seed_key=('source-seeds',analysis,bar,seed)
|
|
if seed_key not in context.pools:context.pools[seed_key]=seeds(analysis,bar,seed)
|
|
roots=context.pools[seed_key]
|
|
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=6*(batch+1),final=final)
|
|
if not final:ending_options=(None,*ending_options)
|
|
states=[(0.,(),incoming.home,incoming,ending) for ending in ending_options]
|
|
while states and any(not units or units[-1].end<source.end for _,units,_,_,_ in states):
|
|
next_states=[]
|
|
for cost,units,home,state,ending in states:
|
|
cursor=units[-1].end if units else source.start
|
|
if cursor>=source.end:
|
|
next_states.append((cost,units,home,state,ending))
|
|
continue
|
|
finish=unit_end(source,cursor,home,ending,bar)
|
|
introducing=home is None
|
|
if introducing:
|
|
requests=[(r,'introduce') for r in roots]
|
|
else:
|
|
operations=operation_choices(home,state,cursor,source.start,finish==source.end)
|
|
requests=[(home_reference(home,op),op) for op in operations]
|
|
# A uniform duration alone is not identifying musical evidence.
|
|
requests=[(r,op) for r,op in requests if op!='develop_rhythm' or len({n.end-n.start for n in r.notes})>1 or any(a.end!=b.start for a,b in zip(r.notes,r.notes[1:]))]
|
|
for ref,operation in requests:
|
|
if introducing and ending and source.end-source.start<2*bar and ending.earliest-cursor<ref.notes[-1].end:
|
|
# A missing-harmony boundary can leave less than one full statement.
|
|
# Declare a smaller contour seed; never pretend it proves the full motif.
|
|
count=min(3,int(2*(ending.earliest-cursor)),len(ref.notes))
|
|
if count<2:
|
|
if len(ref.notes)!=1:continue
|
|
n=dataclasses.replace(ref.notes[0],end=min(ref.notes[0].end,F(1),ending.earliest-cursor))
|
|
ref=dataclasses.replace(ref,id=ref.id+':constrained_pitch',notes=(n,),treatment='opening_fragment')
|
|
else:
|
|
ref=dataclasses.replace(ref,id=ref.id+':constrained_contour',notes=ref.notes[:count],treatment='contour_seed')
|
|
if operation=='continue' and state.notes:
|
|
recent=state.notes[-min(3,len(state.notes)):]
|
|
origin=recent[0].start
|
|
ref=dataclasses.replace(ref,notes=tuple(dataclasses.replace(n,start=n.start-origin,end=n.end-origin) for n in recent),treatment='contour_seed')
|
|
if ref.notes[-1].end>finish-cursor:
|
|
reduced=tuple(n for n in ref.notes if n.end<=finish-cursor)
|
|
if len(reduced)<2:continue
|
|
ref=dataclasses.replace(ref,notes=reduced,treatment='contour_seed')
|
|
operation='continue'
|
|
terminal=bool(ending and finish==source.end)
|
|
base=state.last.pitch.number() if state.last else ref.notes[0].pitch.number()
|
|
destination=ending.pitch.number() if ending else phrasing.register_goal(state,part,base,harmony.profile)
|
|
goal=destination if finish==source.end else round(base+(destination-base)*float((finish-cursor)/(source.end-cursor)))
|
|
options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation,
|
|
terminal=terminal,batch=batch,ending=ending if terminal else None,goal=goal,allow_expressions=stage=='elaborated')
|
|
for unit in options:
|
|
if introducing and similarity.near_copy(unit.notes,head,cursor,finish)['near_copy']:
|
|
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
|
|
actual_home=home or make_home(unit,harmony,cursor,finish)
|
|
figure=phrasing.heard(unit.notes,operation,unit.anchor,bar)
|
|
repetition=phrasing.repetition(figure,state.history,bar)
|
|
appearance=phrasing.appearance_cost(operation,state,cursor,solo_end) if home else 0.
|
|
history=phrasing.remember(state.history,figure,bar)
|
|
following=dataclasses.replace(state,home=actual_home,last=unit.notes[-1],notes=unit.notes,source_reference=state.source_reference or ref,
|
|
history=history,returns=(*state.returns,*((cursor,) if operation=='return' else ())),
|
|
register_peak=max(state.register_peak,*(n.pitch.number() for n in unit.notes)))
|
|
next_states.append((cost+float(finish-cursor)*(unit.score+.8*repetition+appearance),(*units,unit),actual_home,following,ending))
|
|
def rank(value):
|
|
cost,units,home,state,ending=value
|
|
elapsed=units[-1].end-source.start
|
|
return cost/max(.001,float(elapsed)),policy.stable_rank(seed,tuple(u.notes for u in units))
|
|
states=sequences.diverse(next_states,rank,
|
|
lambda v:(v[2].id,(v[4].pitch.number(),v[4].duration) if v[4] else 'open',len(v[1][-1].notes),v[1][-1].operation),policy.DEFAULT.theme_continuations)
|
|
if not states:return ()
|
|
result=[]
|
|
for cost,units,home,state,ending in states:
|
|
plain=tuple(n for u in units for n in u.notes)
|
|
if similarity.near_copy(plain,head,source.start,source.end)['near_copy']:
|
|
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
|
|
features=tuple(u.feature for u in units if u.feature)
|
|
# Cell tails are not compulsory melodic arrivals. Only the selected close
|
|
# has a sounding contract; intermediate turns remain editable.
|
|
anchors=(ending,) if ending else ()
|
|
tails=tuple(types.SustainedTail(n.start,n.end,n.pitch.number()) for n in plain if n.role=='arrival')
|
|
if ending:tails=(*tails,types.SustainedTail(ending.earliest,ending.hold_until,ending.pitch.number()))
|
|
options=[(plain,(),())]
|
|
for notes,edits,selected_routes in options:
|
|
identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,leads.signature(notes))[:12]
|
|
transforms=tuple(types.Transformation(identifier+f':{i}',u.reference.id,home.id,u.operation,u.notes[0].start,u.notes[-1].end,
|
|
tuple(n.event_id for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),tuple((n.start-u.notes[0].start,n.end-u.notes[0].start) for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),preserves=(),source=u.reference.id+'; rhythm '+u.source) for i,u in enumerate(units))
|
|
source_ref=state.source_reference
|
|
draft=types.MelodicDraft(identifier,source.start,source.end,notes,transforms,tuple(n.event_id for n in notes if n.role=='arrival'),(),anchors,home,'introduce' if not incoming.home else 'develop',
|
|
features=features,reference=source_ref.id,reference_motif=home,source_phrase=source,source_reference=source_ref,
|
|
source_links=tuple((n.event_id,n.sources) for n in notes),sustained_tails=tails,
|
|
expressions=tuple(c for u in units for c in u.expressions))
|
|
motif=types.Motif(home.id,home.notes,home.duration,(), 'realized solo theme from '+source_ref.id)
|
|
landmarks=(types.Landmark(ending.earliest,ending.latest,ending.pitch.number(),ending.pitch.number(),0,'sounding destination'),) if ending else ()
|
|
closed=bool(ending and ending.role=='terminal')
|
|
plan=types.PhrasePlan(identifier,source.start,source.end,motif,draft.relationship,'thematic_recomposition',None,harmony.profile.register,F(5,4),
|
|
source.end-ending.hold_until if ending else F(),phrasing.activity(source,ending,tuple(n for n in notes if n.role=='arrival')),landmarks,
|
|
'sustain' if ending else 'connected','closed' if closed else 'open',seed,
|
|
source.id,source.start,source.end,(anchors,))
|
|
before_rhythm=draft
|
|
from generator.solo import gesture_rhythm
|
|
if stage=='elaborated':
|
|
draft,rhythm_edits=gesture_rhythm.revise(draft,harmony,incoming.treatments)
|
|
notes=draft.notes;edits=(*edits,*rhythm_edits);transforms=draft.transformations
|
|
phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits)
|
|
if phrase is None:continue
|
|
history=incoming.history
|
|
for transform in transforms:
|
|
group=tuple(n for n in notes if n.event_id in transform.events)
|
|
if group:history=phrasing.remember(history,phrasing.heard(group,transform.operation,ending if ending and group[-1].end==ending.hold_until else None,bar),bar)
|
|
current=dataclasses.replace(phrase.state,next_destination=phrasing.next_destination(ending,harmony) if ending else incoming.next_destination,home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units),
|
|
history=history,returns=state.returns,closures=incoming.closures+int(closed),register_peak=max(state.register_peak,*(n.pitch.number() for n in notes)))
|
|
if not incoming.home:
|
|
introduced=tuple(n for n in notes if n.start<units[0].notes[-1].end)
|
|
heard=dataclasses.replace(home,notes=tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start,position=None) for n in introduced),
|
|
degree_steps=relative_steps(introduced,harmony),signature=tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start,position=None) for n in introduced[:len(units[0].reference.notes)]))
|
|
current=dataclasses.replace(current,home=heard,themes=(heard,))
|
|
source_control=leads.shift(source.notes,next((o for o in (0,1,-1,2,-2) if all(41<=n.pitch.number()+12*o<=76 for n in source.notes)),0))
|
|
original=dataclasses.replace(draft,id=identifier+':source',notes=source_control,features=(),anchors=(),structural=(),sustained_tails=())
|
|
refined=dataclasses.replace(draft,id=identifier+':plain',notes=plain)
|
|
result.append(dataclasses.replace(phrase,state=current,initial=original,refined=refined,before_rhythm=before_rhythm,routes=(*tuple(r for u in units for r in u.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 notes) for n in r.notes)),*selected_routes)))
|
|
from generator.solo import expressions,gesture_rhythm
|
|
if stage=='elaborated':
|
|
revisions=[]
|
|
for base in result:
|
|
for changed,edit,route in gesture_rhythm.join_variants(base.draft,harmony,library):
|
|
revised=realization.checked_phrase(base.plan,changed,changed.notes,incoming.treatments,incoming,harmony,tempo,(*base.edits,edit))
|
|
if revised is None:continue
|
|
state=dataclasses.replace(base.state,notes=revised.state.notes,last=revised.state.last,
|
|
motifs=revised.state.motifs,travel=revised.state.travel)
|
|
history=incoming.history
|
|
for t in changed.transformations:
|
|
heard=tuple(n for n in changed.notes if n.event_id in t.events)
|
|
if heard:history=phrasing.remember(history,phrasing.heard(heard,t.operation,bar=bar),bar)
|
|
state=dataclasses.replace(state,history=history)
|
|
retained=tuple(r for r in base.routes if not any(n.start<edit.end and n.end>edit.start for n in r.notes))
|
|
revisions.append(dataclasses.replace(revised,state=state,initial=base.initial,refined=base.refined,
|
|
before_rhythm=base.draft,routes=(*retained,route)))
|
|
context.diagnostics['join_revision_candidates']=context.diagnostics.get('join_revision_candidates',0)+len(revisions)
|
|
result.extend(revisions)
|
|
if stage=='elaborated' and getattr(context,'expressions_enabled',True):
|
|
result=expressions.joint_candidates(result,incoming,harmony,tempo,context,bar=bar)
|
|
selected=sequences.diverse(result,lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
|
|
lambda p:(p.state.home.id,p.plan.closure,p.state.last.pitch.number(),tuple(e.kind for e in p.draft.expressions)),policy.DEFAULT.phrase_alternatives)
|
|
context.diagnostics.setdefault('evaluations',[]).extend(
|
|
{'id':p.draft.id,'retained':p in selected,'evaluation':dataclasses.asdict(p.evaluation)} for p in sorted(result,key=lambda p:p.score)[:12] if p.evaluation)
|
|
return selected
|