Files
product-vamp/generator/solo/realization.py
T
khodowany 28fc67fba1
ci / checks (push) Successful in 1h16m41s
Soloing & Measures
2026-10-01 14:05:51 -05:00

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]