This commit is contained in:
khodowany
2026-10-01 20:20:24 -05:00
parent f5861fcdf6
commit f2f371b951
20 changed files with 700 additions and 32 deletions
+1 -1
View File
@@ -143,7 +143,7 @@ def capture(root, stage, libraries=None, contexts=None):
if placed is None:
raise ValueError(stage_name + ': selected theme cannot be placed')
sounded = generator.solo.types.sounding_notes(placed[1])
contracts = tuple(dict.fromkeys(t for p in solo.phrases for t in p.state.treatments))
contracts = tuple(dict.fromkeys(t for p in solo.phrases for t in p.state.treatments if t.kind != 'chromatic_approach'))
checked = generator.solo.harmonic.Harmony(solo.choices).evaluate(sounded, contracts, until=solo.end, complete=True)
if not checked.valid:
raise ValueError(stage_name + ': ' + checked.error)
+4 -4
View File
@@ -22,7 +22,7 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
for choice in choices:
if choice.span.chord and choice.scale is None:
raise ValueError(f'{part} solo, source measure {choice.span.source_measure}, beat {choice.span.beat}, {generator.harmony.symbol(choice.span.chord)}: No exact match within the permitted catalog')
plans = generator.solo.planning.plans(analysis, measures, choices, end, part, sequences)
plans = generator.solo.planning.plans(analysis, measures, choices, end, part, sequences, lead=bool(references))
retained = tuple((plan.start + n.start, plan.start + n.end, {n.pitch.number() % 12}) for plan in plans if plan.treatment in ('quote', 'exact_quote') for n in plan.motif.notes)
if retained and not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
choices = generator.scales.selection.select(timeline, region, scales, retained=retained)
@@ -41,7 +41,7 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
options = generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context)
return options
except generator.solo.realization.SearchExhausted as error:
if attempt == generator.solo.policy.DEFAULT.replan_attempts or plan.treatment in ('quote', 'exact_quote'):
if attempt == generator.solo.policy.DEFAULT.replan_attempts or plan.treatment in ('quote', 'exact_quote', 'lead'):
raise
context.diagnostics.setdefault('replanning', []).append({'phrase': plan.id, 'attempt': attempt + 1, 'reason': str(error), 'adjustment': 'next diverse complete rhythm and anchor constructions; contracts unchanged'})
@@ -116,8 +116,8 @@ def validate(solo):
for note in solo.notes():
if note.start < 0 or note.end > solo.end or note.end <= note.start or previous and previous.end > note.start:
raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}')
if note.pitch.number() % 12 not in generator.solo.types.permitted(solo.choices, note.start, note.end):
raise ValueError(f'{solo.part} solo: held pitch {note.pitch.label()} violates a scale span at {note.start}-{note.end}')
# Harmony.evaluate below owns scale permission and the narrow, verified
# chromatic exception; no separate permissive final-validation path.
if note.position not in generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())):
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
previous = note
+5 -2
View File
@@ -215,7 +215,7 @@ def construct(plan, reference, relationship, template, anchors, variant, choices
operation = relationship if index == 0 else 'repeat' if index == 1 and variant == 0 else 'answer' if remaining <= period else 'extend' if variant == 1 and index == 1 else 'recall'
activity = next((p for p in plan.passages if p.start <= cursor < p.end), None)
rate = F(len(pattern), 1) / period
unit = F(2) if activity and activity.activity < F(3, 4) else F(1, 2) if activity and activity.activity > rate + F(1, 4) and index else F(1)
unit = F(1) # Rhythmic development is musical intent, never a density repair.
local = pattern
length = period
if index and operation == 'extend':
@@ -369,6 +369,9 @@ def contract_valid(draft, notes):
for feature in draft.features:
if generator.solo.references.feature_error(feature, local_sounding(notes)):
return False, f'{feature.id}: selected identifying relationship changed'
for first, repeated in draft.correspondence:
if first not in by_id or repeated not in by_id:
return False, 'lead statement/return correspondence was lost'
return True, ''
def relationship_features(draft, notes):
@@ -382,7 +385,7 @@ def relationship_features(draft, notes):
actual = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(group, group[1:]))
direction.extend(a != b for a, b in zip(actual, transform.directions))
reference = draft.reference_motif if transform.operation in ('statement', 'contrast') else draft.home
if reference and transform.operation not in ('close', 'fragment', 'broaden_ending'):
if reference and not draft.lead_identity and transform.operation not in ('close', 'fragment', 'broaden_ending'):
development.append(generator.solo.references.relationship(group, reference))
joins = []
for time in draft.joins:
+30 -3
View File
@@ -99,6 +99,32 @@ class Harmony:
for treatment in treatments:
if treatment.start >= until:
continue
if treatment.kind == 'chromatic_approach':
approach = next((n for n in notes if n.event_id == treatment.event), None)
if (approach is None or approach.role == 'quote' or approach.pitch.number() != treatment.pitch
or approach.start != treatment.start or approach.end != treatment.end
or approach.end - approach.start != fractions.Fraction(1, 2)
or approach.start % 1 != fractions.Fraction(1, 2)
or treatment.resolution is None or abs(treatment.pitch - treatment.resolution) != 1
or treatment.deadline != approach.end or len(self.segments(approach)) != 1):
return Result(False, 0, error=f'{treatment.id}: invalid chromatic approach event or timing')
span = self.at(approach.start)
if not span or not span.scale or treatment.pitch % 12 in span.scale.pitches:
return Result(False, 0, error=f'{treatment.id}: approach requires mapped harmony and an out-of-collection pitch')
resolution = next((n for n in notes if n.start == approach.end), None)
if resolution:
destination = self.at(resolution.start)
if (resolution.event_id != treatment.resolution_event or resolution.continuation
or resolution.pitch.number() != treatment.resolution or not destination or not destination.scale
or treatment.resolution % 12 not in destination.roles.basic
or treatment.resolution % 12 not in generator.solo.types.permitted(self.choices, resolution.start, resolution.end)):
return Result(False, 0, error=f'{treatment.id}: chromatic approach has no immediate chord-tone resolution')
elif complete or until > approach.end:
return Result(False, 0, error=f'{treatment.id}: unresolved chromatic approach')
else:
pending.append(treatment)
valid_contracts.append(treatment)
continue
if treatment.kind == 'color':
spans = [s for s in self.spans if s.start < treatment.end and s.end > treatment.start]
if not spans or any(treatment.pitch % 12 not in s.roles.basic | s.roles.specified for s in spans):
@@ -129,13 +155,13 @@ class Harmony:
sounding += float(duration)
covered = note.start
for span, start, end in self.segments(note):
if start > covered or not span.scale or number % 12 not in span.scale.pitches:
contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end and (t.kind != 'chromatic_approach' or t.event == note.event_id)), None)
if start > covered or not span.scale or number % 12 not in span.scale.pitches and not (contract and contract.kind == 'chromatic_approach'):
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony')
covered = end
function = self.function(number, span)
if function == 'basic' or note.role == 'quote':
continue
contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), None)
if duration >= generator.solo.policy.DEFAULT.long_note and contract is None:
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end} over {span.chord}: sustained {function} without a planned treatment')
connected = duration < generator.solo.policy.DEFAULT.stopping_note and self.connected_motion(notes, index)
@@ -149,7 +175,8 @@ class Harmony:
result = []
for index, note in enumerate(notes):
for span, start, end in self.segments(note):
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'protected': note.role == 'quote', 'treatments': [t.id for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end]})
contracts = [t for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end]
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': 'chromatic_approach' if any(t.kind == 'chromatic_approach' for t in contracts) else self.connected_motion(notes, index), 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]})
return result
def anchor_note(anchor, notes, *, until=None):
+4 -4
View File
@@ -83,8 +83,8 @@ def render(root):
current = json.loads((root / 'elaborated/snapshot.json').read_text())
cache, clips = {}, []
gain = 0.4 # Shared audition gain; every mixed clip is checked for clipping.
manifest = {'implementation': current['implementation'], 'baseline_implementation': baseline['implementation'], 'policy': current['policy'], 'shared_gain': gain, 'clips': clips, 'stage_semantics': 'Initial and refined are intermediate versions of the selected production branches, with the same incoming context. They are not independently rerun compositions.'}
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Lead melody comparisons</title><style>body{font:16px system-ui;max-width:1500px;margin:2rem auto;padding:1rem}table{border-collapse:collapse}td,th{padding:.6rem;border-bottom:1px solid #ccc;text-align:left}audio{width:240px}small{display:block}</style><h1>Lead melody comparisons</h1><p>Compare the tune before and after refinement, then hear whether the runs preserve it. All fixed mixes use the original backing, identical tempo and one shared gain. Solo-only clips remove the backing. Initial/refined exports follow the selected production branches; they are not independent searches.</p><p>These metrics verify behavior, not musical quality. Listen for a recognizable tune, prepared destinations, and runs that belong to the tune. <a href="manifest.json">Diagnostics manifest</a>.</p>']
manifest = {'implementation': current['implementation'], 'baseline_implementation': baseline['implementation'], 'policy': current['policy'], 'shared_gain': gain, 'clips': clips, 'stage_semantics': 'Initial is the selected plain tune; refined adds complete diatonic runs; elaborated adds validated chromatic approaches. All stages share the same underlying selected tune.'}
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Lead melody comparisons</title><style>body{font:16px system-ui;max-width:1500px;margin:2rem auto;padding:1rem}table{border-collapse:collapse}td,th{padding:.6rem;border-bottom:1px solid #ccc;text-align:left}audio{width:240px}small{display:block}</style><h1>Lead melody comparisons</h1><p>Compare the plain tune, its diatonic runs, and its chromatic approaches. All fixed mixes use the original backing, identical tempo and one shared gain. Solo-only clips remove the backing. Initial/refined exports follow the selected production branches; they are not independent searches.</p><p>These metrics verify behavior, not musical quality. Listen for a recognizable tune, prepared destinations, and runs that belong to the tune. <a href="manifest.json">Diagnostics manifest</a>.</p>']
def clip(prefix, foreground, tempo, backing=()):
path = root / (prefix + '.wav')
@@ -103,7 +103,7 @@ def render(root):
raise ValueError(name + ': source changed during comparison')
for part, solo in song['solos'].items():
other = 'electric' if part == 'acoustic' else 'acoustic'
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part) + '</h2><table><tr><th>Excerpt</th><th>Baseline</th><th>Initial tune</th><th>Refined tune</th><th>With runs</th></tr>')
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part) + '</h2><table><tr><th>Excerpt</th><th>Baseline</th><th>Plain lead</th><th>Diatonic runs</th><th>Chromatic approaches</th></tr>')
for window, offset, count in (('opening8', 0, 8), ('closing4', solo['count'] - 4, 4), ('full', 0, solo['count'])):
first = solo['first'] + offset
backing = tuple(audition.excerpt(old['parts'][p], first, count) for p in (other, 'vocal'))
@@ -141,7 +141,7 @@ def render(root):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_lead_auditions'))
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_lead_melody_auditions'))
action = parser.add_mutually_exclusive_group(required=True)
action.add_argument('--capture', action='store_true')
action.add_argument('--render', action='store_true')
+317
View File
@@ -0,0 +1,317 @@
"""Complete paired lead ideas, realized by the existing phrase/output pipeline.
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.
"""
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
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:
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)
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
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)
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)
return tuple(result)
+6 -4
View File
@@ -152,7 +152,7 @@ def anchor_backbones(identifier, start, end, release, register, choices, bar, se
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], generator.solo.policy.stable_rank(seed, tuple(a.pitch for a in item[1]))), lambda item: (item[2], item[1][0].pitch.number()), generator.solo.policy.DEFAULT.anchor_lines))
return tuple(dict.fromkeys(anchors for _, anchors, _ in states))
def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choices, end: fractions.Fraction, part: str, sequences: generator.solo.sequences.Library) -> tuple[generator.solo.types.PhrasePlan, ...]:
def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choices, end: fractions.Fraction, part: str, sequences: generator.solo.sequences.Library, *, lead=False) -> tuple[generator.solo.types.PhrasePlan, ...]:
boundaries = [fractions.Fraction()]
for measure in measures:
boundaries.append(min(end, boundaries[-1] + generator.model.fraction(measure['duration'])))
@@ -187,13 +187,15 @@ def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choi
release = fractions.Fraction(1, 2) if decision(passage_seed, 'breathing') % 3 == 0 else fractions.Fraction()
release = min(release, (finish - start) / 8)
envelope = activity_passages(start, finish, release, activity, decision(passage_seed, 'activity') % 3)
anchors = anchor_backbones(passage_id, start, finish, release, register, choices, bar, passage_seed)
anchors = () if lead else anchor_backbones(passage_id, start, finish, release, register, choices, bar, passage_seed)
# Prefer four-bar phrasing; extend when a sustained harmony crosses that seam.
middle = min(index + 4, stop)
before = next((c for c in reversed(choices) if c.span.start < boundaries[middle]), None)
after = next((c for c in choices if c.span.start <= boundaries[middle] < c.span.end), None)
extended = stop - index > 4 and same_harmony(before, after)
phrase_stops = [stop] if extended or middle == stop else [middle, stop]
# A bounded complete lead draft can express an extended phrase directly.
# Computational note groups below do not require additional phrase ends.
phrase_stops = [stop] if lead or extended or middle == stop else [middle, stop]
local = tuple(c for c in choices if c.span.start < boundaries[phrase_stops[0]] and c.span.end > start)
slot = decision(passage_seed, 'motif') % 3
if part == 'acoustic':
@@ -210,7 +212,7 @@ def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choi
for phrase_stop in phrase_stops:
phrase_end = boundaries[phrase_stop]
terminal = phrase_end == finish
treatment = 'opening_reference' if motif.origin == 'melody' else 'invented'
treatment = 'lead' if lead else 'opening_reference' if motif.origin == 'melody' else 'invented'
landmarks = tuple(generator.solo.types.Landmark(a.earliest, a.latest, min(v[i].pitch.number() for v in anchors), max(v[i].pitch.number() for v in anchors), 0, a.role) for i, a in enumerate(anchors[0])) if anchors and anchors[0] else ()
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', phrase_start, phrase_end, motif, 'statement' if phrase_start == start else 'continue', treatment, None, register, activity, release if terminal else fractions.Fraction(), envelope, landmarks, 'breath' if terminal and release else 'sustain' if terminal else 'connected', 'closed' if terminal else 'open', passage_seed, passage_id, start, finish, anchors))
phrase_start = phrase_end
+7 -2
View File
@@ -8,8 +8,8 @@ import json
@dataclasses.dataclass(frozen=True)
class Policy:
acoustic_activity: float = 1.125
electric_activity: float = 1.375
active_occupancy: float = 0.75
electric_activity: float = 1.125
active_occupancy: float = 0.925
arrival_occupancy: float = 0.9
weights: tuple[tuple[str, float], ...] = (('activity', 4), ('occupancy', 4), ('landmarks', 3), ('register', 1), ('contour', 1), ('connection', 1), ('repetition', 1), ('movement', 0.5), ('harmonic_exposure', 2), ('draft_preservation', 3), ('development', 2), ('direction', 1), ('join_coherence', 2), ('ending_integration', 2))
beam_width: int = 64
@@ -31,6 +31,11 @@ class Policy:
join_window: float = 2.0
activity_deadband: float = 1.0
occupancy_deadband: float = 0.1
lead_activity_range: tuple[float, float] = (1.0, 1.5)
lead_occupancy_range: tuple[float, float] = (0.85, 1.0)
lead_identities: int = 12
lead_realizations: int = 2
chromatic_max_per_eight_bars: int = 4
# Preserve the former default root seeds while decoupling them from policy edits.
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
+34 -2
View File
@@ -524,6 +524,10 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit
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))
if draft.lead_identity:
supported = {e for feature in draft.features for e in feature.events}
motions = [(a, b) for a, b in zip(notes, notes[1:]) if not (a.event_id in supported and b.event_id in supported)]
features['contour'] = sum(min(1, max(0, abs(b.pitch.number() - a.pitch.number()) - 5) / 12) for a, b in motions) / max(1, len(motions))
# Fidelity is checked against explicit transformed identities, not against
# the source head as one undifferentiated phrase-long quotation.
features.pop('fidelity', None)
@@ -719,7 +723,14 @@ def elaborate(phrase, incoming, harmony, sequences, tempo, context):
left = next((i for i in range(right - 1, -1, -1) if notes[right].start - notes[i].start >= 4), None)
if left is not None and notes[right].start - notes[left].start <= 4:
windows.append((notes[left].start, notes[right].start))
for low, high in windows[-2:]:
limit = max(1, min(2, int((phrase.plan.end - phrase.plan.start) / 16))) if phrase.draft.lead_identity else 2
if phrase.draft.lead_identity:
intended = {phrase.plan.start + a for reference in context.references for a, _ in reference.runs}
windows.sort(key=lambda window: (sum(n.event_id in protected for n in notes if window[0] <= n.start < window[1]), window[0] not in intended, window))
selected_windows = windows[:limit]
else:
selected_windows = windows[-limit:]
for low, high in selected_windows:
if any(low < r.destination.end and high > r.notes[0].start for r in phrase.routes):
continue
notes = emitted_notes(phrase)
@@ -754,7 +765,21 @@ def elaborate(phrase, incoming, harmony, sequences, tempo, context):
if any(not any((n.event_id, n.start, n.end) == (p.event_id, p.start, p.end) for p in pattern) for n in notes[left:right + 1] if n.event_id in protected):
continue
for route in generator.solo.routes.candidates(pattern, protected, harmony, sequences, phrase.plan.register, 'approach the theme destination'):
if phrase.draft.lead_identity:
# A run must actually contain connected eighth-note movement;
# relabeling the unchanged quarter-note answer is not elaboration.
run = peak = 0
previous_end = None
for note in route.notes:
eighth = note.end - note.start == fractions.Fraction(1, 2)
run = run + 1 if eighth and previous_end == note.start else int(eighth)
peak = max(peak, run)
previous_end = note.end
if peak < 4:
continue
actual = (*notes[:left], *route.notes, *notes[right + 1:])
if phrase.draft.lead_identity and len(actual) > generator.solo.policy.DEFAULT.lead_activity_range[1] * float(phrase.plan.end - phrase.plan.start):
continue
context.diagnostics['complete_routes_tried'] = context.diagnostics.get('complete_routes_tried', 0) + 1
if not generator.solo.drafts.contract_valid(phrase.draft, actual)[0]:
continue
@@ -767,7 +792,11 @@ def elaborate(phrase, incoming, harmony, sequences, tempo, context):
viable.append(dataclasses.replace(revised, state=state, initial=phrase.initial, refined=phrase.refined, routes=(*phrase.routes, route)))
else:
context.diagnostics['complete_routes_rejected'] = context.diagnostics.get('complete_routes_rejected', 0) + 1
chosen = min(viable, key=lambda p: (p.score, len(p.edits), tuple(n.pitch for n in emitted_notes(p))))
# A selected approach is an explicit musical operation. Its complete valid
# routes compete with one another; a zero-edit movement advantage must not
# silently cancel every requested run. Failure retains the plain tune.
eligible = viable[1:] if phrase.draft.lead_identity and len(viable) > 1 else viable
chosen = min(eligible, key=lambda p: (p.score, len(p.edits), tuple(n.pitch for n in emitted_notes(p))))
if chosen is not phrase:
context.diagnostics['complete_routes_selected'] = context.diagnostics.get('complete_routes_selected', 0) + 1
phrase = chosen
@@ -831,6 +860,9 @@ def revise_pair(first, second, incoming, choices, sequences, tempo, context):
def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None):
context = context if context is not None else SearchContext()
if plan.treatment == 'lead':
from generator.solo import leads
return leads.alternatives(plan, measures, choices, sequences, tempo, previous, context)
# 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
+3 -1
View File
@@ -25,6 +25,7 @@ class ReferencePhrase:
features: tuple[tuple[str, F, F], ...]
runs: tuple[tuple[F, F], ...]
acceptance: str
development: tuple[tuple[str, F, F, tuple[int, ...]], ...] = ()
def windows(self):
# Windows retain their parent and context; they are not separate approvals.
result = []
@@ -69,7 +70,8 @@ def read(path):
for _, chord in harmony:
generator.harmony.parse_symbol(chord)
return ReferencePhrase(data['id'], hashlib.sha256(raw).hexdigest(), plain, decorated, duration, number, harmony,
tuple((f['id'], F(f['start']), F(f['end'])) for f in data['features']), tuple((F(w['start']), F(w['end'])) for w in data['run_windows']), data['acceptance'])
tuple((f['id'], F(f['start']), F(f['end'])) for f in data['features']), tuple((F(w['start']), F(w['end'])) for w in data['run_windows']), data['acceptance'],
tuple((d['role'], F(d['start']), F(d['end']), tuple(d.get('degrees', ()))) for d in data.get('development', ())))
def load_library(root: pathlib.Path):
return tuple(read(path) for path in sorted(root.glob('*.json')))
+3 -1
View File
@@ -27,6 +27,8 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
plan = phrase.plan
lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources)))
lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}')
if plan.treatment == 'lead':
lines.append(' Lead range: 4–6 attacks per four beats across the passage; active occupancy 85–100%; local breaths and runs allowed.')
if phrase.draft:
draft = phrase.draft
lines.append(f' Complete draft {draft.id}: {draft.relationship}; home {draft.home.id if draft.home else "none"}; {len(draft.notes)} attacks; rests {draft.rests()}')
@@ -60,7 +62,7 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
choice = next(c for c in solo.choices if c.span.start <= note.start < c.span.end)
root = generator.scales.selection.root_number(choice.span.chord)
degrees = [str(d) for d, a in generator.harmony.degrees(choice.span.chord).items() if (root + generator.harmony.degree_semitones(d, a)) % 12 == note.pitch.number() % 12]
function = 'chord degree ' + '/'.join(degrees) if degrees else 'in-collection connection'
function = 'chord degree ' + '/'.join(degrees) if degrees else 'chromatic approach' if note.role == 'chromatic_approach' else 'in-collection connection'
lines.append(f' {note.start}-{note.end}: {note.pitch.label()}; {note.role}; {generator.harmony.symbol(choice.span.chord)}, {function}; {choice.scale.label()}; string {note.position.string_number}, fret {note.position.fret}')
lines.append('\nSounding harmonic spans (ties merged before classification):')
for event in detail['harmonic_roles']:
+11
View File
@@ -81,6 +81,17 @@ class Evaluation:
deviation = max(0, abs(attacks - expected) - max(tolerance, generator.solo.policy.DEFAULT.activity_deadband * length / self.duration))
activity += length * min(1, deviation / max(1, expected))
occupancy += length * max(0, abs(occupied / length - occupied_goal) - generator.solo.policy.DEFAULT.occupancy_deadband)
if self.plan.treatment == 'lead':
# One broad range over the complete thought; quiet bars are not failed
# local quotas. Missing harmony and explicit release are excluded here.
active = tuple(window for window in self.windows if window[4] > 0)
length = sum(w[3] for w in active)
attacks = sum(any(a <= onset < b for w in active for a, b in w[2]) for onset, _, _ in sounded)
occupied = sum(max(0, min(end, b) - max(start, a)) for start, end, _ in sounded for w in active for a, b in w[2])
low, high = generator.solo.policy.DEFAULT.lead_activity_range
activity = length * corridor(attacks / max(length, .001), low, high) / high
low, high = generator.solo.policy.DEFAULT.lead_occupancy_range
occupancy = length * corridor(occupied / max(length, .001), low, high)
start, end = float(self.plan.start), float(self.until)
durations = [max(0, min(b, end) - max(a, start)) for a, b, _ in sounded]
register = sum(length * min(1, corridor(pitch, *self.plan.register) / 12) for (_, _, pitch), length in zip(sounded, durations)) / max(sum(durations), 0.001)
+12
View File
@@ -83,6 +83,15 @@ class HarmonicTreatment:
resolution: int | None = None
deadline: fractions.Fraction | None = None
preparation: fractions.Fraction = fractions.Fraction(1, 2)
event: str = ''
resolution_event: str = ''
@dataclasses.dataclass(frozen=True)
class LeadIdentity:
id: str
durations: tuple[fractions.Fraction, ...]
degrees: tuple[int, ...]
source: str
@dataclasses.dataclass(frozen=True)
class Anchor:
@@ -136,6 +145,7 @@ class ContinuationState:
themes: tuple[RealizedMotif, ...] = ()
selected_anchors: tuple[Anchor, ...] = ()
ending: str = ''
lead_identity: LeadIdentity | None = None
@dataclasses.dataclass(frozen=True)
class Transformation:
@@ -193,6 +203,8 @@ class MelodicDraft:
features: tuple[IdentifyingFeature, ...] = ()
reference: str = ''
reference_motif: Motif | RealizedMotif | None = None
lead_identity: LeadIdentity | None = None
correspondence: tuple[tuple[str, str], ...] = ()
def rests(self):
cursor, result = self.start, []
for note in self.notes: