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

192 lines
11 KiB
Python

"""Duration-normalized musical evaluation, independent of search/notation chunks."""
from __future__ import annotations
import fractions
import dataclasses
import generator.solo.policy
import generator.solo.types
def passages(plan):
if plan.passages:
return plan.passages
end = plan.end - plan.release
active = (generator.solo.types.ActivitySpan(plan.start, end, 'active', plan.activity, fractions.Fraction(3, 4)),) if end > plan.start else ()
return active + ((generator.solo.types.ActivitySpan(end, plan.end, 'space', fractions.Fraction(), fractions.Fraction()),) if plan.release else ())
def intervals(choices, start, end):
return tuple((max(start, c.span.start), min(end, c.span.end)) for c in choices if c.scale and c.span.start < end and c.span.end > start)
def overlap(start, end, intervals):
return sum((max(0, min(end, b) - max(start, a)) for a, b in intervals), fractions.Fraction())
def corridor(pitch, low, high):
return max(low - pitch, 0, pitch - high)
def motif_fidelity(notes, motif, treatment):
if treatment in ('quote', 'exact_quote'):
return 0.0
if treatment == 'rhythmic_reference':
return 1.0 if len(notes) < min(2, len(motif.notes)) else 0.0
actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))
expected = motif.contour()
if not actual or not expected:
return 0.0 if len(notes) == len(motif.notes) == 1 else 1.0
count = min(2, len(actual), len(expected)) if treatment == 'opening_reference' else max(1, len(actual) // 2) if treatment == 'answer' else len(actual)
errors = []
for index in range(count):
reference = expected[min(len(expected) - 1, index * len(expected) // max(1, len(actual)))]
motion = actual[index]
direction = ((reference > 0) - (reference < 0)) != ((motion > 0) - (motion < 0))
errors.append(0.75 * direction + 0.25 * min(1, abs(motion - reference) / 12))
evidence = min(2, len(expected))
return max(sum(errors) / len(errors), max(0, evidence - len(actual)) / evidence)
class Evaluation:
"""Prepare fixed harmonic windows once; only soft penalties use float time."""
def __init__(self, plan, choices, until=None):
self.plan = plan
self.until = plan.end if until is None else until
self.duration = max(float(self.until - plan.start), 0.001)
self.windows = []
self.required = []
for passage in passages(plan):
end = min(passage.end, self.until)
if end <= passage.start:
continue
active = intervals(choices, passage.start, end)
length = float(sum((b - a for a, b in active), fractions.Fraction()))
if length:
self.windows.append((float(passage.start), float(end), tuple((float(a), float(b)) for a, b in active), length, float(passage.activity) * length, float(passage.occupancy), 0.5 if self.until < passage.end else 0))
if passage.end <= self.until:
protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference') and passage.end <= plan.start + plan.motif.duration
authored_silence = protected and not any(overlap(plan.start + n.start, plan.start + n.end, active) for n in plan.motif.notes)
self.required.append((passage, active, authored_silence))
self.landmarks = tuple((float(p.earliest), float(p.latest), p.low, p.high, p.direction) for p in plan.landmarks if p.latest <= self.until)
def feasible(self, notes):
# Hard silence/space requirements retain exact rational boundaries.
for passage, active, authored_silence in self.required:
if passage.intent == 'space':
if any(n.start < passage.end and n.end > passage.start for n in notes):
return False
elif active and not authored_silence and not any(n.start < b and n.end > a for n in notes for a, b in active):
return False
return True
def features(self, notes, *, previous=None, movement=0):
notes = generator.solo.types.sounding_notes(notes)
sounded = tuple((float(n.start), float(n.end), n.pitch.number()) for n in notes)
activity, occupancy = 0.0, 0.0
for start, end, active, length, expected, occupied_goal, tolerance in self.windows:
attacks = sum(start <= a < end and any(x <= a < y for x, y in active) for a, _, _ in sounded)
occupied = sum(max(0, min(b, y) - max(a, x)) for a, b, _ in sounded for x, y in active)
deviation = max(0, abs(attacks - expected) - max(tolerance, generator.solo.policy.DEFAULT.activity_deadband * length / self.duration))
activity += length * min(1, deviation / max(1, expected))
occupancy += length * max(0, abs(occupied / length - occupied_goal) - generator.solo.policy.DEFAULT.occupancy_deadband)
if self.plan.treatment in ('lead', 'source_variation', 'thematic_recomposition'):
# One broad range over the complete thought; quiet bars are not failed
# local quotas. Missing harmony and explicit release are excluded here.
active = tuple(window for window in self.windows if window[4] > 0)
length = sum(w[3] for w in active)
attacks = sum(any(a <= onset < b for w in active for a, b in w[2]) for onset, _, _ in sounded)
occupied = sum(max(0, min(end, b) - max(start, a)) for start, end, _ in sounded for w in active for a, b in w[2])
low, high = generator.solo.policy.DEFAULT.lead_activity_range
activity = length * corridor(attacks / max(length, .001), low, high) / high
low, high = generator.solo.policy.DEFAULT.lead_occupancy_range
occupancy = length * corridor(occupied / max(length, .001), low, high)
if self.plan.treatment in ('source_variation', 'thematic_recomposition'):
occupancy = 0.0 # Space belongs to the source and its explicit edits.
start, end = float(self.plan.start), float(self.until)
durations = [max(0, min(b, end) - max(a, start)) for a, b, _ in sounded]
register = sum(length * min(1, corridor(pitch, *self.plan.register) / 12) for (_, _, pitch), length in zip(sounded, durations)) / max(sum(durations), 0.001)
motions = [b[2] - a[2] for a, b in zip(sounded, sounded[1:])]
contour = sum(min(1, max(0, abs(m) - 5) / 12) for m in motions) / max(1, len(motions))
repetition = sum(a == b == 0 for a, b in zip(motions, motions[1:])) / max(1, len(motions) - 1)
landmarks = []
for earliest, latest, low, high, direction in self.landmarks:
prior = next((n for n in reversed(sounded) if n[0] < earliest), None)
landmarks.append(min((0.75 * min(1, corridor(n[2], low, high) / 12) + 0.25 * bool(prior and direction and (n[2] - prior[2]) * direction <= 0) for n in sounded if earliest <= n[0] <= latest), default=1))
motif_notes = tuple(n for n in notes if n.role in ('motif', 'quote'))
connection = min(1, abs(sounded[0][2] - previous.pitch.number()) / 12) if sounded and previous else 0
return {'activity': activity / self.duration, 'occupancy': occupancy / self.duration, 'register': register, 'contour': contour, 'repetition': repetition, 'landmarks': sum(landmarks) / max(1, len(landmarks)), 'connection': connection, 'movement': min(1, movement / max(1, len(notes)) / 2000), 'fidelity': motif_fidelity(motif_notes, self.plan.motif, self.plan.treatment) if motif_notes else 0}
def features(plan, notes, choices, *, until=None, previous=None, movement=0):
return Evaluation(plan, choices, until).features(notes, previous=previous, movement=movement)
def value(features):
return sum(weight * features.get(name, 0) for name, weight in generator.solo.policy.DEFAULT.weights)
def feasible(plan, notes, choices, until):
return Evaluation(plan, choices, until).feasible(notes)
@dataclasses.dataclass(frozen=True)
class MusicalEvaluation:
version: str
scope: tuple
valid: bool
pending: tuple
raw: dict
denominators: dict
weights: dict
contributions: dict
total: float
error: str = ''
MUSICAL_WEIGHTS = {'rhythm':3., 'destination':2., 'harmony':2.,
'register':1., 'repetition':1., 'activity':1., 'difficulty':.5}
def musical(plan, draft, notes, incoming, harmony, *, travel=0, weights=None, bar=None):
"""The sole final evaluator of the active thematic composer.
Scores are normalized on complete sounding events, never notation fragments.
Contracts are validity requirements, not additional similarity rewards.
Retrieval estimates are deliberately not added to this result.
"""
from generator.solo import drafts, groups, harmonic, phrasing, types
notes=types.sounding_notes(notes)
bar=getattr(harmony,'bar',fractions.Fraction(4)) if bar is None else bar
from generator import vocabulary
weights=dict(vocabulary.data('grammar').get('evaluation',{}).get('weights',MUSICAL_WEIGHTS) if weights is None else weights)
if set(weights)!=set(MUSICAL_WEIGHTS):raise ValueError('unknown or missing evaluation contribution')
checked=harmony.evaluate(notes, incoming.treatments, until=plan.end, complete=True,
enclosures=draft.enclosures, expressions=draft.expressions)
identity,identity_error=drafts.contract_valid(draft,notes)
anchors,anchor_error=harmonic.anchors_valid(draft.anchors,notes,plan.end,complete=True,closure=plan.closure)
feasible=Evaluation(plan,harmony.choices).feasible(notes)
valid=checked.valid and identity and anchors and feasible
error=checked.error or identity_error or anchor_error or ('' if feasible else 'activity intent not fulfilled')
span=float(plan.end-plan.start); sounded=sum(float(n.end-n.start) for n in notes)
islands=sum(a.end<b.start and b.end<c.start and b.end-b.start<=fractions.Fraction(1,2)
for a,b,c in zip(notes,notes[1:],notes[2:]))
# Rests at the edges can be entrance/release. Long internal gaps need a
# completed gesture, rather than receiving a universal occupancy penalty.
settled={a.hold_until for a in draft.anchors}
broken=sum(max(0,float(b.start-a.end)-1) for a,b in zip(notes,notes[1:])
if a.end not in settled and a.end-a.start<2)
entry=max(0,abs(notes[0].pitch.number()-incoming.last.pitch.number())-7)/12 if notes and incoming.last else 0.
rhythm=min(1.,islands/max(1,len(notes)-2)+broken/max(1,span)+entry/2)
# Explicit source groups retain their imported timing contract. Generated
# groups are checked before construction, not retroactively quantized here.
destinations=[]
for anchor in draft.anchors:
approach=[n for n in notes if n.start<anchor.earliest][-3:]
if approach:
distances=[abs(n.pitch.number()-anchor.pitch.number()) for n in approach]
# A broad approach may turn; large last-second jumps are a preference,
# not a nearest-note mandate for all intermediate events.
destinations.append(min(1,max(0,distances[-1]-5)/12))
goal=harmony.profile.reference
register=sum(float(n.end-n.start)*min(1,max(0,abs(n.pitch.number()-goal)-9)/12)
for n in notes)/max(sounded,.001) if goal is not None else 0.
history=phrasing.repetition(phrasing.heard(notes,draft.relationship),incoming.history,bar) if notes else 0.
activity=Evaluation(plan,harmony.choices).features(notes)['activity']
raw={'rhythm':rhythm,'destination':sum(destinations)/max(1,len(destinations)),
'harmony':checked.exposure,'register':register,'repetition':min(1,history),
'activity':activity,'difficulty':min(1,travel/max(1,len(notes))/2000)}
contributions={name:weights[name]*value for name,value in raw.items()}
return MusicalEvaluation('joint-musical-1',(plan.start,plan.end),valid,checked.pending,raw,
{'span':span,'sounding':sounded,'attacks':len(notes),'destinations':len(destinations)},weights,
contributions,sum(contributions.values()) if valid else float('inf'),error)