Files
product-vamp/generator/solo/similarity.py
T
khodowany a0bd19b3ba Solo
2026-10-02 00:35:31 -05:00

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))