Soloing
ci / checks (push) Successful in 50m39s

This commit is contained in:
khodowany
2026-10-01 06:31:14 -05:00
parent ce695a626a
commit eae432aa83
29 changed files with 1402 additions and 158 deletions
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import fractions
import generator.harmony
import generator.model
import generator.scales.selection
import generator.solo.analysis
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)):
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')
def new_motif(sequences: generator.solo.sequences.Library, length: fractions.Fraction) -> generator.solo.types.Motif:
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 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])
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)
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'
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))
index = stop
return tuple(result)