Solo
This commit is contained in:
+367
-298
@@ -1,317 +1,386 @@
|
||||
"""Complete paired lead ideas, realized by the existing phrase/output pipeline.
|
||||
"""Source-phrase variations: edit actual music, then realize complete routes.
|
||||
|
||||
The bounded search branches on a whole timed hook or answer. Every pitch in
|
||||
that group is measured from one fixed origin, never from a previously repaired
|
||||
note. The original identity survives harmonic adaptation and passage changes.
|
||||
The external lead references are audition controls, never production seeds.
|
||||
All notes use sounding coordinates and all time uses quarter-note Fractions.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import itertools
|
||||
import time
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.solo.drafts as drafts
|
||||
import generator.solo.harmonic as harmonic
|
||||
import generator.solo.policy as policy
|
||||
import generator.solo.types as types
|
||||
import generator.fretboard
|
||||
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
def identities(references, incoming):
|
||||
reference = references[0]
|
||||
low, high = reference.features[0][1:]
|
||||
source = tuple(n for n in reference.notes if low <= n.start < high)
|
||||
rhythm = tuple(n.end - n.start for n in source)
|
||||
if len(rhythm) != 4:
|
||||
raise ValueError('lead reference requires a complete four-attack identifying figure')
|
||||
rhythms = (rhythm, (rhythm[1], rhythm[2], rhythm[0], rhythm[3]), (F(1), F(1, 2), F(3, 2), F(1)))
|
||||
shapes = ((0, 3, 2, 0), (0, -3, -2, 0), (0, 2, 1, -1), (0, 0, 2, 1))
|
||||
result = [types.LeadIdentity(f'{reference.id}:lead:{i}:{j}', durations, degrees, reference.id)
|
||||
for i, durations in enumerate(rhythms) for j, degrees in enumerate(shapes)]
|
||||
if incoming.lead_identity:
|
||||
result = [incoming.lead_identity, *(x for x in result if x != incoming.lead_identity)]
|
||||
return tuple(result[:policy.DEFAULT.lead_identities])
|
||||
|
||||
def timings(start, length, durations, finish, harmony):
|
||||
"""Fit meter on an eighth-note grid; never compress into tiny tuplets."""
|
||||
cursor, result = start, []
|
||||
ideal = [duration * length / sum(durations) * 2 for duration in durations]
|
||||
ticks = [max(1, int(value)) for value in ideal]
|
||||
available = int(length * 2)
|
||||
if available < len(ticks) or length * 2 != available:
|
||||
def signature(notes):
|
||||
return tuple((n.start, n.end, n.pitch.number()) for n in notes)
|
||||
|
||||
|
||||
def shift(notes, octave):
|
||||
return tuple(dataclasses.replace(n, pitch=dataclasses.replace(n.pitch, octave=n.pitch.octave+octave),
|
||||
position=None, role='source', event_id=n.event_id or n.sources[0]) for n in notes)
|
||||
|
||||
|
||||
def available(harmony, start, end, *, members=False):
|
||||
span = harmony.at(start)
|
||||
allowed = types.permitted(harmony.choices, start, end)
|
||||
if members:
|
||||
allowed &= harmony.members(start, end)
|
||||
if not span or not span.scale:
|
||||
return ()
|
||||
while sum(ticks) < available:
|
||||
index = max(range(len(ticks)), key=lambda i: (ideal[i] - ticks[i], -i))
|
||||
ticks[index] += 1
|
||||
while sum(ticks) > available:
|
||||
index = max((i for i in range(len(ticks)) if ticks[i] > 1), key=lambda i: (ticks[i] - ideal[i], -i))
|
||||
ticks[index] -= 1
|
||||
for duration in ticks:
|
||||
end = min(finish, cursor + F(duration, 2))
|
||||
if cursor < end:
|
||||
# An authored rest in the harmony is not filled with a melodic fallback.
|
||||
spans = [s for s in harmony.spans if s.start < end and s.end > cursor]
|
||||
if spans and all(s.scale for s in spans):
|
||||
result.append((cursor, end))
|
||||
cursor = end
|
||||
return tuple(result)
|
||||
return tuple(p for i in range(-35, 28) if 41 <= (p := types.scale_pitch(span.scale, i)).number() <= 76 and p.number()%12 in allowed)
|
||||
|
||||
def group_options(spans, degrees, register, harmony, previous, identifier, *, home=(), terminal=False):
|
||||
"""Enumerate whole paired figures, including non-nearest melodic intervals."""
|
||||
from generator.solo.realization import options
|
||||
from generator.solo.planning import target_pitches
|
||||
if not spans:
|
||||
return ((0., ()),)
|
||||
cache = getattr(harmony, 'lead_groups', None)
|
||||
if cache is None:
|
||||
harmony.lead_groups = cache = {}
|
||||
key = (spans, degrees, register, previous.pitch if previous else None, identifier, tuple(n.pitch for n in home), terminal)
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
domains = []
|
||||
for index, (start, end) in enumerate(spans):
|
||||
available = options(harmony.choices, start, end, register)
|
||||
if end - start >= policy.DEFAULT.long_note:
|
||||
members = harmony.members(start, end)
|
||||
available = tuple(p for p in available if p.number() % 12 in members)
|
||||
if terminal and index == len(spans) - 1:
|
||||
chord = next(c.span.chord for c in harmony.choices if c.span.start <= start < c.span.end)
|
||||
eligible = target_pitches(chord, True) & harmony.members(start, end)
|
||||
available = tuple(p for p in available if p.number() % 12 in eligible)
|
||||
if not available:
|
||||
return ()
|
||||
domains.append(available)
|
||||
collection = harmony.at(spans[0][0]).scale
|
||||
result = []
|
||||
for origin in domains[0]:
|
||||
index = types.scale_index(collection, origin.number())
|
||||
desired = tuple(origin.number() + n.pitch.number() - home[0].pitch.number() for n in home) if home else tuple(types.scale_pitch(collection, index + degree).number() for degree in degrees)
|
||||
desired = desired[:len(spans)]
|
||||
if len(desired) < len(spans):
|
||||
continue
|
||||
narrowed = [(origin,)] + [tuple(sorted(domain, key=lambda p: (abs(p.number() - target), p))[:2]) for domain, target in zip(domains[1:], desired[1:])]
|
||||
for pitches in itertools.product(*narrowed):
|
||||
actual = tuple(b.number() - a.number() for a, b in zip(pitches, pitches[1:]))
|
||||
intended = tuple(b - a for a, b in zip(desired, desired[1:]))
|
||||
# A return must supply evidence of the complete heard figure, not just
|
||||
# its first interval. Harmonic adaptation may alter interval quality.
|
||||
if home and any((a > 0) - (a < 0) != (b > 0) - (b < 0) or abs(a - b) > 2 for a, b in zip(actual, intended)):
|
||||
continue
|
||||
shape = sum(abs(a - b) / 12 + .5 * (((a > 0) - (a < 0)) != ((b > 0) - (b < 0))) for a, b in zip(actual, intended)) / max(1, len(actual))
|
||||
if not home and shape > .45:
|
||||
continue
|
||||
notes = tuple(types.Note(start, end, pitch, (identifier,), 'motif', event_id=f'{identifier}:{start}') for (start, end), pitch in zip(spans, pitches))
|
||||
stopped = sum(end - start >= 1 and p.number() % 12 not in harmony.at(start).roles.basic for (start, end), p in zip(spans, pitches)) / len(notes)
|
||||
connection = min(1, abs(origin.number() - previous.pitch.number()) / 12) if previous else 0
|
||||
result.append((shape + .3 * stopped + .15 * connection, notes))
|
||||
result.sort(key=lambda v: (v[0], tuple(n.pitch for n in v[1])))
|
||||
selected = tuple(result[:policy.DEFAULT.lead_realizations])
|
||||
cache[key] = selected
|
||||
return selected
|
||||
|
||||
def make_phrase(plan, notes, groups, identity, incoming, harmony, tempo, fit):
|
||||
from generator.solo import realization
|
||||
def adapt(notes, harmony):
|
||||
"""Document required harmonic rewrites; never repair a sequence one step at a time."""
|
||||
result, edits = [], []
|
||||
for index, note in enumerate(notes):
|
||||
terminal = index == len(notes)-1
|
||||
choices = available(harmony,note.start,note.end,members=terminal or note.end-note.start >= 2)
|
||||
pieces = [(note.start,note.end,choices)]
|
||||
if not choices:
|
||||
pieces = [(a,b,available(harmony,a,b,members=terminal or b-a >= 2)) for _,a,b in harmony.segments(note)]
|
||||
for j,(start,end,domain) in enumerate(pieces):
|
||||
if not domain:
|
||||
if harmony.at(start) and harmony.at(start).scale:
|
||||
return (), ()
|
||||
edits.append(types.DraftEdit('missing_harmony',start,end,(note.event_id,),(), 'explicit no-chord space'))
|
||||
continue
|
||||
pitch = min(domain,key=lambda p:(abs(p.number()-note.pitch.number()),p.number()))
|
||||
identifier = note.event_id if j == 0 else note.event_id+f':harmonic:{j}'
|
||||
actual = dataclasses.replace(note,start=start,end=end,pitch=pitch,event_id=identifier)
|
||||
result.append(actual)
|
||||
if (start,end,pitch) != (note.start,note.end,note.pitch):
|
||||
edits.append(types.DraftEdit('harmonic_adaptation',start,end,(note.event_id,),(identifier,),
|
||||
f'{note.pitch.label()} to {pitch.label()}: sounding duration / {harmony.at(start).chord}'))
|
||||
if result:
|
||||
last=result[-1]
|
||||
domain=available(harmony,last.start,last.end,members=True)
|
||||
if not domain:
|
||||
return (),()
|
||||
if last.pitch.number()%12 not in harmony.members(last.start,last.end):
|
||||
pitch=min(domain,key=lambda p:(abs(p.number()-last.pitch.number()),p.number()))
|
||||
result[-1]=dataclasses.replace(last,pitch=pitch)
|
||||
edits.append(types.DraftEdit('harmonic_destination',last.start,last.end,(last.event_id,),(last.event_id,),
|
||||
f'{last.pitch.label()} to {pitch.label()}: final sounding arrival before release'))
|
||||
return tuple(result),tuple(edits)
|
||||
|
||||
|
||||
def identifying(notes, source):
|
||||
"""A complete heard figure, chosen by its musical content rather than its index."""
|
||||
if not notes:
|
||||
return None
|
||||
first = next((g for g in groups if g[2] in ('statement', 'recall', 'answer_theme')), groups[0])
|
||||
home_notes = tuple(n for n in notes if first[0] <= n.start < first[1])
|
||||
home = realization.make_identity(first[3], identity.id, home_notes, first[0], first[1], first[2], harmony)
|
||||
transformations, features, pairs = [], [], []
|
||||
parent = incoming.development.id if incoming.development else identity.id
|
||||
for start, end, operation, group_id in groups:
|
||||
actual = tuple(n for n in notes if start <= n.start < end)
|
||||
if not actual:
|
||||
continue
|
||||
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(actual, actual[1:]))
|
||||
transformations.append(types.Transformation(group_id, parent, home.id, operation, start, end, tuple(n.event_id for n in actual), tuple((n.start - start, n.end - start) for n in actual), directions, (), identity.source))
|
||||
parent = group_id
|
||||
if operation in ('statement', 'recall', 'answer_theme', 'repeat_with_harmonic_adaptation') and len(actual) == len(identity.degrees):
|
||||
features.append(types.IdentifyingFeature(group_id + ':hook', tuple(n.event_id for n in actual), tuple((n.start - actual[0].start, n.end - n.start) for n in actual), tuple(b.pitch.number() - a.pitch.number() for a, b in zip(actual, actual[1:])), identity.id))
|
||||
if operation == 'repeat_with_harmonic_adaptation':
|
||||
pairs.extend((a.event_id, b.event_id) for a, b in zip(home_notes, actual))
|
||||
anchors = []
|
||||
for note, role in ((notes[0], 'opening'), *((n, 'intermediate') for n in notes[1:-1] if any(n.start == g[0] and g[2] == 'repeat_with_harmonic_adaptation' for g in groups)), (notes[-1], 'terminal')):
|
||||
span = harmony.at(note.start)
|
||||
if note.pitch.number() % 12 not in span.roles.basic:
|
||||
if role != 'terminal':
|
||||
continue
|
||||
return None
|
||||
anchors.append(types.Anchor(note.event_id + ':anchor', note.start, note.start, note.pitch, span.roles.basic, plan.register, note.end - note.start, role, span.chord, hold_until=note.end))
|
||||
last = notes[-1]
|
||||
passages = (types.ActivitySpan(plan.start, last.end, 'active', plan.activity, F('0.925')),)
|
||||
if last.end < plan.end:
|
||||
passages += (types.ActivitySpan(last.end, plan.end, 'space', F(), F()),)
|
||||
motif = types.Motif(home.id, home.notes, home.duration, tuple(s.chord for s in harmony.spans if s.start < first[1] and s.end > first[0]), 'invented paired lead: ' + identity.id)
|
||||
actual_plan = dataclasses.replace(plan, motif=motif, passages=passages, release=plan.end - last.end, landmarks=(), anchor_options=(tuple(anchors),), ending='breath' if last.end < plan.end else 'sustain', closure='closed')
|
||||
draft = types.MelodicDraft(plan.id + ':' + identity.id + ':' + policy.stable_rank(plan.seed, tuple(n.pitch for n in notes))[:10], plan.start, plan.end, notes, tuple(transformations), tuple(a.id.removesuffix(':anchor') for a in anchors), (), tuple(anchors), home, first[2], tuple(g[0] for g in groups[1:]), tuple(features), identity.source, motif, identity, tuple(pairs))
|
||||
phrase = realization.checked_phrase(actual_plan, draft, notes, (), incoming, harmony, tempo)
|
||||
if phrase:
|
||||
state = dataclasses.replace(phrase.state, lead_identity=identity)
|
||||
# Different complete musical constructions compete before any ornament.
|
||||
return dataclasses.replace(phrase, state=state, score=phrase.score + fit, initial=draft, refined=draft)
|
||||
return ()
|
||||
count = min(4,max(1,len(notes)//2))
|
||||
windows = [notes[i:i+count] for i in range(len(notes)-count+1)]
|
||||
def rank(group):
|
||||
intervals = tuple(b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:]))
|
||||
rhythm = {(n.end-n.start) for n in group}
|
||||
# Favor a rhythm/turn with information; leave long holds available for development.
|
||||
return (-len(rhythm),-len(set(intervals)),sum(max(F(),n.end-n.start-1) for n in group),group[0].start)
|
||||
group = min(windows,key=rank)
|
||||
return (types.IdentifyingFeature(source+':figure',tuple(n.event_id for n in group),
|
||||
tuple((n.start-group[0].start,n.end-n.start) for n in group),
|
||||
tuple(b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:])),source),)
|
||||
|
||||
def candidates(plan, measures, choices, incoming, tempo, context):
|
||||
from generator.solo import realization, sequences
|
||||
harmony_key = ('lead_harmony', policy.stable_rank('harmony', choices))
|
||||
if harmony_key not in context.resources:
|
||||
context.resources[harmony_key] = harmonic.Harmony(choices)
|
||||
harmony = context.resources[harmony_key]
|
||||
active = [s for s in harmony.spans if s.scale and s.start < plan.end and s.end > plan.start]
|
||||
if not active:
|
||||
silent_plan = dataclasses.replace(plan, passages=(types.ActivitySpan(plan.start, plan.end, 'space', F(), F()),), release=plan.end-plan.start, landmarks=(), anchor_options=())
|
||||
draft = types.MelodicDraft(plan.id + ':space', plan.start, plan.end, (), (), (), (), (), None, 'space')
|
||||
phrase = realization.checked_phrase(silent_plan, draft, (), (), incoming, harmony, tempo)
|
||||
return (dataclasses.replace(phrase, initial=draft, refined=draft),) if phrase else ()
|
||||
finish = min(plan.end, active[-1].end)
|
||||
boundaries, cursor = [], F()
|
||||
for measure in measures:
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
if plan.start <= cursor < finish:
|
||||
boundaries.append((cursor, min(cursor + length, finish)))
|
||||
cursor += length
|
||||
result = []
|
||||
for identity in identities(context.references, incoming):
|
||||
states = [(0., (), (), ())]
|
||||
for index, (start, end) in enumerate(boundaries):
|
||||
final = index == len(boundaries) - 1
|
||||
hook = index in (0, 2) and not final
|
||||
returning = hook and index == 2
|
||||
operation = ('recall' if incoming.lead_identity == identity else 'answer_theme' if incoming.lead_identity else 'statement') if index == 0 else 'repeat_with_harmonic_adaptation' if returning else 'close' if final else 'extend' if index >= 4 else 'answer'
|
||||
# Paired answers retain the hook's direction of departure. Ending
|
||||
# alternatives supply timing and pitches together, not inserted holds.
|
||||
specs = [(identity.durations, identity.degrees)] if hook else [((F(1), F(1, 2), F(1, 2), F(1), F(1)), (0, 1, 2, 1, -1)), ((F(2), F(1, 2), F(1, 2), F(1)), (0, 1, 2, 1))]
|
||||
if index >= 4 and not final:
|
||||
reference = context.references[0]
|
||||
profiles = [d for d in reference.development if d[1] == index * 4 and d[3]]
|
||||
if profiles:
|
||||
_, low, high, degrees = profiles[0]
|
||||
source = tuple(n for n in reference.notes if low <= n.start < high)
|
||||
specs = [(tuple(n.end - n.start for n in source), degrees)]
|
||||
if final:
|
||||
specs = [((F(1), F(1, 2), F(1, 2), F(2)), (0, -1, -2, -1)), ((F(1, 2), F(1, 2), F(1), F(1)), (0, 2, 1, 0))]
|
||||
if end - start < 2:
|
||||
specs = [((end - start,), (0,))]
|
||||
elif end - start < 4:
|
||||
specs = [((F(1, 2), end - start - F(1, 2)), (0, -1)), ((F(1), end - start - 1), (0, 1))]
|
||||
following = []
|
||||
for cost, notes, groups, home in states:
|
||||
for variant, (durations, degrees) in enumerate(specs):
|
||||
release = F(1) if final and variant == 1 and end - start >= 4 else F()
|
||||
spans = timings(start, end - start - release, durations, end - release, harmony)
|
||||
identifier = f'{plan.id}:{identity.id}:{index}:{variant}'
|
||||
previous = notes[-1] if notes else incoming.last
|
||||
heard = home if returning else incoming.home.notes if index == 0 and incoming.home and incoming.lead_identity == identity else ()
|
||||
for local_cost, actual in group_options(spans, degrees, plan.register, harmony, previous, identifier, home=heard, terminal=final):
|
||||
if not actual:
|
||||
following.append((cost, notes, groups, home))
|
||||
continue
|
||||
following.append((cost + local_cost, (*notes, *actual), (*groups, (start, end, operation, identifier)), actual if index == 0 else home))
|
||||
states = sorted(following, key=lambda v: (v[0], tuple(n.pitch for n in v[1])))[:policy.DEFAULT.lead_realizations]
|
||||
context.diagnostics['evaluated_extensions'] += len(following)
|
||||
context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states))
|
||||
if not states:
|
||||
context.diagnostics.setdefault('lead_failures', []).append({'phrase': plan.id, 'identity': identity.id, 'start': str(start), 'end': str(end), 'operation': operation, 'reason': 'no complete figure satisfies the harmonic, duration and return constraints within the bounded candidate pool'})
|
||||
break
|
||||
for cost, notes, groups, home in states:
|
||||
if not notes:
|
||||
continue
|
||||
phrase = make_phrase(plan, notes, groups, identity, incoming, harmony, tempo, cost / max(1, len(groups)))
|
||||
if phrase:
|
||||
# A heard identity is a real option across passages, not obligatory
|
||||
# perpetual development of whichever fragment happened most recently.
|
||||
if incoming.lead_identity and identity != incoming.lead_identity:
|
||||
phrase = dataclasses.replace(phrase, score=phrase.score + .15)
|
||||
result.append(phrase)
|
||||
context.diagnostics['generated_candidates'] += len(result)
|
||||
return sequences.diverse(result, lambda p: (p.score, policy.stable_rank(plan.seed, p.draft.id)), lambda p: p.draft.lead_identity.id, policy.DEFAULT.phrase_alternatives)
|
||||
|
||||
def approach_pitch(target, number):
|
||||
"""Spell a semitone neighbor using the adjacent diatonic letter."""
|
||||
letters = generator.model.LETTERS
|
||||
step = 1 if number > target.number() else -1
|
||||
index = letters.index(target.letter) + step
|
||||
octave, degree = divmod(index, 7)
|
||||
candidates = []
|
||||
for letter, octave in ((letters[degree], target.octave + octave), (target.letter, target.octave)):
|
||||
natural = generator.model.NATURAL[letter] + 12 * (octave + 1)
|
||||
candidates.append(types.Pitch(letter, F(number - natural), octave))
|
||||
# A natural above A-flat is clearer than B-double-flat; an ordinary
|
||||
# leading tone still uses its adjacent letter (D-sharp into E).
|
||||
return min(candidates, key=lambda p: abs(p.alter))
|
||||
|
||||
def chromatic(phrase, incoming, harmony, tempo):
|
||||
from generator.solo import realization
|
||||
notes = realization.emitted_notes(phrase)
|
||||
protected = {*phrase.draft.protected, *phrase.draft.structural, *(e for f in phrase.draft.features for e in f.events)}
|
||||
budget = min(policy.DEFAULT.chromatic_max_per_eight_bars, max(1, int((phrase.plan.end - phrase.plan.start) / 8)))
|
||||
proposals = []
|
||||
for index, (note, target) in enumerate(zip(notes, notes[1:])):
|
||||
if note.event_id in protected or note.end - note.start != F(1, 2) or note.start % 1 != F(1, 2) or note.end != target.start or len(harmony.segments(note)) != 1:
|
||||
continue
|
||||
span, destination = harmony.at(note.start), harmony.at(target.start)
|
||||
if not span or not span.scale or not destination or target.pitch.number() % 12 not in destination.roles.basic:
|
||||
continue
|
||||
for number in (target.pitch.number() - 1, target.pitch.number() + 1):
|
||||
if number % 12 in span.scale.pitches or not phrase.plan.register[0] <= number <= phrase.plan.register[1]:
|
||||
continue
|
||||
pitch = approach_pitch(target.pitch, number)
|
||||
treatment = types.HarmonicTreatment(note.event_id + ':chromatic', 'chromatic_approach', number, note.start, note.end, target.pitch.number(), target.start, F(), note.event_id, target.event_id)
|
||||
smooth = abs(number - (notes[index - 1].pitch.number() if index else note.pitch.number()))
|
||||
proposals.append((smooth, abs(number - note.pitch.number()), note.start, index, pitch, treatment))
|
||||
chosen_times, used_routes = [], set()
|
||||
previous_times = [t.start for t in incoming.treatments if t.kind == 'chromatic_approach']
|
||||
original_score = phrase.score
|
||||
for _, _, _, index, pitch, treatment in sorted(proposals, key=lambda p: p[:4]):
|
||||
if len(chosen_times) >= budget or any(abs(treatment.start - t) < 8 for t in (*previous_times, *chosen_times)):
|
||||
continue
|
||||
route_ids = {r.id for r in phrase.routes if any(n.event_id == treatment.event for n in r.notes)}
|
||||
if route_ids & used_routes:
|
||||
continue
|
||||
original = realization.emitted_notes(phrase)
|
||||
changed = tuple(dataclasses.replace(n, pitch=pitch, position=None, role='chromatic_approach') if n.event_id == treatment.event else n for n in original)
|
||||
contracts = (*phrase.state.treatments, treatment)
|
||||
if not harmony.evaluate(changed, contracts, complete=True).valid:
|
||||
continue
|
||||
draft = realization.rebase_draft(phrase.draft, changed, harmony)
|
||||
edit = types.DraftEdit('chromatic_approach', treatment.start, treatment.deadline, (treatment.event,), (treatment.event,), 'semitone resolution to ' + treatment.resolution_event)
|
||||
checked = realization.checked_phrase(phrase.plan, draft, changed, contracts, incoming, harmony, tempo, (*phrase.edits, edit))
|
||||
if checked:
|
||||
routes = tuple(dataclasses.replace(r, notes=tuple(dataclasses.replace(n, pitch=pitch, position=None, role='chromatic_approach') if n.event_id == treatment.event else n for n in r.notes), relationship=r.relationship + ('; chromatic approach' if r.id in route_ids else '')) for r in phrase.routes)
|
||||
phrase = dataclasses.replace(checked, score=original_score, initial=phrase.initial, refined=phrase.refined, routes=routes, state=dataclasses.replace(checked.state, lead_identity=phrase.state.lead_identity))
|
||||
chosen_times.append(treatment.start)
|
||||
used_routes.update(route_ids)
|
||||
return phrase
|
||||
|
||||
def alternatives(plan, measures, choices, sequences, tempo, previous, context):
|
||||
from generator.solo import realization
|
||||
incoming = previous if isinstance(previous, types.ContinuationState) else types.ContinuationState('', last=previous)
|
||||
context.resources[id(sequences)] = sequences
|
||||
key = ('paired-lead', plan, incoming, policy.stable_rank('harmony', choices), tuple(generator.model.fraction(m['duration']) for m in measures), tempo, id(sequences), tuple(r.version for r in context.references), policy.describe()['fingerprint'], context.stage)
|
||||
if key in context.results:
|
||||
context.diagnostics['cache_hits'] += 1
|
||||
return context.results[key]
|
||||
started = time.monotonic()
|
||||
context.diagnostics['searches'] += 1
|
||||
complete = candidates(plan, measures, choices, incoming, tempo, context)
|
||||
if not complete:
|
||||
raise realization.SearchExhausted(f'{plan.id}: no complete paired lead reaches the terminal harmony within {policy.DEFAULT.lead_identities} identities / {policy.DEFAULT.lead_realizations} realizations; not proof of incompatibility')
|
||||
harmony = harmonic.Harmony(choices)
|
||||
result = []
|
||||
for phrase in complete:
|
||||
score, identity = phrase.score, phrase.state.lead_identity
|
||||
if context.stage != 'initial':
|
||||
# The complete plain tune is selected by the same score in all stages.
|
||||
# Ornament quality cannot rescue a weak underlying idea.
|
||||
phrase = realization.elaborate(phrase, incoming, harmony, sequences, tempo, context)
|
||||
diatonic = realization.rebase_draft(phrase.draft, realization.emitted_notes(phrase), harmony)
|
||||
phrase = dataclasses.replace(phrase, refined=diatonic, score=score, state=dataclasses.replace(phrase.state, lead_identity=identity))
|
||||
if context.stage == 'elaborated':
|
||||
phrase = chromatic(phrase, incoming, harmony, tempo)
|
||||
result.append(dataclasses.replace(phrase, score=score))
|
||||
context.diagnostics['construction_seconds'] = context.diagnostics.get('construction_seconds', 0) + time.monotonic() - started
|
||||
context.diagnostics.setdefault('complete_ideas', {})[policy.stable_rank('incoming', (plan.id, incoming))[:16]] = [{'id': p.draft.id, 'identity': p.draft.lead_identity.id if p.draft.lead_identity else 'space', 'score': p.score, 'attacks': len(p.initial.notes), 'return_pairs': len(p.draft.correspondence), 'edits': [e.kind for e in p.edits]} for p in result]
|
||||
context.results[key] = tuple(result)
|
||||
def variations(notes, source, harmony):
|
||||
"""One purposeful timing or pitch edit; no generic replacement openings."""
|
||||
result=[]
|
||||
if source.pickup and notes and source.start==0:
|
||||
length=-source.pickup[0].start
|
||||
if notes[0].end-length>=F(1,2):
|
||||
pickup=tuple(dataclasses.replace(n,start=n.start+length,end=n.end+length) for n in source.pickup)
|
||||
changed=(*pickup,dataclasses.replace(notes[0],start=length),*notes[1:])
|
||||
if harmony.evaluate(changed,complete=True).valid:
|
||||
result.append((changed,(types.DraftEdit('pickup_entrance',F(),notes[0].end,(notes[0].event_id,),
|
||||
tuple(n.event_id for n in pickup)+(notes[0].event_id,),source.id+': bring source pickup inside the solo and shorten its first hold'),)))
|
||||
for i,(before,note) in enumerate(zip(notes,notes[1:]),1):
|
||||
if before.end == note.start and before.end-before.start >= 1 and note.end-note.start >= F(1,2):
|
||||
for displacement in (F(-1,2),F(1,2)):
|
||||
time = note.start+displacement
|
||||
if time-before.start < F(1,2) or note.end-time < F(1,2):
|
||||
continue
|
||||
changed=(*notes[:i-1],dataclasses.replace(before,end=time),dataclasses.replace(note,start=time),*notes[i+1:])
|
||||
if harmony.evaluate(changed,complete=True).valid:
|
||||
edit=types.DraftEdit('rhythmic_paraphrase',before.start,note.end,(before.event_id,note.event_id),(before.event_id,note.event_id),
|
||||
f'{source.id}: retain both pitches and their order; move shared boundary {displacement} beat')
|
||||
result.append((changed,(edit,)))
|
||||
# Structural color is chosen in relation to the original turn, never a new random contour.
|
||||
if 0 < i < len(notes)-1:
|
||||
after=notes[i+1]
|
||||
for p in available(harmony,note.start,note.end,members=note.end-note.start>=1):
|
||||
if p == note.pitch or abs(p.number()-note.pitch.number())>2:
|
||||
continue
|
||||
direction=lambda a,b:(b>a)-(b<a)
|
||||
if (direction(before.pitch.number(),p.number()),direction(p.number(),after.pitch.number())) != (direction(before.pitch.number(),note.pitch.number()),direction(note.pitch.number(),after.pitch.number())):
|
||||
continue
|
||||
changed=(*notes[:i],dataclasses.replace(note,pitch=p),*notes[i+1:])
|
||||
if harmony.evaluate(changed,complete=True).valid:
|
||||
edit=types.DraftEdit('melodic_paraphrase',note.start,note.end,(note.event_id,),(note.event_id,),f'{source.id}: {note.pitch.label()} to {p.label()}, retain rhythm and turn')
|
||||
result.append((changed,(edit,)))
|
||||
# Broaden or release an ending as an explicit whole-phrase alternative.
|
||||
if notes and notes[-1].end-notes[-1].start >= F(3,2):
|
||||
tail=dataclasses.replace(notes[-1],end=notes[-1].end-F(1,2))
|
||||
result.append(((*notes[:-1],tail),(types.DraftEdit('ending_release',tail.end,notes[-1].end,(tail.event_id,),(tail.event_id,),source.id+': half-beat breath after the arrival'),)))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def rhythm_catalog(library, meter):
|
||||
# Keep complete snippet objects. Slices below retain real rests and durations.
|
||||
return drafts.catalog(library,meter)
|
||||
|
||||
|
||||
def connection_patterns(start,end,entry,destination,rhythms,meter):
|
||||
length=end-start
|
||||
spans={( (F(),length), ):'held'}
|
||||
for identifier,pattern,total in rhythm_catalog(rhythms,meter):
|
||||
if total < length:
|
||||
continue
|
||||
# Retrieve every exact-duration window, including offbeat and cross-bar windows.
|
||||
for offset in sorted({F(),*(a for a,_ in pattern)}):
|
||||
if offset+length>total:
|
||||
continue
|
||||
if any(a<offset<b or a<offset+length<b for a,b in pattern):
|
||||
continue
|
||||
selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length)
|
||||
if 2<=len(selected)<=7 and selected[0][0]==0:
|
||||
spans.setdefault(selected,identifier)
|
||||
ranked=sequences.diverse(tuple(spans.items()),
|
||||
lambda item:(abs(len(item[0])-float(length)*1.5),item[1]),
|
||||
lambda item:(len(item[0]),any(a[1]!=b[0] for a,b in zip(item[0],item[0][1:]))),8)
|
||||
result=[]
|
||||
for pattern,identifier in ranked:
|
||||
events=tuple(dataclasses.replace(entry,start=start+a,end=start+b,role='connection',
|
||||
event_id=f'{entry.event_id}:route:{a}',position=None) for a,b in pattern)
|
||||
if len(events)>=2:
|
||||
result.append(((*events,destination),identifier))
|
||||
return result
|
||||
|
||||
|
||||
def elaborate(notes, features, harmony, library, rhythms, meter, source, *, sustain_entry=False):
|
||||
"""Edit an exposed held tail or a complete interior span, with both endpoints known."""
|
||||
fixed={e for f in features for e in f.events}
|
||||
windows=[]
|
||||
for i,(note,target) in enumerate(zip(notes,notes[1:])):
|
||||
if note.event_id in fixed or note.end != target.start:
|
||||
continue
|
||||
if note.end-note.start>=F(3,2):
|
||||
start=max(note.start+F(1,2),target.start-3)
|
||||
windows.append((i,start,target,False))
|
||||
# A source-held ending can arrive later via a related in-scale turn.
|
||||
if notes and notes[-1].event_id not in fixed and notes[-1].end-notes[-1].start>=F(5,2):
|
||||
tail=notes[-1]
|
||||
for duration in (F(1,2),F(1),F(3,2),F(2)):
|
||||
target=dataclasses.replace(tail,start=tail.end-duration,event_id=tail.event_id+':arrival')
|
||||
start=max(tail.start+F(1,2),target.start-3)
|
||||
if target.start-start>=1:
|
||||
windows.append((len(notes)-1,start,target,True))
|
||||
result=[]
|
||||
for i,start,target,terminal in windows:
|
||||
entry=notes[i];end=target.start
|
||||
prefix=dataclasses.replace(entry,end=start)
|
||||
seed=dataclasses.replace(entry,start=start,event_id=entry.event_id+':tail',role='connection')
|
||||
for pattern,rhythm_id in connection_patterns(start,end,seed,target,rhythms,meter):
|
||||
for route in routes.candidates(pattern,{target.event_id},harmony,library,(41,76),'develop '+source.id):
|
||||
if sustain_entry:
|
||||
# The route owns its complete sounding entry, including the old hold.
|
||||
joined=dataclasses.replace(route.notes[0],start=entry.start,event_id=entry.event_id)
|
||||
route=dataclasses.replace(route,notes=(joined,*route.notes[1:]))
|
||||
actual=(*notes[:i],*route.notes,*notes[i+1 if terminal else i+2:])
|
||||
else:
|
||||
actual=(*notes[:i],prefix,*route.notes,*notes[i+1 if terminal else i+2:])
|
||||
if not harmony.evaluate(actual,complete=True).valid:
|
||||
continue
|
||||
if any(abs(b.pitch.number()-a.pitch.number())>7 for a,b in zip(route.notes,route.notes[1:])):
|
||||
continue
|
||||
route=dataclasses.replace(route,sources=(*route.sources,'rhythm:'+rhythm_id))
|
||||
edit=types.DraftEdit('complete_route',start,target.start,(entry.event_id,),tuple(n.event_id for n in route.notes),
|
||||
source.id+'; '+rhythm_id+'; '+('later terminal arrival on ' if terminal else 'lead into ')+target.pitch.label())
|
||||
result.append((actual,edit,route))
|
||||
# Whole routes compete; bounded diversity retains different locations and rhythms.
|
||||
return sequences.diverse(result,lambda v:(abs(len(v[0])*4/float(source.end-source.start)-5),
|
||||
sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(v[2].notes,v[2].notes[1:])),v[2].id),
|
||||
lambda v:(v[1].start,v[2].destination.end-v[2].destination.start,len(v[2].notes)),8)
|
||||
|
||||
|
||||
def make_phrase(source, original, refined, notes, edits, route_list, octave, part, harmony, tempo, seed):
|
||||
from generator.solo import realization
|
||||
features=identifying(refined,source.id)
|
||||
# Only fully retained source turns become committed anchor events.
|
||||
anchors=[]
|
||||
for i,n in enumerate(notes):
|
||||
if n.pitch.number()%12 not in harmony.members(n.start,n.end):
|
||||
continue
|
||||
role='terminal' if i==len(notes)-1 else 'opening' if i==0 else 'intermediate'
|
||||
if role=='intermediate' and n.end-n.start<2:
|
||||
continue
|
||||
anchors.append(types.Anchor(n.event_id+':anchor',n.start,n.start,n.pitch,harmony.members(n.start,n.end),
|
||||
(41,76),n.end-n.start,role,harmony.at(n.start).chord,hold_until=n.end))
|
||||
if notes and (not anchors or anchors[-1].role!='terminal'):
|
||||
return None
|
||||
identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,signature(notes))[:12]
|
||||
motif=types.Motif(source.id,tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start) for n in refined),
|
||||
source.end-source.start,tuple(s.chord for s in harmony.spans if s.start<source.end and s.end>source.start),'song melody '+source.family)
|
||||
home=realization.make_identity(identifier,source.id,refined,source.start,source.end,'source_variation',harmony)
|
||||
transform=types.Transformation(identifier,source.id,source.family,'source_variation',source.start,source.end,
|
||||
tuple(n.event_id for n in notes),tuple((n.start-source.start,n.end-source.start) for n in notes),preserves=(),source=source.id)
|
||||
draft=types.MelodicDraft(identifier,source.start,source.end,notes,(transform,),
|
||||
tuple(a.id.removesuffix(':anchor') for a in anchors),(),tuple(anchors),home,'source_variation',features=features,
|
||||
reference=source.id,reference_motif=motif,source_phrase=source,octave_transfer=octave,
|
||||
source_links=tuple((n.event_id,n.sources) for n in notes))
|
||||
spaces=[];cursor=source.start
|
||||
for n in notes:
|
||||
if n.start>cursor:spaces.append(types.ActivitySpan(cursor,n.start,'space',F(),F()))
|
||||
spaces.append(types.ActivitySpan(n.start,n.end,'active',F(5,4),F()))
|
||||
cursor=n.end
|
||||
if cursor<source.end:spaces.append(types.ActivitySpan(cursor,source.end,'space',F(),F()))
|
||||
if not notes:spaces=[types.ActivitySpan(source.start,source.end,'space',F(),F())]
|
||||
plan=types.PhrasePlan(identifier,source.start,source.end,motif,'source_variation','source_variation',None,(41,76),F(5,4),
|
||||
source.end-cursor,tuple(spaces),(), 'breath' if cursor<source.end else 'sustain','closed',seed,source.id,source.start,source.end, (tuple(anchors),))
|
||||
phrase=realization.checked_phrase(plan,draft,notes,(),types.ContinuationState(''),harmony,tempo,edits)
|
||||
if phrase is None:
|
||||
return None
|
||||
# Source correspondence is evaluated once. Do not score the imported tune's
|
||||
# characteristic leaps as generic contour defects or reward filling its rests.
|
||||
source_by_id={n.event_id:n for n in original}
|
||||
retained={n.event_id:n for n in notes if n.event_id in source_by_id}
|
||||
deviation=sum(min(1,abs(n.pitch.number()-retained[k].pitch.number())/5)+
|
||||
min(1,float(abs(n.start-retained[k].start))/2)+min(1,float(abs((n.end-n.start)-(retained[k].end-retained[k].start)))/2) for k,n in source_by_id.items() if k in retained)/max(1,len(source_by_id))
|
||||
exposure=harmony.evaluate(notes,complete=True).exposure
|
||||
density=len(notes)*4/max(.001,float(source.end-source.start))
|
||||
score=3*deviation+2*exposure+.35*max(0,4-density,density-6)
|
||||
source_draft=dataclasses.replace(draft,id=identifier+':source',notes=original,features=(),anchors=(),structural=())
|
||||
refined_draft=dataclasses.replace(draft,id=identifier+':variation',notes=refined,features=features,anchors=(),structural=())
|
||||
return dataclasses.replace(phrase,score=score,initial=source_draft,refined=refined_draft,routes=route_list)
|
||||
|
||||
|
||||
def candidates(source, harmony, sequences_library, rhythms, meter, part, tempo, seed, *, stage='elaborated'):
|
||||
result=[]
|
||||
for octave in range(-3,4):
|
||||
original=shift(source.notes,octave)
|
||||
if any(not generator.fretboard.single_positions_for_pitch(F(n.pitch.number())) for n in original):
|
||||
continue
|
||||
adapted,required=adapt(original,harmony)
|
||||
if original and not adapted and any(harmony.at(n.start) and harmony.at(n.start).scale for n in original):
|
||||
continue
|
||||
variants=list(variations(adapted,dataclasses.replace(source,pickup=shift(source.pickup,octave)),harmony))
|
||||
if required or not adapted:
|
||||
variants.append((adapted,()))
|
||||
# A whole preserved phrase is the baseline, not an accidental production fallback.
|
||||
if not variants:
|
||||
continue
|
||||
variants=sequences.diverse(variants,lambda v:(len(v[1]),policy.stable_rank(seed,signature(v[0]))),
|
||||
lambda v:(v[1][0].kind if v[1] else 'harmonic_adaptation',v[1][0].start if v[1] else source.start),8)
|
||||
for refined,changes in variants:
|
||||
options=[(refined,(),())]
|
||||
if stage=='elaborated':
|
||||
connections=elaborate(refined,identifying(refined,source.id),harmony,sequences_library,rhythms,meter,source)
|
||||
options.extend((notes,(edit,),(route,)) for notes,edit,route in connections)
|
||||
# A second, distinct source-held span may develop the same phrase.
|
||||
# Reuse complete routes; never elaborate an already elaborated event.
|
||||
for first_notes,first_edit,first_route in connections[:2]:
|
||||
fixed=(*identifying(refined,source.id),types.IdentifyingFeature('route',
|
||||
tuple(n.event_id for n in first_route.notes)+first_edit.removed,(),(),source.id))
|
||||
second=elaborate(first_notes,fixed,harmony,sequences_library,rhythms,meter,source)
|
||||
options.extend((actual,(first_edit,edit),(first_route,route)) for actual,edit,route in second[:2])
|
||||
for notes,added,route_list in options:
|
||||
phrase=make_phrase(source,original,refined,notes,(*required,*changes,*added),route_list,octave,part,harmony,tempo,seed)
|
||||
if phrase:
|
||||
result.append(phrase)
|
||||
# Preserve register AND edit-location diversity, rather than eight near-copies.
|
||||
unique={signature(p.draft.notes):p for p in result}
|
||||
return sequences.diverse(tuple(unique.values()),lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
|
||||
lambda p:p.draft.octave_transfer,policy.DEFAULT.source_variations)
|
||||
|
||||
|
||||
def clipped_sources(analysis,end):
|
||||
result=[]
|
||||
for source in analysis.source_phrases:
|
||||
if source.start>=end:break
|
||||
finish=min(source.end,end)
|
||||
notes=tuple(dataclasses.replace(n,start=max(F(),n.start),end=min(n.end,finish)) for n in source.notes if n.start<finish and n.end>0)
|
||||
result.append(dataclasses.replace(source,end=finish,notes=notes,rests=tuple((a,min(b,finish)) for a,b in source.rests if a<finish)))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def transition(first,second):
|
||||
if not first or not first.decisions or not second.decisions:return 0.
|
||||
a=first.state.last;b=second.decisions[0].notes[0]
|
||||
leap=max(0,abs(a.pitch.number()-b.pitch.number())-7)/12
|
||||
same_family=first.draft.source_phrase.family==second.draft.source_phrase.family
|
||||
repeated_end=not same_family and first.anchors and second.anchors and first.anchors[-1].pitch==second.anchors[-1].pitch and first.anchors[-1].duration==second.anchors[-1].duration
|
||||
return .2*leap+.15*bool(repeated_end)
|
||||
|
||||
|
||||
def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'):
|
||||
from generator.solo import realization, themes
|
||||
if mode not in ('thematic','paraphrase'):raise ValueError('Unknown solo treatment: '+mode)
|
||||
context.resources[id(library)]=library
|
||||
context.resources[id(rhythms)]=rhythms
|
||||
harmony=harmonic.Harmony(choices)
|
||||
sources=themes.windows(analysis,measures,end,harmony) if mode=='thematic' else clipped_sources(analysis,end)
|
||||
seed=policy.stable_rank('source_variations', (analysis.melody,choices,policy.SEED_NAMESPACE))
|
||||
states=[(0.,())]
|
||||
for source in sources:
|
||||
meter=measures[min(len(measures)-1,int(source.start//generator.model.fraction(measures[0]['duration'])))]['time_signature']
|
||||
following=[]
|
||||
for cost,history in states:
|
||||
incoming=history[-1].state if history else types.ContinuationState('')
|
||||
key=(mode,source,incoming if mode=='thematic' else None,policy.stable_rank("harmony",choices),part,analysis.tempo,seed,stage,id(library),id(rhythms),policy.DEFAULT)
|
||||
if key in context.results:
|
||||
pool=context.results[key];context.diagnostics['cache_hits']+=1
|
||||
else:
|
||||
if mode=='thematic' and any(c.scale and c.span.start<source.end and c.span.end>source.start for c in choices):
|
||||
pool=()
|
||||
for batch in range(2):
|
||||
pool=themes.phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,analysis.tempo,context,seed,stage=stage,batch=batch,final=source.end==end)
|
||||
if pool:break
|
||||
context.diagnostics['theme_replans']=context.diagnostics.get('theme_replans',0)+1
|
||||
elif mode=='thematic':
|
||||
pool=themes.silence(source,incoming,part,harmony,analysis.tempo,seed)
|
||||
else:
|
||||
pool=candidates(source,harmony,library,rhythms,meter,part,analysis.tempo,seed,stage=stage)
|
||||
context.results[key]=pool;context.diagnostics['generated_candidates']+=len(pool)
|
||||
context.diagnostics.setdefault('source_candidate_pools', {}).setdefault(source.id,[]).extend(
|
||||
{'id':p.draft.id,'score':p.score,'octave':p.draft.octave_transfer,'attacks':len(p.draft.notes),'edits':[e.kind for e in p.edits],'source_family':source.family,'theme':p.state.home.id if p.state.home else None} for p in pool)
|
||||
for candidate in pool:
|
||||
# The thematic constructor has already placed all decisions from the
|
||||
# actual incoming state; do not collapse its motif groups into one bar.
|
||||
if mode=='thematic':
|
||||
total=cost+candidate.score*float(source.end-source.start)/float(end)+transition(history[-1] if history else None,candidate)
|
||||
following.append((total,(*history,candidate)))
|
||||
continue
|
||||
last=incoming.last
|
||||
placements=placement.alternatives(tuple(n for d in candidate.decisions for n in d.notes),analysis.tempo,last,limit=2)
|
||||
for travel,notes in placements:
|
||||
decision=dataclasses.replace(candidate.decisions[0],notes=notes) if candidate.decisions else None
|
||||
state=dataclasses.replace(candidate.state,notes=notes,last=notes[-1] if notes else last,travel=travel)
|
||||
placed=dataclasses.replace(candidate,decisions=(decision,) if decision else (),state=state)
|
||||
total=cost+candidate.score*float(source.end-source.start)/float(end)+.05*travel/max(1,len(notes))/2000+transition(history[-1] if history else None,placed)
|
||||
following.append((total,(*history,placed)))
|
||||
if not following:
|
||||
raise realization.SearchExhausted(f'{part} {source.id}: {mode} feature, harmonic destination or continuation failed within the bounded search; not proof of incompatibility')
|
||||
states=sequences.diverse(following,lambda v:(v[0],policy.stable_rank(seed,tuple((p.draft.id,p.state.last.position if p.state.last else None) for p in v[1]))),
|
||||
lambda v:(v[1][-1].state.home.id if mode=='thematic' and v[1][-1].state.home else tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier)
|
||||
return tuple((cost,types.Solo(part,choices,phrases,analysis.tempo,end,{**context.diagnostics,'construction':'thematic_recomposition' if mode=='thematic' else 'source_phrase_variations','policy':policy.describe()['fingerprint']},
|
||||
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases))) for cost,phrases in states)
|
||||
|
||||
|
||||
def similarity(first,second):
|
||||
"""Compare composition independently of octave and fingering; callbacks are explicit."""
|
||||
def events(solo):
|
||||
callbacks={e for p in solo.phrases if p.draft for f in p.draft.features if f.id.endswith(':callback') for e in f.events}
|
||||
return tuple(n for p in solo.phrases if p.plan.relationship!='callback' for d in p.decisions for n in d.notes if n.event_id not in callbacks)
|
||||
a,b=events(first),events(second)
|
||||
def tokens(notes):
|
||||
return {(n.start,n.end,n.pitch.number()%12) for n in notes}
|
||||
left,right=tokens(a),tokens(b)
|
||||
literal=len(left&right)/max(1,min(len(left),len(right)))
|
||||
def runs(notes):
|
||||
return {tuple((n.start-notes[i].start,n.end-n.start,n.pitch.number()-notes[i].pitch.number()) for n in notes[i:i+4]) for i in range(len(notes)-3)}
|
||||
left,right=runs(a),runs(b)
|
||||
interval=len(left&right)/max(1,min(len(left),len(right)))
|
||||
return .65*literal+.35*interval
|
||||
|
||||
Reference in New Issue
Block a user