"""Accepted, paired pitch/rhythm examples; production uses contextual profiles.""" from __future__ import annotations import dataclasses import fractions import hashlib import json import pathlib import generator.harmony import generator.model import generator.solo.types as types F = fractions.Fraction @dataclasses.dataclass(frozen=True) class ReferencePhrase: id: str version: str notes: tuple[types.Note, ...] decorated: tuple[types.Note, ...] duration: F tonic: int harmony: tuple[tuple[F, str], ...] features: tuple[tuple[str, F, F], ...] runs: tuple[tuple[F, F], ...] acceptance: str development: tuple[tuple[str, F, F, tuple[int, ...]], ...] = () def windows(self): # Windows retain their parent and context; they are not separate approvals. result = [] for length in (F(8), F(16)): for start in range(0, int(self.duration), int(length)): end = min(self.duration, start + length) selected = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in self.notes if start <= n.start and n.end <= end) if len(selected) >= 2: chords = tuple(chord for time, chord in self.harmony if start <= time < end) result.append(types.Motif(f'{self.id}:{start}:{end}', selected, end - start, chords, 'accepted reference profile')) return tuple(result) def read(path): raw = path.read_bytes() data = json.loads(raw) if data['pitch_convention'] != 'sounding' or not data.get('acceptance'): raise ValueError(f'{path}: reference requires sounding pitches and explicit acceptance provenance') def notes(variant): result, cursor = [], F() for i, event in enumerate(data['variants'][variant]['events']): start, duration = F(event['onset_beats']), F(event['duration_beats']) if start != cursor or duration <= 0: raise ValueError(f'{path}: reference events must fill exact sequential time') cursor = start + duration if event['pitch'] is None: continue name = event['pitch'] pitch = types.Pitch(name[0].lower(), F(1 if '#' in name else -1 if 'b' in name else 0), int(name[-1])) if pitch.number() != event['midi_pitch']: raise ValueError(f'{path}: inconsistent sounding pitch {name}') result.append(types.Note(start, cursor, pitch, (data['id'],), event_id=f'{variant}_{i}')) return tuple(result), cursor plain, duration = notes('lead_theme') decorated, other_duration = notes('lead_with_runs') if duration != other_duration or len(plain) != data['variants']['lead_theme']['attacks'] or len(decorated) != data['variants']['lead_with_runs']['attacks']: raise ValueError(f'{path}: inconsistent reference variants') tonic = generator.model.parse_pitch_name(data['tonic']) number = int(generator.model.NATURAL[tonic['letter']] + generator.model.alteration(tonic['alter'])) harmony = tuple((F(4 * (h['bar'] - 1)), h['chord']) for h in data['harmony']) for _, chord in harmony: generator.harmony.parse_symbol(chord) return ReferencePhrase(data['id'], hashlib.sha256(raw).hexdigest(), plain, decorated, duration, number, harmony, tuple((f['id'], F(f['start']), F(f['end'])) for f in data['features']), tuple((F(w['start']), F(w['end'])) for w in data['run_windows']), data['acceptance'], tuple((d['role'], F(d['start']), F(d['end']), tuple(d.get('degrees', ()))) for d in data.get('development', ()))) def load_library(root: pathlib.Path): return tuple(read(path) for path in sorted(root.glob('*.json'))) from generator.solo.features import identifying_features, feature_error, relationship