Files
product-vamp/generator/solo/authored.py
T
khodowany bc7baea7c4
ci / checks (push) Failing after 23m22s
Cleanup
2026-10-08 02:13:45 -05:00

101 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 fractions
import hashlib
import json
import pathlib
import generator.harmonic_time
import generator.musicxml
import generator.parts
import generator.solo.harmonic
import generator.solo.placement
import generator.solo.profiles
import generator.solo.types
import generator.source
import generator.training_io
import generator.vocabulary
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, *, composition=None):
"""Training uses the same source-form harmonic context as solo playback."""
composition = composition or generator.vocabulary.current()
h = generator.solo.harmonic.Harmony(generator.harmonic_time.solo_source(document['staves'][0]), composition=composition)
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.source.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, *, composition=None):
composition = composition or generator.vocabulary.current()
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, composition=composition).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)