"""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, score, vocabulary from generator.arrange import form, generate, pickup from generator.solo import analysis, composer, notation, report, learned from generator.solo import authored as repertoire 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 = ('harmony', 'lead_ins') if role.endswith('_solo') else ('accompaniment',) 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 += list((root/'solo').glob('*.py')) paths += [root/'harmonic_time.py', root/'musicxml.py',root/'training_io.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): return {'model': hashlib.sha256((directory/'models'/'solo.json').read_bytes()).hexdigest()} if role.endswith('_solo') else {} @functools.lru_cache(maxsize=4) def _solo_model(directory, fingerprint): return learned.Model.load(Path(directory)/'models'/'solo.json') 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