Soloing
ci / checks (push) Successful in 1h9m55s

This commit is contained in:
khodowany
2026-10-01 08:35:12 -05:00
parent eae432aa83
commit 75655786e9
16 changed files with 1015 additions and 161 deletions
+79 -12
View File
@@ -6,6 +6,7 @@ import generator.harmony
import generator.model
import generator.scales.selection
import generator.solo.analysis
import generator.solo.policy
import generator.solo.sequences
import generator.solo.types
@@ -19,16 +20,44 @@ def target_pitches(chord: dict, final: bool) -> frozenset[int]:
root = generator.scales.selection.root_number(chord)
return frozenset(int(root + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items() if d in preferred) or frozenset(generator.harmony.pitch_classes(chord))
def destination(choices, start, end, final=False, release=fractions.Fraction(1, 2)):
def destination(choices, start, end, final=False, release=fractions.Fraction(1, 2), closure='closed'):
last = next((c for c in reversed(choices) if c.span.start < end and c.span.end > start), None)
if not last or not last.span.chord:
return None
latest = end - release - fractions.Fraction(1, 4)
if last.span.start > latest:
return None
return generator.solo.types.TargetEvent(max(start, last.span.start, end - 1 - release), latest, target_pitches(last.span.chord, final), generator.harmony.symbol(last.span.chord), 'phrase resolution' if final else 'defining chord-tone arrival')
pitches = target_pitches(last.span.chord, final)
if closure == 'open' and last.scale:
root = generator.scales.selection.root_number(last.span.chord)
degrees = generator.harmony.degrees(last.span.chord)
additions = {int(root + generator.harmony.degree_semitones(d, degrees.get(d, 0))) % 12 for d in (5, 9, 11, 13) if d not in {v['value'] for v in last.span.chord.get('degrees', []) if v['type'] == 'subtract'}}
pitches = frozenset(additions & last.scale.pitches) or pitches
return generator.solo.types.TargetEvent(max(start, last.span.start, end - 2 - release), latest, pitches, generator.harmony.symbol(last.span.chord), 'open continuation' if closure == 'open' else 'phrase resolution' if final else 'defining chord-tone arrival')
def new_motif(sequences: generator.solo.sequences.Library, length: fractions.Fraction) -> generator.solo.types.Motif:
def new_motif(sequences: generator.solo.sequences.Library, length: fractions.Fraction, *, choices=(), register=(55, 74), seed='', variant=0) -> generator.solo.types.Motif:
if choices and any(c.scale for c in choices):
scale = next(c.scale for c in choices if c.scale)
arrival = next(c for c in reversed(choices) if c.scale)
count = min(6, max(3, int(length * fractions.Fraction(3, 2))))
shapes = (('rising', 4), ('falling', -4), ('arch', 1), ('valley', -1), ('leap_return', 2), ('neighbor', 0))
shape, displacement = shapes[(int(generator.solo.policy.stable_rank(seed, 'invention')[:8], 16) + variant) % len(shapes)]
paths = sequences.retrieve(count, displacement, (displacement > 0) - (displacement < 0))
candidates = []
target = target_pitches(arrival.span.chord, False)
for path in paths:
for start in range(-14, 15):
pitches = tuple(generator.solo.types.scale_pitch(scale, start + offset) for offset in path.offsets())
if not all(register[0] <= pitch.number() <= register[1] for pitch in pitches):
continue
score = (generator.solo.sequences.family(path.steps) != shape, pitches[-1].number() % 12 not in target, abs(path.offsets()[-1] - displacement), abs(pitches[0].number() - (register[0] + 5)), generator.solo.policy.stable_rank(seed, (variant, path.steps, start)))
candidates.append((score, pitches, path))
if candidates:
_, pitches, path = min(candidates, key=lambda c: c[0])
unit = min(fractions.Fraction(1, 2), length / count)
notes = tuple(generator.solo.types.Note(i * unit, (i + 1) * unit, pitch) for i, pitch in enumerate(pitches))
identifier = generator.solo.policy.stable_rank(seed, (variant, path.steps))[:12]
return generator.solo.types.Motif('invented_' + identifier, notes, length, (), 'sequences ' + ' + '.join(path.sources))
paths = sequences.connecting(3) or sequences.connecting(2) or sequences.connecting(4)
if not paths:
raise ValueError('solo composition requires a two-, three-, or four-note sequence')
@@ -49,11 +78,33 @@ def choose_motifs(analysis, sequences, length):
contrast = min(others, key=lambda m: (m.contour() == head.contour(), abs(len(m.notes) - 3), m.duration, m.id)) if others else head
return head, contrast, new_motif(sequences, length)
def activity_passages(start, end, release, activity, shape=0):
music_end = end - release
boundaries = sorted({start, music_end} | {start + fractions.Fraction(round(float((end - start) * i / 4) * 2), 2) for i in (1, 2, 3) if start < start + fractions.Fraction(round(float((end - start) * i / 4) * 2), 2) < music_end})
weights = ((1, 1.6, 1.1), (0.8, 1.7, 1.2), (1.5, 1.4, 0.8))[shape % 3]
spans = list(zip(boundaries, boundaries[1:]))
arrival_rate = fractions.Fraction(3, 8)
desired = max(0, activity * (end - start) - arrival_rate * (spans[-1][1] - spans[-1][0]))
denominator = sum(float(b - a) * weights[min(i, 2)] for i, (a, b) in enumerate(spans[:-1]))
result = []
for i, (a, b) in enumerate(spans):
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))))
if release:
result.append(generator.solo.types.Passage(music_end, end, 'space', fractions.Fraction(), fractions.Fraction()))
return tuple(result)
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
while index < len(measures) and boundaries[index] < end:
start = boundaries[index]
@@ -74,17 +125,33 @@ def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choi
finish = boundaries[stop]
final = finish == end
phase = (start + finish) / (2 * end)
motif = head if part == 'acoustic' or final else invented
if len(result) > 1 and not final and len(result) % 3 == 2:
motif = contrast if part == 'acoustic' else invented
relationship = 'statement' if not result else 'callback' if final else 'repeat' if len(result) == 1 else 'answer' if len(result) % 2 else 'variation'
treatment = 'quote' if (part == 'acoustic' and not result) or (part == 'electric' and final and motif.origin == 'melody') else 'thematic'
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]
low, high = (55, 74) if part == 'acoustic' else (59, 79)
# Reserve the upper register for a prepared peak, not every active passage.
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(3, 4) if part == 'acoustic' else fractions.Fraction(1)
if final:
activity *= fractions.Fraction(2, 3)
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', start, finish, motif, relationship, treatment, destination(choices, start, finish, final), register, activity))
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))
index = stop
return tuple(result)