+64
-19
@@ -10,6 +10,7 @@ import generator.scales.selection
|
||||
import generator.scales.timeline
|
||||
import generator.solo.analysis
|
||||
import generator.solo.planning
|
||||
import generator.solo.policy
|
||||
import generator.solo.realization
|
||||
import generator.solo.types
|
||||
|
||||
@@ -21,27 +22,71 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
|
||||
if choice.span.chord and choice.scale is None:
|
||||
raise ValueError(f'{part} solo, source measure {choice.span.source_measure}, beat {choice.span.beat}, {generator.harmony.symbol(choice.span.chord)}: No exact match within the permitted catalog')
|
||||
plans = generator.solo.planning.plans(analysis, measures, choices, end, part, sequences)
|
||||
# A protected quote may change the local collection, never its individual pitches.
|
||||
for plan in plans:
|
||||
if plan.treatment != 'quote' or plan.motif.duration > plan.end - plan.start:
|
||||
continue
|
||||
retained = tuple((plan.start + n.start, plan.start + n.end, {n.pitch.number() % 12}) for n in plan.motif.notes)
|
||||
if all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
|
||||
continue
|
||||
alternative = generator.scales.selection.select(timeline, region, scales, retained=retained)
|
||||
if all(pc <= generator.solo.types.permitted(alternative, start, finish) for start, finish, pc in retained) and all(c.scale or c.span.chord is None for c in alternative):
|
||||
choices = tuple(dataclasses.replace(c, reason=c.reason + '; retained quotation') if any(a < c.span.end and b > c.span.start for a, b, _ in retained) else c for c in alternative)
|
||||
phrases, previous = [], None
|
||||
for plan in plans:
|
||||
phrase = generator.solo.realization.compose(plan, measures, choices, sequences, rhythms, analysis.tempo, previous)
|
||||
phrases.append(phrase)
|
||||
notes = tuple(n for d in phrase.decisions for n in d.notes)
|
||||
if notes:
|
||||
previous = notes[-1]
|
||||
result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end)
|
||||
retained = tuple((plan.start + n.start, plan.start + n.end, {n.pitch.number() % 12}) for plan in plans if plan.treatment in ('quote', 'exact_quote') for n in plan.motif.notes)
|
||||
if retained and not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
|
||||
choices = generator.scales.selection.select(timeline, region, scales, retained=retained)
|
||||
if not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
|
||||
raise ValueError(f'{part} solo: protected quotation has no exact compatible scale collection')
|
||||
choices = tuple(dataclasses.replace(c, reason=c.reason + '; retained quotation') if any(a < c.span.end and b > c.span.start for a, b, _ in retained) else c for c in choices)
|
||||
context = generator.solo.realization.SearchContext()
|
||||
|
||||
def realize(plan, previous):
|
||||
return generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context)
|
||||
|
||||
phrases = choose_phrases(plans, realize)
|
||||
context.diagnostics['policy'] = generator.solo.policy.describe()['fingerprint']
|
||||
context.diagnostics['musical_identity'] = generator.solo.analysis.identity(analysis, measures, part)
|
||||
result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end, context.diagnostics)
|
||||
validate(result)
|
||||
return result
|
||||
|
||||
def phrase_notes(phrase):
|
||||
return generator.solo.types.sounding_notes(n for d in phrase.decisions for n in d.notes)
|
||||
|
||||
def transition_cost(first, second):
|
||||
before, after = phrase_notes(first), phrase_notes(second)
|
||||
if not before or not after:
|
||||
return 0.0
|
||||
gap = max(0, after[0].start - before[-1].end - first.plan.release)
|
||||
jump = abs(after[0].pitch.number() - before[-1].pitch.number())
|
||||
# The next phrase's local score already includes physical movement. Here we
|
||||
# evaluate pacing and conversational contrast at the join, once per pair.
|
||||
before_shape = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(before, before[1:]))
|
||||
after_shape = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(after, after[1:]))
|
||||
repeated_answer = second.plan.relationship == 'answer' and before_shape == after_shape
|
||||
return min(1, float(gap) / 2) + 0.5 * min(1, max(0, jump - 5) / 12) + 0.5 * repeated_answer
|
||||
|
||||
def choose_phrases(plans, realize):
|
||||
"""Commit only after the next phrase has been evaluated from each ending."""
|
||||
result, previous = [], None
|
||||
for index, plan in enumerate(plans):
|
||||
options = realize(plan, previous)
|
||||
ranked, failure = [], None
|
||||
for option in options:
|
||||
notes = phrase_notes(option)
|
||||
last = notes[-1] if notes else previous
|
||||
incoming = transition_cost(result[-1], option) if result else 0
|
||||
if index + 1 == len(plans):
|
||||
ranked.append((option.score + incoming, option))
|
||||
continue
|
||||
try:
|
||||
following = realize(plans[index + 1], last)
|
||||
except generator.solo.realization.SearchExhausted as error:
|
||||
failure = error
|
||||
continue
|
||||
length = float(option.plan.end - option.plan.start)
|
||||
next_length = float(plans[index + 1].end - plans[index + 1].start)
|
||||
cost = min((option.score * length + future.score * next_length) / (length + next_length) + transition_cost(option, future) for future in following)
|
||||
ranked.append((cost + incoming, option))
|
||||
if not ranked:
|
||||
raise failure or generator.solo.realization.SearchExhausted(f'{plan.id}: no feasible phrase transition')
|
||||
_, chosen = min(ranked, key=lambda item: (item[0], tuple((n.pitch, n.start, n.end, n.position) for n in phrase_notes(item[1]))))
|
||||
result.append(chosen)
|
||||
notes = phrase_notes(chosen)
|
||||
if notes:
|
||||
previous = notes[-1]
|
||||
return tuple(result)
|
||||
|
||||
def validate(solo):
|
||||
previous = None
|
||||
for note in solo.notes():
|
||||
@@ -53,6 +98,6 @@ def validate(solo):
|
||||
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
|
||||
previous = note
|
||||
for phrase in solo.phrases:
|
||||
notes = tuple(n for d in phrase.decisions for n in d.notes)
|
||||
notes = phrase_notes(phrase)
|
||||
if not generator.solo.realization.target_ok(notes, phrase.plan.target):
|
||||
raise ValueError(f'{phrase.plan.id}: planned harmonic arrival was not realized')
|
||||
|
||||
Reference in New Issue
Block a user