Cleanup
ci / checks (push) Failing after 23m22s

This commit is contained in:
khodowany
2026-10-08 02:13:45 -05:00
parent 300097a37e
commit bc7baea7c4
88 changed files with 18844 additions and 1335 deletions
+2 -9
View File
@@ -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):
+100
View File
@@ -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
+9 -23
View File
@@ -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:], [])
+22
View File
@@ -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()
+58
View File
@@ -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)
+13 -27
View File
@@ -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)