"""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) leadin=int(result.get('details',{}).get('allocation',{}).get('lead_in_measures',0)) first-=leadin;count+=leadin 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+leadin, '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