Files
product-vamp/generator/solo/references.py
T
2026-10-04 09:05:25 -05:00

80 lines
3.5 KiB
Python

"""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