158 lines
11 KiB
Python
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)
|