Files
product-vamp/generator/solo/planning.py
T
khodowany 75655786e9
ci / checks (push) Successful in 1h9m55s
Soloing
2026-10-01 08:35:12 -05:00

158 lines
11 KiB
Python

from __future__ import annotations
import fractions
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
def target_pitches(chord: dict, final: bool) -> frozenset[int]:
degrees = generator.harmony.degrees(chord)
explicit = {d['value'] for d in chord.get('degrees', []) if d['type'] != 'subtract'}
preferred = {1, 3} if final else {3, 7}
preferred |= explicit | {d for d, a in degrees.items() if d == 5 and a}
if 3 not in degrees:
preferred |= {2, 4}
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), 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
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, *, 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')
path = min(paths, key=lambda s: (sum(a * b >= 0 for a, b in zip(s.steps, s.steps[1:])), s.rank()))
count = len(path.steps) + 1
unit = min(fractions.Fraction(1, 2), length / (count + 1))
notes = []
for i, offset in enumerate(path.offsets()):
octave, degree = divmod(28 + offset, 7)
pitch = generator.solo.types.Pitch(generator.model.LETTERS[degree], fractions.Fraction(), octave)
notes.append(generator.solo.types.Note(unit * i, unit * (i + 1), pitch))
return generator.solo.types.Motif('new_' + path.id.replace('/', '_').removesuffix('.xml'), tuple(notes), length, (), 'sequence ' + path.id)
def choose_motifs(analysis, sequences, length):
eligible = [m for m in analysis.motifs if m.duration <= 2 * length]
head = next((m for m in eligible if len(m.notes) >= 2), eligible[0] if eligible else new_motif(sequences, length))
others = [m for m in eligible if m.id != head.id]
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]
possible = list(range(min(index + 2, len(measures)), min(index + 4, len(measures)) + 1))
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
stop = min(possible, key=boundary_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]
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(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)