@@ -1,20 +1,13 @@
|
||||
"""One learned accompaniment per instrument, reused at authored form positions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import fractions
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.acoustic_voicing
|
||||
import generator.electric
|
||||
import generator.guitar
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.parts
|
||||
import generator.score
|
||||
import generator.arrange.form
|
||||
import generator.arrange.pickup
|
||||
import generator.model
|
||||
import generator.score
|
||||
|
||||
def harmonic_key(measure, previous):
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Assemble source-defined role windows without composing or respelling."""
|
||||
from __future__ import annotations
|
||||
import copy
|
||||
import fractions
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.arrange.pickup
|
||||
import generator.composition_types
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.parts
|
||||
import generator.validation
|
||||
|
||||
def mask(document, allowed):
|
||||
"""Apply source-defined role exclusions, without observing another part."""
|
||||
absolute = fractions.Fraction()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
for voice in measure['voices']:
|
||||
selected = []
|
||||
for n in voice['notes']:
|
||||
start = absolute + generator.model.fraction(n['onset'])
|
||||
end = start + generator.model.fraction(n['duration'])
|
||||
intervals = [(max(start, a), min(end, b)) for a, b in allowed if a < end and b > start]
|
||||
for a, b in intervals:
|
||||
note = copy.deepcopy(n)
|
||||
if a != start:
|
||||
note['onset'] = generator.model.rational(a - absolute)
|
||||
for pitch in note['pitches']:
|
||||
pitch.pop('tie_from', None)
|
||||
generator.arrange.pickup.truncate(note, b - absolute)
|
||||
if a < b:
|
||||
selected.append(note)
|
||||
voice['notes'] = fill(selected, length, measure['id'] + '_' + voice['id'], voice['id'])
|
||||
absolute += length
|
||||
staff = document['staves'][0]
|
||||
staff['tuplets'] = generator.arrange.pickup.retain_links(staff['measures'], staff.get('tuplets', []))
|
||||
|
||||
def fill(notes, length, prefix, voice='voice_arranged'):
|
||||
notes = sorted(notes, key=lambda n: generator.model.fraction(n['onset']))
|
||||
result = []
|
||||
cursor = fractions.Fraction()
|
||||
|
||||
def rests(end):
|
||||
nonlocal cursor
|
||||
for duration, written in generator.accompaniment.written_durations(end - cursor):
|
||||
result.append(generator.model.make_event(prefix + '_rest_' + str(len(result)), cursor, duration, 'rest', written=written) | {'voice': voice})
|
||||
cursor += duration
|
||||
|
||||
for n in notes:
|
||||
start = generator.model.fraction(n['onset'])
|
||||
if start < cursor:
|
||||
raise ValueError(prefix + ': overlapping independently generated roles')
|
||||
rests(start)
|
||||
n['voice'] = voice
|
||||
result.append(n)
|
||||
cursor = start + generator.model.fraction(n['duration'])
|
||||
rests(length)
|
||||
return result
|
||||
|
||||
def assemble(source, results):
|
||||
"""Union prevalidated role windows; no selection or pitch/fingering rewrite."""
|
||||
documents = {}
|
||||
for part in generator.parts.PART_TYPES:
|
||||
selected = [results[r]['document'] for r in generator.composition_types.ROLES if r.startswith(part + '_')]
|
||||
document = copy.deepcopy(selected[0])
|
||||
staff = document['staves'][0]
|
||||
if part == 'vocal':
|
||||
documents[part] = document
|
||||
continue
|
||||
staff['tuplets'] = []
|
||||
staff['octave_shifts'] = []
|
||||
staff['scale_spans'] = []
|
||||
for other in selected:
|
||||
for field in ('tuplets', 'octave_shifts', 'scale_spans'):
|
||||
staff[field].extend(copy.deepcopy(other['staves'][0].get(field, [])))
|
||||
for i, m in enumerate(staff['measures']):
|
||||
# Tuplet rests belong to the source/role rhythm and must survive union.
|
||||
lanes = {}
|
||||
for d in selected:
|
||||
for voice in d['staves'][0]['measures'][i]['voices']:
|
||||
lane = voice['id'] if part == 'acoustic' and voice['id'] in ('upper', 'bass') else 'voice_arranged'
|
||||
sounding = [copy.deepcopy(n) for n in voice['notes'] if n['pitches'] or n.get('tuplet_ids')]
|
||||
if sounding:
|
||||
lanes.setdefault(lane, []).extend(sounding)
|
||||
lanes = lanes or {'voice_arranged': []}
|
||||
m['voices'] = [{'id': lane, 'notes': fill(notes, generator.model.fraction(m['duration']), m['id'] + '_' + lane, lane)} for lane, notes in lanes.items()]
|
||||
# Attribute changes belong to source melody pickups and are assembly metadata.
|
||||
for d in selected:
|
||||
other = d['staves'][0]['measures'][i]
|
||||
if other.get('attributes'):
|
||||
m['attributes'] = copy.deepcopy(other['attributes'])
|
||||
staff['tuplets'] = generator.arrange.pickup.retain_links(staff['measures'], staff['tuplets'])
|
||||
owners = {n['id']: v['id'] for m in staff['measures'] for v in m['voices'] for n in v['notes']}
|
||||
for span in staff['tuplets']:
|
||||
span['voice'] = owners[span['start']]
|
||||
generator.validation.validate_document(document)
|
||||
generator.fretboard.validate_document(document)
|
||||
documents[part] = document
|
||||
return documents
|
||||
@@ -4,44 +4,30 @@ import fractions
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.ending_plan
|
||||
import generator.comping
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.parts
|
||||
import generator.score
|
||||
|
||||
BARS = 3
|
||||
|
||||
def template(source):
|
||||
result = copy.deepcopy(source[-1])
|
||||
# An ending is three full bars in the final active meter, never a pickup.
|
||||
for change in result.get('attributes', []):
|
||||
for key in ('meter', 'key', 'clef', 'transpose', 'staff_details'):
|
||||
if key in change:
|
||||
result[key] = copy.deepcopy(change[key])
|
||||
result.update(duration=generator.model.rational(generator.model.meter_length(result['meter'])), implicit=False,
|
||||
attributes=[], harmonies=[], voices=[], tempos=[], section_label='')
|
||||
result['time_signature'] = generator.model.meter_label(result['meter'])
|
||||
return result
|
||||
|
||||
def duration(source):
|
||||
return BARS * generator.model.fraction(template(source)['duration']) if source else fractions.Fraction()
|
||||
import generator.vocabulary
|
||||
|
||||
def chord(source):
|
||||
effective = [{**m, 'harmonies': [h for h in m['harmonies'] if not h.get('chord_optional')]} for m in source]
|
||||
prior = generator.accompaniment.carried_chords(effective)[-1]
|
||||
return generator.accompaniment.active_chord(effective[-1], generator.model.fraction(effective[-1]['duration']), prior)
|
||||
|
||||
def append(source, output, part, *, sounding=False, center=55):
|
||||
def append(source, output, part, *, sounding=False, center=55, composition=None):
|
||||
composition = composition or generator.vocabulary.current()
|
||||
if not source or not output:
|
||||
return
|
||||
model = template(source)
|
||||
model = generator.arrange.ending_plan.template(source)
|
||||
harmony = chord(source)
|
||||
grip = ()
|
||||
if sounding:
|
||||
if harmony is None:
|
||||
raise ValueError('Ending: final performed harmony is missing or N.C.; cannot invent a closing chord')
|
||||
options = generator.comping.voicings(generator.comping.semantic_key(harmony), part)
|
||||
options = generator.comping.voicings(generator.comping.semantic_key(harmony), part, composition=composition)
|
||||
recent = {}
|
||||
for m in reversed(output):
|
||||
for n in sorted(generator.score.measure_notes(m), key=lambda n: generator.model.fraction(n['onset']), reverse=True):
|
||||
@@ -55,10 +41,10 @@ def append(source, output, part, *, sounding=False, center=55):
|
||||
if part == 'acoustic':
|
||||
bass = next((recent[s] for s in (5, 6) if s in recent), None)
|
||||
previous = generator.acoustic_voicing.Voicing(bass, *(recent[s] for s in (4, 3, 2))) if bass and all(s in recent for s in (4, 3, 2)) else ()
|
||||
grip = min(options, key=lambda v: (generator.comping.local_cost(part, generator.comping.semantic_key(harmony), v, center).moved(generator.comping.movement(part, previous, v)), v))
|
||||
grip = min(options, key=lambda v: (generator.comping.local_cost(part, generator.comping.semantic_key(harmony), v, center, composition=composition).moved(generator.comping.movement(part, previous, v)), v))
|
||||
length = generator.model.fraction(model['duration'])
|
||||
previous = None
|
||||
for index in range(BARS):
|
||||
for index in range(generator.arrange.ending_plan.BARS):
|
||||
measure = generator.parts.build_measure(model, f'measure_{len(output) + 1}', 'rest', part, None, 'Ending' if index == 0 else '')
|
||||
measure['ending_sustain'] = True
|
||||
if index == 0 and harmony:
|
||||
@@ -77,4 +63,4 @@ def append(source, output, part, *, sounding=False, center=55):
|
||||
cursor += duration
|
||||
measure['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
|
||||
output.append(measure)
|
||||
generator.score.reidentify(output[-BARS:], [])
|
||||
generator.score.reidentify(output[-generator.arrange.ending_plan.BARS:], [])
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Source-derived ending geometry, independent of instrument composition."""
|
||||
import copy
|
||||
import fractions
|
||||
|
||||
import generator.model
|
||||
|
||||
BARS = 3
|
||||
|
||||
def template(source):
|
||||
result = copy.deepcopy(source[-1])
|
||||
# An ending is three full bars in the final active meter, never a pickup.
|
||||
for change in result.get('attributes', []):
|
||||
for key in ('meter', 'key', 'clef', 'transpose', 'staff_details'):
|
||||
if key in change:
|
||||
result[key] = copy.deepcopy(change[key])
|
||||
result.update(duration=generator.model.rational(generator.model.meter_length(result['meter'])), implicit=False,
|
||||
attributes=[], harmonies=[], voices=[], tempos=[], section_label='')
|
||||
result['time_signature'] = generator.model.meter_label(result['meter'])
|
||||
return result
|
||||
|
||||
def duration(source):
|
||||
return BARS * generator.model.fraction(template(source)['duration']) if source else fractions.Fraction()
|
||||
@@ -1,8 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import typing
|
||||
|
||||
import generator.arrange.ending_plan
|
||||
import generator.arrange.pickup
|
||||
import generator.model
|
||||
import generator.score
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
@@ -55,3 +59,57 @@ def full_arrangement_sections(measures: list[dict], part_type: str) -> list[Arra
|
||||
for label, start, count, index in specs
|
||||
if count > 0
|
||||
]
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class FormPlan:
|
||||
sections: tuple[ArrangementSection, ...]
|
||||
windows: tuple[tuple[fractions.Fraction, fractions.Fraction, str], ...]
|
||||
stops: tuple[tuple[int, fractions.Fraction] | None, ...]
|
||||
@classmethod
|
||||
def read(cls, document, part):
|
||||
source = generator.score.source_staff(document)['measures']
|
||||
sections = tuple(full_arrangement_sections(source, part))
|
||||
stops = tuple(section_stop(source, section, sections[i + 1] if i + 1 < len(sections) else None) for i, section in enumerate(sections))
|
||||
return cls(sections, _windows(document, part, sections), stops)
|
||||
|
||||
def section_stop(source, section, following):
|
||||
if following is None or not generator.score.body_start_measure_index(source):
|
||||
return None
|
||||
last = section.source_measure_index + section.measure_count - 1
|
||||
pad = generator.arrange.pickup.offset(source[last], source[0])
|
||||
if following.role == 'slash':
|
||||
return last, pad
|
||||
if following.role == 'melody' and generator.arrange.pickup.enters(following, source):
|
||||
attacks = [generator.model.fraction(note['onset']) for note in generator.score.measure_notes(source[0]) if note['pitches']]
|
||||
if attacks:
|
||||
return last, pad + min(attacks)
|
||||
return None
|
||||
|
||||
def windows(document, part):
|
||||
return FormPlan.read(document, part).windows
|
||||
|
||||
def _windows(document, part, sections):
|
||||
source = generator.score.source_staff(document)['measures']
|
||||
result, cursor = [], fractions.Fraction()
|
||||
for section in sections:
|
||||
duration = sum((generator.model.fraction(m['duration']) for m in source[section.source_measure_index:section.source_measure_index + section.measure_count]), fractions.Fraction())
|
||||
role = {'slash': 'solo', 'vocal': 'melody'}.get(section.role, section.role)
|
||||
if section.role in ('vocal', 'melody') and generator.arrange.pickup.enters(section, source) and result:
|
||||
attacks = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(source[0]) if n['pitches']]
|
||||
if attacks:
|
||||
start = cursor - generator.model.fraction(source[0]['duration']) + min(attacks)
|
||||
a, _, old = result[-1]
|
||||
result[-1] = (a, start, old)
|
||||
result.append((start, cursor, part + '_' + role))
|
||||
end = cursor + duration
|
||||
start = cursor
|
||||
if section.role == 'slash' and generator.score.body_start_measure_index(source) and result:
|
||||
# Allocate the source's complete pickup bar before composition. This
|
||||
# does not inspect either generated solo; a role may leave it silent.
|
||||
start = cursor - generator.model.fraction(source[0]['duration'])
|
||||
a, _, old = result[-1]
|
||||
result[-1] = (a, start, old)
|
||||
result.append((start, end, part + '_' + role))
|
||||
cursor = end
|
||||
result.append((cursor, cursor + generator.arrange.ending_plan.duration(source), part + ('_accompaniment' if part != 'vocal' else '_rest')))
|
||||
return tuple(result)
|
||||
|
||||
@@ -2,36 +2,31 @@ 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.comping
|
||||
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:
|
||||
def arrange_document(document: dict, part_type: str, *, composition=None) -> dict:
|
||||
"""Canonical source-only template; solos are inserted by production roles."""
|
||||
composition = composition or generator.vocabulary.CompositionContext.create(generator.vocabulary.load(), part_type + '_accompaniment')
|
||||
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)
|
||||
backing = generator.comping.Canonical(document, part_type, 'canonical', composition=composition) if part_type != 'vocal' else None
|
||||
return _arrange_document(document, part_type, accompaniment=backing, composition=composition)
|
||||
|
||||
def section_source(source: list[dict], section, next_section=None) -> list[dict]:
|
||||
result = copy.deepcopy(source)
|
||||
@@ -44,12 +39,14 @@ def section_source(source: list[dict], section, next_section=None) -> list[dict]
|
||||
generator.arrange.pickup.harmonies(destination, result[0])
|
||||
return result
|
||||
|
||||
def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None, accompaniment=None) -> dict:
|
||||
def _arrange_document(document: dict, part_type: str, plan=None, *, owned=None, accompaniment=None, composition=None) -> dict:
|
||||
composition = composition or generator.vocabulary.current()
|
||||
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)
|
||||
plan = plan or generator.arrange.form.FormPlan.read(document, part_type)
|
||||
sections = plan.sections
|
||||
output, tuplets, octave_shifts, voicing_origins = [], [], [], []
|
||||
absolute = fractions.Fraction()
|
||||
source_starts = []
|
||||
@@ -62,16 +59,8 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
|
||||
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
|
||||
stop = plan.stops[index]
|
||||
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:
|
||||
@@ -111,7 +100,7 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
|
||||
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))
|
||||
center=composition.role(part_type + '_accompaniment').get('center', 55), composition=composition)
|
||||
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)
|
||||
@@ -123,15 +112,12 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
|
||||
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)
|
||||
generator.guitar.place_guitar(result, composition=composition)
|
||||
if generator.accompaniment.connect_continuations(result):
|
||||
generator.guitar.place_guitar(result)
|
||||
generator.guitar.place_guitar(result, composition=composition)
|
||||
# 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)
|
||||
|
||||
Reference in New Issue
Block a user