"""Independent, reproducible role generation and non-composing assembly. Only source documents and production resources enter generate_role. Completed roles and published assets are downstream values, never composition inputs. """ from __future__ import annotations import concurrent.futures import copy import dataclasses import hashlib import functools import json import multiprocessing import os from pathlib import Path import tempfile import time from fractions import Fraction as F from generator import accompaniment, comping, fretboard, model, musicxml, parts, rhythm, score, vocabulary from generator.arrange import form, generate, pickup from generator.solo import analysis, composer, notation, report, sequences from generator.scales import library as scales VERSION = 'independent-roles-1' ROLES = ('acoustic_accompaniment', 'electric_accompaniment', 'electric_melody', 'vocal_melody', 'acoustic_solo', 'electric_solo') @dataclasses.dataclass(frozen=True) class SourceAnalysis: document_json: str musical_key: str @classmethod def read(cls, document): prepared = copy.deepcopy(document) staff = score.source_staff(prepared) for i, m in enumerate(staff['measures']): m['id'], m['number'] = f'source_{i}', str(i+1) staff['tuplets'] = score.reidentify(staff['measures'], staff.get('tuplets', [])) prepared['metadata'] = {k:v for k,v in prepared['metadata'].items() if k not in ('title', 'composer', 'copyright', 'software')} prepared['metadata'].update(title='Source', composer='') staff.update(id='source', name='Source') melodic = analysis.analyze(prepared) content = {'melody': [(str(n.start), str(n.end), dataclasses.asdict(n.pitch)) for n in melodic.melody], 'measures': [{k: m.get(k) for k in ('duration', 'meter', 'key', 'harmonies', 'tempos', 'section_label', 'implicit')} for m in staff['measures']]} # Fraction/string spelling is canonical and never includes filesystem IDs. key = hashlib.sha256(json.dumps(content, sort_keys=True, default=str).encode()).hexdigest() return cls(json.dumps(prepared, sort_keys=True), key) def document(self): return json.loads(self.document_json) def windows(document, part): source = score.source_staff(document)['measures'] sections = form.full_arrangement_sections(source, part) result, cursor = [], F() for section in sections: duration = sum((model.fraction(m['duration']) for m in source[section.source_measure_index:section.source_measure_index+section.measure_count]), F()) role = {'slash': 'solo', 'vocal': 'melody'}.get(section.role, section.role) if section.role in ('vocal', 'melody') and pickup.enters(section, source) and result: attacks = [model.fraction(n['onset']) for n in score.measure_notes(source[0]) if n['pitches']] if attacks: start = cursor-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 result.append((cursor, end, part+'_'+role)) if part == 'electric' and section.role == 'slash' and score.body_start_measure_index(source): attacks = [model.fraction(n['onset']) for n in score.measure_notes(source[0]) if n['pitches']] if attacks: cutoff = end-model.fraction(source[0]['duration'])+min(attacks) result[-1] = (cursor, cutoff, part+'_solo') result.append((cutoff, end, part+'_rest')) cursor = end return tuple(result) def dependencies(role, resources): names = ('grammar', 'harmony', 'lead_ins', 'rhythms', 'melodic_vocabulary') if role.endswith('_solo') else ('rhythms',) if role.endswith('_accompaniment') else () return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']}, **{name: resources[name] for name in names}} def _code_key(role): root = Path(__file__).parent paths = [root / n for n in ('production.py', 'model.py', 'harmony.py', 'pitch.py', 'fretboard.py', 'parts.py', 'score.py', 'vocabulary.py')] paths += list((root/'arrange').glob('*.py')) if role.endswith('_solo'): paths += [p for p in (root/'solo').glob('*.py') if 'audition' not in p.name and p.name != 'references.py'] paths += list((root/'scales').glob('*.py')) + [root/'rhythm.py'] elif role.endswith('_accompaniment'): paths += [root/n for n in ('comping.py', 'accompaniment.py', 'guitar.py')] elif role == 'electric_melody': paths += [root/'guitar.py', root/'melody.py'] return vocabulary.fingerprint([(p.relative_to(root).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted(set(paths))]) def _resource_key(directory, role): names = ('scales', 'sequences', 'rhythms') if role.endswith('_solo') else () return {name: vocabulary.fingerprint([(p.relative_to(directory/name).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted((directory/name).rglob('*.xml'))]) for name in names} @functools.lru_cache(maxsize=4) def _solo_libraries(directory, fingerprints): return scales.load_library(directory/'scales'),sequences.load_library(directory/'sequences'),rhythm.load_library(directory/'rhythms') def _mask(document, allowed): """Apply source-defined role exclusions, without observing another part.""" absolute = F() for measure in document['staves'][0]['measures']: length = model.fraction(measure['duration']) for voice in measure['voices']: selected = [] for n in voice['notes']: start = absolute+model.fraction(n['onset']) end = start+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'] = model.rational(a-absolute) for pitch in note['pitches']: pitch.pop('tie_from',None) 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']=pickup.retain_links(staff['measures'],staff.get('tuplets',[])) def _fill(notes, length, prefix, voice='voice_arranged'): notes=sorted(notes,key=lambda n:model.fraction(n['onset'])) result=[]; cursor=F() def rests(end): nonlocal cursor for duration,written in accompaniment.written_durations(end-cursor): result.append(model.make_event(prefix+'_rest_'+str(len(result)),cursor,duration,'rest',written=written)|{'voice':voice}) cursor+=duration for n in notes: start=model.fraction(n['onset']) if start