139 lines
8.1 KiB
Python
139 lines
8.1 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import fractions
|
|
|
|
import generator.fretboard
|
|
import generator.harmony
|
|
import generator.model
|
|
import generator.scales.selection
|
|
import generator.scales.timeline
|
|
import generator.solo.harmonic
|
|
import generator.solo.analysis
|
|
import generator.solo.planning
|
|
import generator.solo.policy
|
|
import generator.solo.realization
|
|
import generator.solo.types
|
|
|
|
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated'):
|
|
timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
|
|
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
|
|
choices = generator.scales.selection.select(timeline, region, scales)
|
|
for choice in choices:
|
|
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, lead=bool(references))
|
|
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 = context if context is not None else generator.solo.realization.SearchContext()
|
|
context.revise = revise
|
|
context.references = references
|
|
context.stage = stage
|
|
|
|
def realize(plan, previous):
|
|
for attempt in range(generator.solo.policy.DEFAULT.replan_attempts + 1):
|
|
context.batch = attempt
|
|
try:
|
|
options = generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context)
|
|
return options
|
|
except generator.solo.realization.SearchExhausted as error:
|
|
if attempt == generator.solo.policy.DEFAULT.replan_attempts or plan.treatment in ('quote', 'exact_quote', 'lead'):
|
|
raise
|
|
context.diagnostics.setdefault('replanning', []).append({'phrase': plan.id, 'attempt': attempt + 1, 'reason': str(error), 'adjustment': 'next diverse complete rhythm and anchor constructions; contracts unchanged'})
|
|
|
|
phrases = choose_phrases(plans, realize, lambda first, second, incoming: generator.solo.realization.revise_pair(first, second, incoming, choices, sequences, analysis.tempo, context))
|
|
context.diagnostics['policy'] = generator.solo.policy.describe()['fingerprint']
|
|
context.diagnostics['musical_identity'] = generator.solo.analysis.identity(analysis, measures, part)
|
|
passage_ids = tuple(dict.fromkeys(p.passage_id for p in plans if p.passage_id))
|
|
passages = tuple(generator.solo.types.MelodicPassagePlan(identifier, min(p.start for p in plans if p.passage_id == identifier), max(p.end for p in plans if p.passage_id == identifier), tuple(p.id for p in plans if p.passage_id == identifier), next(p.seed for p in plans if p.passage_id == identifier)) for identifier in passage_ids)
|
|
result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end, context.diagnostics, passages)
|
|
validate(result)
|
|
return result
|
|
|
|
def phrase_notes(phrase):
|
|
notes = tuple(n for d in phrase.decisions for n in d.notes)
|
|
if notes and notes[0].continuation:
|
|
notes = (dataclasses.replace(notes[0], continuation=False), *notes[1:])
|
|
return generator.solo.types.sounding_notes(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, revise_pair=None):
|
|
"""Commit only after the next phrase has been evaluated from each ending."""
|
|
result, previous, pending = [], None, None
|
|
for index, plan in enumerate(plans):
|
|
options = realize(plan, previous)
|
|
if pending is not None and pending not in options:
|
|
options = (pending, *options)
|
|
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, None))
|
|
continue
|
|
try:
|
|
following = realize(plans[index + 1], option.state or 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)
|
|
shared_passage = bool(plan.passage_id and plan.passage_id == plans[index + 1].passage_id)
|
|
for future in following:
|
|
left, right = revise_pair(option, future, previous) if revise_pair else (option, future)
|
|
cost = (right.score if shared_passage else (left.score * length + right.score * next_length) / (length + next_length)) + transition_cost(left, right)
|
|
ranked.append((cost + incoming, left, right))
|
|
if not ranked:
|
|
raise failure or generator.solo.realization.SearchExhausted(f'{plan.id}: no feasible phrase transition')
|
|
_, chosen, pending = 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 chosen.state:
|
|
previous = chosen.state
|
|
elif notes:
|
|
previous = notes[-1]
|
|
return tuple(result)
|
|
|
|
def validate(solo):
|
|
previous = None
|
|
for note in solo.notes():
|
|
if note.start < 0 or note.end > solo.end or note.end <= note.start or previous and previous.end > note.start:
|
|
raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}')
|
|
# Harmony.evaluate below owns scale permission and the narrow, verified
|
|
# chromatic exception; no separate permissive final-validation path.
|
|
if note.position not in generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())):
|
|
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
|
|
previous = note
|
|
for phrase in solo.phrases:
|
|
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')
|
|
harmony = generator.solo.harmonic.Harmony(solo.choices)
|
|
contracts = tuple(dict.fromkeys(t for phrase in solo.phrases if phrase.state for t in phrase.state.treatments))
|
|
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True)
|
|
if not checked.valid:
|
|
raise ValueError(f'{solo.part} solo: {checked.error}')
|
|
for phrase in solo.phrases:
|
|
if phrase.state:
|
|
complete = phrase.plan.end == (phrase.plan.passage_end or phrase.plan.end)
|
|
valid, error = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=complete, closure=phrase.plan.closure)
|
|
if not valid:
|
|
raise ValueError(error)
|