Save
This commit is contained in:
@@ -31,11 +31,14 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
|
||||
raise ValueError(f'{part} solo, source measure {choice.span.source_measure}, beat {choice.span.beat}, {generator.harmony.symbol(choice.span.chord)}: No exact match within the permitted catalog')
|
||||
from generator.solo import leads
|
||||
context = context if context is not None else generator.solo.realization.SearchContext()
|
||||
context.expressions_enabled = enclosures_enabled
|
||||
options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode)
|
||||
result = options[0][1]
|
||||
result.diagnostics['strict_composition_seconds'] = time.monotonic()-started
|
||||
from generator.solo import expressions
|
||||
result = expressions.refine(result,enclosures_enabled and stage=='elaborated' and mode=='thematic')
|
||||
if mode!='thematic':result = expressions.refine(result,False)
|
||||
result.diagnostics['enclosures_enabled']=enclosures_enabled
|
||||
result.diagnostics['expressions_selected']=[e.kind for p in result.phrases for e in p.draft.expressions]
|
||||
validate(result)
|
||||
return result
|
||||
|
||||
|
||||
@@ -89,6 +89,17 @@ def validate(notes,contracts,harmony):
|
||||
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=='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'
|
||||
@@ -116,6 +127,34 @@ def validate(notes,contracts,harmony):
|
||||
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
|
||||
@@ -160,6 +199,73 @@ def proposals(phrase,harmony):
|
||||
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=(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=(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):
|
||||
|
||||
@@ -4,7 +4,44 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
|
||||
from generator.solo import drafts, harmonic, types
|
||||
from generator.solo import drafts, harmonic, types, groups
|
||||
|
||||
|
||||
def join_variants(draft,harmony,library):
|
||||
"""Recompose both sides of an editable internal join as one complete route.
|
||||
|
||||
Keep endpoints and exact span. A provisional held tail can reopen into two
|
||||
binary attacks; a committed hold or identifying event is never consumed.
|
||||
Alternatives are returned to the authoritative phrase evaluator.
|
||||
"""
|
||||
from generator.solo import routes, policy
|
||||
fixed=set(draft.protected)|set(draft.structural)|{e for f in draft.features for e in f.events}
|
||||
produced=0
|
||||
for join in (t.start for t in draft.transformations[1:]):
|
||||
inside=tuple(n for n in draft.notes if join-2<=n.start and n.end<=join+2)
|
||||
if len(inside)<3 or any(n.event_id in fixed or n.role=='quote' for n in inside[1:-1]):continue
|
||||
if any(t.start<inside[-1].end and t.end>inside[0].start for t in draft.sustained_tails):continue
|
||||
pattern=tuple((n.start-inside[0].start,n.end-inside[0].start) for n in inside)
|
||||
if not groups.valid(pattern):continue
|
||||
alternatives=[inside]
|
||||
for i,n in enumerate(inside[1:-1],1):
|
||||
if n.end==join and n.end-n.start>=1 and (n.start*2).denominator==1:
|
||||
cut=n.end-F(1,2)
|
||||
alternatives.append((*inside[:i],dataclasses.replace(n,end=cut),
|
||||
dataclasses.replace(n,start=cut,event_id=n.event_id+':extend'),*inside[i+1:]))
|
||||
break
|
||||
for template in alternatives:
|
||||
for route in routes.candidates(template,{template[-1].event_id},harmony,library,harmony.profile.register,'two-sided join'):
|
||||
if route.notes==inside:continue
|
||||
actual=tuple(n for n in draft.notes if n.end<=inside[0].start)+route.notes+tuple(n for n in draft.notes if n.start>=inside[-1].end)
|
||||
if not drafts.contract_valid(draft,actual)[0] or not harmony.evaluate(actual,until=draft.end,complete=True).valid:continue
|
||||
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,id=draft.id+':join:'+route.id,notes=actual,transformations=transforms)
|
||||
yield changed,types.DraftEdit('two_sided_join',inside[0].start,inside[-1].end,
|
||||
tuple(n.event_id for n in inside),tuple(n.event_id for n in route.notes),'complete route over both sides; endpoints and contracts preserved'),route
|
||||
produced+=1
|
||||
if produced>=policy.DEFAULT.join_alternatives:return
|
||||
|
||||
|
||||
def fragmentation(notes):
|
||||
@@ -71,6 +108,7 @@ def revise(draft, harmony, treatments=()):
|
||||
if len(proposals)>=8: break
|
||||
valid=[]
|
||||
for actual in proposals[:8]:
|
||||
if not groups.valid(tuple((n.start-draft.start,n.end-draft.start) for n in actual)):continue
|
||||
if fragmentation(actual)>=fragmentation(notes): continue
|
||||
if not drafts.contract_valid(draft,actual)[0]: continue
|
||||
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True).valid: continue
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Exact rhythmic groups for generated material; imported music is not quantized.
|
||||
|
||||
Binary boundaries may be displaced together. Tuplet runs must return to their
|
||||
entry subdivision phase before binary material resumes. A search boundary is
|
||||
not a rhythmic boundary and must not be used to clip a group.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RhythmGroup:
|
||||
start: F
|
||||
end: F
|
||||
family: str
|
||||
events: tuple[tuple[F, F], ...]
|
||||
displacement: F = F()
|
||||
source: str = ''
|
||||
|
||||
|
||||
def describe(pattern, *, origin=F(), source='', imported=False):
|
||||
if not pattern:return ()
|
||||
if imported:
|
||||
return (RhythmGroup(origin, origin+pattern[-1][1], 'source', pattern, source=source),)
|
||||
result=[]; i=0
|
||||
while i<len(pattern):
|
||||
a,b=pattern[i]
|
||||
if b<=a or i and a<pattern[i-1][1]:raise ValueError('overlapping rhythm group')
|
||||
# A complete ternary figure can be intentionally displaced on an eighth.
|
||||
if (b-a).denominator%3==0:
|
||||
if (a*2).denominator!=1:raise ValueError('unexplained tuplet entry phase')
|
||||
start=i; cursor=a
|
||||
while i<len(pattern):
|
||||
x,y=pattern[i]
|
||||
if x!=cursor or (y-x).denominator not in (3,6):break
|
||||
cursor=y;i+=1
|
||||
if (cursor-a).denominator==1:break
|
||||
if i==start or (cursor-a).denominator!=1:
|
||||
raise ValueError('incomplete tuplet group')
|
||||
group=pattern[start:i]
|
||||
result.append(RhythmGroup(origin+a,origin+cursor,'triplet',group,a%1,source))
|
||||
else:
|
||||
if (a*4).denominator!=1 or (b*4).denominator!=1:
|
||||
raise ValueError('unexplained binary subdivision phase')
|
||||
family='sixteenth' if (a*2).denominator!=1 or (b*2).denominator!=1 else 'binary'
|
||||
result.append(RhythmGroup(origin+a,origin+b,family,((a,b),),a%1,source));i+=1
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def valid(pattern):
|
||||
try:describe(pattern);return True
|
||||
except ValueError:return False
|
||||
|
||||
|
||||
def signature(notes):
|
||||
"""Translation-invariant duration, inter-onset and rest vocabulary."""
|
||||
return tuple((n.end-n.start, notes[i+1].start-n.end if i+1<len(notes) else F())
|
||||
for i,n in enumerate(notes))
|
||||
|
||||
|
||||
def broaden(pattern, arrival, release):
|
||||
"""Reopen an editable binary tail; never slice a tuplet or inherit its phase."""
|
||||
if (arrival*2).denominator!=1 or (release*2).denominator!=1:return ()
|
||||
for group in describe(pattern):
|
||||
if group.family=='triplet' and group.start<arrival<group.end:return ()
|
||||
result=tuple((a,min(b,arrival)) for a,b in pattern if a<arrival)+((arrival,release),)
|
||||
return result if valid(result) else ()
|
||||
@@ -150,15 +150,15 @@ class Harmony:
|
||||
|
||||
def evaluate(self, notes, treatments=(), *, until=None, complete=False, enclosures=(), expressions=()):
|
||||
notes = generator.solo.types.sounding_notes(notes)
|
||||
from generator.solo.expressions import validate
|
||||
permissions, expression_error = validate(notes, expressions, self)
|
||||
if expression_error:
|
||||
return Result(False, 0, error=expression_error)
|
||||
from generator.solo.expressions import validate_frontier
|
||||
explicit_frontier=until is not None
|
||||
until = until if until is not None else max((n.end for n in notes), default=fractions.Fraction())
|
||||
permissions, expression_pending, expression_error = validate_frontier(notes,expressions,self,until,complete or not explicit_frontier)
|
||||
if expression_error:return Result(False,0,error=expression_error)
|
||||
ornaments, error = self.enclosure_events(notes, enclosures, until) if enclosures else ({}, '')
|
||||
if error:
|
||||
return Result(False, 0, error=error)
|
||||
pending, valid_contracts = [], []
|
||||
pending, valid_contracts = list(expression_pending), []
|
||||
for treatment in treatments:
|
||||
if treatment.start >= until:
|
||||
continue
|
||||
|
||||
+15
-11
@@ -27,13 +27,17 @@ def shift(notes, octave):
|
||||
|
||||
|
||||
def available(harmony, start, end, *, members=False):
|
||||
span = harmony.at(start)
|
||||
allowed = types.permitted(harmony.choices, start, end)
|
||||
if members:
|
||||
allowed &= harmony.members(start, end)
|
||||
if not span or not span.scale:
|
||||
return ()
|
||||
return tuple(p for i in range(-35, 28) if harmony.profile.register[0] <= (p := types.scale_pitch(span.scale, i)).number() <= harmony.profile.register[1] and p.number()%12 in allowed and placement.positions(p.number(),harmony.profile))
|
||||
if not hasattr(harmony,'available_pitches'):harmony.available_pitches={}
|
||||
span=harmony.at(start)
|
||||
allowed=types.permitted(harmony.choices,start,end)
|
||||
if members:allowed &= harmony.members(start,end)
|
||||
if not span or not span.scale:return ()
|
||||
key=(span.scale,allowed,harmony.profile)
|
||||
if key not in harmony.available_pitches:
|
||||
harmony.available_pitches[key]=tuple(p for i in range(-35,28)
|
||||
if harmony.profile.register[0]<=(p:=types.scale_pitch(span.scale,i)).number()<=harmony.profile.register[1]
|
||||
and p.number()%12 in allowed and placement.positions(p.number(),harmony.profile))
|
||||
return harmony.available_pitches[key]
|
||||
|
||||
|
||||
def adapt(notes, harmony):
|
||||
@@ -350,6 +354,7 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
|
||||
if elapsed>start:
|
||||
meter=measure['time_signature'];break
|
||||
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
||||
harmony.bar=bar
|
||||
for source in sources:
|
||||
key=('phrasing',source,incoming,part,end,stage,seed,id(library),id(rhythms),policy.DEFAULT,harmony.profile)
|
||||
if key in context.results:
|
||||
@@ -367,11 +372,10 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
|
||||
context.results[key]=pool
|
||||
context.diagnostics.setdefault('source_candidate_pools',{}).setdefault(source.id,[]).extend(
|
||||
{'id':p.draft.id,'score':p.score,'attacks':len(p.draft.notes),'theme':p.state.home.id if p.state.home else None,
|
||||
'ending':str(p.anchors[-1].duration) if p.anchors else None,'returns':len(p.state.returns)} for p in pool)
|
||||
'ending':str(p.anchors[-1].duration) if p.anchors else None,'returns':len(p.state.returns),'evaluation':dataclasses.asdict(p.evaluation) if p.evaluation else None} for p in pool)
|
||||
for candidate in pool:
|
||||
span=float((source.end-source.start)/end)
|
||||
total=cost+span*(candidate.score+phrasing.endpoint_cost(source.end,start,end,incoming.closures,harmony,bar))
|
||||
total+=transition(history[-1] if history else None,candidate)
|
||||
total=cost+span*candidate.score
|
||||
following.append((total,(*history,candidate)))
|
||||
if not pool:failures.append(f'{source.id}: planned sustained destination, feature or incoming continuation')
|
||||
if not following:
|
||||
@@ -385,7 +389,7 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
|
||||
finished=[(cost,history) for cost,history in states if history[-1].plan.end==end]
|
||||
if not finished:raise realization.SearchExhausted(f'{part}: phrase-boundary search budget exhausted; not proof of incompatibility')
|
||||
return tuple((rank((cost,phrases))[0],types.Solo(part,choices,phrases,analysis.tempo,end,
|
||||
{**context.diagnostics,'construction':'thematic_recomposition','policy':policy.describe()['fingerprint']},
|
||||
{**context.diagnostics,'selection_total':rank((cost,phrases))[0], 'selection_scope':'duration-weighted complete phrase evaluations; partial frontiers use elapsed-duration rates', 'construction':'thematic_recomposition','policy':policy.describe()['fingerprint']},
|
||||
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases),harmony.profile)) for cost,phrases in finished)
|
||||
|
||||
|
||||
|
||||
@@ -57,9 +57,13 @@ def register_goal(incoming, part, fallback, profile=None):
|
||||
if profile and profile.reference is not None:
|
||||
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
|
||||
center = round(sum(recent)/len(recent)) if recent else profile.reference
|
||||
trajectory=generator.vocabulary.role(part+'_solo').get('register_motion',[0,4,-3,0])
|
||||
phase=min(len(trajectory)-1,incoming.closures%len(trajectory))
|
||||
goal=profile.reference+trajectory[phase]
|
||||
# Follow a whole-gesture excursion with a possible return, not an
|
||||
# instrument-specific octave or a repeating register cycle.
|
||||
if center>profile.reference+7:goal=profile.reference
|
||||
elif center<profile.reference-7:goal=profile.reference
|
||||
else:
|
||||
motion=incoming.direction*4 if incoming.home else 0
|
||||
goal=profile.reference+motion
|
||||
return max(profile.register[0]+3,min(profile.register[1]-3,round((center+goal)/2)))
|
||||
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
|
||||
if not recent:
|
||||
@@ -127,7 +131,7 @@ def endings(source, incoming, part, harmony, bar, *, limit=8, final=False):
|
||||
for pitch in available(harmony,arrival,arrival+hold,members=True):
|
||||
if abs(pitch.number()-goal)>12:continue
|
||||
anchor=types.Anchor(f'{source.id}:close:{arrival}:{pitch.number()}',arrival,arrival,pitch,
|
||||
harmony.members(arrival,arrival+hold),harmony.profile.register,hold,'terminal',harmony.at(arrival).chord,
|
||||
harmony.members(arrival,arrival+hold),harmony.profile.register,hold,'terminal' if final or ending_effect(arrival,harmony)=='cadential_arrival' else 'intermediate',harmony.at(arrival).chord,
|
||||
hold_until=arrival+hold,effect=ending_effect(arrival,harmony,final))
|
||||
cadence=sum(h.ending==(pitch.number()%12,arrival%bar,hold) for h in incoming.history)
|
||||
rank=harmony.profile.cost(pitch.number())+destination_cost(pitch,arrival,arrival+hold,harmony,incoming,goal,True)+.16*min(2,cadence)+.035*abs(float(hold)-3)
|
||||
@@ -145,6 +149,7 @@ def appearance_cost(operation, incoming, start, end):
|
||||
|
||||
|
||||
def activity(source, ending):
|
||||
if ending is None:return (types.ActivitySpan(source.start,source.end,'active',F(5,4),F()),)
|
||||
spans=[]
|
||||
if ending.earliest>source.start:
|
||||
spans.append(types.ActivitySpan(source.start,ending.earliest,'active',F(5,4),F()))
|
||||
@@ -154,17 +159,6 @@ def activity(source, ending):
|
||||
return tuple(spans)
|
||||
|
||||
|
||||
def evaluate(notes, incoming, harmony, start, end, bar):
|
||||
"""Final musical cost uses a complete sounding phrase, never cell totals."""
|
||||
notes=types.sounding_notes(notes)
|
||||
checked=harmony.evaluate(notes,until=end,complete=True)
|
||||
if not checked.valid or not notes:return float('inf')
|
||||
density=4*len(notes)/float(end-start)
|
||||
entry=max(0,abs(notes[0].pitch.number()-incoming.last.pitch.number())-5)/12 if incoming.last else 0.
|
||||
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1)
|
||||
history_cost=repetition(heard(notes,'develop'),incoming.history,bar)
|
||||
return harmony.profile.cost(profiles.weighted_center(notes))+2*checked.exposure+entry+leaps+.8*history_cost
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def defining_members(symbol):
|
||||
|
||||
@@ -50,7 +50,7 @@ class Policy:
|
||||
enclosure_routes: int = 4
|
||||
enclosure_frontier: int = 8
|
||||
enclosure_max_per_32_bars: int = 4
|
||||
composition_version: str = 'purposeful-phrases-2'
|
||||
composition_version: str = 'joint-gestures-1'
|
||||
|
||||
# Preserve the former default root seeds while decoupling them from policy edits.
|
||||
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
||||
|
||||
@@ -47,10 +47,9 @@ def for_part(part, analysis=None):
|
||||
strings = (1, 2, 3, 4, 5, 6)
|
||||
if analysis is None or not analysis.melody:
|
||||
return Instrument(part, strings)
|
||||
# Analysis contains the prepared concert source. Electric melody is written
|
||||
# two octaves up and sounds one down (parts.build_measure), hence +12.
|
||||
# Use the source voice, never the highest added harmonization voice.
|
||||
shift = generator.vocabulary.role(part + '_solo').get('register_offset', 12 if part == 'electric' else 0)
|
||||
notes = tuple(n for n in analysis.melody if n.end > analysis.body_offset)
|
||||
return Instrument(part, strings, weighted_center(notes) + shift,
|
||||
(min(n.pitch.number() for n in notes) + shift, max(n.pitch.number() for n in notes) + shift))
|
||||
# Source identity is independent of sounding register. Roles have soft,
|
||||
# overlapping centers; all legal positions remain available to both guitars.
|
||||
center=generator.vocabulary.role(part+'_solo').get('register_center',57 if part=='acoustic' else 62)
|
||||
notes=tuple(n for n in analysis.melody if n.end>analysis.body_offset)
|
||||
return Instrument(part,strings,center,
|
||||
(min(n.pitch.number() for n in notes),max(n.pitch.number() for n in notes)))
|
||||
|
||||
@@ -510,39 +510,45 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit
|
||||
except ValueError:
|
||||
return None
|
||||
complete = plan.end == (plan.passage_end or plan.end)
|
||||
checked = harmony.evaluate(canonical, contracts, until=plan.end, complete=complete, enclosures=draft.enclosures)
|
||||
checked = harmony.evaluate(canonical, contracts, until=plan.end, complete=complete, enclosures=draft.enclosures, expressions=draft.expressions)
|
||||
valid, _ = generator.solo.harmonic.anchors_valid(draft.anchors, canonical, plan.end, complete=complete, closure=plan.closure)
|
||||
if not checked.valid or not valid or not target_ok(notes, plan.target):
|
||||
return None
|
||||
shared = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
|
||||
score_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
|
||||
evaluation_key = (plan, draft.anchors)
|
||||
if evaluation_key not in harmony.evaluations:
|
||||
harmony.evaluations[evaluation_key] = generator.solo.scoring.Evaluation(evaluation_plan(plan, draft.anchors), harmony.choices, plan.end)
|
||||
evaluation = harmony.evaluations[evaluation_key]
|
||||
if not evaluation.feasible(score_notes):
|
||||
return None
|
||||
features = evaluation.features(score_notes, previous=incoming.last if not shared else None, movement=travel + (incoming.travel if shared else 0))
|
||||
features.update(generator.solo.drafts.relationship_features(draft, notes))
|
||||
if draft.lead_identity or draft.source_phrase:
|
||||
supported = {e for feature in draft.features for e in feature.events}
|
||||
motions = [(a, b) for a, b in zip(notes, notes[1:]) if not (a.event_id in supported and b.event_id in supported)]
|
||||
features['contour'] = sum(min(1, max(0, abs(b.pitch.number() - a.pitch.number()) - 5) / 12) for a, b in motions) / max(1, len(motions))
|
||||
# Fidelity is checked against explicit transformed identities, not against
|
||||
# the source head as one undifferentiated phrase-long quotation.
|
||||
features.pop('fidelity', None)
|
||||
features['harmonic_exposure'] = checked.exposure
|
||||
intended_repeats = 0
|
||||
if draft.home:
|
||||
reference = draft.home.notes
|
||||
for transform in draft.transformations:
|
||||
actual = tuple(n for n in notes if n.event_id in transform.events)
|
||||
for i, (a, b, c) in enumerate(zip(actual, actual[1:], actual[2:])):
|
||||
expected = tuple(reference[j % len(reference)].pitch for j in (i, i + 1, i + 2)) if reference else ()
|
||||
if a.pitch == b.pitch == c.pitch and len(expected) == 3 and expected[0] == expected[1] == expected[2]:
|
||||
intended_repeats += 1
|
||||
features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(score_notes) - 2))
|
||||
score = generator.solo.scoring.value(features)
|
||||
musical = None
|
||||
if plan.treatment == 'thematic_recomposition':
|
||||
musical = generator.solo.scoring.musical(plan,draft,notes,incoming,harmony,travel=travel)
|
||||
if not musical.valid:return None
|
||||
score = musical.total
|
||||
else:
|
||||
evaluation_key = (plan, draft.anchors)
|
||||
if evaluation_key not in harmony.evaluations:
|
||||
harmony.evaluations[evaluation_key] = generator.solo.scoring.Evaluation(evaluation_plan(plan, draft.anchors), harmony.choices, plan.end)
|
||||
evaluation = harmony.evaluations[evaluation_key]
|
||||
if not evaluation.feasible(score_notes):
|
||||
return None
|
||||
features = evaluation.features(score_notes, previous=incoming.last if not shared else None, movement=travel + (incoming.travel if shared else 0))
|
||||
features.update(generator.solo.drafts.relationship_features(draft, notes))
|
||||
if draft.lead_identity or draft.source_phrase:
|
||||
supported = {e for feature in draft.features for e in feature.events}
|
||||
motions = [(a, b) for a, b in zip(notes, notes[1:]) if not (a.event_id in supported and b.event_id in supported)]
|
||||
features['contour'] = sum(min(1, max(0, abs(b.pitch.number() - a.pitch.number()) - 5) / 12) for a, b in motions) / max(1, len(motions))
|
||||
# Fidelity is checked against explicit transformed identities, not against
|
||||
# the source head as one undifferentiated phrase-long quotation.
|
||||
features.pop('fidelity', None)
|
||||
features['harmonic_exposure'] = checked.exposure
|
||||
intended_repeats = 0
|
||||
if draft.home:
|
||||
reference = draft.home.notes
|
||||
for transform in draft.transformations:
|
||||
actual = tuple(n for n in notes if n.event_id in transform.events)
|
||||
for i, (a, b, c) in enumerate(zip(actual, actual[1:], actual[2:])):
|
||||
expected = tuple(reference[j % len(reference)].pitch for j in (i, i + 1, i + 2)) if reference else ()
|
||||
if a.pitch == b.pitch == c.pitch and len(expected) == 3 and expected[0] == expected[1] == expected[2]:
|
||||
intended_repeats += 1
|
||||
features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(score_notes) - 2))
|
||||
score = generator.solo.scoring.value(features)
|
||||
motifs = list(incoming.motifs) if shared else []
|
||||
decisions = []
|
||||
for transform in draft.transformations:
|
||||
@@ -568,7 +574,7 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit
|
||||
(notes[-1].pitch.number() > notes[0].pitch.number()) - (notes[-1].pitch.number() < notes[0].pitch.number()) if notes else incoming.direction,
|
||||
last, 0, (), travel + (incoming.travel if shared else 0), home, motifs[-1] if motifs else incoming.development, themes, draft.anchors, plan.ending)
|
||||
actual_plan = dataclasses.replace(plan, relationship=draft.relationship)
|
||||
return generator.solo.types.Phrase(actual_plan, tuple(decisions), score, state, draft.anchors, draft, edits)
|
||||
return generator.solo.types.Phrase(actual_plan, tuple(decisions), score, state, draft.anchors, draft, edits, evaluation=musical)
|
||||
|
||||
def legacy_target_plan(plan, choices):
|
||||
if not plan.target or plan.anchor_options:
|
||||
|
||||
@@ -16,13 +16,13 @@ import generator.solo.similarity
|
||||
import generator.solo.features
|
||||
import generator.solo.placement
|
||||
|
||||
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> str:
|
||||
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0, *, detail=None) -> str:
|
||||
region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo')
|
||||
positioned = tuple(dataclasses.replace(c, span=dataclasses.replace(c.span, start=c.span.start + time_offset, end=c.span.end + time_offset, measure=c.span.measure + measure_offset)) for c in solo.choices)
|
||||
lines = [generator.scales.report.passage(solo.part, region, positioned, scales), f'Phrase composition: {solo.part}', f'Placement reference tempo: {solo.tempo} quarter notes/minute. Runtime tempo changes are not a comfort guarantee.', 'Times below are quarter-note offsets from the start of this solo; pitches are sounding.']
|
||||
if solo.diagnostics:
|
||||
lines.append('Composition diagnostics: ' + json.dumps({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}, sort_keys=True))
|
||||
detail = structure(solo)
|
||||
lines.append('Composition diagnostics: ' + json.dumps({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}, sort_keys=True,default=str))
|
||||
detail = structure(solo) if detail is None else detail
|
||||
lines.append("Sustained phrasing: " + json.dumps(detail["phrasing"], sort_keys=True))
|
||||
for passage in detail['passages']:
|
||||
lines.append(f"Melodic passage {passage['id']}: {passage['start']}-{passage['end']}; phrases {', '.join(passage['phrases'])}")
|
||||
@@ -126,9 +126,15 @@ def structure(solo):
|
||||
entry['head_comparison_elaborated']=generator.solo.similarity.compare(tuple(n for d in phrase.decisions for n in d.notes),local_source)
|
||||
if phrase.state and phrase.state.home:
|
||||
entry['theme_relationship']=generator.solo.features.relationship(phrase.refined.notes,phrase.state.home)
|
||||
entry['evaluation'] = rational(dataclasses.asdict(phrase.evaluation)) if phrase.evaluation else None
|
||||
from generator.solo import groups
|
||||
try:
|
||||
entry['rhythm_groups']=rational([dataclasses.asdict(g) for g in groups.describe(tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in phrase.draft.notes),origin=phrase.plan.start)]) if phrase.draft else []
|
||||
except ValueError as error:
|
||||
entry['rhythm_groups']=[{'family':'source','reason':str(error)}]
|
||||
entry['score'] = phrase.score
|
||||
phrases.append(entry)
|
||||
return {'instrument_profile': rational(dataclasses.asdict(solo.profile)) if solo.profile else None, 'phrasing': phrasing_report(solo), 'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'expressions': rational([dataclasses.asdict(e) for e in expressions]), 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures,expressions), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
|
||||
return {'schema':'composer-report-2','selection_trace':rational({k:v for k,v in solo.diagnostics.items() if not k.endswith('_seconds')}), 'instrument_profile': rational(dataclasses.asdict(solo.profile)) if solo.profile else None, 'phrasing': phrasing_report(solo), 'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'expressions': rational([dataclasses.asdict(e) for e in expressions]), 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures,expressions), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
|
||||
|
||||
|
||||
def phrasing_report(solo):
|
||||
|
||||
@@ -89,7 +89,7 @@ def _candidates(notes, fixed, harmony, library, register, purpose='connect'):
|
||||
if not span or not span.scale:
|
||||
return ()
|
||||
contexts=tuple((harmony.at(n.start).scale if harmony.at(n.start) else None,
|
||||
types.permitted(harmony.choices,n.start,n.end),
|
||||
types.permitted(harmony.choices,n.start,n.end) & (harmony.members(n.start,n.end) if n.end-n.start>=2 else frozenset(range(12))),
|
||||
n.pitch.number() if n.event_id in fixed or i==len(notes)-1 else None) for i,n in enumerate(notes))
|
||||
if not hasattr(harmony,'pitch_routes'):harmony.pitch_routes={}
|
||||
key=(first.pitch,contexts,id(library),register,harmony.profile)
|
||||
@@ -175,6 +175,11 @@ def pitch_paths(first, contexts, library, register, profile=None):
|
||||
low,high=generator.fretboard.single_register((register[0]-7,register[1]+5))
|
||||
low, high = max(low, profile.register[0]), min(high, profile.register[1])
|
||||
result=[]
|
||||
# Reattacks are an explicit primitive, separate from interval catalog paths
|
||||
# (whose zero steps are deliberately excluded). Tied holds remain one event.
|
||||
if all(first.number()%12 in allowed and (fixed is None or fixed==first.number())
|
||||
for _,allowed,fixed in contexts):
|
||||
result.append(((first,)*len(contexts),('primitive:repeated_attack',)))
|
||||
for path in paths:
|
||||
realized=[first]
|
||||
for (scale,allowed,fixed),step in zip(contexts[1:],path.steps):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fractions
|
||||
import dataclasses
|
||||
|
||||
import generator.solo.policy
|
||||
import generator.solo.types
|
||||
@@ -116,3 +117,75 @@ def value(features):
|
||||
|
||||
def feasible(plan, notes, choices, until):
|
||||
return Evaluation(plan, choices, until).feasible(notes)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MusicalEvaluation:
|
||||
version: str
|
||||
scope: tuple
|
||||
valid: bool
|
||||
pending: tuple
|
||||
raw: dict
|
||||
denominators: dict
|
||||
weights: dict
|
||||
contributions: dict
|
||||
total: float
|
||||
error: str = ''
|
||||
|
||||
|
||||
MUSICAL_WEIGHTS = {'rhythm':3., 'destination':2., 'harmony':2.,
|
||||
'register':1., 'repetition':1., 'activity':1., 'difficulty':.5}
|
||||
|
||||
|
||||
def musical(plan, draft, notes, incoming, harmony, *, travel=0, weights=None, bar=None):
|
||||
"""The sole final evaluator of the active thematic composer.
|
||||
|
||||
Scores are normalized on complete sounding events, never notation fragments.
|
||||
Contracts are validity requirements, not additional similarity rewards.
|
||||
Retrieval estimates are deliberately not added to this result.
|
||||
"""
|
||||
from generator.solo import drafts, groups, harmonic, phrasing, types
|
||||
notes=types.sounding_notes(notes)
|
||||
bar=getattr(harmony,'bar',fractions.Fraction(4)) if bar is None else bar
|
||||
from generator import vocabulary
|
||||
weights=dict(vocabulary.data('grammar').get('evaluation',{}).get('weights',MUSICAL_WEIGHTS) if weights is None else weights)
|
||||
if set(weights)!=set(MUSICAL_WEIGHTS):raise ValueError('unknown or missing evaluation contribution')
|
||||
checked=harmony.evaluate(notes, incoming.treatments, until=plan.end, complete=True,
|
||||
enclosures=draft.enclosures, expressions=draft.expressions)
|
||||
identity,identity_error=drafts.contract_valid(draft,notes)
|
||||
anchors,anchor_error=harmonic.anchors_valid(draft.anchors,notes,plan.end,complete=True,closure=plan.closure)
|
||||
feasible=Evaluation(plan,harmony.choices).feasible(notes)
|
||||
valid=checked.valid and identity and anchors and feasible
|
||||
error=checked.error or identity_error or anchor_error or ('' if feasible else 'activity intent not fulfilled')
|
||||
span=float(plan.end-plan.start); sounded=sum(float(n.end-n.start) for n in notes)
|
||||
islands=sum(a.end<b.start and b.end<c.start and b.end-b.start<=fractions.Fraction(1,2)
|
||||
for a,b,c in zip(notes,notes[1:],notes[2:]))
|
||||
# Rests at the edges can be entrance/release. Long internal gaps need a
|
||||
# completed gesture, rather than receiving a universal occupancy penalty.
|
||||
settled={a.hold_until for a in draft.anchors}
|
||||
broken=sum(max(0,float(b.start-a.end)-1) for a,b in zip(notes,notes[1:])
|
||||
if a.end not in settled and a.end-a.start<2)
|
||||
entry=max(0,abs(notes[0].pitch.number()-incoming.last.pitch.number())-7)/12 if notes and incoming.last else 0.
|
||||
rhythm=min(1.,islands/max(1,len(notes)-2)+broken/max(1,span)+entry/2)
|
||||
# Explicit source groups retain their imported timing contract. Generated
|
||||
# groups are checked before construction, not retroactively quantized here.
|
||||
destinations=[]
|
||||
for anchor in draft.anchors:
|
||||
approach=[n for n in notes if n.start<anchor.earliest][-3:]
|
||||
if approach:
|
||||
distances=[abs(n.pitch.number()-anchor.pitch.number()) for n in approach]
|
||||
# A broad approach may turn; large last-second jumps are a preference,
|
||||
# not a nearest-note mandate for all intermediate events.
|
||||
destinations.append(min(1,max(0,distances[-1]-5)/12))
|
||||
goal=harmony.profile.reference
|
||||
register=sum(float(n.end-n.start)*min(1,max(0,abs(n.pitch.number()-goal)-9)/12)
|
||||
for n in notes)/max(sounded,.001) if goal is not None else 0.
|
||||
history=phrasing.repetition(phrasing.heard(notes,draft.relationship),incoming.history,bar) if notes else 0.
|
||||
activity=Evaluation(plan,harmony.choices).features(notes)['activity']
|
||||
raw={'rhythm':rhythm,'destination':sum(destinations)/max(1,len(destinations)),
|
||||
'harmony':checked.exposure,'register':register,'repetition':min(1,history),
|
||||
'activity':activity,'difficulty':min(1,travel/max(1,len(notes))/2000)}
|
||||
contributions={name:weights[name]*value for name,value in raw.items()}
|
||||
return MusicalEvaluation('joint-musical-1',(plan.start,plan.end),valid,checked.pending,raw,
|
||||
{'span':span,'sounding':sounded,'attacks':len(notes),'destinations':len(destinations)},weights,
|
||||
contributions,sum(contributions.values()) if valid else float('inf'),error)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
import pathlib
|
||||
import types
|
||||
import functools
|
||||
|
||||
import generator.solo.policy
|
||||
|
||||
@@ -13,6 +14,7 @@ import generator.musicxml
|
||||
class Sequence:
|
||||
id: str
|
||||
steps: tuple[int, ...]
|
||||
@functools.lru_cache(maxsize=32768)
|
||||
def offsets(self) -> tuple[int, ...]:
|
||||
result = [0]
|
||||
for step in self.steps:
|
||||
@@ -26,12 +28,14 @@ class Sequence:
|
||||
class Path:
|
||||
steps: tuple[int, ...]
|
||||
sources: tuple[str, ...]
|
||||
@functools.lru_cache(maxsize=32768)
|
||||
def offsets(self):
|
||||
result = [0]
|
||||
for step in self.steps:
|
||||
result.append(result[-1] + step)
|
||||
return tuple(result)
|
||||
|
||||
@functools.lru_cache(maxsize=32768)
|
||||
def family(steps):
|
||||
if not steps or all(s == 0 for s in steps):
|
||||
return 'repeated'
|
||||
@@ -47,14 +51,19 @@ def family(steps):
|
||||
def diverse(items, rank, category, limit):
|
||||
"""Round-robin eligible musical families before a destructive cutoff."""
|
||||
groups = {}
|
||||
for item in sorted(items, key=rank):
|
||||
groups.setdefault(category(item), []).append(item)
|
||||
# Rank once: hashing full event provenance and classifying interval paths
|
||||
# used to be repeated for every retained layer. Preserve stable tie order.
|
||||
decorated = sorted(((rank(item), i, item) for i,item in enumerate(items)),key=lambda v:v[:2])
|
||||
for value in decorated:
|
||||
groups.setdefault(category(value[2]), []).append(value)
|
||||
result, index = [], 0
|
||||
while len(result) < limit:
|
||||
layer = [values[index] for values in groups.values() if len(values) > index]
|
||||
if not layer:
|
||||
break
|
||||
result.extend(sorted(layer, key=rank)[:limit - len(result)])
|
||||
# Equal ranks retain category insertion order, just as the original stable
|
||||
# layer sort did; the input index is only for the initial sort.
|
||||
result.extend(v[2] for v in sorted(layer,key=lambda v:v[0])[:limit-len(result)])
|
||||
index += 1
|
||||
return tuple(result)
|
||||
|
||||
|
||||
+150
-167
@@ -11,7 +11,7 @@ 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
|
||||
from generator.solo import types, policy, sequences, routes, features as references, similarity, phrasing, profiles, groups as rhythm_groups
|
||||
|
||||
|
||||
def source_notes(analysis):
|
||||
@@ -71,13 +71,16 @@ def seeds(analysis, bar, seed):
|
||||
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={(n.start,n.end-n.start) for n in ref.notes}
|
||||
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)
|
||||
return sequences.diverse(groups,rank,lambda r:(r.treatment,int(max(0,r.start)//(4*bar))),policy.DEFAULT.theme_seed_limit)
|
||||
# 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):
|
||||
@@ -87,7 +90,7 @@ def feature(reference, actual, *, treatment=None):
|
||||
'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':('contour',)}[treatment]
|
||||
'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'])
|
||||
@@ -103,7 +106,7 @@ 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=='continue' else 'continue'
|
||||
return 'connect' if operation in ('continue','connect') else 'continue'
|
||||
|
||||
|
||||
def rhythm_cost(pattern,length,purpose):
|
||||
@@ -145,12 +148,12 @@ def rhythm_windows(library,meter,length,context,purpose='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:patterns.setdefault(selected,identifier)
|
||||
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(),F(1,2),max(F(),length-total)):
|
||||
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
|
||||
@@ -159,8 +162,10 @@ def rhythm_windows(library,meter,length,context,purpose='continue'):
|
||||
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
|
||||
combined=(*head,*((a,b) for a,b in tail if a>=offset+total))
|
||||
if 2<=len(combined)<=16:patterns.setdefault(combined,'authored:'+identifier)
|
||||
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)
|
||||
@@ -193,7 +198,7 @@ def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operatio
|
||||
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:continue
|
||||
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'
|
||||
@@ -201,10 +206,12 @@ def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operatio
|
||||
arrivals={}
|
||||
arrival=ending.earliest-start
|
||||
for original,label in values.items():
|
||||
head=tuple((a,min(b,arrival)) for a,b in original if a<arrival)
|
||||
if len(head)>=len(ref.notes):
|
||||
arrivals[(*head,(arrival,ending.hold_until-start))]=label+'; planned sustained close'
|
||||
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)
|
||||
|
||||
@@ -233,61 +240,6 @@ def relative_steps(notes,harmony):
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def instantiate(templates,first,steps,harmony):
|
||||
result=[dataclasses.replace(templates[0],pitch=first)]
|
||||
for note,step in zip(templates[1:],steps):
|
||||
span=harmony.at(note.start)
|
||||
if not span or not span.scale:return ()
|
||||
options=routes.advance(span.scale,result[-1].pitch.number(),step)
|
||||
if not options:return ()
|
||||
p=options[0]
|
||||
if not harmony.profile.permits(p.number()) or p.number()%12 not in types.permitted(harmony.choices,note.start,note.end):return ()
|
||||
result.append(dataclasses.replace(note,pitch=p))
|
||||
return tuple(result) if len(result)==len(templates) else ()
|
||||
|
||||
|
||||
def prefix_paths(templates,ref,first,harmony,library):
|
||||
if not hasattr(harmony,'theme_prefixes'):harmony.theme_prefixes={}
|
||||
key=(templates[:len(ref.notes)],ref,first,id(library))
|
||||
if key not in harmony.theme_prefixes:
|
||||
harmony.theme_prefixes[key]=_prefix_paths(templates,ref,first,harmony,library)
|
||||
return harmony.theme_prefixes[key]
|
||||
|
||||
|
||||
def _prefix_paths(templates,ref,first,harmony,library):
|
||||
count=len(ref.notes); original=ref.notes
|
||||
if count==1:return ((dataclasses.replace(templates[0],pitch=first),),)
|
||||
if ref.treatment in ('opening_fragment','characteristic_interval','callback'):
|
||||
delta=first.number()-original[0].pitch.number()
|
||||
if ref.treatment in ('opening_fragment','callback') and delta%12:return ()
|
||||
result=[]
|
||||
from generator.solo.leads import available
|
||||
for template,n in zip(templates,original):
|
||||
target=n.pitch.number()+delta
|
||||
p=next((p for p in available(harmony,template.start,template.end,members=template.end-template.start>=2) if p.number()==target),None)
|
||||
if p is None:return ()
|
||||
result.append(dataclasses.replace(template,pitch=p))
|
||||
return (tuple(result),)
|
||||
paths=[]
|
||||
if ref.treatment == 'return':
|
||||
from generator.solo.leads import available
|
||||
delta=first.number()-original[0].pitch.number()
|
||||
direct=[]
|
||||
for template,n in zip(templates,original):
|
||||
p=next((p for p in available(harmony,template.start,template.end,members=template.end-template.start>=2) if p.number()==n.pitch.number()+delta),None)
|
||||
if p is None: break
|
||||
direct.append(dataclasses.replace(template,pitch=p))
|
||||
if len(direct)==count: paths.append(tuple(direct))
|
||||
end_template=templates[count-1]
|
||||
source_motion=original[-1].pitch.number()-original[0].pitch.number()
|
||||
for target in pitches(harmony,end_template,first.number()+source_motion,limit=4):
|
||||
template=tuple(dataclasses.replace(n,pitch=first if i==0 else target) for i,n in enumerate(templates[:count]))
|
||||
paths.extend(r.notes for r in routes.candidates(template,{template[-1].event_id},harmony,library,harmony.profile.register,'seed '+ref.id,purpose='entrance'))
|
||||
valid=[p for p in paths if not references.feature_error(feature(ref,p),p)]
|
||||
return sequences.diverse(valid,lambda ns:(routes.path_cost(ns),policy.stable_rank(ref.id,ns)),
|
||||
lambda ns:routes.path_family(ns,harmony),4)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Unit:
|
||||
notes: tuple[types.Note,...]
|
||||
@@ -303,81 +255,86 @@ class Unit:
|
||||
|
||||
|
||||
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None):
|
||||
# History is evaluated by the caller after retrieving these concrete options.
|
||||
# Every state component used to construct this pool is in its cache key.
|
||||
if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',(harmony.choices,harmony.profile))
|
||||
key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,incoming.history,home,operation,ending,goal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT)
|
||||
"""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,id(harmony),id(library),id(rhythms))
|
||||
if key in context.results:
|
||||
context.diagnostics['cache_hits']+=1;return context.results[key]
|
||||
result=[]; count=len(ref.notes)
|
||||
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)<count or len(pattern)>16:continue
|
||||
templates=tuple(types.Note(start+a,start+b,ref.notes[0].pitch,tuple(s for n in ref.notes for s in n.sources),
|
||||
'theme',event_id=f'{ref.id}:{start}:{i}') for i,(a,b) in enumerate(pattern))
|
||||
base=incoming.last.pitch.number() if incoming.last else harmony.profile.reference if harmony.profile.reference is not None else ref.notes[0].pitch.number()
|
||||
firsts=pitches(harmony,templates[0],base,limit=6 if batch else 3)
|
||||
targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,members=True,limit=policy.DEFAULT.theme_destinations,incoming=incoming)
|
||||
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:
|
||||
# Rank joint jobs, retaining rhythm and destination coverage before
|
||||
# expensive path instantiation. The budget is spent on complete routes.
|
||||
rank=harmony.profile.cost(first.number())+max(0,abs(first.number()-base)-5)/12+phrasing.destination_cost(target,templates[-1].start,templates[-1].end,harmony,incoming,goal if goal is not None else base,terminal)
|
||||
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))
|
||||
jobs.sort(key=lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4]))))
|
||||
selected=[];rhythm_seen=set();target_seen=set();entry_seen=set();degrees_seen=set()
|
||||
for job in jobs:
|
||||
family=phrasing.destination_family(job[4],job[1][-1].start,harmony)
|
||||
if family not in degrees_seen:
|
||||
selected.append(job);degrees_seen.add(family)
|
||||
for job in jobs:
|
||||
rhythm=tuple((n.start,n.end) for n in job[1]);target=job[4].number();entry=job[3].number()
|
||||
if rhythm not in rhythm_seen or target not in target_seen or entry not in entry_seen:
|
||||
if job not in selected:selected.append(job)
|
||||
rhythm_seen.add(rhythm);target_seen.add(target);entry_seen.add(entry)
|
||||
job_limit=policy.DEFAULT.theme_route_jobs*(batch+1)
|
||||
for job in jobs:
|
||||
if len(selected)>=job_limit:break
|
||||
if job not in selected:selected.append(job)
|
||||
for _,templates,label,first,target in selected[:job_limit]:
|
||||
proposed=[]
|
||||
if home and operation in ('repeat_with_harmonic_adaptation','return') and len(templates)==len(home.notes):
|
||||
heard=instantiate(templates,first,home.degree_steps,harmony)
|
||||
if heard and heard[-1].pitch==target:proposed.append((heard,()))
|
||||
for prefix in prefix_paths(templates,ref,first,harmony,library)[:2+batch]:
|
||||
if len(prefix)==len(templates):
|
||||
if prefix[-1].pitch==target:proposed.append((prefix,()))
|
||||
continue
|
||||
tail=(prefix[-1],*templates[count:])
|
||||
tail=(*tail[:-1],dataclasses.replace(tail[-1],pitch=target))
|
||||
for route in routes.candidates(tail,{tail[-1].event_id},harmony,library,harmony.profile.register,operation+' '+ref.id,purpose=rhythmic_purpose(operation,terminal))[:3+batch]:
|
||||
proposed.append(((*prefix[:-1],*route.notes),(route,)))
|
||||
seen=set()
|
||||
for actual,connections in proposed:
|
||||
identity=tuple(n.pitch.number() for n in actual)
|
||||
if identity in seen:continue
|
||||
seen.add(identity)
|
||||
core=actual[:count]; contract=feature(ref,core,treatment=operation if operation!='introduce' else None)
|
||||
if references.feature_error(contract,actual):continue
|
||||
last=actual[-1]
|
||||
if last.pitch.number()%12 not in harmony.members(last.start,last.end):continue
|
||||
checked=harmony.evaluate(actual,until=finish,complete=True)
|
||||
if not checked.valid:continue
|
||||
entry=max(0,abs(first.number()-incoming.last.pitch.number())-5)/12 if incoming.last else 0.
|
||||
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(actual,actual[1:]))/max(1,len(actual)-1)
|
||||
direction=min(1,abs(last.pitch.number()-goal)/12) if goal is not None else 0.
|
||||
# Activity belongs to the complete phrase; contracts are pass/fail.
|
||||
score=harmony.profile.cost(profiles.weighted_center(actual))+2*checked.exposure+entry+leaps+phrasing.destination_cost(last.pitch,last.start,last.end,harmony,incoming,goal,terminal)
|
||||
anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),harmony.profile.register,
|
||||
last.end-last.start,'intermediate',harmony.at(last.start).chord,hold_until=last.end)
|
||||
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,anchor,score,label,connections,start,finish))
|
||||
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=[]
|
||||
for _,templates,label,first,target in selected:
|
||||
timed=(dataclasses.replace(templates[0],pitch=first),*templates[1:-1],dataclasses.replace(templates[-1],pitch=target))
|
||||
proposed=list(routes.candidates(timed,{timed[-1].event_id},harmony,library,harmony.profile.register,
|
||||
operation+' '+ref.id,purpose=rhythmic_purpose(operation,terminal)))
|
||||
# A substantial literal return is a musical action, not merely the hope
|
||||
# that a general interval search will accidentally reproduce the theme.
|
||||
if 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
|
||||
checked=harmony.evaluate(actual,until=finish,complete=True)
|
||||
if not checked.valid:trace['rejected_by_rule']+=1;continue
|
||||
trace['valid']+=1
|
||||
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))
|
||||
unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result}
|
||||
selected=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)),
|
||||
lambda u:(u.notes[0].pitch.number()//12,u.notes[-1].pitch.number(),tuple(n.end-n.start for n in u.notes[:count])),policy.DEFAULT.theme_unit_alternatives)
|
||||
context.diagnostics['generated_candidates']+=len(selected)
|
||||
context.results[key]=selected
|
||||
return selected
|
||||
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)),policy.DEFAULT.theme_unit_alternatives)
|
||||
trace['retained']+=len(chosen)
|
||||
context.diagnostics['generated_candidates']+=len(chosen)
|
||||
context.results[key]=chosen
|
||||
return chosen
|
||||
|
||||
|
||||
def home_reference(home,operation):
|
||||
@@ -400,6 +357,18 @@ def make_home(unit,harmony,start=None,end=None):
|
||||
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
|
||||
@@ -409,7 +378,7 @@ def unit_end(source,cursor,home,ending,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.earliest<finish:
|
||||
if source.end-finish<bar+2 or ending and ending.earliest<finish:
|
||||
finish=source.end
|
||||
return finish
|
||||
|
||||
@@ -424,7 +393,8 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
|
||||
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=8*(batch+1),final=final)
|
||||
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=[]
|
||||
@@ -438,15 +408,12 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
|
||||
if introducing:
|
||||
requests=[(r,'introduce') for r in roots]
|
||||
else:
|
||||
operations=['continue'] if finish==source.end and cursor>source.start else ['develop_contour','develop_rhythm']
|
||||
if cursor==source.start:operations.append('return')
|
||||
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:]))]
|
||||
if cursor>=8*bar and cursor==source.start and incoming.callbacks<2:
|
||||
requests.extend((dataclasses.replace(r,treatment='callback'),'callback') for r in roots[:1] if r.end-r.start<=bar)
|
||||
for ref,operation in requests:
|
||||
if introducing and source.end-source.start<2*bar and ending.earliest-cursor<ref.notes[-1].end:
|
||||
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))
|
||||
@@ -465,9 +432,10 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
|
||||
if len(reduced)<2:continue
|
||||
ref=dataclasses.replace(ref,notes=reduced,treatment='contour_seed')
|
||||
operation='continue'
|
||||
terminal=finish==source.end
|
||||
terminal=bool(ending and finish==source.end)
|
||||
base=state.last.pitch.number() if state.last else ref.notes[0].pitch.number()
|
||||
goal=ending.pitch.number() if terminal else round(base+(ending.pitch.number()-base)*float((finish-cursor)/(source.end-cursor)))
|
||||
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)
|
||||
for unit in options:
|
||||
@@ -487,22 +455,19 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
|
||||
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,len(v[1][-1].notes),v[1][-1].operation),policy.DEFAULT.theme_continuations)
|
||||
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)
|
||||
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,)
|
||||
tails=(types.SustainedTail(ending.earliest,ending.hold_until,ending.pitch.number()),)
|
||||
anchors=(ending,) if ending else ()
|
||||
tails=(types.SustainedTail(ending.earliest,ending.hold_until,ending.pitch.number()),) if ending else ()
|
||||
options=[(plain,(),())]
|
||||
if stage=='elaborated':
|
||||
for actual,edit,route in leads.elaborate(plain,features,harmony,library,rhythms,meter,source,sustain_entry=True,tails=tails):
|
||||
options.append((actual,(edit,),(route,)))
|
||||
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,
|
||||
@@ -512,9 +477,11 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
|
||||
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)
|
||||
motif=types.Motif(home.id,home.notes,home.duration,(), 'realized solo theme from '+source_ref.id)
|
||||
landmark=types.Landmark(ending.earliest,ending.latest,ending.pitch.number(),ending.pitch.number(),0,'sustained destination')
|
||||
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,phrasing.activity(source,ending),(landmark,), 'sustain','closed',seed,
|
||||
source.end-ending.hold_until if ending else F(),phrasing.activity(source,ending),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
|
||||
@@ -523,20 +490,12 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
|
||||
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
|
||||
score=phrasing.evaluate(notes,incoming,harmony,source.start,source.end,bar)+.1*gesture_rhythm.fragmentation(notes)
|
||||
score+=sum(phrasing.destination_cost(u.anchor.pitch,u.anchor.earliest,u.anchor.hold_until,harmony,incoming,phrasing.register_goal(incoming,part,u.notes[0].pitch.number(),harmony.profile),u.anchor.role=='terminal') for u in units)/len(units)
|
||||
appearance_state=incoming
|
||||
for unit in units:
|
||||
score+=phrasing.appearance_cost(unit.operation,appearance_state,unit.notes[0].start,solo_end)/len(units)
|
||||
if unit.operation=='return':
|
||||
appearance_state=dataclasses.replace(appearance_state,returns=(*appearance_state.returns,unit.notes[0].start))
|
||||
score+=.05*phrase.state.travel/max(1,len(notes))/2000
|
||||
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 group[-1].end==ending.hold_until else None,bar),bar)
|
||||
current=dataclasses.replace(phrase.state,next_destination=phrasing.next_destination(ending,harmony),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+1,register_peak=max(state.register_peak,*(n.pitch.number() for n in notes)))
|
||||
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),
|
||||
@@ -545,6 +504,30 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
|
||||
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,score=score,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)))
|
||||
return sequences.diverse(result,lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
|
||||
lambda p:(p.state.home.id,p.state.last.pitch.number(),p.anchors[-1].duration,len(p.state.returns)),policy.DEFAULT.phrase_alternatives)
|
||||
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
|
||||
|
||||
@@ -361,6 +361,8 @@ class Phrase:
|
||||
routes: tuple[RouteCandidate, ...] = ()
|
||||
before_enclosure: MelodicDraft | None = None
|
||||
before_rhythm: MelodicDraft | None = None
|
||||
evaluation: object | None = None
|
||||
rhythm_groups: tuple = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Solo:
|
||||
|
||||
Reference in New Issue
Block a user