221 lines
15 KiB
Python
221 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import fractions
|
|
|
|
import generator.fretboard
|
|
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
|
|
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] and generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch.number())) 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.ActivitySpan(a, b, 'sustained_arrival' if arrival else 'active', rate, fractions.Fraction(str(occupancy))))
|
|
if release:
|
|
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, (), intention) for intention in ('rising', 'falling', 'returning', 'repeated')]
|
|
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 and generator.fretboard.single_positions_for_pitch(fractions.Fraction(p.number()))]
|
|
desired = register[0] + (5 if index == 0 else 8 if role == 'terminal' else 11)
|
|
following = []
|
|
for cost, anchors, intention in states:
|
|
for pitch in pitches:
|
|
jump = abs(pitch.number() - anchors[-1].pitch.number()) if anchors else 0
|
|
direction = 1 if intention == 'rising' or intention == 'returning' and role != 'terminal' else -1 if intention != 'repeated' else 0
|
|
wrong_way = bool(anchors and (pitch.number() - anchors[-1].pitch.number()) * direction < 0)
|
|
preferred = target_pitches(choice.span.chord, role == 'terminal')
|
|
destination_motion = 0 if intention == 'repeated' else 4
|
|
score = cost + abs(jump - destination_motion) / 12 + 0.8 * wrong_way + abs(pitch.number() - desired) / 36 + 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), intention))
|
|
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, ...]:
|
|
boundaries = [fractions.Fraction()]
|
|
for measure in measures:
|
|
boundaries.append(min(end, boundaries[-1] + generator.model.fraction(measure['duration'])))
|
|
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]
|
|
stops = sorted({min(index + size, len(measures)) for size in (4, 6, 8)})
|
|
|
|
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(stops, key=destination_rank)
|
|
finish = boundaries[stop]
|
|
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)
|
|
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)
|
|
register = generator.fretboard.single_register(register)
|
|
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]
|
|
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'
|
|
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
|
|
index = stop
|
|
return tuple(result)
|