@@ -240,6 +240,20 @@ def render(root):
|
||||
samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)
|
||||
clips.append((path, samples))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'window': label, 'first': offset, 'count': count, 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']})
|
||||
if stage == 'after' and 'refined' in solo.get('lead_stages', {}):
|
||||
plain = excerpt(solo['lead_stages']['refined']['document'], offset, count)
|
||||
plain_prefix = prefix.with_name(prefix.name + '_plain')
|
||||
generator.musicxml_writer.write_musicxml(plain_prefix.with_suffix('.musicxml'), plain)
|
||||
backing = excerpt(baseline['parts'][other], first, count)
|
||||
vocal = excerpt(baseline['parts']['vocal'], first, count)
|
||||
path = plain_prefix.with_name(plain_prefix.name + '_fixed').with_suffix('.wav')
|
||||
clips.append((path, mix((plain, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'plain_theme', 'window': label})
|
||||
if label == 'full':
|
||||
for kind, document in (('plain', plain), ('elaborated', foreground)):
|
||||
path = prefix.with_name(prefix.name + '_' + kind + '_solo').with_suffix('.wav')
|
||||
clips.append((path, mix((document,), fractions.Fraction(solo['tempo']), root, cache)))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': kind + '_unaccompanied', 'window': label})
|
||||
for draft in solo.get('draft_documents', ()):
|
||||
prefix = root / stage / f'{song_name}_{part}_draft_{draft["id"]}'
|
||||
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), draft['document'])
|
||||
@@ -314,14 +328,21 @@ def write_passage_index(root, snapshots):
|
||||
for stage in ('before', 'after'):
|
||||
metric = snapshots[stage]['songs'][name]['solos'][part]['metrics']
|
||||
body.append(f"<p>{stage.title()}: {metric['attacks_per_four_beats']:.2f} attacks / four beats; {metric['sounding_occupancy']:.1%} occupancy; longest unplanned gap {metric['longest_unplanned_gap_beats']:g} beats.</p>")
|
||||
body.append('<table><tr><th>Passage</th><th>Before, fixed backing</th><th>After, fixed backing</th><th>After, responsive backing</th></tr>')
|
||||
plain_available='refined' in song['solos'][part].get('lead_stages',{})
|
||||
body.append('<table><tr><th>Passage</th><th>Before, fixed backing</th><th>After, fixed backing</th><th>After, responsive backing</th>' + ('<th>Plain theme, fixed backing</th>' if plain_available else '') + '</tr>')
|
||||
for window in ('full', 'opening8', 'closing4'):
|
||||
body.append(f'<tr><th>{window}</th>')
|
||||
for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')):
|
||||
prefix = stage + '/' + name + '_' + part + '_' + window
|
||||
body.append(f'<td><audio controls preload="none" src="{prefix}_{mix_name}.wav"></audio><small><a href="{prefix}.musicxml">MusicXML</a> · <a href="{prefix}.txt">Report</a>' + (f' · <a href="{prefix}.json">Passage data</a>' if stage == 'after' else '') + '</small></td>')
|
||||
if plain_available:
|
||||
prefix=f'after/{name}_{part}_{window}_plain'
|
||||
body.append(f'<td><audio controls preload="none" src="{prefix}_fixed.wav"></audio><small><a href="{prefix}.musicxml">Plain theme notation</a></small></td>')
|
||||
body.append('</tr>')
|
||||
body.append('</table>')
|
||||
if plain_available:
|
||||
prefix=f'after/{name}_{part}_full'
|
||||
body.append(f'<p>Unaccompanied plain theme: <audio controls preload="none" src="{prefix}_plain_solo.wav"></audio> Elaborated: <audio controls preload="none" src="{prefix}_elaborated_solo.wav"></audio></p>')
|
||||
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
||||
|
||||
def main():
|
||||
|
||||
@@ -350,6 +350,9 @@ def local_sounding(notes):
|
||||
|
||||
def contract_valid(draft, notes):
|
||||
by_id = {n.event_id: n for n in local_sounding(notes)}
|
||||
for tail in draft.sustained_tails:
|
||||
if not any(n.pitch.number() == tail.pitch and n.start <= tail.start and n.end >= tail.end for n in by_id.values()):
|
||||
return False, f'{tail.start}-{tail.end}: required sounding arrival tail changed'
|
||||
original = {n.event_id: n for n in draft.notes}
|
||||
for identity in (*draft.structural, *draft.protected):
|
||||
before, after = original[identity], by_id.get(identity)
|
||||
|
||||
+108
-50
@@ -10,7 +10,7 @@ import fractions
|
||||
|
||||
import generator.model
|
||||
import generator.fretboard
|
||||
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts
|
||||
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, references
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
@@ -160,49 +160,54 @@ def connection_patterns(start,end,entry,destination,rhythms,meter):
|
||||
return result
|
||||
|
||||
|
||||
def elaborate(notes, features, harmony, library, rhythms, meter, source, *, sustain_entry=False):
|
||||
"""Edit an exposed held tail or a complete interior span, with both endpoints known."""
|
||||
fixed={e for f in features for e in f.events}
|
||||
def elaborate(notes, features, harmony, library, rhythms, meter, source, *, sustain_entry=False, tails=None):
|
||||
"""Replace complete internal spans while retaining identity and sounding tails."""
|
||||
legacy=tails is None
|
||||
tails=tails or ()
|
||||
fixed={e for f in features for e in f.events} if legacy else {n.event_id for n in notes if n.role=='quote'}
|
||||
windows=[]
|
||||
for i,(note,target) in enumerate(zip(notes,notes[1:])):
|
||||
if note.event_id in fixed or note.end != target.start:
|
||||
continue
|
||||
if note.event_id in fixed or note.end!=target.start:continue
|
||||
if note.end-note.start>=F(3,2):
|
||||
start=max(note.start+F(1,2),target.start-3)
|
||||
windows.append((i,start,target,False))
|
||||
# A source-held ending can arrive later via a related in-scale turn.
|
||||
if notes and notes[-1].event_id not in fixed and notes[-1].end-notes[-1].start>=F(5,2):
|
||||
windows.append((i,i+1,start,target))
|
||||
if not legacy:
|
||||
for j in range(i+2,min(i+5,len(notes))):
|
||||
target=notes[j]
|
||||
if not 1<=target.start-note.start<=3:continue
|
||||
if any(n.event_id in fixed for n in notes[i:j]):continue
|
||||
if any(t.start<target.start and t.end>note.start for t in tails):continue
|
||||
windows.append((i,j,note.start,target))
|
||||
if legacy and notes and notes[-1].event_id not in fixed and notes[-1].end-notes[-1].start>=F(5,2):
|
||||
tail=notes[-1]
|
||||
for duration in (F(1,2),F(1),F(3,2),F(2)):
|
||||
target=dataclasses.replace(tail,start=tail.end-duration,event_id=tail.event_id+':arrival')
|
||||
start=max(tail.start+F(1,2),target.start-3)
|
||||
if target.start-start>=1:
|
||||
windows.append((len(notes)-1,start,target,True))
|
||||
if target.start-start>=1:windows.append((len(notes)-1,len(notes)-1,start,target))
|
||||
# Consequential edits only; do not exhaustively branch at each passing note.
|
||||
windows=sequences.diverse(windows,lambda w:(policy.stable_rank(source.id,w),),lambda w:w[0],8)
|
||||
result=[]
|
||||
for i,start,target,terminal in windows:
|
||||
entry=notes[i];end=target.start
|
||||
for i,j,start,target in windows:
|
||||
entry=notes[i]
|
||||
prefix=dataclasses.replace(entry,end=start)
|
||||
seed=dataclasses.replace(entry,start=start,event_id=entry.event_id+':tail',role='connection')
|
||||
for pattern,rhythm_id in connection_patterns(start,end,seed,target,rhythms,meter):
|
||||
for pattern,rhythm_id in connection_patterns(start,target.start,seed,target,rhythms,meter):
|
||||
for route in routes.candidates(pattern,{target.event_id},harmony,library,(41,76),'develop '+source.id):
|
||||
if sustain_entry:
|
||||
# The route owns its complete sounding entry, including the old hold.
|
||||
if sustain_entry or start==entry.start:
|
||||
joined=dataclasses.replace(route.notes[0],start=entry.start,event_id=entry.event_id)
|
||||
route=dataclasses.replace(route,notes=(joined,*route.notes[1:]))
|
||||
actual=(*notes[:i],*route.notes,*notes[i+1 if terminal else i+2:])
|
||||
actual=(*notes[:i],*route.notes,*notes[j+1:])
|
||||
else:
|
||||
actual=(*notes[:i],prefix,*route.notes,*notes[i+1 if terminal else i+2:])
|
||||
if not harmony.evaluate(actual,complete=True).valid:
|
||||
continue
|
||||
if any(abs(b.pitch.number()-a.pitch.number())>7 for a,b in zip(route.notes,route.notes[1:])):
|
||||
continue
|
||||
actual=(*notes[:i],prefix,*route.notes,*notes[j+1:])
|
||||
if any(references.feature_error(f,actual) for f in features):continue
|
||||
if any(not any(n.pitch.number()==t.pitch and n.start<=t.start and n.end>=t.end for n in actual) for t in tails):continue
|
||||
if not harmony.evaluate(actual,complete=True).valid:continue
|
||||
if any(abs(b.pitch.number()-a.pitch.number())>7 for a,b in zip(route.notes,route.notes[1:])):continue
|
||||
route=dataclasses.replace(route,sources=(*route.sources,'rhythm:'+rhythm_id))
|
||||
edit=types.DraftEdit('complete_route',start,target.start,(entry.event_id,),tuple(n.event_id for n in route.notes),
|
||||
source.id+'; '+rhythm_id+'; '+('later terminal arrival on ' if terminal else 'lead into ')+target.pitch.label())
|
||||
edit=types.DraftEdit('complete_route',start,target.start,tuple(n.event_id for n in notes[i:j+1]),tuple(n.event_id for n in route.notes),
|
||||
source.id+'; '+rhythm_id+'; lead into '+target.pitch.label())
|
||||
result.append((actual,edit,route))
|
||||
# Whole routes compete; bounded diversity retains different locations and rhythms.
|
||||
return sequences.diverse(result,lambda v:(abs(len(v[0])*4/float(source.end-source.start)-5),
|
||||
sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(v[2].notes,v[2].notes[1:])),v[2].id),
|
||||
return sequences.diverse(result,lambda v:(max(0,4-len(v[0])*4/float(source.end-source.start),len(v[0])*4/float(source.end-source.start)-6),v[2].id),
|
||||
lambda v:(v[1].start,v[2].destination.end-v[2].destination.start,len(v[2].notes)),8)
|
||||
|
||||
|
||||
@@ -315,13 +320,81 @@ def transition(first,second):
|
||||
return .2*leap+.15*bool(repeated_end)
|
||||
|
||||
|
||||
def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'):
|
||||
from generator.solo import realization, themes
|
||||
if mode not in ('thematic','paraphrase'):raise ValueError('Unknown solo treatment: '+mode)
|
||||
def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage):
|
||||
from generator.solo import realization,themes,phrasing
|
||||
context.resources[id(library)]=library
|
||||
context.resources[id(rhythms)]=rhythms
|
||||
harmony=harmonic.Harmony(choices)
|
||||
sources=themes.windows(analysis,measures,end,harmony) if mode=='thematic' else clipped_sources(analysis,end)
|
||||
seed=policy.stable_rank('source_variations',(analysis.melody,choices,policy.SEED_NAMESPACE))
|
||||
states=[(0.,())]
|
||||
failures=[]
|
||||
for _ in range(2*len(measures)+1):
|
||||
if all(history and history[-1].plan.end==end for _,history in states):break
|
||||
following=[]
|
||||
for cost,history in states:
|
||||
start=history[-1].plan.end if history else F()
|
||||
if start==end:
|
||||
following.append((cost,history));continue
|
||||
incoming=history[-1].state if history else types.ContinuationState('')
|
||||
sources=phrasing.window_options(analysis,measures,start,end,harmony,incoming)
|
||||
elapsed=F();meter=measures[0]['time_signature']
|
||||
for measure in measures:
|
||||
elapsed+=generator.model.fraction(measure['duration'])
|
||||
if elapsed>start:
|
||||
meter=measure['time_signature'];break
|
||||
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
||||
for source in sources:
|
||||
key=('phrasing',source,incoming,part,end,stage,seed,id(library),id(rhythms),policy.DEFAULT)
|
||||
if key in context.results:
|
||||
pool=context.results[key];context.diagnostics['cache_hits']+=1
|
||||
else:
|
||||
pool=()
|
||||
if not harmony.at(start) or not harmony.at(start).scale:
|
||||
pool=themes.silence(source,incoming,part,harmony,analysis.tempo,seed)
|
||||
else:
|
||||
for batch in range(policy.DEFAULT.replan_attempts):
|
||||
pool=themes.phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,analysis.tempo,context,seed,
|
||||
stage=stage,batch=batch,final=source.end==end,solo_end=end)
|
||||
if pool:break
|
||||
context.diagnostics['theme_replans']=context.diagnostics.get('theme_replans',0)+1
|
||||
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)
|
||||
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)
|
||||
following.append((total,(*history,candidate)))
|
||||
if not pool:failures.append(f'{source.id}: planned sustained destination, feature or incoming continuation')
|
||||
if not following:
|
||||
raise realization.SearchExhausted(f'{part}: {"; ".join(failures[-4:])} failed within the bounded search; not proof of incompatibility')
|
||||
def rank(value):
|
||||
cost,history=value;state=history[-1].state;elapsed=history[-1].plan.end
|
||||
musical=cost/max(.001,float(elapsed/end))
|
||||
if elapsed==end:
|
||||
target=policy.DEFAULT.settling_target*float(end)/(32*4)
|
||||
musical+=.1*max(0,abs(state.closures-target)-1)
|
||||
musical+=.15*abs(len(state.returns)-policy.DEFAULT.return_target*float(end)/(32*4))
|
||||
return musical,policy.stable_rank(seed,tuple(p.draft.id for p in history))
|
||||
states=sequences.diverse(following,rank,
|
||||
lambda v:(v[1][-1].state.home.id if v[1][-1].state.home else '',v[1][-1].plan.end,len(v[1][-1].state.returns)),policy.DEFAULT.chorus_frontier)
|
||||
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']},
|
||||
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases))) for cost,phrases in finished)
|
||||
|
||||
|
||||
def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'):
|
||||
from generator.solo import realization
|
||||
if mode not in ('thematic','paraphrase'):raise ValueError('Unknown solo treatment: '+mode)
|
||||
if mode=='thematic':
|
||||
return thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage)
|
||||
context.resources[id(library)]=library
|
||||
context.resources[id(rhythms)]=rhythms
|
||||
harmony=harmonic.Harmony(choices)
|
||||
sources=clipped_sources(analysis,end)
|
||||
seed=policy.stable_rank('source_variations', (analysis.melody,choices,policy.SEED_NAMESPACE))
|
||||
states=[(0.,())]
|
||||
for source in sources:
|
||||
@@ -329,30 +402,15 @@ def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stag
|
||||
following=[]
|
||||
for cost,history in states:
|
||||
incoming=history[-1].state if history else types.ContinuationState('')
|
||||
key=(mode,source,incoming if mode=='thematic' else None,policy.stable_rank("harmony",choices),part,analysis.tempo,seed,stage,id(library),id(rhythms),policy.DEFAULT)
|
||||
key=(mode,source,None,policy.stable_rank("harmony",choices),part,analysis.tempo,seed,stage,id(library),id(rhythms),policy.DEFAULT)
|
||||
if key in context.results:
|
||||
pool=context.results[key];context.diagnostics['cache_hits']+=1
|
||||
else:
|
||||
if mode=='thematic' and any(c.scale and c.span.start<source.end and c.span.end>source.start for c in choices):
|
||||
pool=()
|
||||
for batch in range(2):
|
||||
pool=themes.phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,analysis.tempo,context,seed,stage=stage,batch=batch,final=source.end==end)
|
||||
if pool:break
|
||||
context.diagnostics['theme_replans']=context.diagnostics.get('theme_replans',0)+1
|
||||
elif mode=='thematic':
|
||||
pool=themes.silence(source,incoming,part,harmony,analysis.tempo,seed)
|
||||
else:
|
||||
pool=candidates(source,harmony,library,rhythms,meter,part,analysis.tempo,seed,stage=stage)
|
||||
pool=candidates(source,harmony,library,rhythms,meter,part,analysis.tempo,seed,stage=stage)
|
||||
context.results[key]=pool;context.diagnostics['generated_candidates']+=len(pool)
|
||||
context.diagnostics.setdefault('source_candidate_pools', {}).setdefault(source.id,[]).extend(
|
||||
{'id':p.draft.id,'score':p.score,'octave':p.draft.octave_transfer,'attacks':len(p.draft.notes),'edits':[e.kind for e in p.edits],'source_family':source.family,'theme':p.state.home.id if p.state.home else None} for p in pool)
|
||||
for candidate in pool:
|
||||
# The thematic constructor has already placed all decisions from the
|
||||
# actual incoming state; do not collapse its motif groups into one bar.
|
||||
if mode=='thematic':
|
||||
total=cost+candidate.score*float(source.end-source.start)/float(end)+transition(history[-1] if history else None,candidate)
|
||||
following.append((total,(*history,candidate)))
|
||||
continue
|
||||
last=incoming.last
|
||||
placements=placement.alternatives(tuple(n for d in candidate.decisions for n in d.notes),analysis.tempo,last,limit=2)
|
||||
for travel,notes in placements:
|
||||
@@ -364,8 +422,8 @@ def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stag
|
||||
if not following:
|
||||
raise realization.SearchExhausted(f'{part} {source.id}: {mode} feature, harmonic destination or continuation failed within the bounded search; not proof of incompatibility')
|
||||
states=sequences.diverse(following,lambda v:(v[0],policy.stable_rank(seed,tuple((p.draft.id,p.state.last.position if p.state.last else None) for p in v[1]))),
|
||||
lambda v:(v[1][-1].state.home.id if mode=='thematic' and v[1][-1].state.home else tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier)
|
||||
return tuple((cost,types.Solo(part,choices,phrases,analysis.tempo,end,{**context.diagnostics,'construction':'thematic_recomposition' if mode=='thematic' else 'source_phrase_variations','policy':policy.describe()['fingerprint']},
|
||||
lambda v:(tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier)
|
||||
return tuple((cost,types.Solo(part,choices,phrases,analysis.tempo,end,{**context.diagnostics,'construction':'source_phrase_variations','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))) for cost,phrases in states)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Sustained destinations and bounded memory for the existing theme composer."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
|
||||
import generator.model
|
||||
import generator.harmony
|
||||
from generator.solo import types, policy, sequences
|
||||
|
||||
|
||||
def heard(notes, operation, anchor=None):
|
||||
notes = types.sounding_notes(notes)
|
||||
first = notes[0].start
|
||||
events = tuple((n.start-first, n.end-n.start, n.pitch.number()) for n in notes)
|
||||
ending = (anchor.pitch.number()%12, anchor.earliest%4, anchor.duration) if anchor and anchor.role == 'terminal' else ()
|
||||
return types.HeardFigure(first, notes[-1].end, events, operation, ending)
|
||||
|
||||
|
||||
def remember(history, figure, bar):
|
||||
return tuple(h for h in (*history, figure) if h.end > figure.end-policy.DEFAULT.history_bars*bar)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def fingerprints(events, relative=False):
|
||||
result = set()
|
||||
for size in (3, 4, 6, 8):
|
||||
for i in range(len(events)-size+1):
|
||||
group = events[i:i+size]
|
||||
start, _, pitch = group[0]
|
||||
result.add(tuple((a-start, d, p-pitch if relative else p) for a,d,p in group))
|
||||
return frozenset(result)
|
||||
|
||||
|
||||
def repetition(figure, history, bar):
|
||||
"""Bounded overuse, not a reward for arbitrary difference or extra attacks."""
|
||||
exact, relative = fingerprints(figure.events), fingerprints(figure.events, True)
|
||||
overuse = cadence = 0.
|
||||
for old in history:
|
||||
age = max(0., float((figure.start-old.end)/bar))
|
||||
recent = max(0., 1-age/policy.DEFAULT.history_bars)
|
||||
a,b = fingerprints(old.events), fingerprints(old.events, True)
|
||||
exact_match = len(exact & a)/max(1,min(len(exact),len(a)))
|
||||
shifted_match = len(relative & b)/max(1,min(len(relative),len(b)))
|
||||
overuse += recent*(.65*exact_match+.35*shifted_match)
|
||||
if figure.ending and figure.ending == old.ending:
|
||||
cadence += recent
|
||||
pitches = tuple(p for _,_,p in figure.events)
|
||||
oscillation = sum(a==c and b==d for a,b,c,d in zip(pitches,pitches[1:],pitches[2:],pitches[3:]))/max(1,len(pitches)-3)
|
||||
discount = .45 if figure.operation == 'return' else 1.
|
||||
return min(1., discount*overuse/2) + .3*oscillation + .3*min(1.,cadence/2)
|
||||
|
||||
|
||||
def register_goal(incoming, part, fallback):
|
||||
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
|
||||
if not recent:
|
||||
return fallback
|
||||
center = sum(recent)/len(recent)
|
||||
if part == 'electric' and (center >= 67 or incoming.register_peak >= 72 and center >= 64):
|
||||
return max(48, round(center)-7)
|
||||
if part == 'acoustic' and max(recent)-min(recent) < 8:
|
||||
home = sum(n.pitch.number() for n in incoming.home.notes)/len(incoming.home.notes) if incoming.home else center
|
||||
return min(72, round(center)+5) if center <= home+7 else max(45, round(center)-5)
|
||||
return round(center)
|
||||
|
||||
|
||||
def endpoint_cost(finish, start, end, count, harmony, bar):
|
||||
length = float((finish-start)/bar)
|
||||
remaining = float((end-finish)/bar)
|
||||
projected = count+1+remaining/(32/policy.DEFAULT.settling_target)
|
||||
low, high = policy.DEFAULT.settling_range
|
||||
pacing = .2*max(0,low-projected,projected-high)+.025*abs(projected-policy.DEFAULT.settling_target)
|
||||
span = harmony.at(finish-F(1,100))
|
||||
# A dominant may close an open question, but a nearby resolution is preferred.
|
||||
degrees=generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)) if span and span.scale else {}
|
||||
dominant=degrees.get(3)==0 and degrees.get(7)==-1
|
||||
short_remainder=.2*max(0,4-remaining) if remaining else 0.
|
||||
return pacing+.035*abs(length-5)+.16*dominant+.1*(length==8)+short_remainder
|
||||
|
||||
|
||||
def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2):
|
||||
from generator.solo.themes import source_notes
|
||||
bounds=[F()]
|
||||
for measure in measures:bounds.append(bounds[-1]+generator.model.fraction(measure['duration']))
|
||||
index=next((i for i,b in enumerate(bounds[:-1]) if b<=start<bounds[i+1]),len(measures)-1)
|
||||
bar=bounds[index+1]-bounds[index]
|
||||
current=harmony.at(start)
|
||||
if not current or not current.scale:
|
||||
ends=(min(end,current.end if current else bounds[index+1]),)
|
||||
else:
|
||||
barrier=min((s.start for s in harmony.spans if not s.scale and s.start>start),default=end)
|
||||
ends=tuple(sorted({min(end,barrier,bounds[min(index+n,len(measures))]) for n in (4,5,6,8)}))
|
||||
if barrier>=end:
|
||||
# The returning pickup may shorten the last measure. Do not strand a
|
||||
# one-beat "new phrase" after an otherwise completed sustained close.
|
||||
ends=tuple(sorted({end if 0<end-finish<bar else finish for finish in ends}))
|
||||
melody=source_notes(analysis)
|
||||
values=[]
|
||||
for finish in ends:
|
||||
if finish<=start:continue
|
||||
notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.start<start)
|
||||
for n in melody if n.start<finish and n.end>start)
|
||||
values.append(types.SourcePhrase(f'theme_window_{start}_{finish}',start,finish,notes,(),''))
|
||||
return tuple(sorted(values,key=lambda s:(endpoint_cost(s.end,start,end,incoming.closures,harmony,bar),s.end))[:limit])
|
||||
|
||||
|
||||
def endings(source, incoming, part, harmony, bar, *, limit=8):
|
||||
"""Choose pitch AND sounding interval before generating the approach."""
|
||||
from generator.solo.leads import available
|
||||
base=incoming.last.pitch.number() if incoming.last else source.notes[0].pitch.number() if source.notes else 55
|
||||
goal=register_goal(incoming,part,base)
|
||||
values=[]
|
||||
duration=source.end-source.start
|
||||
holds=(F(2),F(5,2),F(3),F(7,2),F(4)) if duration>=4 else (min(F(2),duration/2),)
|
||||
for hold in holds:
|
||||
for release in (F(),F(1,2),F(1),F(2)):
|
||||
arrival=source.end-release-hold
|
||||
if arrival-source.start<F(3,2):continue
|
||||
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),(41,76),hold,'terminal',harmony.at(arrival).chord,
|
||||
hold_until=arrival+hold)
|
||||
cadence=sum(h.ending==(pitch.number()%12,arrival%4,hold) for h in incoming.history)
|
||||
rank=abs(pitch.number()-goal)/24+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release)
|
||||
values.append((rank,anchor))
|
||||
selected=sequences.diverse(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])),
|
||||
lambda x:(x[1].duration,x[1].earliest%bar,x[1].pitch.number()>goal),limit)
|
||||
return tuple(a for _,a in selected)
|
||||
|
||||
|
||||
def appearance_cost(operation, incoming, start, end):
|
||||
expected=policy.DEFAULT.return_target*float(start/max(end,F(1)))
|
||||
actual=len(incoming.returns)
|
||||
if operation=='return':
|
||||
gap=start-(incoming.returns[-1] if incoming.returns else F())
|
||||
return .65*max(0,actual+1-expected)+.5*max(0,1-float(gap)/24)
|
||||
return .45*max(0,expected-actual)
|
||||
|
||||
|
||||
def activity(source, ending):
|
||||
spans=[]
|
||||
if ending.earliest>source.start:
|
||||
spans.append(types.ActivitySpan(source.start,ending.earliest,'active',F(5,4),F()))
|
||||
spans.append(types.ActivitySpan(ending.earliest,ending.hold_until,'sustained_arrival',F(),F(1)))
|
||||
if ending.hold_until<source.end:
|
||||
spans.append(types.ActivitySpan(ending.hold_until,source.end,'space',F(),F()))
|
||||
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 2*checked.exposure+entry+leaps+.35*max(0,4-density,density-6)+.8*history_cost
|
||||
@@ -40,7 +40,15 @@ class Policy:
|
||||
theme_copy_attacks: int = 4
|
||||
theme_seed_limit: int = 8
|
||||
theme_unit_alternatives: int = 8
|
||||
composition_version: str = 'thematic-recomposition-1'
|
||||
theme_rhythm_alternatives: int = 8
|
||||
theme_destinations: int = 6
|
||||
theme_continuations: int = 8
|
||||
theme_route_jobs: int = 16
|
||||
settling_range: tuple[int, int] = (5, 7)
|
||||
settling_target: int = 6
|
||||
return_target: int = 2
|
||||
history_bars: int = 16
|
||||
composition_version: str = 'thematic-phrasing-2'
|
||||
|
||||
# Preserve the former default root seeds while decoupling them from policy edits.
|
||||
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
||||
|
||||
@@ -22,6 +22,7 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
|
||||
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("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'])}")
|
||||
lines.append(f"Harmonic exposure: {detail['harmonic_exposure']:.4f}; unresolved treatments: {len(detail['unresolved_treatments'])}")
|
||||
@@ -108,13 +109,61 @@ def structure(solo):
|
||||
entry['lead_stages'] = {name: {'id': draft.id, 'attacks': len(draft.notes), 'notes': rational([dataclasses.asdict(n) for n in draft.notes])} for name, draft in (('initial', phrase.initial), ('refined', phrase.refined)) if draft}
|
||||
entry['complete_routes'] = rational([dataclasses.asdict(r) for r in phrase.routes])
|
||||
if phrase.initial and phrase.refined:
|
||||
entry['head_comparison_plain']=generator.solo.similarity.compare(phrase.refined.notes,phrase.initial.notes)
|
||||
entry['head_comparison_elaborated']=generator.solo.similarity.compare(tuple(n for d in phrase.decisions for n in d.notes),phrase.initial.notes)
|
||||
# A source hold may already be sounding at this generated phrase boundary.
|
||||
# Detach its clipped entrance for the local comparison only; the full
|
||||
# source comparison below still merges its original tied continuations.
|
||||
local_source=generator.solo.drafts.local_sounding(phrase.initial.notes)
|
||||
entry['head_comparison_plain']=generator.solo.similarity.compare(phrase.refined.notes,local_source)
|
||||
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.references.relationship(phrase.refined.notes,phrase.state.home)
|
||||
entry['score'] = phrase.score
|
||||
phrases.append(entry)
|
||||
return {'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, 'harmonic_roles': harmony.describe(solo.notes(), contracts), '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 {'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, 'harmonic_roles': harmony.describe(solo.notes(), contracts), '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):
|
||||
import collections
|
||||
from generator.solo import phrasing
|
||||
notes=solo.notes()
|
||||
figures=[phrasing.heard(notes,'chorus')] if notes else []
|
||||
exact=collections.Counter();relative=collections.Counter()
|
||||
for figure in figures:
|
||||
# Count occurrences, not just unique vocabulary within each phrase.
|
||||
for i in range(len(figure.events)-3):
|
||||
group=figure.events[i:i+4];start,_,pitch=group[0]
|
||||
exact[tuple((a-start,d,p) for a,d,p in group)]+=1
|
||||
relative[tuple((a-start,d,p-pitch) for a,d,p in group)]+=1
|
||||
endings=[];regions=[];appearances=[]
|
||||
for phrase in solo.phrases:
|
||||
local=tuple(n for d in phrase.decisions for n in d.notes)
|
||||
for anchor in phrase.anchors:
|
||||
if anchor.role!='terminal' or phrase.plan.closure!='closed':continue
|
||||
note=generator.solo.harmonic.anchor_note(anchor,phrase.state.notes)
|
||||
endings.append({'phrase':phrase.plan.id,'arrival':str(note.start) if note else None,'pitch':anchor.pitch.label(),
|
||||
'required_beats':str(anchor.duration),'sounding_beats':str(note.end-note.start) if note else None,
|
||||
'release':str(phrase.plan.release),'completed':note is not None})
|
||||
if local:
|
||||
regions.append({'start':str(phrase.plan.start),'end':str(phrase.plan.end),
|
||||
'low':min(n.pitch.number() for n in local),'high':max(n.pitch.number() for n in local),
|
||||
'duration_weighted_mean':sum(n.pitch.number()*float(n.end-n.start) for n in local)/sum(float(n.end-n.start) for n in local)})
|
||||
if phrase.draft:
|
||||
for transform,feature in zip(phrase.draft.transformations,phrase.draft.features):
|
||||
appearances.append({'start':str(transform.start),'operation':transform.operation,'home':transform.home,
|
||||
'preserves':list(feature.preserves),'feature_source':feature.source})
|
||||
activity={}
|
||||
for intent in ('active','sustained_arrival','space'):
|
||||
spans=[s for p in solo.phrases for s in p.plan.passages if s.intent==intent]
|
||||
duration=sum(float(s.end-s.start) for s in spans)
|
||||
attacks=sum(any(s.start<=n.start<s.end for s in spans) for n in notes)
|
||||
occupancy=sum(float(max(0,min(n.end,s.end)-max(n.start,s.start))) for n in notes for s in spans)
|
||||
activity[intent]={'beats':duration,'attacks':attacks,'attacks_per_four_beats':4*attacks/duration if duration else 0,
|
||||
'occupancy':occupancy/duration if duration else 0}
|
||||
return {'completed_sustained_endings':sum(e['completed'] and fractions.Fraction(e['sounding_beats'])>=2 for e in endings),
|
||||
'endings':endings,'appearances':appearances,'clear_returns':sum(a['operation']=='return' for a in appearances),
|
||||
'exact_four_note_recurrences':sum(max(0,n-1) for n in exact.values()),
|
||||
'interval_rhythm_four_note_recurrences':sum(max(0,n-1) for n in relative.values()),
|
||||
'activity':activity,'register_regions':regions}
|
||||
|
||||
|
||||
def fretboard_report(solo):
|
||||
|
||||
+71
-38
@@ -31,11 +31,34 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
|
||||
if not hasattr(harmony,'route_cache'):
|
||||
harmony.route_cache={}
|
||||
harmony.route_libraries={}
|
||||
harmony.concrete_routes={}
|
||||
harmony.route_libraries[id(library)]=library
|
||||
key=(notes,frozenset(fixed),id(library),register)
|
||||
concrete_key=(notes,frozenset(fixed),id(library),register)
|
||||
if concrete_key in harmony.concrete_routes:
|
||||
return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.concrete_routes[concrete_key])
|
||||
# Event/source IDs and non-fixed placeholder pitches do not affect route
|
||||
# feasibility. Cache the pitch work across different thematic histories,
|
||||
# then restore this caller's provenance before ranking complete routes.
|
||||
fixed_indices=frozenset(i for i,n in enumerate(notes) if n.event_id in fixed)
|
||||
canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i),role='connection',position=None,
|
||||
pitch=n.pitch if i==0 or i==len(notes)-1 or i in fixed_indices else types.Pitch('c',fractions.Fraction(),0)) for i,n in enumerate(notes))
|
||||
key=(canonical,fixed_indices,id(library),register)
|
||||
if key not in harmony.route_cache:
|
||||
harmony.route_cache[key]=_candidates(notes,fixed,harmony,library,register)
|
||||
return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.route_cache[key])
|
||||
harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register)
|
||||
result=[]
|
||||
for route in harmony.route_cache[key]:
|
||||
actual=tuple(n if i==0 else dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==r.pitch.number() else r.pitch,position=None)
|
||||
for i,(n,r) in enumerate(zip(notes,route.notes)))
|
||||
identity=generator.solo.policy.stable_rank('complete-route',(actual,route.sources))[:16]
|
||||
result.append(dataclasses.replace(route,id=identity,notes=actual,destination=notes[-1],relationship=relationship))
|
||||
selected=generator.solo.sequences.diverse(result,
|
||||
lambda r:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(r.notes,r.notes[1:])),r.id),
|
||||
lambda r:generator.solo.sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(r.notes,r.notes[1:]))),
|
||||
generator.solo.policy.DEFAULT.join_alternatives)
|
||||
if len(harmony.concrete_routes)>=8192:harmony.concrete_routes.pop(next(iter(harmony.concrete_routes)))
|
||||
if len(harmony.route_cache)>=8192:harmony.route_cache.pop(next(iter(harmony.route_cache)))
|
||||
harmony.concrete_routes[concrete_key]=selected
|
||||
return selected
|
||||
|
||||
def _candidates(notes, fixed, harmony, library, register):
|
||||
"""Every returned candidate includes its entry, complete rhythm and destination.
|
||||
@@ -49,41 +72,51 @@ def _candidates(notes, fixed, harmony, library, register):
|
||||
span = harmony.at(first.start)
|
||||
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),
|
||||
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)
|
||||
if key not in harmony.pitch_routes:
|
||||
harmony.pitch_routes[key]=pitch_paths(first.pitch,contexts,library,register)
|
||||
if len(harmony.pitch_routes)>8192:harmony.pitch_routes.pop(next(iter(harmony.pitch_routes)))
|
||||
route_spans=tuple((max(first.start,c.span.start),min(destination.end,c.span.end),c.scale.label())
|
||||
for c in harmony.choices if c.scale and c.span.start<destination.end and c.span.end>first.start)
|
||||
result=[]
|
||||
for pitches,sources in harmony.pitch_routes[key]:
|
||||
realized=(first,*(dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==p.number() else p,position=None)
|
||||
for n,p in zip(notes[1:],pitches[1:])))
|
||||
identity=generator.solo.policy.stable_rank('complete-route',(realized,sources))[:16]
|
||||
result.append(types.RouteCandidate(identity,realized,destination,route_spans,'connect theme',sources))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def pitch_paths(first, contexts, library, register):
|
||||
"""Time-independent pitch work; context includes every overlapped collection.
|
||||
|
||||
Sounding durations and chord roles are still validated on each concrete
|
||||
realization. Equal collection contexts can reuse paths across rhythmic edits.
|
||||
"""
|
||||
destination=contexts[-1][2]
|
||||
try:
|
||||
displacement = types.scale_index(span.scale, destination.pitch.number()) - types.scale_index(span.scale, first.pitch.number())
|
||||
displacements = tuple([displacement])
|
||||
displacement=types.scale_index(contexts[0][0],destination)-types.scale_index(contexts[0][0],first.number())
|
||||
displacements=(displacement,)
|
||||
except ValueError:
|
||||
# Enumerate possible degree routes when the destination changes collection;
|
||||
# actual endpoint equality below decides compatibility, not a pitch estimate.
|
||||
displacements = tuple(range(-7, 8))
|
||||
direction = (destination.pitch.number() > first.pitch.number()) - (destination.pitch.number() < first.pitch.number())
|
||||
paths = dict.fromkeys(path for d in displacements for path in library.retrieve(len(notes), d, direction))
|
||||
result = []
|
||||
low, high = generator.fretboard.single_register((register[0] - 7, register[1] + 5))
|
||||
allowed = {n.start: types.permitted(harmony.choices, n.start, n.end) for n in notes}
|
||||
displacements=tuple(range(-7,8))
|
||||
direction=(destination>first.number())-(destination<first.number())
|
||||
paths=dict.fromkeys(path for d in displacements for path in library.retrieve(len(contexts),d,direction))
|
||||
low,high=generator.fretboard.single_register((register[0]-7,register[1]+5))
|
||||
result=[]
|
||||
for path in paths:
|
||||
realized = [first]
|
||||
for template, step in zip(notes[1:], path.steps):
|
||||
current = harmony.at(template.start)
|
||||
if not current or not current.scale:
|
||||
break
|
||||
options = advance(current.scale, realized[-1].pitch.number(), step)
|
||||
if not options:
|
||||
break
|
||||
pitch = options[0]
|
||||
if not low <= pitch.number() <= high or pitch.number() % 12 not in allowed[template.start]:
|
||||
break
|
||||
if (template.event_id in fixed or template is destination) and pitch.number() != template.pitch.number():
|
||||
break
|
||||
if not generator.solo.placement.positions(pitch.number()):
|
||||
break
|
||||
realized.append(dataclasses.replace(template, pitch=template.pitch if pitch.number() == template.pitch.number() else pitch, position=None))
|
||||
if len(realized) != len(notes):
|
||||
continue
|
||||
route_spans = tuple((max(first.start, c.span.start), min(destination.end, c.span.end), c.scale.label()) for c in harmony.choices if c.scale and c.span.start < destination.end and c.span.end > first.start)
|
||||
identity = generator.solo.policy.stable_rank('complete-route', (tuple(realized), path.sources))[:16]
|
||||
result.append(types.RouteCandidate(identity, tuple(realized), destination, route_spans, 'connect theme', path.sources))
|
||||
return generator.solo.sequences.diverse(result,
|
||||
lambda r: (sum(abs(b.pitch.number() - a.pitch.number()) for a, b in zip(r.notes, r.notes[1:])), r.id),
|
||||
lambda r: generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(r.notes, r.notes[1:]))),
|
||||
generator.solo.policy.DEFAULT.join_alternatives)
|
||||
realized=[first]
|
||||
for (scale,allowed,fixed),step in zip(contexts[1:],path.steps):
|
||||
if not scale:break
|
||||
options=advance(scale,realized[-1].number(),step)
|
||||
if not options:break
|
||||
pitch=options[0];number=pitch.number()
|
||||
if not low<=number<=high or number%12 not in allowed:break
|
||||
if fixed is not None and number!=fixed:break
|
||||
if not generator.solo.placement.positions(number):break
|
||||
realized.append(pitch)
|
||||
if len(realized)==len(contexts):result.append((tuple(realized),path.sources))
|
||||
return tuple(result)
|
||||
|
||||
+182
-107
@@ -10,7 +10,7 @@ from fractions import Fraction as F
|
||||
|
||||
import generator.model
|
||||
import generator.harmony
|
||||
from generator.solo import types, policy, sequences, routes, references, similarity
|
||||
from generator.solo import types, policy, sequences, routes, references, similarity, phrasing
|
||||
|
||||
|
||||
def source_notes(analysis):
|
||||
@@ -18,34 +18,6 @@ def source_notes(analysis):
|
||||
event_id=n.sources[0],position=None) for n in analysis.melody if n.end>analysis.body_offset)
|
||||
|
||||
|
||||
def windows(analysis, measures, end, harmony):
|
||||
"""Existing phrase representation; source extraction seams do not close it."""
|
||||
bounds=[F()]
|
||||
for measure in measures:bounds.append(bounds[-1]+generator.model.fraction(measure['duration']))
|
||||
melody=source_notes(analysis); result=[]; index=0
|
||||
while bounds[index]<end:
|
||||
stop=min(index+4,len(measures))
|
||||
# Permit an extension through a cadence rather than closing on its dominant.
|
||||
last=harmony.at(min(end,bounds[stop])-F(1,100))
|
||||
if last and last.chord!='N.C.' and generator.harmony.degrees(generator.harmony.parse_symbol(last.chord)).get(7)==-1 and generator.harmony.degrees(generator.harmony.parse_symbol(last.chord)).get(3)==0:
|
||||
for size in (6,8):
|
||||
candidate=min(index+size,len(measures)); span=harmony.at(min(end,bounds[candidate])-F(1,100))
|
||||
if span and span.chord!='N.C.' and generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)).get(3)==-1:
|
||||
stop=candidate;break
|
||||
start,finish=bounds[index],min(end,bounds[stop])
|
||||
notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.start<start)
|
||||
for n in melody if n.start<finish and n.end>start)
|
||||
result.append(types.SourcePhrase(f'theme_window_{start}_{finish}',start,finish,notes,(),''))
|
||||
index=stop
|
||||
# Explicit missing harmony interrupts sounding material, not thematic memory.
|
||||
divided=[]
|
||||
for window in result:
|
||||
cuts=sorted({window.start,window.end,*(max(window.start,s.start) for s in harmony.spans if not s.scale and s.start<window.end and s.end>window.start),*(min(window.end,s.end) for s in harmony.spans if not s.scale and s.start<window.end and s.end>window.start)})
|
||||
for start,finish in zip(cuts,cuts[1:]):
|
||||
notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.start<start) for n in window.notes if n.start<finish and n.end>start)
|
||||
divided.append(dataclasses.replace(window,id=f'theme_window_{start}_{finish}',start=start,end=finish,notes=notes))
|
||||
return tuple(divided)
|
||||
|
||||
|
||||
def silence(source,incoming,part,harmony,tempo,seed):
|
||||
from generator.solo import realization
|
||||
@@ -55,7 +27,8 @@ def silence(source,incoming,part,harmony,tempo,seed):
|
||||
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)
|
||||
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.),)
|
||||
|
||||
@@ -76,7 +49,8 @@ def short_fragment(source,incoming,part,harmony,tempo,seed,final):
|
||||
plan=types.PhrasePlan(draft.id,source.start,source.end,motif,'fragment','thematic_recomposition',None,(41,76),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)
|
||||
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)
|
||||
|
||||
@@ -107,7 +81,9 @@ def feature(reference, actual, *, treatment=None):
|
||||
required={'rhythmic_seed':('rhythm',),'contour_seed':('contour',),
|
||||
'opening_fragment':('rhythm','pitches'),'characteristic_interval':('exact_intervals',),
|
||||
'repeat_with_harmonic_adaptation':('rhythm','contour'),'answer':('contour',),
|
||||
'extend':('rhythm','contour'),'fragment':('rhythm',),'callback':('rhythm','pitches')}[treatment]
|
||||
'extend':('contour',),'fragment':('rhythm',),'callback':('rhythm','pitches'),
|
||||
'return':('rhythm','intervals'), 'develop_rhythm':('rhythm',),
|
||||
'develop_contour':('intervals',), 'continue':('contour',)}[treatment]
|
||||
original=reference.notes
|
||||
origin=original[0].start
|
||||
octave=12*round((actual[0].pitch.number()-original[0].pitch.number())/12)
|
||||
@@ -122,6 +98,17 @@ def rhythm_windows(library,meter,length,context):
|
||||
key=('theme-rhythm',id(library),meter,length)
|
||||
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):
|
||||
for right,right_id in rhythm_windows(library,meter,length-split,context):
|
||||
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)}):
|
||||
@@ -134,13 +121,14 @@ def rhythm_windows(library,meter,length,context):
|
||||
return context.pools[key]
|
||||
|
||||
|
||||
def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operation='introduce', terminal=False):
|
||||
def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operation='introduce', terminal=False, ending=None):
|
||||
length=finish-start; values={}
|
||||
if home and operation=='repeat_with_harmonic_adaptation':
|
||||
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)
|
||||
if ref.treatment in ('rhythmic_seed','opening_fragment','callback') or operation in ('extend','fragment','repeat_with_harmonic_adaptation'):
|
||||
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:
|
||||
@@ -150,15 +138,28 @@ def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operatio
|
||||
values.update(rhythm_windows(rhythms,meter,length,context))
|
||||
if home and operation=='answer':
|
||||
for p,label in rhythm_windows(rhythms,meter,length,context):values.setdefault(p,label)
|
||||
if terminal:
|
||||
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()):
|
||||
for hold in (F(1,2),F(1),F(3,2),F(2)):
|
||||
for release in (F(),F(1,2)):
|
||||
arrival=length-hold-release
|
||||
head=tuple((a,min(b,arrival)) for a,b in original if a<arrival)
|
||||
if len(head)>=len(ref.notes):values[(*head,(arrival,length-release))]=label+f'; arrival {hold}, release {release}'
|
||||
return sequences.diverse(tuple(values.items()),lambda x:(max(0,len(x[0])-1.5*float(length),float(length)-len(x[0])),policy.stable_rank(ref.id,x[0])),
|
||||
lambda x:(len(x[0]),x[0][-1][1]-x[0][-1][0],x[0][0][0]),4)
|
||||
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: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():
|
||||
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'
|
||||
values=arrivals
|
||||
return sequences.diverse(tuple(values.items()),lambda x:(len(x[0])>16,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):
|
||||
@@ -196,6 +197,14 @@ def instantiate(templates,first,steps,harmony):
|
||||
|
||||
|
||||
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'):
|
||||
@@ -232,64 +241,91 @@ class Unit:
|
||||
routes: tuple[types.RouteCandidate,...] = ()
|
||||
|
||||
|
||||
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0):
|
||||
# Position and the recent realization participate even when two paths share a last pitch.
|
||||
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)
|
||||
key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,home,operation,terminal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT)
|
||||
key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,home,operation,ending,goal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT)
|
||||
if key in context.results:
|
||||
context.diagnostics['cache_hits']+=1;return context.results[key]
|
||||
result=[]; count=len(ref.notes)
|
||||
for pattern,label in patterns(ref,start,finish,rhythms,meter,context,home=home,operation=operation,terminal=terminal):
|
||||
if len(pattern)<count:continue
|
||||
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 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)
|
||||
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=max(0,abs(first.number()-base)-5)/12+abs(target.number()-(goal if goal is not None else base))/48
|
||||
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()
|
||||
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:
|
||||
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=='repeat_with_harmonic_adaptation' and len(templates)==len(home.notes):
|
||||
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:proposed.append((heard,()))
|
||||
for prefix in prefix_paths(templates,ref,first,harmony,library):
|
||||
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):
|
||||
proposed.append((prefix,()));continue
|
||||
base=prefix[-1].pitch.number()
|
||||
for target in pitches(harmony,templates[-1],base,members=True,limit=4 if batch else 2):
|
||||
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,(41,76),operation+' '+ref.id):
|
||||
proposed.append(((*prefix[:-1],*route.notes),(route,)))
|
||||
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,(41,76),operation+' '+ref.id)[: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
|
||||
density=len(actual)*4/float(finish-start)
|
||||
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)
|
||||
# A repeat preserves the full heard idea. Answers and extensions
|
||||
# preserve their declared fragment, not every event of the old ending.
|
||||
development=references.relationship(actual,home) if home and operation=='repeat_with_harmonic_adaptation' else 0.
|
||||
score=2*checked.exposure+entry+leaps+development+.35*max(0,4-density,density-6)
|
||||
anchor=types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),(41,76),
|
||||
last.end-last.start,'terminal' if terminal else 'intermediate',harmony.at(last.start).chord,hold_until=last.end)
|
||||
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=2*checked.exposure+entry+leaps+.35*direction
|
||||
anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),(41,76),
|
||||
last.end-last.start,'intermediate',harmony.at(last.start).chord,hold_until=last.end)
|
||||
result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections))
|
||||
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()%12,tuple(n.end-n.start for n in u.notes[:count])),8)
|
||||
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
|
||||
|
||||
|
||||
def home_reference(home,operation):
|
||||
notes=home.notes[:min(4,len(home.notes))]
|
||||
# A short characteristic beginning leaves room for a newly composed answer.
|
||||
while len(notes)>3 and notes[-1].end>home.duration/2:notes=notes[:-1]
|
||||
return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes,
|
||||
'rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','extend','fragment') else 'contour_seed')
|
||||
# Prefer a distinguishing turn/leap wherever it actually occurred. The heard
|
||||
# home remains immutable; a development never becomes the new source theme.
|
||||
groups=[home.notes[i:i+3] for i in range(max(1,len(home.notes)-2))]
|
||||
def rank(group):
|
||||
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:])]
|
||||
turn=len(intervals)>1 and intervals[0]*intervals[1]<0
|
||||
return (-int(turn),-max((abs(x) for x in intervals),default=0),group[0].start)
|
||||
notes=min(groups,key=rank)
|
||||
origin=notes[0].start
|
||||
notes=tuple(dataclasses.replace(n,start=n.start-origin,end=n.end-origin) for n in notes)
|
||||
treatment='rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','fragment','return','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):
|
||||
@@ -300,96 +336,135 @@ def make_home(unit,harmony,start=None,end=None):
|
||||
return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony))
|
||||
|
||||
|
||||
def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False):
|
||||
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])
|
||||
unit_length=2*bar
|
||||
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]
|
||||
states=[(0.,(),incoming.home,incoming)]
|
||||
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=8*(batch+1))
|
||||
states=[(0.,(),incoming.home,incoming,ending) for ending in ending_options]
|
||||
cursor=source.start
|
||||
while cursor<source.end:
|
||||
while cursor<source.end and states:
|
||||
finish=min(cursor+unit_length,source.end)
|
||||
if source.end-finish<6 or any(ending.earliest<finish for *_,ending in states):finish=source.end
|
||||
next_states=[]
|
||||
for cost,units,home,state in states:
|
||||
for cost,units,home,state,ending in states:
|
||||
introducing=home is None
|
||||
if introducing:
|
||||
requests=[(r,'introduce') for r in roots]
|
||||
else:
|
||||
operations=('repeat_with_harmonic_adaptation',) if cursor==source.start and source.start<=4*bar else ('repeat_with_harmonic_adaptation','answer','extend')
|
||||
operations=['continue'] if finish==source.end and cursor>source.start else ['develop_contour','develop_rhythm']
|
||||
if cursor==source.start:operations.append('return')
|
||||
requests=[(home_reference(home,op),op) for op in operations]
|
||||
used=incoming.callbacks+sum(u.operation=='callback' for u in units)
|
||||
if cursor>=8*bar and cursor==source.start and used<2:
|
||||
requests.extend((dataclasses.replace(r,treatment='callback'),'callback') for r in roots[:2] if r.end-r.start<=bar)
|
||||
# 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:
|
||||
# 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='answer'
|
||||
operation='continue'
|
||||
terminal=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)))
|
||||
options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation,
|
||||
terminal=final and finish==source.end,batch=batch)
|
||||
terminal=terminal,batch=batch,ending=ending if terminal else None,goal=goal)
|
||||
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)
|
||||
following=dataclasses.replace(state,home=actual_home,last=unit.notes[-1],notes=unit.notes,source_reference=state.source_reference or ref)
|
||||
next_states.append((cost+unit.score,(*units,unit),actual_home,following))
|
||||
states=sequences.diverse(next_states,lambda v:(v[0],policy.stable_rank(seed,tuple(u.notes for u in v[1]))),
|
||||
lambda v:(v[2].id,v[1][-1].notes[-1].pitch.number()),2 if incoming.home and finish<source.end else 8)
|
||||
figure=phrasing.heard(unit.notes,operation,unit.anchor)
|
||||
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+unit.score+.8*repetition+appearance,(*units,unit),actual_home,following,ending))
|
||||
def rank(value):
|
||||
cost,units,home,state,ending=value
|
||||
attacks=sum(len(u.notes) for u in units)
|
||||
remaining=max(F(),ending.earliest-finish)
|
||||
possible=attacks+2*float(remaining)+(0 if finish==source.end else 1)
|
||||
expected=float(source.end-source.start)
|
||||
activity_bound=.35*max(0,expected-possible,attacks-1.5*expected)/max(1,float(source.end-source.start)/4)
|
||||
return cost/max(1,len(units))+activity_bound,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)
|
||||
if not states:return ()
|
||||
cursor=finish
|
||||
result=[]
|
||||
for cost,units,home,state in states:
|
||||
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)
|
||||
anchors=tuple(u.anchor for u in units)
|
||||
# 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()),)
|
||||
options=[(plain,(),())]
|
||||
if stage=='elaborated':
|
||||
protected=(*features,types.IdentifyingFeature('destinations',tuple(n.event_id for n in plain if any(n.event_id+':arrival'==a.id for a in anchors)),(),(),'',()))
|
||||
for actual,edit,route in leads.elaborate(plain,protected,harmony,library,rhythms,meter,source,sustain_entry=True):
|
||||
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,
|
||||
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(a.id.removesuffix(':arrival') for a in anchors),(),anchors,home,'introduce' if not incoming.home else 'develop',
|
||||
draft=types.MelodicDraft(identifier,source.start,source.end,notes,transforms,(),(),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))
|
||||
# One shared musical context crosses computational/source boundaries.
|
||||
activity=[];cursor=source.start
|
||||
for n in notes:
|
||||
if n.start>cursor:activity.append(types.ActivitySpan(cursor,n.start,'space',F(),F()))
|
||||
activity.append(types.ActivitySpan(n.start,n.end,'active',F(5,4),F()))
|
||||
cursor=n.end
|
||||
if cursor<source.end:activity.append(types.ActivitySpan(cursor,source.end,'space',F(),F()))
|
||||
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')
|
||||
plan=types.PhrasePlan(identifier,source.start,source.end,motif,draft.relationship,'thematic_recomposition',None,(41,76),F(5,4),
|
||||
source.end-notes[-1].end,tuple(activity),(), 'sustain' if final else 'connected','closed' if final else 'open',seed,
|
||||
source.end-ending.hold_until,phrasing.activity(source,ending),(landmark,), 'sustain','closed',seed,
|
||||
source.id,source.start,source.end,(anchors,))
|
||||
phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits)
|
||||
if phrase is None:continue
|
||||
density=len(notes)*4/float(source.end-source.start)
|
||||
score=cost/len(units)+.35*(max(0,4-density,density-6)-max(0,4-len(plain)*4/float(source.end-source.start),len(plain)*4/float(source.end-source.start)-6))
|
||||
score=phrasing.evaluate(notes,incoming,harmony,source.start,source.end,bar)
|
||||
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
|
||||
current=dataclasses.replace(phrase.state,home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units))
|
||||
# Store the actual introduced sound, including any legal elaboration.
|
||||
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)
|
||||
current=dataclasses.replace(phrase.state,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 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))
|
||||
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=())
|
||||
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,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(),sum(e.kind=='complete_route' for e in p.edits)),8)
|
||||
lambda p:(p.state.home.id,p.state.last.pitch.number(),p.anchors[-1].duration,len(p.state.returns)),policy.DEFAULT.phrase_alternatives)
|
||||
|
||||
@@ -159,6 +159,24 @@ class ContinuationState:
|
||||
lead_identity: LeadIdentity | None = None
|
||||
source_reference: SourceReference | None = None
|
||||
callbacks: int = 0
|
||||
history: tuple[HeardFigure, ...] = ()
|
||||
returns: tuple[fractions.Fraction, ...] = ()
|
||||
closures: int = 0
|
||||
register_peak: int = 0
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class HeardFigure:
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
events: tuple[tuple[fractions.Fraction, fractions.Fraction, int], ...]
|
||||
operation: str
|
||||
ending: tuple = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SustainedTail:
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
pitch: int
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Transformation:
|
||||
@@ -233,6 +251,7 @@ class MelodicDraft:
|
||||
octave_transfer: int = 0
|
||||
source_links: tuple[tuple[str, tuple[str, ...]], ...] = ()
|
||||
source_reference: SourceReference | None = None
|
||||
sustained_tails: tuple[SustainedTail, ...] = ()
|
||||
def rests(self):
|
||||
cursor, result = self.start, []
|
||||
for note in self.notes:
|
||||
|
||||
Reference in New Issue
Block a user