98 lines
5.4 KiB
Python
98 lines
5.4 KiB
Python
"""Explicit registered repertoire. Authored playback is not model prediction.
|
||
|
||
Matching is by canonical source music and role, never title or filename. This
|
||
path intentionally reads the registered solo, following the user's exact-reuse
|
||
policy. Unknown song/role pairs go to the learned composer.
|
||
"""
|
||
from __future__ import annotations
|
||
import dataclasses
|
||
import hashlib
|
||
import json
|
||
import pathlib
|
||
import fractions
|
||
import generator.musicxml
|
||
import generator.parts
|
||
import generator.training_io
|
||
import generator.harmonic_time
|
||
import generator.solo.analysis
|
||
import generator.solo.types
|
||
import generator.solo.placement
|
||
import generator.solo.profiles
|
||
import generator.solo.harmonic
|
||
import generator.production
|
||
|
||
def sounding(evidence, offset=fractions.Fraction()):
|
||
result = []
|
||
for i, event in enumerate(evidence['sounding_events']):
|
||
p = event['pitch']
|
||
number = fractions.Fraction(event['sounding_midi'])
|
||
if number.denominator != 1:
|
||
raise ValueError('Microtonal guitar solo is unsupported')
|
||
pitch = generator.solo.types.Pitch(p['step'].lower(), fractions.Fraction(p['alter']), p['octave'] + (int(number) - int(fractions.Fraction(event['encoded_midi']))) // 12)
|
||
result.append(generator.solo.types.Note(fractions.Fraction(event['start']) - offset, fractions.Fraction(event['end']) - offset, pitch, role='authored', event_id=f'authored_{i}'))
|
||
return tuple(result)
|
||
|
||
def validate_evidence(document, evidence):
|
||
"""Training uses the same source-form harmonic context as solo playback."""
|
||
h = generator.solo.harmonic.Harmony(generator.harmonic_time.solo_source(document['staves'][0]))
|
||
checked = h.evaluate(sounding(evidence), complete=True)
|
||
if not checked.valid:
|
||
raise ValueError('Unsupported harmonic relationship in authored solo: ' + checked.error)
|
||
return checked
|
||
|
||
def lookup(source, role, data_dir):
|
||
root = pathlib.Path(data_dir)
|
||
manifest = root / 'training/manifest.json'
|
||
if not manifest.exists():
|
||
return None
|
||
value = json.loads(manifest.read_text())
|
||
if value.get('version') != 1:
|
||
raise ValueError('Unsupported training manifest')
|
||
matches = []
|
||
for entry in value['examples']:
|
||
if entry['slot'] != (1 if role == 'acoustic' else 2):
|
||
continue
|
||
path = (manifest.parent / entry['source']).resolve()
|
||
if not path.is_relative_to((root / 'songs').resolve()):
|
||
raise ValueError('Registered sources must be in data/songs')
|
||
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
|
||
if generator.production.SourceAnalysis.read(doc).musical_key == source.musical_key:
|
||
target = (manifest.parent / entry['solo']).resolve()
|
||
if not target.is_relative_to(manifest.parent.resolve()):
|
||
raise ValueError('Registered solo must be inside data/training')
|
||
evidence = generator.training_io.read(target, role, pitch_convention=entry['pitch_convention'])
|
||
generator.training_io.validate_harmony(doc, evidence, entry['id'])
|
||
matches.append({**entry, 'path': target, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
|
||
if len(matches) > 1:
|
||
raise ValueError('Multiple authored solos registered for the same source music and role')
|
||
return matches[0] if matches else None
|
||
|
||
def realize(entry, source_analysis, measures, part, start, end, initial=None):
|
||
evidence = generator.training_io.read(entry['path'], part, pitch_convention=entry['pitch_convention'])
|
||
if evidence['discrepancies']:
|
||
raise ValueError(entry['id'] + ': invalid authored timing/ties: ' + str(evidence['discrepancies']))
|
||
expected = [(a, b - a) for a, b in zip(source_analysis.boundaries, source_analysis.boundaries[1:])]
|
||
if [(fractions.Fraction(m['start']), fractions.Fraction(m['duration'])) for m in evidence['measures']] != expected:
|
||
raise ValueError(entry['id'] + ': source/solo forms differ; register and retrain the correct pair')
|
||
notes = sounding(evidence, source_analysis.body_offset)
|
||
for n in notes:
|
||
if n.start < start or n.end > end:
|
||
raise ValueError(f'{entry["id"]}: authored event {n.start}–{n.end} conflicts with arrangement window {start}–{end}; no clipping applied')
|
||
profile = generator.solo.profiles.for_part(part, source_analysis)
|
||
illegal = next((n for n in notes if not generator.solo.placement.positions(n.pitch.number(), profile)), None)
|
||
if illegal:
|
||
raise ValueError(f'{entry["id"]}: {illegal.pitch.label()} at {illegal.start} is outside strings 1–6/frets 1–12; authored solo left unchanged')
|
||
placed = generator.solo.placement.place(tuple(notes), source_analysis.tempo, profile=profile)
|
||
if placed is None:
|
||
raise ValueError(entry['id'] + ': no legal complete guitar placement')
|
||
spans = tuple(dataclasses.replace(s, start=s.start + start, end=s.end + start) for s in generator.harmonic_time.read({'measures': measures}, initial=initial))
|
||
checked = generator.solo.harmonic.Harmony(spans).evaluate(placed[1], complete=True)
|
||
if not checked.valid:
|
||
raise ValueError(entry['id'] + ': ' + checked.error)
|
||
phrase = generator.solo.types.Phrase('authored', start, end, placed[1], 'authored', 'authored', 'handoff', theme=entry['id'], expressions=checked.contracts)
|
||
return generator.solo.types.Solo(part, spans, (phrase,), source_analysis.tempo, end,
|
||
{'construction': 'authored_repertoire', 'authored_id': entry['id'], 'authored_sha256': entry['sha256'],
|
||
'pitch_convention': entry['pitch_convention'], 'exact_sounding_events': True,
|
||
'note': 'Authored music is preserved and passes automatic contextual harmonic validation.',
|
||
'annotations': evidence['annotations']}, profile, start)
|