Soloing
ci / checks (push) Successful in 1h53m40s

This commit is contained in:
khodowany
2026-10-01 11:23:47 -05:00
parent 75655786e9
commit 5414caf30a
14 changed files with 1085 additions and 259 deletions
+118 -48
View File
@@ -1,10 +1,12 @@
from __future__ import annotations
import dataclasses
import fractions
import generator.harmony
import generator.model
import generator.scales.selection
import generator.solo.harmonic
import generator.solo.analysis
import generator.solo.policy
import generator.solo.sequences
@@ -91,67 +93,135 @@ def activity_passages(start, end, release, activity, shape=0):
arrival = i == len(spans) - 1
rate = arrival_rate if arrival else fractions.Fraction(str(min(2, float(desired) * weights[min(i, 2)] / max(denominator, 0.001)))).limit_denominator(1000)
occupancy = generator.solo.policy.DEFAULT.arrival_occupancy if arrival else generator.solo.policy.DEFAULT.active_occupancy
result.append(generator.solo.types.Passage(a, b, 'sustained_arrival' if arrival else 'active', rate, fractions.Fraction(str(occupancy))))
result.append(generator.solo.types.ActivitySpan(a, b, 'sustained_arrival' if arrival else 'active', rate, fractions.Fraction(str(occupancy))))
if release:
result.append(generator.solo.types.Passage(music_end, end, 'space', fractions.Fraction(), fractions.Fraction()))
result.append(generator.solo.types.ActivitySpan(music_end, end, 'space', fractions.Fraction(), fractions.Fraction()))
return tuple(result)
def decision(seed, name):
return int(generator.solo.policy.stable_rank(seed, name)[:8], 16)
def same_harmony(left, right):
return bool(left and right and left.span.chord and right.span.chord and generator.harmony.symbol(left.span.chord) == generator.harmony.symbol(right.span.chord))
def resolutions(choices):
distinct = []
for choice in choices:
if not distinct or not same_harmony(distinct[-1], choice):
distinct.append(choice)
return tuple(right for left, right in zip(distinct, distinct[1:]) if left.span.chord and right.span.chord and generator.scales.selection.dominant(left.span.chord) and (generator.scales.selection.root_number(left.span.chord) - generator.scales.selection.root_number(right.span.chord)) % 12 == 7)
def anchor_backbones(identifier, start, end, release, register, choices, bar, seed):
active = [c for c in choices if c.scale and c.span.start < end - release and c.span.end > start]
if not active:
return tuple([()])
finish = min(end - release, active[-1].span.end)
terminal_duration = min(fractions.Fraction(2), (finish - start) / 2)
terminal_start = max(start, active[-1].span.start, finish - terminal_duration)
terminal_duration = finish - terminal_start
opening = max(start, active[0].span.start)
moments = [(opening, 'opening')]
meaningful = list(resolutions(active)) + [b for a, b in zip(active, active[1:]) if a.scale.pitches != b.scale.pitches]
for choice in sorted(meaningful, key=lambda c: c.span.start):
time = choice.span.start
if time >= moments[-1][0] + bar * fractions.Fraction(3, 2) and time + bar < terminal_start and len(moments) < 4:
moments.append((time, 'intermediate'))
if terminal_start <= opening + fractions.Fraction(1, 2):
moments = []
moments.append((terminal_start, 'terminal'))
states = [(0.0, ())]
for index, (time, role) in enumerate(moments):
choice = next(c for c in active if c.span.start <= time < c.span.end)
chord_roles = generator.solo.harmonic.roles(choice.span.chord)
eligible = (chord_roles.basic | chord_roles.specified) & choice.scale.pitches
duration = terminal_duration if role == 'terminal' else fractions.Fraction(1, 2)
latest = time if role == 'terminal' else min(time + fractions.Fraction(3, 2), choice.span.end - duration, terminal_start - duration)
pitches = [generator.solo.types.scale_pitch(choice.scale, degree) for degree in range(-21, 28)]
pitches = [p for p in pitches if register[0] <= p.number() <= register[1] and p.number() % 12 in eligible]
desired = register[0] + (5 if index == 0 else 8 if role == 'terminal' else 11)
following = []
for cost, anchors in states:
for pitch in pitches:
jump = abs(pitch.number() - anchors[-1].pitch.number()) if anchors else 0
direction = 1 if role != 'terminal' else -1
wrong_way = bool(anchors and (pitch.number() - anchors[-1].pitch.number()) * direction < 0)
preferred = target_pitches(choice.span.chord, role == 'terminal')
score = cost + jump / 12 + 0.3 * wrong_way + abs(pitch.number() - desired) / 18 + 0.3 * (pitch.number() % 12 not in chord_roles.basic) + 0.2 * (role == 'terminal' and pitch.number() % 12 not in preferred)
anchor = generator.solo.types.Anchor(f'{identifier}_anchor_{index + 1}', time, latest, pitch, frozenset(eligible), register, duration, role, generator.harmony.symbol(choice.span.chord), False, finish if role == 'terminal' else None)
following.append((score, (*anchors, anchor)))
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[1][0].pitch.number(), item[1][-1].pitch.number()), generator.solo.policy.DEFAULT.alternatives))
return tuple(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, ...]:
boundaries = [fractions.Fraction()]
for measure in measures:
boundaries.append(min(end, boundaries[-1] + generator.model.fraction(measure['duration'])))
head, contrast, invented = choose_motifs(analysis, sequences, boundaries[1])
seed = generator.solo.policy.stable_rank(generator.solo.analysis.identity(analysis, measures, part), generator.solo.policy.describe()['fingerprint'])
corridor = (55, 74) if part == 'acoustic' else (59, 79)
invented = new_motif(sequences, boundaries[1], choices=choices, register=corridor, seed=seed)
alternate = new_motif(sequences, boundaries[1], choices=choices, register=corridor, seed=seed, variant=1)
result, index = [], 0
head, contrast, _ = choose_motifs(analysis, sequences, boundaries[1])
seed = generator.solo.policy.stable_rank(generator.solo.analysis.identity(analysis, measures, part), generator.solo.policy.SEED_NAMESPACE)
activity = fractions.Fraction(str(generator.solo.policy.DEFAULT.acoustic_activity if part == 'acoustic' else generator.solo.policy.DEFAULT.electric_activity))
result, index, passage_index, inventions = [], 0, 0, {}
cadences = resolutions(choices)
while index < len(measures) and boundaries[index] < end:
start = boundaries[index]
possible = list(range(min(index + 2, len(measures)), min(index + 4, len(measures)) + 1))
stops = sorted({min(index + size, len(measures)) for size in (4, 6, 8)})
def boundary_rank(stop):
time = boundaries[stop]
before = next((c for c in reversed(choices) if c.span.start < time), None)
after = next((c for c in choices if c.span.start >= time and c.span.chord), None)
resolution = before and after and before.span.chord and generator.scales.selection.dominant(before.span.chord) and (generator.scales.selection.root_number(before.span.chord) - generator.scales.selection.root_number(after.span.chord)) % 12 == 7
source_index = min(stop + int(len(analysis.boundaries) > len(measures) + 1), len(analysis.boundaries) - 1)
source_time = analysis.boundaries[source_index]
preceding = [n for n in analysis.melody if n.start < source_time]
breathing = bool(preceding) and preceding[-1].end <= source_time and (preceding[-1].end < source_time or preceding[-1].end - preceding[-1].start >= 2)
return not resolution, not breathing, abs(stop - index - 3), stop
def destination_rank(stop):
finish = boundaries[stop]
last = next((c for c in reversed(choices) if c.scale and c.span.start < finish and c.span.end > start), None)
resolved = bool(last and any(start < c.span.start < finish and same_harmony(c, last) for c in cadences))
dominant = bool(last and generator.scales.selection.dominant(last.span.chord))
return (0 if resolved else 2 if dominant else 1), abs(stop - index - 4), finish
stop = min(possible, key=boundary_rank)
stop = min(stops, key=destination_rank)
finish = boundaries[stop]
final = finish == end
phase = (start + finish) / (2 * end)
phrase_seed = generator.solo.policy.stable_rank(seed, (str(start), str(finish)))
choice = int(phrase_seed[:8], 16)
relationship = 'statement' if not result else 'repeat' if len(result) == 1 else 'answer' if len(result) % 2 else 'variation'
if part == 'acoustic':
motif = head if len(result) < 2 else (head, contrast, invented)[choice % 3]
else:
motif = invented if len(result) < 2 else (invented, alternate, head)[choice % 3]
treatment = 'opening_reference' if not result and motif.origin == 'melody' else 'invented' if motif.origin != 'melody' else 'answer' if relationship == 'answer' else ('rhythmic_reference', 'contour_reference')[choice % 2]
if finish <= start:
break
passage_index += 1
passage_id = f'{part}_passage_{passage_index}'
passage_seed = generator.solo.policy.stable_rank(seed, (str(start), str(finish)))
low, high = (55, 74) if part == 'acoustic' else (59, 79)
# Reserve the upper register for a prepared peak, not every active passage.
phase = (start + finish) / (2 * end)
register = (low + (5 if fractions.Fraction(1, 2) <= phase < fractions.Fraction(3, 4) else 0), high if phase >= fractions.Fraction(1, 2) else high - 4)
activity = fractions.Fraction(str(generator.solo.policy.DEFAULT.acoustic_activity if part == 'acoustic' else generator.solo.policy.DEFAULT.electric_activity))
ending = ('connected', 'sustain', 'breath')[choice % 3]
release = fractions.Fraction(1, 2) if choice % 2 else fractions.Fraction(1)
release = min(release, (finish - start) / 4) if ending == 'breath' else fractions.Fraction()
if treatment == 'rhythmic_reference' and motif.duration > finish - start - release:
treatment = 'contour_reference'
closure = 'closed' if final or relationship == 'answer' else 'open'
passages = activity_passages(start, finish, release, activity, choice % 3)
sounding = [p for p in passages if p.intent != 'space']
peak = sounding[min(1, len(sounding) - 1)]
ascending = choice % 2 == 0
landmarks = (
generator.solo.types.Landmark(start, min(start + 1, peak.start), register[0] + (3 if ascending else 8), register[0] + (7 if ascending else 12), 0, 'entrance'),
generator.solo.types.Landmark(peak.start, peak.end, register[1] - 6 if ascending else register[0] + 2, register[1] - 2 if ascending else register[0] + 6, 1 if ascending else -1, 'peak' if ascending else 'low point'),
generator.solo.types.Landmark(sounding[-1].start, sounding[-1].end, register[0] + (4 if ascending else 8), register[0] + (9 if ascending else 13), -1 if ascending else 1, 'arrival'),
)
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', start, finish, motif, relationship, treatment, destination(choices, start, finish, final, release, closure), register, activity, release, passages, landmarks, ending, closure, phrase_seed))
bar = boundaries[index + 1] - start
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)
# 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]
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':
slot = 0 if passage_index <= 2 else slot
if slot == 2 and 0 not in inventions:
inventions[0] = new_motif(sequences, bar, choices=local, register=register, seed=passage_seed)
motif = head if slot == 0 else contrast if slot == 1 else inventions[0]
else:
slot = 0 if passage_index == 1 else slot
if slot < 2 and slot not in inventions:
inventions[slot] = new_motif(sequences, bar, choices=local, register=register, seed=passage_seed, variant=slot)
motif = head if slot == 2 else inventions[slot]
gestures, cursor = [], start
while cursor < finish - release:
gesture_end = min(cursor + bar, finish - release)
closing = gesture_end == finish - release
changed = any(c.span.start == cursor and c.scale and prior.scale and c.scale.pitches != prior.scale.pitches for prior, c in zip(choices, choices[1:]))
relationship = 'statement' if not gestures else 'close' if closing else 'repeat_with_harmonic_adaptation' if changed else 'answer' if cursor >= start + (finish - start) / 2 else 'extend'
treatment = 'opening_reference' if not gestures and motif.origin == 'melody' else 'invented' if not gestures else 'rhythmic_reference' if relationship == 'repeat_with_harmonic_adaptation' else 'answer' if relationship in ('answer', 'close') else 'contour_reference'
gestures.append(generator.solo.types.Gesture(f'{passage_id}_gesture_{len(gestures) + 1}', cursor, gesture_end, relationship, treatment))
cursor = gesture_end
phrase_start = start
for phrase_stop in phrase_stops:
phrase_end = boundaries[phrase_stop]
terminal = phrase_end == finish
treatment = 'opening_reference' if motif.origin == 'melody' else 'invented'
local_gestures = tuple(g for g in gestures if g.start < phrase_end and g.end > phrase_start)
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, local_gestures))
phrase_start = phrase_end
index = stop
return tuple(result)