Files
product-vamp/generator/arrange/generate.py
T
khodowany c7f6a30a13 Comping
2026-10-06 21:28:42 -05:00

138 lines
8.1 KiB
Python

from __future__ import annotations
import copy
import fractions
import pathlib
import generator.accompaniment
import generator.arrange.accompaniment
import generator.acoustic
import generator.acoustic_voicing
import generator.arrange.ending
import generator.arrange.form
import generator.arrange.pickup
import generator.electric
import generator.fretboard
import generator.guitar
import generator.melody
import generator.model
import generator.musicxml
import generator.parts
import generator.score
import generator.solo.arrangement
import generator.comping
import generator.vocabulary
import generator.production
FINAL_SECTION_INDEX = 5
def arrange_document(document: dict, part_type: str) -> dict:
"""Canonical source-only template; solos are inserted by production roles."""
document = generator.parts.prepare_document(document)
with generator.vocabulary.using(generator.vocabulary.load(), part_type + '_accompaniment'):
backing = generator.comping.Canonical(document, part_type, 'canonical') if part_type != 'vocal' else None
return _arrange_document(document, part_type, accompaniment=backing)
def section_source(source: list[dict], section, next_section=None) -> list[dict]:
result = copy.deepcopy(source)
for measure in result:
measure['harmonies'] = [h for h in measure['harmonies'] if section.section_index != FINAL_SECTION_INDEX or not h.get('chord_optional')]
for chord in measure['harmonies']:
chord.pop('chord_optional', None)
if generator.arrange.pickup.enters(next_section, source):
destination = result[section.source_measure_index + section.measure_count - 1]
generator.arrange.pickup.harmonies(destination, result[0])
return result
def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None, accompaniment=None) -> dict:
if part_type not in generator.parts.PART_TYPES:
raise ValueError(f'unknown arrangement part: {part_type}')
staff = generator.score.source_staff(document)
source = staff['measures']
sections = generator.arrange.form.full_arrangement_sections(source, part_type)
output, tuplets, octave_shifts, voicing_origins = [], [], [], []
absolute = fractions.Fraction()
source_starts = []
source_time = fractions.Fraction()
for m in source:
source_starts.append(source_time)
source_time += generator.model.fraction(m['duration'])
for index, section in enumerate(sections):
role = section.role if owned is None or section.role in owned else 'rest'
section_measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None)
previous = generator.accompaniment.carried_chords(section_measures)
segment = []
stop = None
next_section = sections[index + 1] if index + 1 < len(sections) else None
if next_section and generator.score.body_start_measure_index(source):
last = section.source_measure_index + section.measure_count - 1
if next_section.role == 'slash':
stop = (last, generator.model.fraction(section_measures[last]['duration']) - generator.model.fraction(source[0]['duration']))
elif next_section.role == 'melody' and generator.arrange.pickup.enters(next_section, source):
attacks = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(source[0]) if n['pitches']]
if attacks:
stop = (last, generator.model.fraction(section_measures[last]['duration']) - generator.model.fraction(source[0]['duration']) + min(attacks))
for si in range(section.source_measure_index, section.source_measure_index + section.measure_count):
m = section_measures[si]
if role == 'accompaniment' and accompaniment is not None:
result = accompaniment(m, previous[si], si, sources=section_measures, bounds=(section.source_measure_index, section.source_measure_index + section.measure_count), stop=stop)
result['id'] = f'measure_{len(output) + len(segment) + 1}'
result['section_label'] = section.label if not segment else ''
else:
result = generator.parts.build_measure(m, f'measure_{len(output) + len(segment) + 1}', role, part_type, previous[si], section.label if not segment else '')
segment.append(result)
if part_type == 'electric' and section.role in ('melody', 'accompaniment'):
voicing_origins.append(generator.electric.MeasureOrigin(source_starts[si], section.role, result))
segment_length = sum((generator.model.fraction(m['duration']) for m in segment), fractions.Fraction())
if role in ('vocal', 'melody'):
start = source_starts[section.source_measure_index]
for shift in staff.get('octave_shifts', []):
low, high = max(start, generator.model.fraction(shift['start'])), min(start + segment_length, generator.model.fraction(shift['end']))
if low < high:
octave_shifts.append({**shift, 'id': f'section_{section.section_index}_{shift["id"]}', 'start': generator.model.rational(absolute + low - start), 'end': generator.model.rational(absolute + high - start)})
pickup = None
if role in ('vocal', 'melody') and generator.arrange.pickup.enters(section, source) and output:
pickup = generator.parts.build_measure(source[0], f'pickup_{section.section_index}', section.role, part_type, None)
tuplets.extend(generator.score.reidentify(([pickup] if pickup else []) + segment, staff.get('tuplets', [])))
if pickup:
destination = output[-1]
pad = generator.arrange.pickup.offset(destination, pickup)
onset = generator.arrange.pickup.splice(destination, pickup)
if part_type == 'electric':
voicing_origins = [generator.electric.MeasureOrigin(o.onset, o.role, o.measure, o.start, onset if o.measure is destination else o.end) for o in voicing_origins]
voicing_origins.append(generator.electric.MeasureOrigin(-pad, 'melody', destination, onset))
for shift in staff.get('octave_shifts', []):
low, high = max(onset - pad, generator.model.fraction(shift['start'])), min(generator.model.fraction(pickup['duration']), generator.model.fraction(shift['end']))
if low < high:
offset = absolute - generator.model.fraction(pickup['duration'])
octave_shifts.append({**shift, 'id': f'pickup_{section.section_index}_{shift["id"]}', 'start': generator.model.rational(offset + low), 'end': generator.model.rational(offset + high)})
output.extend(segment)
absolute += segment_length
if sections and sections[-1].section_index == FINAL_SECTION_INDEX:
generator.arrange.ending.append(source, output, part_type,
sounding=part_type != 'vocal' and (owned is None or 'accompaniment' in owned),
center=generator.vocabulary.role(part_type + '_accompaniment').get('center', 55))
tuplets = generator.arrange.pickup.retain_links(output, tuplets)
result = generator.score.make_arranged_document(document, staff, staff_id='staff_arranged_' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts)
result['metadata'].setdefault('tempo', 124)
if part_type == 'acoustic':
if accompaniment is None and (owned is None or 'accompaniment' in owned):
generator.acoustic.prepare(result)
generator.acoustic_voicing.place(result)
generator.acoustic.validate_comping(result)
generator.acoustic.combine(result)
elif part_type == 'electric' and (owned is None or 'melody' in owned):
generator.melody.harmonize(result)
generator.guitar.place_guitar(result)
if generator.accompaniment.connect_continuations(result):
generator.guitar.place_guitar(result)
# Source labels identify the head while arranging; published labels identify
# the complete performance, including its lead-in and repeated choruses.
for number, measure in enumerate(output, 1):
measure['number'] = str(number)
generator.fretboard.validate_document(result)
return result
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, composition_jobs=2, cache_dir=None, progress=False, timings=None) -> int:
return generator.production.generate_tree(source_dir, output_dir, jobs=composition_jobs, cache_dir=cache_dir, progress=progress, timings=timings)