93 lines
3.7 KiB
Python
93 lines
3.7 KiB
Python
"""Duration-aware comparisons of realized music, independent of event labels."""
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
|
|
from generator.solo import types, policy
|
|
|
|
|
|
def events(notes):
|
|
return tuple((float(n.start),float(n.end),n.pitch.number()) for n in types.sounding_notes(notes))
|
|
|
|
|
|
def occupancy(left, right, modulo=False):
|
|
"""Intersection of pitch functions; repeated attacks cannot dilute a hold."""
|
|
i=j=0; total=0.
|
|
while i<len(left) and j<len(right):
|
|
a,b=left[i],right[j]
|
|
same = a[2]%12==b[2]%12 if modulo else a[2]==b[2]
|
|
if same:
|
|
total+=max(0.,min(a[1],b[1])-max(a[0],b[0]))
|
|
if a[1]<=b[1]:i+=1
|
|
else:j+=1
|
|
return total
|
|
|
|
|
|
def interval_match(left, right):
|
|
"""Longest exact interval/rhythm fragment, allowing relocation/transposition."""
|
|
def tokens(notes):
|
|
return tuple((b[0]-a[0],a[1]-a[0],b[2]-a[2]) for a,b in zip(notes,notes[1:]))
|
|
a,b=tokens(left),tokens(right); previous={}; best=(0.,0)
|
|
for i,x in enumerate(a):
|
|
current={}
|
|
for j,y in enumerate(b):
|
|
if x!=y:continue
|
|
count=previous.get(j-1,0)+1
|
|
current[j]=count
|
|
# The last note's duration also belongs to the matching fragment.
|
|
tail=min(left[i+1][1]-left[i+1][0],right[j+1][1]-right[j+1][0])
|
|
duration=left[i+1][0]+tail-left[i+1-count][0]
|
|
best=max(best,(duration,count+1))
|
|
previous=current
|
|
return best
|
|
|
|
|
|
@functools.lru_cache(maxsize=8192)
|
|
def compare_events(candidate, source):
|
|
denominator=sum(b-a for a,b,_ in source)
|
|
if not denominator:
|
|
return dict(pitch_time=0.,octave_insensitive=0.,attack_agreement=0.,arrival_agreement=0.,
|
|
interval_rhythm_coverage=0.,longest_fragment_beats=0.,longest_fragment_attacks=0)
|
|
length,count=interval_match(candidate,source)
|
|
attacks={(a,b,p%12) for a,b,p in candidate}
|
|
arrivals=[n for n in source if n[1]-n[0]>=2]
|
|
return dict(pitch_time=occupancy(candidate,source)/denominator,
|
|
octave_insensitive=occupancy(candidate,source,True)/denominator,
|
|
attack_agreement=sum((a,b,p%12) in attacks for a,b,p in source)/max(1,len(source)),
|
|
arrival_agreement=sum((a,b,p%12) in attacks for a,b,p in arrivals)/max(1,len(arrivals)),
|
|
interval_rhythm_coverage=min(1.,length/denominator),longest_fragment_beats=length,longest_fragment_attacks=count)
|
|
|
|
|
|
def compare(candidate, source):
|
|
return compare_events(events(candidate),events(source))
|
|
|
|
|
|
@functools.lru_cache(maxsize=8192)
|
|
def copy_evidence(candidate, source, start, end):
|
|
"""Scan source-aligned windows, without an added-event denominator advantage."""
|
|
minimum=policy.DEFAULT.theme_copy_beats
|
|
threshold=policy.DEFAULT.theme_copy_threshold
|
|
fragment,count=interval_match(candidate,source)
|
|
copied=fragment>=minimum and count>=policy.DEFAULT.theme_copy_attacks
|
|
best=0.; best_offset=0.
|
|
if end-start>=minimum and candidate:
|
|
windows=sorted({start,*(a for a,_,_ in candidate if start<=a<=end-minimum)})
|
|
for left in windows:
|
|
right=min(end,left+minimum)
|
|
offsets={a[0]-b[0] for a in candidate if left<=a[0]<left+2 for b in source}
|
|
for offset in sorted(offsets):
|
|
window=tuple((max(left,a+offset),min(right,b+offset),p) for a,b,p in source if a+offset<right and b+offset>left)
|
|
count_in=sum(left<=a+offset<right for a,_,_ in source)
|
|
duration=sum(b-a for a,b,_ in window)
|
|
if count_in<policy.DEFAULT.theme_copy_attacks or duration<minimum/2:
|
|
continue
|
|
score=occupancy(candidate,window,True)/duration
|
|
if score>best:best,best_offset=score,offset
|
|
if score>=threshold:copied=True
|
|
return {'near_copy':copied,'aligned_occupancy':best,'alignment_offset':best_offset,
|
|
'longest_fragment_beats':fragment,'longest_fragment_attacks':count}
|
|
|
|
|
|
def near_copy(notes, source, start, end):
|
|
return copy_evidence(events(notes),events(source),float(start),float(end))
|