Files
product-vamp/generator/solo/selection.py
T
2026-10-05 17:24:16 -05:00

103 lines
5.8 KiB
Python

"""One complete-event evaluator, including the declared musical relationships.
The inputs are realized notes and independently planned obligations. Action
counts and constructor-family labels never award musical credit.
"""
import collections
import fractions
import generator.solo.harmonic
import generator.solo.scoring
import generator.solo.types
VERSION = 'directed-phrases-2'
WEIGHTS = {'continuity': 2, 'repetition': 3, 'interruption': 2, 'activity': 2,
'harmony': 2, 'register': 1, 'identity': 3, 'arrival': 2}
def signature(notes):
notes = generator.solo.types.sounding_notes(notes)
if not notes:
return ()
return tuple((n.start-notes[0].start, n.end-n.start, n.pitch.number()-notes[0].pitch.number()) for n in notes)
def theme_distance(home, sample, purpose):
if not home or purpose not in ('return','develop','answer','extend'):
return fractions.Fraction()
# Returns preserve the whole introduced signature; development preserves
# its selected opening intervals, allowing the rhythm and conclusion to change.
count = len(home) if purpose=='return' else min(3,len(home))
if count<3 or len(sample)<count:
return fractions.Fraction(2)
a,b = signature(home[:count]),signature(sample[:count])
intervals = [(x[i][2]-x[i-1][2]) for x in (a,b) for i in range(1,count)]
left,right = intervals[:count-1],intervals[count-1:]
pitch = sum(min(fractions.Fraction(1),fractions.Fraction(abs(x-y),4)) for x,y in zip(left,right))/(count-1)
rhythm = sum((x[0],x[1])!=(y[0],y[1]) for x,y in zip(a,b))/fractions.Fraction(count) if purpose=='return' else 0
return pitch+rhythm
def figures(notes):
return tuple(signature(notes[i:i+4]) for i in range(max(0,len(notes)-3)))
def evaluate(notes, start, end, harmony, *, incoming=(), home=(), purpose='develop', contracts=(), center=60,
destinations=(), history=(), identity=()):
notes = generator.solo.types.sounding_notes(notes)
duration = end-start
if duration <= 0:
raise ValueError('Evaluation requires a positive span')
checked = harmony.evaluate(notes, expressions=contracts, until=end, complete=True)
anchors_valid,_ = generator.solo.harmonic.anchors_valid(destinations,notes,end,complete=True,closure='closed')
sounding = sum((n.end-n.start for n in notes), fractions.Fraction())
pitches = [n.pitch.number() for n in notes]
joined = [n.pitch.number() for n in incoming[-1:]]+pitches
intervals = [b-a for a,b in zip(joined, joined[1:])]
continuity = fractions.Fraction(sum(max(0, abs(d)-7) for d in intervals),max(1,len(intervals)))
oscillation = sum(a == -b == c and a != 0 for a,b,c in zip(intervals,intervals[1:],intervals[2:]))
patterns = collections.Counter(figures(notes))
recent = collections.Counter(f for p in history[-3:] for f in figures(generator.solo.types.sounding_notes(p.notes)))
# A single clear return is allowed. Repeating the same figure several times
# still costs something; a "return" label is not unlimited immunity.
overuse = sum(max(0,n+recent[f]-2) for f,n in patterns.items())
repetition = fractions.Fraction(oscillation+sum(a==b for a,b in zip(pitches,pitches[1:]))+overuse,max(1,len(notes)))
arrival_ids = {n.event_id for a in destinations if (n:=generator.solo.harmonic.anchor_note(a,notes))}
interruption = fractions.Fraction(sum(a.end<b.start and a.end-a.start<1 and b.end-b.start<1
and a.event_id not in arrival_ids for a,b in zip(notes,notes[1:])),max(1,len(notes)))
density = fractions.Fraction(len(notes)*4,1)/duration
active = any(s.scale and s.start<end and s.end>start for s in harmony.spans)
activity = max(0,density-7)+(2 if active and not notes else 0)
if notes and sounding<duration/3:
activity += (duration/3-sounding)/duration
# Unplanned broad notes in the moving portion have a different function from
# a fulfilled arrival. This does not penalize the required held destination.
motion = [n for n in notes if n.event_id not in arrival_ids and n.role!='quote']
activity += sum((max(0,n.end-n.start-2) for n in motion),fractions.Fraction())/duration
identity_cost = theme_distance(home,identity or notes,purpose)
register = sum(( (n.end-n.start)*max(0,abs(n.pitch.number()-center)-10) for n in notes),fractions.Fraction())/max(1,sounding)
arrival_cost = fractions.Fraction()
for anchor in destinations:
target = generator.solo.harmonic.anchor_note(anchor,notes)
if target is None:
arrival_cost += 2
continue
before = [n for n in notes if n.end<=target.start][-3:]
if before:
distances = [abs(n.pitch.number()-target.pitch.number()) for n in before]+[0]
# A contour may turn. What matters here is whether the final approach
# prepares its already selected destination, rather than jumping to it.
arrival_cost += fractions.Fraction(max(0,distances[-2]-2),6)
arrival_cost += fractions.Fraction(sum(max(0,b-a) for a,b in zip(distances,distances[1:])),12)
if before[-1].end<target.start:
supported_gap = any(c.target.event_id==target.event_id and c.events[-1].end==before[-1].end for c in contracts)
if not supported_gap:
arrival_cost += min(1,target.start-before[-1].end)
arrival_cost /= max(1,len(destinations))
values = dict(continuity=continuity,repetition=repetition,interruption=interruption,
activity=activity,harmony=checked.exposure,register=register,identity=identity_cost,arrival=arrival_cost)
raw = {k:fractions.Fraction(str(v)).limit_denominator(1000000) for k,v in values.items()}
contributions = {k:v*WEIGHTS[k] for k,v in raw.items()}
return generator.solo.scoring.Evaluation(VERSION,checked.valid and anchors_valid,raw,dict(WEIGHTS),contributions,
sum(contributions.values(),fractions.Fraction()),{'span':str(duration),'attacks':len(notes),'sounding':str(sounding),
'destinations':len(destinations),'identity_events':len(identity or notes[:len(home)]),'history_phrases':min(3,len(history))})