85 lines
3.8 KiB
Python
85 lines
3.8 KiB
Python
"""Optional MusicXML views of finished production roles; never composes."""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
from pathlib import Path
|
|
from fractions import Fraction as F
|
|
|
|
from generator import model, musicxml_writer, score
|
|
|
|
|
|
def region(document, part):
|
|
measures = document['staves'][0]['measures']
|
|
first = next(i for i, m in enumerate(measures) if m.get('section_label') == part.capitalize() + ' Solo')
|
|
end = next((i for i in range(first + 1, len(measures)) if measures[i].get('section_label')), len(measures))
|
|
return first, end - first
|
|
|
|
|
|
def excerpt(document, first, count):
|
|
result = copy.deepcopy(document)
|
|
staff = result['staves'][0]
|
|
original = staff['measures']
|
|
staff['measures'] = original[first:first + count]
|
|
if not staff['measures']:
|
|
raise ValueError('Empty solo export region')
|
|
notes = [n for m in staff['measures'] for n in score.measure_notes(m)]
|
|
identities = {p['id'] for n in notes for p in n['pitches']}
|
|
events = {n['id'] for n in notes}
|
|
for note in notes:
|
|
for pitch in note['pitches']:
|
|
for key in ('tie_from', 'tie_to'):
|
|
if pitch.get(key) not in identities:
|
|
pitch.pop(key, None)
|
|
staff['tuplets'] = [t for t in staff.get('tuplets', []) if t['start'] in events and t['end'] in events]
|
|
tuplets = {t['id'] for t in staff['tuplets']}
|
|
for note in notes:
|
|
if 'tuplet_ids' in note:
|
|
note['tuplet_ids'] = [i for i in note['tuplet_ids'] if i in tuplets]
|
|
offset = sum((model.fraction(m['duration']) for m in original[:first]), F())
|
|
end = offset + sum((model.fraction(m['duration']) for m in staff['measures']), F())
|
|
for key in ('scale_spans', 'octave_shifts'):
|
|
staff[key] = [{**s, 'start': model.rational(max(offset, model.fraction(s['start'])) - offset),
|
|
'end': model.rational(min(end, model.fraction(s['end'])) - offset)} for s in staff.get(key, [])
|
|
if model.fraction(s['start']) < end and model.fraction(s['end']) > offset]
|
|
model.validate_document(result)
|
|
return result
|
|
|
|
|
|
def write_role(result, path):
|
|
role = result['role']
|
|
if role not in ('acoustic_solo', 'electric_solo'):
|
|
raise ValueError('Solo XML export requires an acoustic_solo or electric_solo role')
|
|
part = role.split('_')[0]
|
|
document = result['document']
|
|
first, count = region(document, part)
|
|
selected = excerpt(document, first, count)
|
|
numbers = [m['number'] for m in selected['staves'][0]['measures']]
|
|
for i, measure in enumerate(selected['staves'][0]['measures'], 1):
|
|
measure['number'] = str(i)
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
musicxml_writer.write_musicxml(path, selected, tablature=True)
|
|
return {'role': role, 'file': path.name, 'form_start_measure': 1,
|
|
'measure_count': count, 'arrangement_measures': numbers,
|
|
'arrangement_start_beat': str(sum((model.fraction(m['duration']) for m in document['staves'][0]['measures'][:first]), F())),
|
|
'pitch_convention': 'guitar-written; explicit octave transposition -1',
|
|
'trace': result['trace'], 'phrase_report': result.get('details', {})}
|
|
|
|
|
|
def export_tree(assets, output=None):
|
|
assets = Path(assets)
|
|
output = Path(output) if output else assets / 'solo_xml'
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
entries = {}
|
|
for path in sorted((assets / 'analysis').glob('*.roles')):
|
|
roles = json.loads(path.read_text())
|
|
for i, part in enumerate(('acoustic', 'electric'), 1):
|
|
name = path.stem.replace('_', '-') + f'-solo{i}.xml'
|
|
entries[path.stem + '/' + part] = write_role(roles[part + '_solo'], output / name)
|
|
manifest = {'version': 1, 'composition_inputs': False, 'solos': entries}
|
|
# Runtime score discovery treats *.json under assets as score documents.
|
|
# Diagnostic manifests deliberately use a non-score extension, like .roles.
|
|
(output / 'manifest.solo').write_text(json.dumps(manifest, sort_keys=True, indent=2) + '\n')
|
|
return manifest
|