"""Immutable source events, meter boundaries and tempo. No solo construction.""" from __future__ import annotations import copy import dataclasses import fractions import hashlib import json import generator.model import generator.score import generator.solo.types @dataclasses.dataclass(frozen=True) class Analysis: boundaries: tuple tempo: fractions.Fraction melody: tuple body_offset: fractions.Fraction = fractions.Fraction() def attacks(document: dict) -> tuple[generator.solo.types.Note, ...]: """Collapse tied tones before extracting monophonic melody fragments.""" result, pending = [], {} absolute = fractions.Fraction() for measure in generator.score.source_staff(document)['measures']: for event in generator.score.measure_notes(measure): if event.get('grace') or event.get('cue'): continue for tone in event['pitches']: number = generator.model.pitch_number(tone) if number.denominator != 1: continue start = absolute + generator.model.fraction(event['onset']) end = start + generator.model.fraction(tone['duration']) pitch = generator.solo.types.Pitch(tone['letter'], generator.model.alteration(tone['alter']), tone['octave']) source = pending.pop(tone.get('tie_from'), None) if source is not None: result[source] = dataclasses.replace(result[source], end=end, sources=(*result[source].sources, event['id'])) else: source = len(result) result.append(generator.solo.types.Note(start, end, pitch, (event['id'],))) if 'tie_to' in tone: pending[tone['id']] = source absolute += generator.model.fraction(measure['duration']) return tuple(sorted(result, key=lambda n: (n.start, -n.pitch.number(), n.sources))) def analyze(document): measures = generator.score.source_staff(document)['measures'] boundaries = [fractions.Fraction()] for m in measures: boundaries.append(boundaries[-1] + generator.model.fraction(m['duration'])) tempo = next((generator.model.fraction(t['bpm']) * generator.model.written_length(t['written']) for m in measures for t in m.get('tempos', []) if 'bpm' in t), fractions.Fraction(120)) return Analysis(tuple(boundaries), tempo, attacks(document), boundaries[generator.score.body_start_measure_index(measures)]) @dataclasses.dataclass(frozen=True) class SourceAnalysis: document_json: str musical_key: str @classmethod def read(cls, document): prepared = copy.deepcopy(document) staff = generator.score.source_staff(prepared) for i, m in enumerate(staff['measures']): m['id'], m['number'] = f'source_{i}', str(i + 1) staff['tuplets'] = generator.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 = 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 accompaniment_key(self): document = self.document() document['metadata'].pop('tempo', None) for measure in generator.score.source_staff(document)['measures']: measure['tempos'] = [] return SourceAnalysis.read(document).musical_key