Files
product-vamp/generator/solo/gesture_rhythm.py
T
khodowany f234dd6d51 Save
2026-10-04 15:51:18 -05:00

127 lines
7.2 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}
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):
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(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).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(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).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)