Cleanup
This commit is contained in:
+95
-267
@@ -1,271 +1,99 @@
|
||||
"""Instantiate complete authored routes; a failed path is never repaired note by note."""
|
||||
from __future__ import annotations
|
||||
|
||||
"""Complete pitch paths for joint phrase proposals, with explicit structural motion."""
|
||||
import dataclasses
|
||||
import fractions
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary
|
||||
from generator.solo import types, policy, sequences
|
||||
|
||||
import generator.fretboard
|
||||
import generator.solo.placement
|
||||
import generator.solo.policy
|
||||
import generator.solo.sequences
|
||||
import generator.solo.types as types
|
||||
import generator.vocabulary
|
||||
class Routes:
|
||||
def __init__(self,harmony,library):
|
||||
self.harmony=harmony;self.library=library;self.pitch_cache={};self.cache={}
|
||||
# Every short relative path is reachable by attack count and exact displacement.
|
||||
self.index={}
|
||||
self.steps=frozenset(s.steps[0] for s in library.paths.get(2,()))
|
||||
for values in library.paths.values():
|
||||
for value in values:self.index.setdefault((len(value.steps)+1,value.offsets()[-1]),[]).append(value)
|
||||
|
||||
def pitches(self,start,end):
|
||||
key=(start,end)
|
||||
if key not in self.pitch_cache:
|
||||
span=self.harmony.at(start)
|
||||
allowed=types.permitted(self.harmony.choices,start,end)
|
||||
if end-start>=2:allowed &= self.harmony.members(start,end)| (span.roles.specified if span else frozenset())
|
||||
self.pitch_cache[key]=tuple(types.scale_pitch(span.scale,i) for i in range(-28,29)
|
||||
if types.scale_pitch(span.scale,i).number()%12 in allowed and self.harmony.profile.permits(types.scale_pitch(span.scale,i).number())) if span and span.scale else ()
|
||||
return self.pitch_cache[key]
|
||||
|
||||
def path_cost(notes):
|
||||
"""Penalize unexplained extremes, not every third or arpeggio interval."""
|
||||
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(notes,notes[1:])]
|
||||
excess=sum(max(0,abs(v)-7) for v in intervals)/max(1,len(intervals))
|
||||
oscillation=sum(a!=0 and a==-b and a==c and b==d for a,b,c,d in zip(intervals,intervals[1:],intervals[2:],intervals[3:]))
|
||||
return excess+.1*oscillation
|
||||
|
||||
|
||||
def path_family(notes,harmony):
|
||||
intervals=tuple(b.pitch.number()-a.pitch.number() for a,b in zip(notes,notes[1:]))
|
||||
chordal=all(n.pitch.number()%12 in harmony.members(n.start,n.end) for n in notes)
|
||||
return ('arpeggio' if chordal and any(abs(v)>=3 for v in intervals) else 'mapped',
|
||||
generator.solo.sequences.family(intervals))
|
||||
|
||||
def advance(scale, previous, step):
|
||||
try:
|
||||
degree = types.scale_index(scale, previous)
|
||||
return tuple([types.scale_pitch(scale, degree + step)])
|
||||
except ValueError:
|
||||
# At a changed collection count actual intervening scale tones from the
|
||||
# incoming sound. No old degree number is transferred to the new scale.
|
||||
if not step:
|
||||
return ()
|
||||
nearby = sorted((types.scale_pitch(scale, i) for i in range(-28, 35)), key=lambda p: p.number())
|
||||
outgoing = [p for p in nearby if (p.number() - previous) * step > 0]
|
||||
if step < 0:
|
||||
outgoing.reverse()
|
||||
return tuple(outgoing[abs(step) - 1:abs(step)])
|
||||
|
||||
def candidates(notes, fixed, harmony, library, register, relationship='connect theme', *, purpose='connect', expressions=()):
|
||||
if expressions:
|
||||
return planned_candidates(notes,fixed,harmony,register,relationship,expressions)
|
||||
# Cache concrete routes only with their endpoints, timing, scale context and
|
||||
# vocabulary. The per-Harmony cache cannot leak across collection changes.
|
||||
if not hasattr(harmony,'route_cache'):
|
||||
harmony.route_cache={}
|
||||
harmony.route_libraries={}
|
||||
harmony.concrete_routes={}
|
||||
harmony.route_libraries[id(library)]=library
|
||||
concrete_key=(notes,frozenset(fixed),id(library),register,harmony.profile,purpose)
|
||||
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,harmony.profile,purpose)
|
||||
if key not in harmony.route_cache:
|
||||
harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register,purpose)
|
||||
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:(path_cost(r.notes),r.id),
|
||||
lambda r:path_family(r.notes,harmony),
|
||||
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 planned_candidates(notes,fixed,harmony,register,relationship,contracts):
|
||||
# Cache complete contract-conditioned pitch work, not a next-note suggestion.
|
||||
# IDs/provenance are restored below; timing, pitch, role, obligations and
|
||||
# profile remain in the key. The cache is owned by this harmonic timeline.
|
||||
ids={n.event_id:str(i) for i,n in enumerate(notes)}
|
||||
canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i)) for i,n in enumerate(notes))
|
||||
def note(n):return canonical[int(ids[n.event_id])]
|
||||
obligations=tuple(dataclasses.replace(c,id=str(i),events=tuple(map(note,c.events)),
|
||||
target=note(c.target),preparation=note(c.preparation) if c.preparation else None) for i,c in enumerate(contracts))
|
||||
locked=frozenset(ids[i] for i in fixed)
|
||||
key=(canonical,locked,register,harmony.profile,obligations)
|
||||
if not hasattr(harmony,'planned_route_cache'):harmony.planned_route_cache={}
|
||||
if key not in harmony.planned_route_cache:
|
||||
harmony.planned_route_cache[key]=_planned_candidates(canonical,locked,harmony,register,'',obligations)
|
||||
if len(harmony.planned_route_cache)>8192:harmony.planned_route_cache.pop(next(iter(harmony.planned_route_cache)))
|
||||
result=[]
|
||||
for route in harmony.planned_route_cache[key]:
|
||||
actual=tuple(dataclasses.replace(original,pitch=realized.pitch,position=realized.position) for original,realized in zip(notes,route.notes))
|
||||
result.append(dataclasses.replace(route,notes=actual,destination=actual[-1],relationship=relationship))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _planned_candidates(notes,fixed,harmony,register,relationship,contracts):
|
||||
"""Solve the whole route around explicit, preselected harmonic obligations.
|
||||
|
||||
Fixed expressive pitches have narrow permissions; all other attacks use the
|
||||
map. Complete contracts are checked after realization, never inferred merely
|
||||
from a label. No incomplete route is handed back to the next cell.
|
||||
"""
|
||||
from generator.solo import gestures
|
||||
proposal=gestures.Proposal(notes,contracts,relationship)
|
||||
locked={n.event_id for c in contracts for n in (*c.events,c.target)}|set(fixed)|{notes[0].event_id,notes[-1].event_id}
|
||||
special={n.event_id for c in contracts for n in c.events}
|
||||
states=[(0.,(notes[0],))]
|
||||
for i,n in enumerate(notes[1:],1):
|
||||
span=harmony.at(n.start)
|
||||
if not span or not span.scale:return ()
|
||||
allowed=types.permitted(harmony.choices,n.start,n.end)
|
||||
if n.end-n.start>=2 and n.event_id not in special:allowed &= harmony.members(n.start,n.end)
|
||||
target=next((a for a in notes[i:] if a.event_id in locked),notes[-1])
|
||||
remaining=max(1,sum(a.start<target.start for a in notes[i:]))
|
||||
following=[]
|
||||
for cost,path in states:
|
||||
prior=path[-1]
|
||||
if n.event_id in locked:options=(n.pitch,)
|
||||
else:
|
||||
options=tuple(p for step in (-3,-2,-1,1,2,3) for p in advance(span.scale,prior.pitch.number(),step))
|
||||
if prior.pitch.number()%12 in allowed:options=(*options,prior.pitch)
|
||||
for pitch in options:
|
||||
number=pitch.number()
|
||||
if not harmony.profile.permits(number) or not register[0]<=number<=register[1]:continue
|
||||
if n.event_id not in special and number%12 not in allowed:continue
|
||||
motion=number-prior.pitch.number()
|
||||
if abs(motion)>12:continue
|
||||
# Continuation aims at the known future destination. Selected thirds
|
||||
# and fourths remain feasible; repeated oscillation is not progress.
|
||||
distance=abs(target.pitch.number()-number)
|
||||
remaining_cost=max(0,distance-5*remaining)/12
|
||||
repeating=len(path)>1 and path[-2].pitch==prior.pitch==pitch
|
||||
value=cost+max(0,abs(motion)-7)/12+.2*repeating
|
||||
following.append((value,(*path,dataclasses.replace(n,pitch=pitch,position=None)),remaining_cost))
|
||||
chosen=generator.solo.sequences.diverse(following,lambda v:(v[0]+v[2],tuple(n.pitch.number() for n in v[1])),
|
||||
lambda v:(generator.solo.sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(v[1],v[1][1:]))),v[1][-1].pitch.number()),8)
|
||||
states=[(cost,path) for cost,path,_ in chosen]
|
||||
if not states:return ()
|
||||
result=[]
|
||||
for _,actual in states:
|
||||
bound=proposal.bind(actual)
|
||||
if not harmony.evaluate(actual,until=actual[-1].end,complete=True,expressions=bound).valid:continue
|
||||
identity=generator.solo.policy.stable_rank('planned-route',tuple((n.start,n.end,n.pitch) for n in actual))[:16]
|
||||
result.append(types.RouteCandidate(identity,actual,actual[-1],(),relationship,tuple('gesture:'+c.rule for c in bound)))
|
||||
return tuple(result)
|
||||
|
||||
def _candidates(notes, fixed, harmony, library, register, purpose='connect'):
|
||||
"""Every returned candidate includes its entry, complete rhythm and destination.
|
||||
|
||||
Callers validate the whole surrounding sounding context and guitar path.
|
||||
This function applies exact scale paths, fixed events and range constraints.
|
||||
"""
|
||||
if not 2 <= len(notes) <= 16:
|
||||
return ()
|
||||
first, destination = notes[0], notes[-1]
|
||||
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) & (harmony.members(n.start,n.end) if n.end-n.start>=2 else frozenset(range(12))),
|
||||
n.pitch.number() if n.event_id in fixed or i==len(notes)-1 else None) for i,n in enumerate(notes))
|
||||
if not hasattr(harmony,'pitch_routes'):harmony.pitch_routes={}
|
||||
key=(first.pitch,contexts,id(library),register,harmony.profile)
|
||||
if key not in harmony.pitch_routes:
|
||||
harmony.pitch_routes[key]=pitch_paths(first.pitch,contexts,library,register,harmony.profile)
|
||||
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=[]
|
||||
paths=list(harmony.pitch_routes[key])
|
||||
# These are traversal operators, not stored phrases. An arpeggio prefix
|
||||
# adapts to the chord at each note and a complete scale route then connects
|
||||
# it to this caller's actual destination. No endpoint is attacked twice.
|
||||
for prefix,rule in chord_prefixes(notes,fixed,harmony,register,purpose):
|
||||
i=len(prefix)-1
|
||||
if i==len(notes)-1:
|
||||
if prefix[-1].pitch.number()==destination.pitch.number():
|
||||
paths.append((tuple(n.pitch for n in prefix),('chord-path:'+rule,)))
|
||||
continue
|
||||
suffix_key=(prefix[-1].pitch,contexts[i:],id(library),register,harmony.profile)
|
||||
if suffix_key not in harmony.pitch_routes:
|
||||
while len(harmony.pitch_routes)>=8192:
|
||||
harmony.pitch_routes.pop(next(iter(harmony.pitch_routes)))
|
||||
harmony.pitch_routes[suffix_key]=pitch_paths(prefix[-1].pitch,contexts[i:],library,register,harmony.profile)
|
||||
for tail,sources in harmony.pitch_routes[suffix_key][:2]:
|
||||
paths.append((tuple(n.pitch for n in prefix[:-1])+tail,('chord-path:'+rule,*sources)))
|
||||
for pitches,sources in paths:
|
||||
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 chord_prefixes(notes,fixed,harmony,register,purpose):
|
||||
from generator.solo import phrasing
|
||||
if len(notes)<2:return ()
|
||||
values=[]
|
||||
first=notes[0];destination=notes[-1]
|
||||
for rule in generator.vocabulary.data('melodic_vocabulary')['chord_paths']:
|
||||
if purpose not in rule['purpose']:continue
|
||||
if rule['require_chord_entry'] and first.pitch.number()%12 not in harmony.members(first.start,first.end):continue
|
||||
displacement=destination.pitch.number()-first.pitch.number()
|
||||
direction=1 if rule['direction']=='up' else -1 if rule['direction']=='down' else (1 if displacement>=0 else -1)
|
||||
for count in rule['prefix_attacks']:
|
||||
if count>len(notes):continue
|
||||
prefix=[first]
|
||||
for i,n in enumerate(notes[1:count]):
|
||||
span=harmony.at(n.start)
|
||||
if not span or not span.scale:break
|
||||
eligible=harmony.members(n.start,n.end)&types.permitted(harmony.choices,n.start,n.end)
|
||||
if rule['members']=='defining':eligible=eligible&phrasing.defining_members(span.chord)
|
||||
step=rule['member_steps'][i%len(rule['member_steps'])]
|
||||
old=prefix[-1].pitch.number()
|
||||
pitches=sorted((types.scale_pitch(span.scale,j) for j in range(-21,29)),key=lambda p:p.number(),reverse=direction*step<0)
|
||||
options=[p for p in pitches if p.number()%12 in eligible and 0<(p.number()-old)*direction*step
|
||||
and abs(p.number()-old)<=rule['max_interval'] and register[0]<=p.number()<=register[1] and harmony.profile.permits(p.number())]
|
||||
if len(options)<abs(step):break
|
||||
selected=options[abs(step)-1]
|
||||
if (n.event_id in fixed or i+1==len(notes)-1) and selected.number()!=n.pitch.number():break
|
||||
prefix.append(dataclasses.replace(n,pitch=selected))
|
||||
if len(prefix)==count:values.append((tuple(prefix),rule['id']))
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def pitch_paths(first, contexts, library, register, profile=None):
|
||||
"""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.
|
||||
"""
|
||||
from generator.solo.profiles import UNRESTRICTED
|
||||
profile = profile or UNRESTRICTED
|
||||
if not profile.permits(first.number()): return ()
|
||||
destination=contexts[-1][2]
|
||||
try:
|
||||
displacement=types.scale_index(contexts[0][0],destination)-types.scale_index(contexts[0][0],first.number())
|
||||
displacements=(displacement,)
|
||||
except ValueError:
|
||||
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))
|
||||
low, high = max(low, profile.register[0]), min(high, profile.register[1])
|
||||
result=[]
|
||||
# Reattacks are an explicit primitive, separate from interval catalog paths
|
||||
# (whose zero steps are deliberately excluded). Tied holds remain one event.
|
||||
if all(first.number()%12 in allowed and (fixed is None or fixed==first.number())
|
||||
for _,allowed,fixed in contexts):
|
||||
result.append(((first,)*len(contexts),('primitive:repeated_attack',)))
|
||||
for path in paths:
|
||||
realized=[first]
|
||||
for (scale,allowed,fixed),step in zip(contexts[1:],path.steps):
|
||||
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,profile):break
|
||||
realized.append(pitch)
|
||||
if len(realized)==len(contexts):result.append((tuple(realized),path.sources))
|
||||
return tuple(result)
|
||||
def realize(self,notes,fixed,purpose,direction,seed,center,incoming=None,hint=()):
|
||||
"""Only completed paths leave this function; internal frontier cuts have no cadence."""
|
||||
if not notes:return ()
|
||||
# Identical musical work is cached without event/provenance IDs.
|
||||
key=(tuple((n.start,n.end,n.pitch if i in fixed else None) for i,n in enumerate(notes)),tuple(sorted(fixed)),purpose,direction,seed,center,incoming.pitch if incoming else None,tuple((n.start,n.end,n.pitch) for n in hint))
|
||||
if key in self.cache:
|
||||
return tuple(tuple(dataclasses.replace(n,pitch=p) for n,p in zip(notes,path)) for path in self.cache[key])
|
||||
width=vocabulary.data('solo')['search']['route_frontier'];states=[(0.,())];count=len(notes)
|
||||
for i,n in enumerate(notes):
|
||||
options=(n.pitch,) if i in fixed else self.pitches(n.start,n.end)
|
||||
region=self.harmony.at(n.start)
|
||||
pitch_order={p:policy.stable_rank(seed,('pitch',i,p)) for p in options}
|
||||
following=[]
|
||||
for cost,path in states:
|
||||
prior=path[-1] if path else incoming.pitch if incoming else None
|
||||
phase=i/max(1,count-1)
|
||||
sign=1 if direction=='up' or direction=='arch' and phase<.5 or direction=='valley' and phase>=.5 else -1
|
||||
target=next((notes[j].pitch for j in sorted(fixed) if j>i),None)
|
||||
choices=[]
|
||||
for pitch in options:
|
||||
number=pitch.number();motion=number-prior.number() if prior else 0
|
||||
if not self.harmony.profile.permits(number) or prior and abs(motion)>12:continue
|
||||
if prior and i not in fixed:
|
||||
try:
|
||||
displacement=types.scale_index(region.scale,number)-types.scale_index(region.scale,prior.number())
|
||||
if displacement and displacement not in self.steps:continue
|
||||
except ValueError:
|
||||
pass # Changed collection: retain the actual incoming pitch.
|
||||
local=max(0,abs(number-center)-10)/12
|
||||
if i not in fixed:
|
||||
local+=max(0,abs(motion)-7)/3
|
||||
if prior:
|
||||
span=region
|
||||
chordal=number%12 in span.roles.basic
|
||||
# Arpeggios expand during the gesture; the last portion connects to its goal.
|
||||
if purpose=='arpeggio' and phase<.65:local+=(0 if chordal else 1)+(.4 if abs(motion)<3 else 0)
|
||||
elif purpose=='neighbor':local+=max(0,abs(motion)-2)/2
|
||||
else:local+=max(0,abs(motion)-3)/3
|
||||
desired=sign
|
||||
if hint and 0<i<len(hint):
|
||||
step=hint[i].pitch.number()-hint[i-1].pitch.number()
|
||||
if purpose=='answer':step=-step
|
||||
desired=(step>0)-(step<0)
|
||||
if purpose in ('return','head') and ((motion>0)-(motion<0))!=desired:continue
|
||||
local+=abs(abs(motion)-abs(step))/12
|
||||
if purpose=='neighbor':desired=sign if i%2 else -sign
|
||||
local+=.35 if motion*desired<0 else .2 if motion==0 else 0
|
||||
if len(path)>=2 and path[-2]==prior==pitch:local+=1
|
||||
if target and phase>.6:
|
||||
remaining=max(1,next(j for j in sorted(fixed) if j>i)-i)
|
||||
local+=max(0,abs(target.number()-number)-remaining*2)/3
|
||||
choices.append((local,pitch))
|
||||
# Preserve distinct register/motion alternatives, not merely two nearest notes.
|
||||
for local,pitch in sorted(choices,key=lambda v:(v[0],pitch_order[v[1]]))[:width]:
|
||||
following.append((cost+local,(*path,pitch)))
|
||||
if not following:return ()
|
||||
states=sequences.diverse(following,lambda v:(round(v[0],8),policy.stable_rank(seed,('path',v[1]))),
|
||||
lambda v:((v[1][-1].number()-v[1][0].number()>0),v[1][-1].number()),width)
|
||||
# Where applicable, add complete catalog routes as peers of the contextual route.
|
||||
if 2<=count<=4 and fixed=={0,count-1}:
|
||||
span=self.harmony.at(notes[0].start)
|
||||
if span and span.scale and all(self.harmony.at(n.start).scale==span.scale for n in notes):
|
||||
try:
|
||||
origin=types.scale_index(span.scale,notes[0].pitch.number());displacement=types.scale_index(span.scale,notes[-1].pitch.number())-origin
|
||||
for route in self.index.get((count,displacement),())[:32]:
|
||||
path=tuple(types.scale_pitch(span.scale,origin+x) for x in route.offsets())
|
||||
if all(p in self.pitches(n.start,n.end) for p,n in zip(path,notes)):states=(*states,(0.,path))
|
||||
except ValueError:pass
|
||||
paths=[]
|
||||
for _,path in sorted(states,key=lambda v:(v[0],policy.stable_rank(seed,v[1]))):
|
||||
if path not in paths:paths.append(path)
|
||||
if len(paths)==2:break
|
||||
self.cache[key]=tuple(paths)
|
||||
return tuple(tuple(dataclasses.replace(n,pitch=p) for n,p in zip(notes,path)) for path in paths)
|
||||
|
||||
Reference in New Issue
Block a user