"""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, resources as catalogs 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', 'time_signature', '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 start=cursor if section.role == 'slash' and 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-model.fraction(source[0]['duration']) a,_,old=result[-1];result[-1]=(a,start,old) result.append((start, end, part+'_'+role)) cursor = end return tuple(result) def dependencies(role, resources): names = ('solo', 'harmony', 'lead_ins', 'rhythms') 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', root/'resources.py', root/'musicxml.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, cache_dir=None): return catalogs.load(directory,dict(fingerprints),cache_dir) 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