"""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)