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)