Cleanup
This commit is contained in:
+38
-181
@@ -1,191 +1,48 @@
|
||||
"""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)
|
||||
"""One authoritative evaluation of completed phrases, never guitar travel."""
|
||||
from dataclasses import dataclass
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary
|
||||
from generator.solo import types
|
||||
|
||||
@dataclass(frozen=True)
|
||||
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 = ''
|
||||
denominators: dict
|
||||
|
||||
def signature(notes):
|
||||
ns=types.sounding_notes(notes)
|
||||
if not ns:return ()
|
||||
origin=ns[0].start;pitch=ns[0].pitch.number()
|
||||
return tuple((n.start-origin,n.end-n.start,n.pitch.number()-pitch) for n in ns)
|
||||
|
||||
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)
|
||||
def evaluate(phrase,incoming,history,center,weights=None):
|
||||
ns=types.sounding_notes(phrase.notes); span=float(phrase.end-phrase.start)
|
||||
weights=dict(vocabulary.data('solo')['weights'] if weights is None else weights)
|
||||
if set(weights)!={'purpose','continuity','repetition','register'}:raise ValueError('Unknown musical contribution')
|
||||
if not ns:
|
||||
valid=phrase.purpose=='space';raw=dict.fromkeys(weights,0.)
|
||||
else:
|
||||
valid=True
|
||||
movement=[b.pitch.number()-a.pitch.number() for a,b in zip(ns,ns[1:])]
|
||||
# Declared expansion permits arpeggio leaps; avoid unprepared octave flailing.
|
||||
purpose=sum(max(0,abs(v)-7)/12 for v in movement)/max(1,len(movement))
|
||||
oscillation=sum(a==-b==c and a!=0 for a,b,c in zip(movement,movement[1:],movement[2:]))
|
||||
purpose+=max(0,oscillation-1)/max(1,len(movement))
|
||||
if phrase.destination:
|
||||
prior=next((n for n in reversed(ns) if n.end<=phrase.destination.earliest),None)
|
||||
if prior:purpose+=max(0,abs(prior.pitch.number()-phrase.destination.pitch.number())-3)/12
|
||||
continuity=max(0,abs(ns[0].pitch.number()-incoming.pitch.number())-5)/12 if incoming else 0.
|
||||
current=signature(ns[:6]); repetition=0.
|
||||
for age,old in enumerate(reversed(history[-4:])):
|
||||
if len(current)>=3 and current==signature(old.notes[:6]):repetition+=1/(age+1)
|
||||
if phrase.purpose=='return':repetition=max(0,repetition-.5)
|
||||
duration=sum(float(n.end-n.start) for n in ns)
|
||||
register=sum(float(n.end-n.start)*max(0,abs(n.pitch.number()-center)-10)/12 for n in ns)/max(duration,.001)
|
||||
raw={'purpose':min(1,purpose),'continuity':min(1,continuity),'repetition':min(1,repetition),'register':min(1,register)}
|
||||
contributions={k:raw[k]*weights[k] for k in weights}
|
||||
return Evaluation('phrase-musical-1',valid,raw,weights,contributions,sum(contributions.values()) if valid else float('inf'),{'span':span,'attacks':len(ns),'sounding':sum(float(n.end-n.start) for n in ns)})
|
||||
|
||||
Reference in New Issue
Block a user