130 lines
7.7 KiB
Python
130 lines
7.7 KiB
Python
"""Bounded timing revisions of complete realized gestures, with fixed pitches."""
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
from fractions import Fraction as F
|
|
|
|
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}
|
|
fixed.update(n.event_id for c in draft.expressions for n in (*c.events,c.target,*((c.preparation,) if c.preparation else ())))
|
|
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,expressions=draft.expressions).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):
|
|
isolated = sum(n.end-n.start <= F(1,2) and a.end < n.start and n.end < b.start
|
|
for a,n,b in zip(notes, notes[1:], notes[2:]))
|
|
gaps = sum(0 < b.start-a.end <= 1 for a,b in zip(notes, notes[1:]))
|
|
return isolated + .1*gaps
|
|
|
|
|
|
def shared_arrival(draft,harmony,treatments=()):
|
|
"""A provisional endpoint may also begin a continuation, without reattack.
|
|
|
|
Retain the continuation event's identity. A rhythm/exact-quote contract or
|
|
a committed destination can forbid sharing; full context is revalidated.
|
|
"""
|
|
notes=draft.notes
|
|
joins={t.start for t in draft.transformations[1:] if t.operation=='continue'}
|
|
fixed=set(draft.protected)|set(draft.structural)
|
|
fixed.update(n.event_id for n in notes if n.role=='quote')
|
|
fixed.update(n.event_id for c in draft.expressions for n in (*c.events,c.target,*((c.preparation,) if c.preparation else ())))
|
|
fixed.update(e for f in draft.features if 'rhythm' in f.preserves for e in f.events)
|
|
edits=[]
|
|
for join in sorted(joins):
|
|
for i,(a,b) in enumerate(zip(notes,notes[1:])):
|
|
if b.start!=join or a.end!=b.start or a.pitch!=b.pitch:continue
|
|
if a.event_id in fixed or b.event_id in fixed:continue
|
|
actual=(*notes[:i],dataclasses.replace(b,start=a.start),*notes[i+2:])
|
|
if not drafts.contract_valid(draft,actual)[0]:continue
|
|
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True,expressions=draft.expressions).valid:continue
|
|
if not harmonic.anchors_valid(draft.anchors,actual,draft.end,complete=True,closure='closed')[0]:continue
|
|
edits.append(types.DraftEdit('shared_arrival',a.start,b.end,(a.event_id,b.event_id),(b.event_id,),
|
|
'reopen provisional endpoint as the same sounding continuation; preserve feature and arrival contracts'))
|
|
notes=actual
|
|
break
|
|
if not edits:return draft,()
|
|
by_id={n.event_id:n for n in notes}
|
|
transforms=[]
|
|
for t in draft.transformations:
|
|
group=tuple(by_id[e] for e in t.events if e in by_id)
|
|
if group:
|
|
transforms.append(dataclasses.replace(t,start=group[0].start,end=group[-1].end,
|
|
events=tuple(n.event_id for n in group),rhythm=tuple((n.start-group[0].start,n.end-group[0].start) for n in group)))
|
|
return dataclasses.replace(draft,notes=notes,transformations=tuple(transforms)),tuple(edits)
|
|
|
|
|
|
def revise(draft, harmony, treatments=()):
|
|
draft,shared=shared_arrival(draft,harmony,treatments)
|
|
notes=draft.notes; edits=list(shared)
|
|
if len(notes)<3: return draft, tuple(edits)
|
|
protected=set(draft.protected)|set(draft.structural)|{n.event_id for n in notes if n.role=='quote'}
|
|
# An identifying rhythm also protects its internal rests.
|
|
protected.update(n.event_id for c in draft.expressions for n in (*c.events,c.target,*((c.preparation,) if c.preparation else ())))
|
|
protected.update(e for feature in draft.features if 'rhythm' in feature.preserves for e in feature.events)
|
|
for _ in range(2):
|
|
proposals=[]
|
|
for i,(a,n,b) in enumerate(zip(notes,notes[1:],notes[2:]),1):
|
|
if n.event_id in protected or n.end-n.start>1: continue
|
|
if not (a.end<n.start or n.end<b.start): continue
|
|
if a.event_id not in protected and 0<n.start-a.end<=1:
|
|
proposals.append((*notes[:i-1],dataclasses.replace(a,end=n.start),*notes[i:]))
|
|
if 0<b.start-n.end<=1:
|
|
proposals.append((*notes[:i],dataclasses.replace(n,end=b.start),*notes[i+1:]))
|
|
for delta in (F(-1,2),F(1,2)):
|
|
if a.end<=n.start+delta and n.end+delta<=b.start:
|
|
proposals.append((*notes[:i],dataclasses.replace(n,start=n.start+delta,end=n.end+delta),*notes[i+1:]))
|
|
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,expressions=draft.expressions).valid: continue
|
|
if not harmonic.anchors_valid(draft.anchors,actual,draft.end,complete=True,closure='closed')[0]: continue
|
|
valid.append(actual)
|
|
if not valid: break
|
|
actual=min(valid,key=lambda ns:(fragmentation(ns),tuple((n.start,n.end,n.pitch.number()) for n in ns)))
|
|
changed=[(a,b) for a,b in zip(notes,actual) if (a.start,a.end)!=(b.start,b.end)]
|
|
edits.append(types.DraftEdit('connected_rhythm',min(min(a.start,b.start) for a,b in changed),
|
|
max(max(a.end,b.end) for a,b in changed),tuple(a.event_id for a,b in changed),
|
|
tuple(b.event_id for a,b in changed),'same pitch sequence and attack count; connect an editable gesture'))
|
|
notes=actual
|
|
transforms=tuple(dataclasses.replace(t,rhythm=tuple((n.start-t.start,n.end-t.start) for n in notes if n.event_id in t.events))
|
|
if not t.preserves else t for t in draft.transformations)
|
|
return dataclasses.replace(draft,notes=notes,transformations=transforms),tuple(edits)
|