Files
product-vamp/generator/train.py
T
khodowany 82349cf897
ci / checks (push) Failing after 23m37s
Playback
2026-10-05 23:41:11 -05:00

204 lines
10 KiB
Python

"""Explicit, deterministic supervised training; never called by an asset build."""
from __future__ import annotations
import argparse
import collections
import dataclasses
import hashlib
import json
import os
import pathlib
import tempfile
import time
import fractions
import generator.harmony
import generator.model
import generator.musicxml
import generator.parts
import generator.training_io
import generator.vocabulary
import generator.harmonic_time
import generator.solo.analysis
import generator.solo.learned
import shutil
import generator.production
import generator.solo.authored
import generator.fretboard
@dataclasses.dataclass(frozen=True)
class Span:
start: fractions.Fraction
end: fractions.Fraction
chord: str
class Context:
def __init__(self, document):
self.spans = tuple(Span(s.start, s.end, generator.harmony.symbol(s.chord) if s.chord else 'N.C.')
for s in generator.harmonic_time.read(document['staves'][0]))
def at(self, time): return next((s for s in self.spans if s.start <= time < s.end), None)
def load_examples(manifest):
manifest = pathlib.Path(manifest)
value = json.loads(manifest.read_text())
if value.get('version') != 1:
raise ValueError('Unsupported training manifest')
examples = []
provenance = []
seen = set()
pairs = set()
for entry in sorted(value['examples'], key=lambda e: e['id']):
if entry['id'] in seen or entry['slot'] not in (1, 2):
raise ValueError('Duplicate example or invalid role')
seen.add(entry['id'])
role = 'acoustic' if entry['slot'] == 1 else 'electric'
source = (manifest.parent / entry['source']).resolve()
solo = (manifest.parent / entry['solo']).resolve()
if not solo.is_relative_to(manifest.parent.resolve()):
raise ValueError('Training solo must be inside the training directory')
if not source.is_relative_to((manifest.parent.parent / 'songs').resolve()):
raise ValueError('Training sources must be inside data/songs')
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(source))
a = generator.solo.analysis.analyze(document)
pair = (generator.production.SourceAnalysis.read(document).musical_key, entry['slot'])
if pair in pairs:
raise ValueError('Duplicate source music and solo slot')
pairs.add(pair)
evidence = generator.training_io.read(solo, role, pitch_convention=entry['pitch_convention'])
spans = [(fractions.Fraction(m['start']), fractions.Fraction(m['duration'])) for m in evidence['measures']]
if spans != [(x, y - x) for x, y in zip(a.boundaries, a.boundaries[1:])]:
raise ValueError(entry['id'] + ': source/solo measure timing differs; no padding or pickup removal allowed')
if evidence['discrepancies']:
raise ValueError(entry['id'] + ': ' + str(evidence['discrepancies']))
ctx = Context(document)
generator.training_io.validate_harmony(document, evidence, entry['id'])
checked = generator.solo.authored.validate_evidence(document, evidence)
events = []
cursor = fractions.Fraction()
for n in evidence['sounding_events']:
start, end = fractions.Fraction(n['start']), fractions.Fraction(n['end'])
number = fractions.Fraction(n['sounding_midi'])
if number.denominator != 1:
raise ValueError(entry['id'] + ': microtonal guitar pitch is unsupported')
pitch = int(number)
if not generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch)):
raise ValueError(entry['id'] + ': unplayable sounding pitch ' + str(pitch) + ' at ' + str(start))
if start < cursor or end <= start:
raise ValueError(entry['id'] + ': non-monophonic/invalid event')
if start > cursor:
events.append((cursor, start, None))
events.append((start, end, pitch))
cursor = end
if cursor < a.boundaries[-1]:
events.append((cursor, a.boundaries[-1], None))
source_hash = hashlib.sha256(source.read_bytes()).hexdigest()
examples.append({'id': entry['id'], 'song': source_hash, 'role': role, 'analysis': a, 'harmony': ctx, 'events': tuple(events),
'harmonic_validation': {'valid': checked.valid, 'treatments': dict(sorted(collections.Counter(c.rule for c in checked.contracts).items()))}})
provenance.append({'id': entry['id'], 'source_sha256': source_hash, 'solo_sha256': evidence['sha256'],
'pitch_convention': entry['pitch_convention'], 'measures': len(spans), 'pickup_beats': str(a.body_offset),
'slot': entry['slot'], 'attacks': len(evidence['sounding_events']), 'annotations': len(evidence['annotations']),
'adapter_notes': ['Trailing chord-display parentheses interpreted without modifying XML.'] if any(h['display'] and h['display'].endswith(')') and '(' not in h['display'] for h in evidence['harmonies']) else []})
if not examples:
raise ValueError('No training examples')
return examples, {'description': value['provenance'], 'examples': provenance, 'training_version': generator.solo.learned.VERSION}
def likelihood(predictor, example):
history = []
previous = None
cost = 0
for start, end, pitch in example['events']:
harmonic, root, _ = generator.solo.learned.chord_context(example['harmony'], start)
action = generator.solo.learned.token(end - start, pitch, previous, root)
relative = start - example['analysis'].body_offset
c = generator.solo.learned.contexts(harmonic, generator.solo.learned.phase_at(example['analysis'], relative), generator.solo.learned.source_feature(example['analysis'], relative, root), tuple(history[-4:]))
cost += predictor.cost(action, c)
history.append(action)
if pitch is not None:
previous = pitch
return cost / len(history)
def atomic_json(path, value):
path = pathlib.Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile('w', dir=path.parent, delete=False) as stream:
json.dump(value, stream, sort_keys=True, separators=(',', ':'))
stream.write('\n')
name = stream.name
os.chmod(name, 0o644)
os.replace(name, path)
def register_example(manifest, source, solo, slot, pitch_convention):
"""Copy and validate a new pair before atomically publishing its manifest."""
manifest = pathlib.Path(manifest).resolve()
root = manifest.parent
sources = root.parent / 'songs'
root.mkdir(parents=True, exist_ok=True)
sources.mkdir(parents=True, exist_ok=True)
value = json.loads(manifest.read_text()) if manifest.exists() else {'version': 1, 'provenance': 'User-authored paired song/solo training corpus.', 'examples': []}
source, solo = pathlib.Path(source).resolve(), pathlib.Path(solo).resolve()
destinations = ((source, sources / source.name), (solo, root / solo.name))
created = []
temporary = None
try:
for original, destination in destinations:
if destination.exists():
if original.read_bytes() != destination.read_bytes():
raise ValueError(str(destination) + ': already exists with different contents; no overwrite performed')
else:
shutil.copyfile(original, destination)
created.append(destination)
entry = {'id': source.stem + '/solo' + str(slot), 'source': '../songs/' + source.name, 'solo': solo.name,
'slot': slot, 'pitch_convention': pitch_convention}
if any(e['id'] == entry['id'] for e in value['examples']):
raise ValueError('Example already registered: ' + entry['id'])
value['examples'].append(entry)
with tempfile.NamedTemporaryFile('w', dir=root, suffix='.json', delete=False) as stream:
json.dump(value, stream)
temporary = pathlib.Path(stream.name)
load_examples(temporary)
atomic_json(manifest, value)
except BaseException:
for p in created:
p.unlink()
raise
finally:
if temporary:
temporary.unlink(missing_ok=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--manifest', type=pathlib.Path, default=pathlib.Path('data/training/manifest.json'))
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('data/models/solo.json'))
parser.add_argument('--report', type=pathlib.Path, default=pathlib.Path('build/training/report.json'))
parser.add_argument('--leave-one-song-out', action='store_true')
parser.add_argument('--add-source', type=pathlib.Path, help='Register a source song (copied to data/songs if needed).')
parser.add_argument('--add-solo', type=pathlib.Path, help='Register its authored solo (copied unchanged to data/training).')
parser.add_argument('--slot', type=int, choices=(1, 2), help='Arrangement mapping only: 1 acoustic, 2 electric; learning is pooled.')
parser.add_argument('--pitch-convention', choices=('guitar-written', 'encoded'), default='guitar-written')
args = parser.parse_args()
began = time.monotonic()
if any((args.add_source, args.add_solo, args.slot)):
if not all((args.add_source, args.add_solo, args.slot)):
parser.error('--add-source, --add-solo and --slot must be supplied together')
register_example(args.manifest, args.add_source, args.add_solo, args.slot, args.pitch_convention)
examples, provenance = load_examples(args.manifest)
value = generator.solo.learned.fit(examples, provenance)
predictor = generator.solo.learned.Model(value)
report = {'version': generator.solo.learned.VERSION, 'training': provenance, 'model_sha256': generator.vocabulary.fingerprint(value),
'contexts': len(value['contexts']), 'in_sample_event_nll': {e['id']: likelihood(predictor, e) for e in examples},
'harmonic_validation': {e['id']: e['harmonic_validation'] for e in examples},
'limitations': 'A small corpus does not establish generalization. Likelihood is teacher-forced, not free generation or musical preference.'}
if args.leave_one_song_out:
report['held_out_event_nll'] = {}
for song in sorted({e['song'] for e in examples}):
remaining = [e for e in examples if e['song'] != song]
if not remaining:
continue
fitted = generator.solo.learned.Model(generator.solo.learned.fit(remaining, {'evaluation_only': True}))
report['held_out_event_nll'].update({e['id']: likelihood(fitted, e) for e in examples if e['song'] == song})
atomic_json(args.output, value)
report['training_seconds'] = time.monotonic() - began
atomic_json(args.report, report)
print(f'Trained {len(examples)} solos: {len(value["contexts"])} contexts in {report["training_seconds"]:.3f}s; {args.output}')
if __name__ == '__main__':
main()