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