781 lines
50 KiB
Python
781 lines
50 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import fractions
|
|
import functools
|
|
import time
|
|
|
|
import generator.solo.drafts
|
|
|
|
import generator.fretboard
|
|
import generator.model
|
|
import generator.rhythm
|
|
import generator.scales.selection
|
|
import generator.solo.harmonic
|
|
import generator.solo.motifs
|
|
import generator.solo.placement
|
|
import generator.solo.planning
|
|
import generator.solo.scoring
|
|
import generator.solo.sequences
|
|
import generator.solo.policy
|
|
import generator.solo.types
|
|
|
|
BEAM_WIDTH = generator.solo.policy.DEFAULT.beam_width
|
|
CELL_LIMIT = generator.solo.policy.DEFAULT.extensions
|
|
|
|
class SearchExhausted(ValueError):
|
|
pass
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class Cell:
|
|
start: fractions.Fraction
|
|
end: fractions.Fraction
|
|
patterns: tuple[tuple[str, tuple[tuple[fractions.Fraction, fractions.Fraction], ...]], ...]
|
|
motif: generator.solo.types.Motif | None = None
|
|
gesture: generator.solo.types.Gesture | None = None
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class Candidate:
|
|
notes: tuple[generator.solo.types.Note, ...]
|
|
source: str
|
|
treatment: str
|
|
rank: tuple
|
|
fidelity: float = 0
|
|
treatments: tuple[generator.solo.types.HarmonicTreatment, ...] = ()
|
|
|
|
def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fractions.Fraction, activity: fractions.Fraction, offset=fractions.Fraction()):
|
|
result = {}
|
|
for snippet in library.for_meter(meter):
|
|
for index, bar in enumerate(snippet.bars):
|
|
spans = []
|
|
for event in bar.events:
|
|
if not event.sounding:
|
|
continue
|
|
if event.continuation:
|
|
if spans and spans[-1][1] == event.onset:
|
|
spans[-1] = (spans[-1][0], event.onset + event.duration)
|
|
else:
|
|
spans.append((event.onset, event.onset + event.duration))
|
|
# Slice exact authored rhythm; later attacks outside the window do not
|
|
# disqualify a useful prefix. A tied continuation never becomes an attack.
|
|
pattern = tuple((start - offset, min(end - offset, length)) for start, end in spans if offset <= start < offset + length)
|
|
result.setdefault(pattern, f'rhythm {snippet.id}:{index + 1}@{offset}+{length}')
|
|
|
|
def rank(item):
|
|
pattern, identifier = item
|
|
return abs(len(pattern) - length * activity), abs(sum(end - start for start, end in pattern) - length * fractions.Fraction(3, 4)), identifier
|
|
|
|
def family(item):
|
|
pattern = item[0]
|
|
return len(pattern), bool(pattern and pattern[0][0]), any(a.denominator > 1 for a, _ in pattern)
|
|
|
|
selected = generator.solo.sequences.diverse(result.items(), rank, family, 12)
|
|
return tuple((identifier, pattern) for pattern, identifier in selected)
|
|
|
|
def cells(plan, measures, rhythms, choices=()):
|
|
result, boundaries = [], [fractions.Fraction()]
|
|
for measure in measures:
|
|
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
|
|
start, music_end = plan.start, plan.end - plan.release
|
|
protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference')
|
|
if protected:
|
|
motif = plan.motif
|
|
if not motif.notes or motif.duration > music_end - start:
|
|
raise SearchExhausted(f'{plan.id}: protected motif rhythm does not fit the phrase')
|
|
spans = tuple((n.start, n.end) for n in motif.notes)
|
|
result.append(Cell(start, start + motif.duration, ((motif.id, spans),), motif))
|
|
start += motif.duration
|
|
while start < music_end:
|
|
index = next(i for i in range(len(measures)) if boundaries[i] <= start < boundaries[i + 1])
|
|
passage = next((p for p in plan.passages if p.start <= start < p.end), None)
|
|
gesture = next((g for g in plan.gestures if g.start <= start < g.end), None)
|
|
chord_end = next((c.span.end for c in choices if c.span.start <= start < c.span.end), music_end)
|
|
end = min(music_end, boundaries[index + 1], passage.end if passage else music_end, gesture.end if gesture else music_end, chord_end)
|
|
activity = passage.activity if passage else plan.activity
|
|
patterns = rhythm_patterns(rhythms, measures[index]['time_signature'], end - start, activity, start - boundaries[index])
|
|
patterns += (('hold', ((fractions.Fraction(), end - start),)), ('space', ()))
|
|
if plan.target and start <= plan.target.latest and plan.target.earliest < end:
|
|
onset = max(start, plan.target.earliest) - start
|
|
patterns = (('arrival_and_release', ((onset, end - start),)), *patterns)
|
|
motif = plan.motif if not result else None
|
|
result.append(Cell(start, end, patterns, motif, gesture))
|
|
start = end
|
|
if plan.release:
|
|
result.append(Cell(music_end, plan.end, (('planned_release', ()),)))
|
|
return tuple(result)
|
|
|
|
@functools.lru_cache(maxsize=2048)
|
|
def scale_options(scale, allowed, low, high):
|
|
return tuple(generator.solo.types.scale_pitch(scale, index) for index in range(-21, 28) if low <= generator.solo.types.scale_pitch(scale, index).number() <= high and generator.solo.types.scale_pitch(scale, index).number() % 12 in allowed and generator.fretboard.single_positions_for_pitch(fractions.Fraction(generator.solo.types.scale_pitch(scale, index).number())))
|
|
|
|
def valid_notes(notes, choices):
|
|
return all(n.pitch.number() % 12 in generator.solo.types.permitted(choices, n.start, n.end) for n in notes)
|
|
|
|
def options(choices, start, end, register):
|
|
scale = next((c.scale for c in choices if c.span.start <= start < c.span.end), None)
|
|
allowed = generator.solo.types.permitted(choices, start, end)
|
|
return scale_options(scale, allowed, *generator.fretboard.single_register((register[0] - 7, register[1] + 5))) if scale and allowed else ()
|
|
|
|
def target_ok(notes, target):
|
|
return target is None or any(target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches for n in notes)
|
|
|
|
def adapt_path(pitches, spans, choices, register):
|
|
"""Connect actual pitches across collections without carrying a stale degree index."""
|
|
states = [(0, ())]
|
|
for index, ((start, end), intended) in enumerate(zip(spans, pitches)):
|
|
available = options(choices, start, end, register)
|
|
following = []
|
|
for cost, path in states:
|
|
desired = path[-1].pitch.number() + intended.number() - pitches[index - 1].number() if path else intended.number()
|
|
for pitch in sorted(available, key=lambda p: (abs(p.number() - desired), p))[:2]:
|
|
following.append((cost + abs(pitch.number() - desired), (*path, generator.solo.types.Note(start, end, pitch, role='adapted_path'))))
|
|
states = sorted(following, key=lambda item: (item[0], tuple(n.pitch for n in item[1])))[:8]
|
|
return [notes for _, notes in states]
|
|
|
|
def anchor_treatments(anchors, harmony):
|
|
result = []
|
|
for anchor in anchors:
|
|
span = harmony.at(anchor.earliest)
|
|
if span and anchor.pitch.number() % 12 in span.roles.specified:
|
|
result.append(generator.solo.types.HarmonicTreatment(anchor.id + '_color', 'color', anchor.pitch.number(), anchor.earliest, anchor.hold_until or anchor.latest + anchor.duration))
|
|
return tuple(result)
|
|
|
|
def rhythm_variants(cell, plan, state, anchors, reference):
|
|
patterns = cell.patterns
|
|
gesture = cell.gesture
|
|
if cell.motif and plan.treatment in ('quote', 'exact_quote'):
|
|
return [(name, tuple((cell.start + a, cell.start + b) for a, b in pattern), {}, False) for name, pattern in patterns]
|
|
if gesture and gesture.treatment == 'rhythmic_reference' and state.motifs:
|
|
# The identity is the rhythm actually heard, including its rests. Cropping
|
|
# at a search boundary does not create a new rhythmic attack.
|
|
pattern = tuple((max(cell.start, gesture.start + n.start) - cell.start, min(cell.end, gesture.start + n.end) - cell.start) for n in reference.notes if gesture.start + n.start < cell.end and gesture.start + n.end > cell.start)
|
|
patterns = ((reference.id + ':rhythm', pattern),)
|
|
due = [a for a in anchors if cell.start <= a.latest and a.earliest < cell.end and not generator.solo.harmonic.anchor_note(a, state.notes)]
|
|
holding = next((a for a in anchors if state.last and state.last.role != 'quote' and a.hold_until and a.hold_until > cell.start and generator.solo.harmonic.anchor_note(a, state.notes, until=cell.start)), None)
|
|
if holding:
|
|
stop = min(cell.end, holding.hold_until)
|
|
return [('anchor_sustain', ((cell.start, stop),), {cell.start: holding.pitch}, True)]
|
|
result = []
|
|
for name, pattern in patterns:
|
|
spans = tuple((cell.start + a, cell.start + b) for a, b in pattern)
|
|
variants = [(spans, {})]
|
|
for anchor in due:
|
|
following = []
|
|
for current, forced in variants:
|
|
onsets = [a for a, b in current if anchor.earliest <= a <= anchor.latest and b - a >= min(anchor.duration, cell.end - a)]
|
|
if gesture and gesture.treatment == 'rhythmic_reference':
|
|
times = onsets[:2]
|
|
else:
|
|
times = list(dict.fromkeys(onsets[:2] + [max(cell.start, anchor.earliest)]))
|
|
for time in times:
|
|
stop = min(cell.end, anchor.hold_until or time + anchor.duration)
|
|
original_end = next((b for a, b in current if a == time), stop)
|
|
stop = max(stop, original_end)
|
|
if gesture and gesture.treatment == 'rhythmic_reference' and stop != original_end:
|
|
continue
|
|
overlaid = [(a, min(b, time)) for a, b in current if a < time]
|
|
overlaid += [(time, stop)] + [(a, b) for a, b in current if a >= stop]
|
|
following.append((tuple((a, b) for a, b in overlaid if b > a), {**forced, time: anchor.pitch}))
|
|
variants = following
|
|
result.extend((name, spans, forced, False) for spans, forced in variants)
|
|
return result
|
|
|
|
def realize_route(spans, first, steps, forced, choices, plan, harmony, contracts, reference):
|
|
"""Instantiate relative motion at actual pitches, adapting only at collection changes."""
|
|
path = []
|
|
for index, (start, end) in enumerate(spans):
|
|
available = options(choices, start, end, plan.register)
|
|
if not available:
|
|
return None
|
|
if start in forced:
|
|
available = tuple(p for p in available if p.number() == forced[start].number())
|
|
if end - start >= generator.solo.policy.DEFAULT.long_note:
|
|
members = harmony.members(start, end)
|
|
available = tuple(p for p in available if p.number() % 12 in members or any(t.pitch == p.number() and t.start <= start and t.end >= end for t in contracts))
|
|
if not available:
|
|
return None
|
|
intended = first.number()
|
|
if path:
|
|
prior = path[-1].pitch.number()
|
|
scale = harmony.at(start).scale
|
|
step, semitones = steps[(index - 1) % len(steps)] if steps else (0, 0)
|
|
try:
|
|
intended = generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, prior) + step).number() if step is not None else prior + semitones
|
|
except ValueError:
|
|
# The old pitch is not in the new collection: preserve its sounding
|
|
# coordinate and adapt its intended motion, never transfer degree IDs.
|
|
intended = prior + semitones
|
|
stopping = end - start >= generator.solo.policy.DEFAULT.stopping_note
|
|
basic = harmony.at(start).roles.basic
|
|
pitch = min(available, key=lambda p: (abs(p.number() - intended) + (0.6 if stopping and p.number() % 12 not in basic else 0), p))
|
|
source = reference.notes[min(index, len(reference.notes) - 1)].sources if reference.notes else ()
|
|
path.append(generator.solo.types.Note(start, end, pitch, source, 'motif'))
|
|
return tuple(path)
|
|
|
|
def candidates(cell, plan, choices, sequences, state=None, anchors=(), harmony=None):
|
|
state = state or generator.solo.types.ContinuationState(plan.passage_id)
|
|
harmony = harmony or generator.solo.harmonic.Harmony(choices)
|
|
reference = generator.solo.motifs.reference(plan, cell.gesture, state.motifs)
|
|
treatment = cell.gesture.treatment if cell.gesture else plan.treatment if cell.motif else 'continue'
|
|
contracts = tuple(dict.fromkeys((*state.treatments, *anchor_treatments(anchors, harmony))))
|
|
protected = cell.motif and plan.treatment in ('quote', 'exact_quote')
|
|
local_plan = dataclasses.replace(plan, start=cell.start, end=cell.end, release=fractions.Fraction(), passages=tuple(dataclasses.replace(p, start=max(cell.start, p.start), end=min(cell.end, p.end)) for p in plan.passages if p.start < cell.end and p.end > cell.start))
|
|
evaluation = generator.solo.scoring.Evaluation(local_plan, choices)
|
|
result = []
|
|
for identifier, spans, forced, sustain in rhythm_variants(cell, plan, state, anchors, reference):
|
|
active = tuple((a, b) for a, b in spans if generator.solo.types.permitted(choices, a, b))
|
|
if active != spans:
|
|
if protected:
|
|
continue
|
|
spans = active
|
|
identifier += ':harmonic_fragment'
|
|
if not spans:
|
|
if not protected:
|
|
result.append(Candidate((), identifier, 'space', (generator.solo.scoring.value(evaluation.features(())),), treatments=contracts))
|
|
continue
|
|
available = options(choices, *spans[0], plan.register)
|
|
pending = next((t for t in state.treatments if t.kind == 'suspension' and t.deadline >= cell.start and state.last and state.last.pitch.number() == t.pitch and state.last.end == cell.start and not any(n.pitch.number() == t.resolution and n.start >= t.start for n in state.notes)), None)
|
|
if pending:
|
|
resolution = next((p for p in available if p.number() == pending.resolution), None)
|
|
if resolution is None or spans[0][0] != cell.start:
|
|
continue
|
|
forced = {**forced, spans[0][0]: resolution}
|
|
sustain = False
|
|
last = state.last
|
|
next_anchor = next((a for a in anchors if a.latest >= cell.start and not generator.solo.harmonic.anchor_note(a, state.notes)), None)
|
|
destination = next_anchor.pitch.number() if next_anchor else last.pitch.number() if last else reference.notes[0].pitch.number()
|
|
if protected:
|
|
paths = []
|
|
for octave in (0, 1, -1, 2, -2):
|
|
notes = tuple(generator.solo.types.Note(a, b, dataclasses.replace(n.pitch, octave=n.pitch.octave + octave), n.sources, 'quote') for (a, b), n in zip(spans, cell.motif.notes))
|
|
if valid_notes(notes, choices):
|
|
paths.append((notes, f'quote octave {octave:+}'))
|
|
elif sustain and last:
|
|
paths = [((generator.solo.types.Note(*spans[0], last.pitch, role='sustain', continuation=True),), 'anchor_sustain')]
|
|
else:
|
|
near = last.pitch.number() if last else destination
|
|
starts = sorted(available, key=lambda p: (abs(p.number() - near), p))[:3]
|
|
if spans[0][0] in forced:
|
|
starts = [forced[spans[0][0]]]
|
|
scale = harmony.at(spans[0][0]).scale
|
|
target = min(available, key=lambda p: abs(p.number() - destination))
|
|
paths = []
|
|
motif_steps = generator.solo.motifs.interval_steps(reference, harmony)
|
|
gesture_start = cell.gesture.start if cell.gesture else cell.start
|
|
heard = tuple(n for n in state.notes if n.start >= gesture_start)
|
|
cursor = len(heard)
|
|
if motif_steps and last:
|
|
step, semitones = motif_steps[max(0, cursor - 1) % len(motif_steps)]
|
|
try:
|
|
expected = generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, last.pitch.number()) + step).number() if step is not None else last.pitch.number() + semitones
|
|
except ValueError:
|
|
expected = last.pitch.number() + semitones
|
|
supported = min(available, key=lambda p: (abs(p.number() - expected), p))
|
|
if supported not in starts:
|
|
starts.append(supported)
|
|
if motif_steps:
|
|
motif_steps = motif_steps[cursor % len(motif_steps):] + motif_steps[:cursor % len(motif_steps)]
|
|
for first in starts:
|
|
displacement = generator.solo.types.scale_index(scale, target.number()) - generator.solo.types.scale_index(scale, first.number())
|
|
if next_anchor and next_anchor.earliest > cell.end:
|
|
displacement = round(displacement * float((cell.end - cell.start) / (next_anchor.earliest - cell.start)))
|
|
direction = (displacement > 0) - (displacement < 0)
|
|
routes = [(motif_steps, 'develop ' + reference.id)]
|
|
retrieved = sequences.retrieve(len(spans), displacement, direction)
|
|
selected = generator.solo.sequences.diverse(retrieved, lambda p: (abs(p.offsets()[-1] - displacement), p.sources), lambda p: generator.solo.sequences.family(p.steps), 4)
|
|
for path in selected:
|
|
steps = tuple((step, generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, first.number()) + step).number() - first.number()) for step in path.steps)
|
|
if motif_steps and treatment != 'rhythmic_reference' and not (cell.gesture and cell.gesture.relationship == 'close'):
|
|
# Preserve identifying motion, then let the authored vocabulary
|
|
# connect it toward the next anchor. The next cell uses this result.
|
|
keep = min(2, len(steps))
|
|
steps = tuple(motif_steps[i % len(motif_steps)] for i in range(keep)) + steps[keep:]
|
|
routes.append((steps, 'sequences ' + ' + '.join(path.sources) + '; develop ' + reference.id))
|
|
for steps, name in routes:
|
|
notes = realize_route(spans, first, steps, forced, choices, plan, harmony, contracts, reference)
|
|
if notes:
|
|
paths.append((notes, name))
|
|
if last and last.end == cell.start and spans[0][0] == cell.start and not protected:
|
|
# An existing sustain is a real alternative, independent of reattack.
|
|
for notes, name in list(paths):
|
|
if notes[0].pitch.number() == last.pitch.number() and last.role != 'quote':
|
|
paths.append(((dataclasses.replace(notes[0], pitch=last.pitch, continuation=True, role='sustain'), *notes[1:]), 'common_tone; ' + name))
|
|
# Plan a prepared suspension from the incoming sound before constructing
|
|
# its continuation. The contract survives subsequent search boundaries.
|
|
if last and last.role != 'quote' and last.end == cell.start and cell.end - cell.start >= 1 and last.start <= cell.start - fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_preparation)):
|
|
span = harmony.at(cell.start)
|
|
prepared = last.pitch.number() % 12 in harmony.members(cell.start - fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_preparation)), cell.start)
|
|
if prepared and span and span.scale and last.pitch.number() % 12 in span.scale.pitches and last.pitch.number() % 12 not in span.roles.basic:
|
|
resolution_time = cell.start + fractions.Fraction(1, 2)
|
|
resolving = [p for p in options(choices, resolution_time, min(cell.end, resolution_time + fractions.Fraction(1, 2)), plan.register) if p.number() % 12 in span.roles.basic and harmony.step(last.pitch.number(), p.number(), resolution_time)]
|
|
for pitch in resolving:
|
|
contract = generator.solo.types.HarmonicTreatment(f'{plan.passage_id}_suspension_{cell.start}_{pitch.number()}', 'suspension', last.pitch.number(), cell.start, resolution_time, pitch.number(), cell.start + fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_resolution)))
|
|
for original, name in paths[:4]:
|
|
tail = tuple(n for n in original if n.start > resolution_time)
|
|
end = tail[0].start if tail else cell.end
|
|
suspended = (generator.solo.types.Note(cell.start, resolution_time, last.pitch, role='sustain', continuation=True), generator.solo.types.Note(resolution_time, end, pitch, role='motif'), *tail)
|
|
raw = tuple(dataclasses.replace(n, position=None) for n in state.notes)
|
|
if not raw:
|
|
continue
|
|
combined = generator.solo.types.sounding_notes((*raw, *suspended))
|
|
checked = harmony.evaluate(combined, (*contracts, contract), until=cell.end)
|
|
if checked.valid:
|
|
features = evaluation.features(tuple(dataclasses.replace(n, continuation=False) for n in suspended))
|
|
features['harmonic_exposure'] = checked.exposure
|
|
fidelity = generator.solo.scoring.motif_fidelity(tuple(n for n in combined if n.start >= (cell.gesture.start if cell.gesture else cell.start)), reference, treatment)
|
|
features['fidelity'] = fidelity
|
|
result.append(Candidate(suspended, identifier + '; prepared_suspension; ' + name, treatment, (generator.solo.scoring.value(features),), fidelity, (*contracts, contract)))
|
|
for notes, name in paths:
|
|
# Continuations are canonicalized with their incoming sounding event.
|
|
raw = tuple(dataclasses.replace(n, position=None) for n in state.notes)
|
|
combined = generator.solo.types.sounding_notes((*raw, *notes)) if raw or not notes[0].continuation else ()
|
|
if not combined:
|
|
continue
|
|
harmonic = harmony.evaluate(combined, contracts, until=cell.end)
|
|
if not harmonic.valid:
|
|
continue
|
|
local = tuple(dataclasses.replace(n, continuation=False) for n in notes)
|
|
features = evaluation.features(local)
|
|
gesture_notes = tuple(n for n in combined if n.start >= (cell.gesture.start if cell.gesture else cell.start))
|
|
fidelity = (generator.solo.scoring.motif_fidelity(gesture_notes, reference, treatment) if len(gesture_notes) > 1 else 0.5) if cell.gesture or cell.motif else 0
|
|
features['fidelity'] = fidelity
|
|
features['harmonic_exposure'] = harmonic.exposure
|
|
features['connection'] = min(1, abs(notes[0].pitch.number() - last.pitch.number()) / 12) if last else 0
|
|
result.append(Candidate(notes, identifier + '; ' + name, name if protected else treatment, (generator.solo.scoring.value(features),), fidelity, contracts))
|
|
unique = {}
|
|
for candidate in sorted(result, key=lambda c: (c.rank, c.source, tuple(n.pitch for n in c.notes))):
|
|
unique.setdefault((candidate.notes, candidate.treatments), candidate)
|
|
return list(unique.values())
|
|
|
|
@dataclasses.dataclass
|
|
class SearchContext:
|
|
pools: dict = dataclasses.field(default_factory=dict)
|
|
results: dict = dataclasses.field(default_factory=dict)
|
|
resources: dict = dataclasses.field(default_factory=dict)
|
|
revise: bool = True
|
|
batch: int = 0
|
|
diagnostics: dict = dataclasses.field(default_factory=lambda: {'searches': 0, 'cache_hits': 0, 'generated_candidates': 0, 'evaluated_extensions': 0, 'peak_states': 0, 'candidate_families': {}, 'surviving_families': {}})
|
|
|
|
def note_family(notes):
|
|
return generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))) if notes else 'space'
|
|
|
|
def phrase_context(plan, incoming, notes):
|
|
return generator.solo.drafts.canonical_context(notes, incoming, plan)
|
|
|
|
def evaluation_plan(plan, anchors):
|
|
landmarks = tuple(generator.solo.types.Landmark(a.earliest, a.latest, a.pitch.number(), a.pitch.number(), name=a.role) for a in anchors)
|
|
return dataclasses.replace(plan, start=plan.passage_start if plan.passage_start is not None else plan.start, end=plan.passage_end or plan.end, landmarks=landmarks)
|
|
|
|
def inherited_treatments(plan, incoming):
|
|
if plan.passage_id and plan.passage_id == incoming.passage_id:
|
|
return incoming.treatments
|
|
retained = []
|
|
for treatment in incoming.treatments:
|
|
future = treatment.start >= plan.start
|
|
color = treatment.kind == 'color' and incoming.last and treatment.end > incoming.last.start
|
|
pending = treatment.kind == 'suspension' and incoming.last and incoming.last.pitch.number() == treatment.pitch and incoming.last.end > treatment.start and not any(n.start >= treatment.start and n.pitch.number() == treatment.resolution for n in incoming.notes)
|
|
if future or color or pending:
|
|
retained.append(treatment)
|
|
# Completed contracts remain in their original phrase reports; only the
|
|
# sounding tail and unresolved obligations require context in a new passage.
|
|
return tuple(retained)
|
|
|
|
def draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, context):
|
|
"""Instantiate a complete timed line. Boundaries here are musical groups."""
|
|
F = fractions.Fraction
|
|
contracts = tuple(dict.fromkeys((*inherited_treatments(plan, incoming), *plan.treatments, *anchor_treatments(rhythm.anchors, harmony))))
|
|
# Cost, actual pitches so far, and the identity of the introduced statement.
|
|
states = [(0.0, (), rhythm.reference.notes if isinstance(rhythm.reference, generator.solo.types.RealizedMotif) else ())]
|
|
next_anchor = [next((a for a in rhythm.anchors if a.latest >= s.start), None) for s in rhythm.slots]
|
|
for index, slot in enumerate(rhythm.slots):
|
|
available = options(choices, slot.start, slot.end, plan.register)
|
|
pending = next((t for t in contracts if t.kind == 'suspension' and t.deadline is not None and incoming.last and incoming.last.pitch.number() == t.pitch and incoming.last.end == slot.start and t.start <= slot.start <= t.deadline and not any(n.start >= t.start and n.pitch.number() == t.resolution for n in incoming.notes)), None)
|
|
if pending:
|
|
available = tuple(p for p in available if p.number() == pending.resolution)
|
|
if slot.forced:
|
|
available = tuple(p for p in available if p == slot.forced)
|
|
quoted = slot.protected and plan.treatment in ('quote', 'exact_quote')
|
|
if not quoted and slot.end - slot.start >= generator.solo.policy.DEFAULT.long_note:
|
|
members = harmony.members(slot.start, slot.end)
|
|
available = tuple(p for p in available if p.number() % 12 in members or any(t.pitch == p.number() and t.start <= slot.start and t.end >= slot.end for t in contracts))
|
|
if not available:
|
|
return (), f'{slot.id}: no valid pitch for sounding interval {slot.start}-{slot.end}'
|
|
following = []
|
|
group = rhythm.groups[slot.group]
|
|
local_index = sum(s.group == slot.group for s in rhythm.slots[:index])
|
|
remaining = sum(s.start >= slot.start and (not next_anchor[index] or s.start <= next_anchor[index].latest) for s in rhythm.slots[index:])
|
|
span = harmony.at(slot.start)
|
|
for cost, notes, home in states:
|
|
prior = notes[-1] if notes else incoming.last
|
|
reference = home if home and slot.group else rhythm.reference.notes
|
|
if local_index >= 3 and group.operation in ('extend', 'extend_diminished', 'answer', 'continue'):
|
|
previous_ids = {s.id for s in rhythm.slots if s.group == slot.group - 1}
|
|
recent = tuple(n for n in notes if n.event_id in previous_ids) if slot.group else incoming.development.notes if incoming.development else ()
|
|
reference = recent or reference
|
|
contour = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(reference, reference[1:]))
|
|
step = contour[(max(0, slot.reference - 1)) % len(contour)] if contour else 0
|
|
if slot.reference == 0 and local_index == 0:
|
|
# A group continues the incoming line; it does not teleport to the
|
|
# source motif's register or to the center of a fresh bar.
|
|
step = contour[-1] if contour and group.operation == 'extend' else 0
|
|
expected = prior.pitch.number() + step if prior else rhythm.reference.notes[0].pitch.number()
|
|
anchor = next_anchor[index]
|
|
target = anchor.pitch.number() if anchor else expected
|
|
route = expected
|
|
if prior and anchor and span and span.scale:
|
|
try:
|
|
start_degree = generator.solo.types.scale_index(span.scale, prior.pitch.number())
|
|
target_degree = generator.solo.types.scale_index(span.scale, target)
|
|
displacement = target_degree - start_degree
|
|
count = min(16, max(2, remaining + 1))
|
|
paths = sequences.retrieve(count, displacement, (displacement > 0) - (displacement < 0))
|
|
if paths:
|
|
route = generator.solo.types.scale_pitch(span.scale, start_degree + paths[0].steps[0]).number()
|
|
except ValueError:
|
|
# Collection changes preserve the incoming sound, never its old index.
|
|
route = prior.pitch.number() + ((target > prior.pitch.number()) - (target < prior.pitch.number())) * 2
|
|
ranked = sorted(available, key=lambda p: (abs(p.number() - expected), p))[:3]
|
|
if available:
|
|
bridge = min(available, key=lambda p: (abs(p.number() - route), p))
|
|
if bridge not in ranked:
|
|
ranked.append(bridge)
|
|
for pitch in ranked:
|
|
direction = (pitch.number() > prior.pitch.number()) - (pitch.number() < prior.pitch.number()) if prior else 0
|
|
intended = (step > 0) - (step < 0)
|
|
identifying = bool(local_index in (1, 2) and group.operation not in ('close', 'answer') and not slot.forced)
|
|
if identifying and reference and direction != intended:
|
|
continue
|
|
jump = abs(pitch.number() - prior.pitch.number()) if prior else 0
|
|
stopping = slot.end - slot.start >= generator.solo.policy.DEFAULT.stopping_note
|
|
motion_cost = abs(pitch.number() - expected) / 12 * (1.5 if identifying else 0.35)
|
|
direction_cost = max(0, abs(pitch.number() - target) - 2 * max(0, remaining - 1)) / 12 if anchor else 0
|
|
exposure = 0.6 if stopping and span and pitch.number() % 12 not in span.roles.basic and not quoted else 0
|
|
score = cost + motion_cost + direction_cost + max(0, jump - 5) / 12 + exposure
|
|
sources = reference[min(slot.reference, len(reference) - 1)].sources if reference else ()
|
|
note = generator.solo.types.Note(slot.start, slot.end, pitch, sources, 'quote' if quoted else 'motif', event_id=slot.id)
|
|
path = (*notes, note)
|
|
identity = home
|
|
if not identity and (index + 1 == len(rhythm.slots) or rhythm.slots[index + 1].group != slot.group):
|
|
identity = tuple(n for n in path if n.event_id in {s.id for s in rhythm.slots if s.group == slot.group})
|
|
following.append((score, path, identity))
|
|
if not following:
|
|
return (), f'{group.id}: identifying motion cannot reach the selected anchor within the draft budget'
|
|
boundary = index + 1 == len(rhythm.slots) or rhythm.slots[index + 1].group != slot.group
|
|
limit = generator.solo.policy.DEFAULT.draft_frontier if boundary else BEAM_WIDTH
|
|
# Relevant home/contour alternatives survive; no barline participates.
|
|
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], tuple(n.pitch for n in item[1])), lambda item: (tuple(n.pitch for n in item[2][:3]), item[1][-1].pitch), limit))
|
|
context.diagnostics['evaluated_extensions'] += len(following)
|
|
context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states))
|
|
return tuple((notes, cost / max(1, len(notes)), contracts) for cost, notes, _ in states[:4]), ''
|
|
|
|
def make_identity(identifier, parent, notes, start, end, relationship, harmony):
|
|
local = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start, position=None) for n in notes)
|
|
degrees = tuple(dict(harmony.at(n.start).roles.degrees).get(n.pitch.number() % 12, ()) for n in notes)
|
|
return generator.solo.types.RealizedMotif(identifier, parent, local, end - start, relationship, 'developed_rhythm', degrees, start)
|
|
|
|
def make_draft(rhythm, notes, plan, harmony, incoming):
|
|
transforms, structural, protected = [], [], []
|
|
home = incoming.home if rhythm.relationship not in ('statement', 'contrast') else None
|
|
if rhythm.relationship == 'recall' and isinstance(rhythm.reference, generator.solo.types.RealizedMotif):
|
|
home = rhythm.reference
|
|
parent = incoming.development.id if incoming.development and home else rhythm.reference.id
|
|
for index, group in enumerate(rhythm.groups):
|
|
ids = {s.id for s in rhythm.slots if s.group == index}
|
|
actual = tuple(n for n in notes if n.event_id in ids)
|
|
if not actual:
|
|
continue
|
|
next_start = rhythm.groups[index + 1].start if index + 1 < len(rhythm.groups) else group.end
|
|
start, end = group.start, max(min(group.end, next_start), actual[-1].end)
|
|
identity = make_identity(group.id, parent, actual, start, end, group.operation, harmony)
|
|
if home is None:
|
|
home = identity
|
|
structural.extend(n.event_id for n in actual[:3])
|
|
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(actual, actual[1:]))
|
|
# Closing simplification explicitly releases the characteristic core.
|
|
preserves = () if group.operation == 'close' else ('rhythm', 'opening_contour')
|
|
if plan.treatment == 'rhythmic_reference':
|
|
preserves = ('rhythm',)
|
|
transforms.append(generator.solo.types.Transformation(group.id, parent, home.id, group.operation, start, end, tuple(n.event_id for n in actual), tuple((n.start - start, n.end - start) for n in actual), directions, preserves, group.source))
|
|
parent = identity.id
|
|
protected.extend(s.id for s in rhythm.slots if s.protected)
|
|
for anchor in rhythm.anchors:
|
|
note = generator.solo.harmonic.anchor_note(anchor, notes)
|
|
if note:
|
|
structural.append(note.event_id)
|
|
joins = tuple(t.start for t in transforms[1:])
|
|
return generator.solo.types.MelodicDraft(rhythm.id, plan.start, plan.end, notes, tuple(transforms), tuple(dict.fromkeys(structural)), tuple(protected), rhythm.anchors, home, rhythm.relationship, joins)
|
|
|
|
def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edits=()):
|
|
valid, _ = generator.solo.drafts.contract_valid(draft, notes)
|
|
if not valid:
|
|
return None
|
|
placed = generator.solo.placement.place(notes, tempo, incoming.last)
|
|
if placed is None:
|
|
return None
|
|
travel, notes = placed
|
|
try:
|
|
canonical = phrase_context(plan, incoming, notes)
|
|
except ValueError:
|
|
return None
|
|
complete = plan.end == (plan.passage_end or plan.end)
|
|
checked = harmony.evaluate(canonical, contracts, until=plan.end, complete=complete)
|
|
valid, _ = generator.solo.harmonic.anchors_valid(draft.anchors, canonical, plan.end, complete=complete, closure=plan.closure)
|
|
if not checked.valid or not valid or not target_ok(notes, plan.target):
|
|
return None
|
|
shared = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
|
|
score_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
|
|
evaluation = generator.solo.scoring.Evaluation(evaluation_plan(plan, draft.anchors), harmony.choices, plan.end)
|
|
if not evaluation.feasible(score_notes):
|
|
return None
|
|
features = evaluation.features(score_notes, previous=incoming.last if not shared else None, movement=travel + (incoming.travel if shared else 0))
|
|
features.update(generator.solo.drafts.relationship_features(draft, notes))
|
|
# Fidelity is checked against explicit transformed identities, not against
|
|
# the source head as one undifferentiated phrase-long quotation.
|
|
features['fidelity'] = features['development']
|
|
features['harmonic_exposure'] = checked.exposure
|
|
intended_repeats = 0
|
|
if draft.home:
|
|
reference = draft.home.notes
|
|
for transform in draft.transformations:
|
|
actual = tuple(n for n in notes if n.event_id in transform.events)
|
|
for i, (a, b, c) in enumerate(zip(actual, actual[1:], actual[2:])):
|
|
expected = tuple(reference[j % len(reference)].pitch for j in (i, i + 1, i + 2)) if reference else ()
|
|
if a.pitch == b.pitch == c.pitch and len(expected) == 3 and expected[0] == expected[1] == expected[2]:
|
|
intended_repeats += 1
|
|
features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(score_notes) - 2))
|
|
score = generator.solo.scoring.value(features)
|
|
motifs = list(incoming.motifs) if shared else []
|
|
decisions = []
|
|
for transform in draft.transformations:
|
|
actual = tuple(n for n in notes if n.event_id in transform.events or transform.start <= n.start < transform.end and n.event_id not in {e for t in draft.transformations for e in t.events})
|
|
if not actual:
|
|
continue
|
|
identity = make_identity(transform.id, transform.parent, actual, transform.start, transform.end, transform.operation, harmony)
|
|
motifs.append(identity)
|
|
treatment = f'quote octave {round((actual[0].pitch.number() - plan.motif.notes[0].pitch.number()) / 12):+}' if actual[0].role == 'quote' else transform.operation
|
|
decisions.append(generator.solo.types.Decision(min(n.start for n in actual), max(n.end for n in actual), 'phrase draft ' + draft.id + '; ' + transform.operation, treatment, actual, 0, transform.id, transform.parent))
|
|
# Output ordering follows absolute events, even when a transformation's tail
|
|
# was reopened by an overlapping extension.
|
|
decisions.sort(key=lambda d: d.start)
|
|
emitted = tuple(n for d in decisions for n in d.notes)
|
|
if emitted != notes:
|
|
return None
|
|
home = next((m for m in motifs if draft.home and m.id == draft.home.id), draft.home)
|
|
themes = tuple(dict.fromkeys((*incoming.themes, *((home,) if home else ()))))
|
|
state_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
|
|
last = state_notes[-1] if state_notes else incoming.last
|
|
state = generator.solo.types.ContinuationState(plan.passage_id, state_notes, tuple(motifs), contracts,
|
|
tuple(a.id for a in draft.anchors if generator.solo.harmonic.anchor_note(a, canonical)),
|
|
(notes[-1].pitch.number() > notes[0].pitch.number()) - (notes[-1].pitch.number() < notes[0].pitch.number()) if notes else incoming.direction,
|
|
last, 0, (), travel + (incoming.travel if shared else 0), home, motifs[-1] if motifs else incoming.development, themes, draft.anchors, plan.ending)
|
|
actual_plan = dataclasses.replace(plan, relationship=draft.relationship)
|
|
return generator.solo.types.Phrase(actual_plan, tuple(decisions), score, state, draft.anchors, draft, edits)
|
|
|
|
def legacy_target_plan(plan, choices):
|
|
if not plan.target or plan.anchor_options:
|
|
return plan
|
|
target = plan.target
|
|
duration = fractions.Fraction(1, 2)
|
|
arrival = next((p for p in plan.passages if p.start <= target.earliest < p.end and p.intent == 'sustained_arrival'), None)
|
|
if arrival and arrival.activity <= fractions.Fraction(1, 4):
|
|
duration = arrival.end - target.earliest
|
|
pitches = options(choices, target.earliest, target.earliest + duration, plan.register)
|
|
anchors = tuple((generator.solo.types.Anchor(plan.id + '_target', target.earliest, target.latest, pitch, target.pitches, plan.register, duration, 'terminal', target.chord, True, target.earliest + duration),) for pitch in pitches if pitch.number() % 12 in target.pitches)
|
|
return dataclasses.replace(plan, anchor_options=anchors)
|
|
|
|
def emitted_notes(phrase):
|
|
return tuple(n for d in phrase.decisions for n in d.notes)
|
|
|
|
def window_variants(phrase, low, high, harmony, sequences, kind):
|
|
"""Two fixed outside endpoints bound edits to the existing melody."""
|
|
notes = emitted_notes(phrase)
|
|
protected = set((*phrase.draft.structural, *phrase.draft.protected))
|
|
inside = [i for i, n in enumerate(notes) if low <= n.start and n.end <= high]
|
|
editable = [i for i in inside if notes[i].event_id not in protected]
|
|
if not editable:
|
|
return ()
|
|
candidates = []
|
|
left, right = max(0, inside[0] - 1), min(len(notes) - 1, inside[-1] + 1)
|
|
# Route both sides of a join as one path, with the original outside pitches.
|
|
if 1 < right - left < 16:
|
|
scale = harmony.at(notes[left].start).scale
|
|
try:
|
|
first = generator.solo.types.scale_index(scale, notes[left].pitch.number())
|
|
displacement = generator.solo.types.scale_index(scale, notes[right].pitch.number()) - first
|
|
paths = sequences.retrieve(right - left + 1, displacement, (displacement > 0) - (displacement < 0))
|
|
except ValueError:
|
|
paths = ()
|
|
for path in paths[:generator.solo.policy.DEFAULT.join_alternatives]:
|
|
updated = list(notes)
|
|
for i in editable:
|
|
offset = path.offsets()[i - left]
|
|
intended = generator.solo.types.scale_pitch(scale, first + offset).number()
|
|
available = options(harmony.choices, notes[i].start, notes[i].end, phrase.plan.register)
|
|
if available:
|
|
pitch = min(available, key=lambda p: (abs(p.number() - intended), p))
|
|
updated[i] = dataclasses.replace(notes[i], pitch=pitch, position=None)
|
|
candidates.append((tuple(updated), 'sequence ' + ' + '.join(path.sources)))
|
|
for index in editable:
|
|
note = notes[index]
|
|
if index + 1 < len(notes) and notes[index + 1].start <= high:
|
|
after = notes[index + 1]
|
|
if after.event_id not in protected and note.end == after.start and note.pitch == after.pitch:
|
|
candidates.append(((*notes[:index], dataclasses.replace(note, end=after.end), *notes[index + 2:]), 'shared common-tone arrival'))
|
|
if kind == 'join' and after.event_id not in protected and note.end == after.start and note.end - note.start > fractions.Fraction(1, 2):
|
|
# The tail and pickup move together, preserving total sounding time.
|
|
time = after.start - fractions.Fraction(1, 2)
|
|
candidates.append(((*notes[:index], dataclasses.replace(note, end=time), dataclasses.replace(after, start=time), *notes[index + 2:]), 'reopen tail into pickup'))
|
|
if kind == 'elaboration' and index + 1 < len(notes) and fractions.Fraction(1) <= note.end - note.start < 2:
|
|
# Add motion only as an alternative evaluated against the unchanged
|
|
# activity envelope; keeping the original draft is always available.
|
|
split = note.end - fractions.Fraction(1, 2)
|
|
scale = harmony.at(split).scale
|
|
target = notes[index + 1].pitch
|
|
try:
|
|
degree = generator.solo.types.scale_index(scale, note.pitch.number())
|
|
finish = generator.solo.types.scale_index(scale, target.number())
|
|
pitch = generator.solo.types.scale_pitch(scale, degree + ((finish > degree) - (finish < degree)))
|
|
addition = generator.solo.types.Note(split, note.end, pitch, note.sources, 'elaboration', event_id=note.event_id + '_passing')
|
|
candidates.append(((*notes[:index], dataclasses.replace(note, end=split), addition, *notes[index + 1:]), 'interpolate toward next structural event'))
|
|
except ValueError:
|
|
pass
|
|
return tuple(candidates)
|
|
|
|
def improve_window(phrase, incoming, harmony, sequences, tempo, low, high, kind, context):
|
|
original = emitted_notes(phrase)
|
|
viable = [phrase]
|
|
proposals = window_variants(phrase, low, high, harmony, sequences, kind)
|
|
# Keep distinct rhythmic and pitch revisions; no generic unlimited search.
|
|
proposals = generator.solo.sequences.diverse(proposals, lambda p: (abs(len(p[0]) - len(original)), p[1], tuple(n.pitch for n in p[0])), lambda p: (p[1].split()[0], len(p[0])), generator.solo.policy.DEFAULT.join_alternatives)
|
|
for notes, source in proposals:
|
|
if notes == original:
|
|
continue
|
|
changed_before = tuple(n.event_id for n in original if n not in notes)
|
|
changed_after = tuple(n.event_id for n in notes if n not in original)
|
|
edit = generator.solo.types.DraftEdit(kind, low, high, changed_before, changed_after, source)
|
|
revised = checked_phrase(phrase.plan, phrase.draft, notes, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
|
|
context.diagnostics[kind + '_attempts'] = context.diagnostics.get(kind + '_attempts', 0) + 1
|
|
if revised:
|
|
viable.append(revised)
|
|
chosen = min(viable, key=lambda p: (p.score, len(p.edits), tuple((n.pitch, n.start, n.end) for n in emitted_notes(p))))
|
|
if chosen is not phrase:
|
|
context.diagnostics[kind + '_accepted'] = context.diagnostics.get(kind + '_accepted', 0) + 1
|
|
return chosen
|
|
|
|
def elaborate(phrase, incoming, harmony, sequences, tempo, context):
|
|
started = time.monotonic()
|
|
for transform in phrase.draft.transformations:
|
|
phrase = improve_window(phrase, incoming, harmony, sequences, tempo, transform.start, transform.end, 'elaboration', context)
|
|
context.diagnostics['elaboration_seconds'] = context.diagnostics.get('elaboration_seconds', 0) + time.monotonic() - started
|
|
return phrase
|
|
|
|
def revise_joins(phrase, incoming, harmony, sequences, tempo, context):
|
|
started = time.monotonic()
|
|
width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window))
|
|
for _ in range(generator.solo.policy.DEFAULT.revision_sweeps):
|
|
before = phrase
|
|
for join in phrase.draft.joins:
|
|
phrase = improve_window(phrase, incoming, harmony, sequences, tempo, max(phrase.plan.start, join - width), min(phrase.plan.end, join + width), 'join', context)
|
|
if phrase == before:
|
|
break
|
|
context.diagnostics['revision_seconds'] = context.diagnostics.get('revision_seconds', 0) + time.monotonic() - started
|
|
return phrase
|
|
|
|
def revise_pair(first, second, incoming, choices, sequences, tempo, context):
|
|
"""Revise an uncommitted connected phrase seam, then rebuild both states."""
|
|
if not context.revise or not first.draft or not second.draft or first.plan.ending != 'connected' or first.plan.passage_id != second.plan.passage_id:
|
|
return first, second
|
|
incoming = incoming if isinstance(incoming, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=incoming)
|
|
harmony = generator.solo.harmonic.Harmony(choices)
|
|
seam = first.plan.end
|
|
width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window))
|
|
combined = (*emitted_notes(first), *emitted_notes(second))
|
|
draft = dataclasses.replace(first.draft, end=second.plan.end, notes=(*first.draft.notes, *second.draft.notes), transformations=(*first.draft.transformations, *second.draft.transformations), structural=tuple(dict.fromkeys((*first.draft.structural, *second.draft.structural))), protected=(*first.draft.protected, *second.draft.protected), joins=(*first.draft.joins, seam, *second.draft.joins))
|
|
joint = dataclasses.replace(first, decisions=(generator.solo.types.Decision(first.plan.start, second.plan.end, 'provisional phrase pair', 'continue', combined),), draft=draft)
|
|
candidates = [(second.score + generator.solo.drafts.relationship_features(draft, combined)['join_coherence'], first, second)]
|
|
left_ids = {n.event_id for n in emitted_notes(first)}
|
|
for notes, source in window_variants(joint, seam - width, seam + width, harmony, sequences, 'join')[:generator.solo.policy.DEFAULT.join_alternatives]:
|
|
left = tuple(n for n in notes if n.event_id in left_ids)
|
|
right = tuple(n for n in notes if n.event_id not in left_ids)
|
|
if any(n.end > seam for n in left) or any(n.start < seam for n in right):
|
|
continue
|
|
edit = generator.solo.types.DraftEdit('phrase_join', seam - width, seam + width, tuple(n.event_id for n in combined if n not in notes), tuple(n.event_id for n in notes if n not in combined), source)
|
|
if not edit.removed:
|
|
continue
|
|
revised_left = checked_phrase(first.plan, first.draft, left, first.state.treatments, incoming, harmony, tempo, (*first.edits, edit))
|
|
if not revised_left:
|
|
continue
|
|
revised_right = checked_phrase(second.plan, second.draft, right, second.state.treatments, revised_left.state, harmony, tempo, (*second.edits, edit))
|
|
context.diagnostics['pair_revision_attempts'] = context.diagnostics.get('pair_revision_attempts', 0) + 1
|
|
if revised_right:
|
|
realized = (*emitted_notes(revised_left), *emitted_notes(revised_right))
|
|
score = revised_right.score + generator.solo.drafts.relationship_features(draft, realized)['join_coherence']
|
|
candidates.append((score, revised_left, revised_right))
|
|
_, left, right = min(candidates, key=lambda item: (item[0], len(item[1].edits) + len(item[2].edits)))
|
|
if left is not first:
|
|
context.diagnostics['pair_revisions_accepted'] = context.diagnostics.get('pair_revisions_accepted', 0) + 1
|
|
return left, right
|
|
|
|
def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None):
|
|
context = context if context is not None else SearchContext()
|
|
# Keep resources alive while identity-keyed cache entries exist. A later
|
|
# library cannot accidentally reuse the identity of a collected object.
|
|
context.resources[id(sequences)] = sequences
|
|
context.resources[id(rhythms)] = rhythms
|
|
pool_key = (plan, previous, generator.solo.policy.stable_rank('harmonic-cache', choices), tempo, id(sequences), id(rhythms))
|
|
key = (*pool_key, context.revise)
|
|
if key in context.results:
|
|
context.diagnostics['cache_hits'] += 1
|
|
return context.results[key]
|
|
started = time.monotonic()
|
|
context.diagnostics['searches'] += 1
|
|
incoming = previous if isinstance(previous, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=previous)
|
|
harmony = generator.solo.harmonic.Harmony(choices)
|
|
plan = legacy_target_plan(plan, choices)
|
|
if pool_key in context.pools:
|
|
selected = context.pools[pool_key]
|
|
context.diagnostics['reused_draft_pools'] = context.diagnostics.get('reused_draft_pools', 0) + 1
|
|
else:
|
|
constructions = generator.solo.drafts.rhythms(plan, incoming, measures, choices, rhythms, context.batch)
|
|
complete, failure = [], ''
|
|
for rhythm in constructions:
|
|
paths, error = draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, context)
|
|
failure = error or failure
|
|
context.diagnostics['generated_candidates'] += len(paths)
|
|
for notes, cost, contracts in paths:
|
|
family = note_family(notes)
|
|
counts = context.diagnostics['candidate_families']
|
|
counts[family] = counts.get(family, 0) + 1
|
|
realizations = [notes]
|
|
if notes and incoming.last and incoming.last.end == notes[0].start and incoming.last.pitch == notes[0].pitch and notes[0].role != 'quote' and not any(a.onset_required and a.earliest <= notes[0].start <= a.latest for a in rhythm.anchors):
|
|
realizations.append((dataclasses.replace(notes[0], continuation=True), *notes[1:]))
|
|
for actual in realizations:
|
|
draft = make_draft(rhythm, actual, plan, harmony, incoming)
|
|
phrase = checked_phrase(plan, draft, actual, contracts, incoming, harmony, tempo)
|
|
if phrase:
|
|
complete.append(dataclasses.replace(phrase, score=phrase.score + 0.1 * cost))
|
|
if not complete:
|
|
raise SearchExhausted(f'{plan.id}: bounded phrase draft search exhausted; {failure or "no complete draft satisfies timing, anchors, treatments and motif contracts"}; {len(constructions)} constructions / {BEAM_WIDTH} pitch states (not proof that the phrase is unplayable)')
|
|
context.diagnostics['construction_seconds'] = context.diagnostics.get('construction_seconds', 0) + time.monotonic() - started
|
|
selected = generator.solo.sequences.diverse(complete, lambda p: (p.score, p.draft.id, tuple(n.pitch for n in p.draft.notes)), lambda p: (p.draft.relationship, note_family(p.draft.notes), p.anchors[-1].duration if p.anchors else None), generator.solo.policy.DEFAULT.phrase_alternatives)
|
|
selected = tuple(elaborate(p, incoming, harmony, sequences, tempo, context) for p in selected)
|
|
context.pools[pool_key] = selected
|
|
input_id = generator.solo.policy.stable_rank(plan.seed, (plan, previous, choices))[:16]
|
|
pool_id = generator.solo.policy.stable_rank(plan.seed, tuple((p.draft.id, emitted_notes(p)) for p in selected))
|
|
context.diagnostics.setdefault('draft_pool_fingerprints', {})[input_id] = pool_id
|
|
result = []
|
|
for phrase in selected:
|
|
if context.revise:
|
|
phrase = revise_joins(phrase, incoming, harmony, sequences, tempo, context)
|
|
result.append(phrase)
|
|
result = tuple(sorted(result, key=lambda p: (p.score, p.draft.id)))
|
|
for phrase in result:
|
|
family = note_family(emitted_notes(phrase))
|
|
counts = context.diagnostics['surviving_families']
|
|
counts[family] = counts.get(family, 0) + 1
|
|
context.results[key] = result
|
|
return result
|
|
|
|
def compose(plan, measures, choices, sequences, rhythms, tempo, previous=None):
|
|
return alternatives(plan, measures, choices, sequences, rhythms, tempo, previous)[0]
|