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

136 lines
6.4 KiB
Python

"""Smoothed joint musical-event model. No examples or song lookup at inference.
Counts are sufficient statistics of short, relative contexts, not stored scores.
The hierarchical Dirichlet estimator uses eight parent pseudo-observations.
All paths through the decoder score the same sounding event stream identically.
"""
from __future__ import annotations
import collections
import json
import math
import functools
import fractions
import pathlib
import generator.model
import generator.solo.expressions
VERSION = 'pooled-events-1'
PRIOR = 8
DURATIONS = ('1/4', '1/3', '1/2', '2/3', '3/4', '1', '3/2', '2', '5/2', '3', '4')
def key(value):
return json.dumps(value, separators=(',', ':'))
def chord_context(harmony, time):
span = harmony.at(time)
current = generator.solo.expressions.parsed_chord(span.chord) if span and span.chord != 'N.C.' else None
future = next((s for s in harmony.spans if s.start > time and s.chord != 'N.C.' and (not span or s.chord != span.chord)), None)
following = generator.solo.expressions.parsed_chord(future.chord) if future else None
def root(c): return int(generator.model.NATURAL[c['root']['letter']] + generator.model.alteration(c['root']['alter'])) % 12 if c else 0
return (generator.solo.expressions.quality(current) if current else 'unknown',
generator.solo.expressions.quality(following) if following else 'end',
(root(following) - root(current)) % 12 if current and following else -1), root(current), future
def token(duration, pitch, previous, root):
return ('rest', str(duration), 0, 0) if pitch is None else ('note', str(duration), pitch - previous if previous is not None else 'start', (pitch - root) % 12)
def source_feature(analysis, time, root=0):
time += analysis.body_offset
before = [n for n in analysis.melody if n.start <= time][-2:]
following = next((n for n in analysis.melody if n.start > time), None)
direction = 0 if len(before) < 2 else (before[-1].pitch.number() > before[-2].pitch.number()) - (before[-1].pitch.number() < before[-2].pitch.number())
held = bool(before and before[-1].end > time)
upcoming = 0 if not before or not following else (following.pitch.number() > before[-1].pitch.number()) - (following.pitch.number() < before[-1].pitch.number())
return (direction, held, upcoming, str(before[-1].end - before[-1].start) if before else 'none',
(before[-1].pitch.number() - root) % 12 if before else -1,
before[-1].pitch.number() - before[-2].pitch.number() if len(before) > 1 else 0)
def phase_at(analysis, time):
absolute = time + analysis.body_offset
start = next((a for a, b in zip(analysis.boundaries, analysis.boundaries[1:]) if a <= absolute < b), analysis.boundaries[-2])
return absolute - start
@functools.lru_cache(maxsize=65536)
def contexts(harmonic, phase, source, history):
# No title, path, absolute bar number, progression ID, or target-derived ID.
base = (*harmonic, str(phase))
result = [(), base, (*base, source)]
# A complete four-event rhythmic/melodic relationship can influence the next
# event, while unseen histories smoothly return to broader musical evidence.
for count in range(1, min(4, len(history)) + 1):
result.append((*base, source, tuple(history[-count:])))
return tuple(key(c) for c in result)
class Model:
def __init__(self, value):
if value.get('version') != VERSION:
raise ValueError('Missing/incompatible solo model; run python -m generator.train')
self.value = value
self.tables = {k: (v['total'], v['counts']) for k, v in value['contexts'].items()}
self.durations = tuple(sorted(map(fractions.Fraction, value['durations'])))
self.rhythms = value['rhythms']
self.steps = value['steps']
self.degrees = value['degrees']
self._prob = {}
self._totals = {id(t): sum(t.values()) for t in (self.rhythms, self.steps, self.degrees)}
@classmethod
def load(cls, path):
try:
return cls(json.loads(pathlib.Path(path).read_text()))
except FileNotFoundError as e:
raise ValueError('Solo model not found; run python -m generator.train') from e
def probability(self, action, context):
cache = (action, context)
if cache in self._prob:
return self._prob[cache]
kind, duration, step, degree = action
def p(table, item, size): return (table.get(str(item), 0) + 1) / (self._totals[id(table)] + size)
probability = p(self.rhythms, kind + ':' + duration, 2 * len(self.durations))
if kind == 'note':
probability *= p(self.steps, step, 50) * p(self.degrees, degree, 12)
encoded = key(action)
for c in context:
if c in self.tables:
total, counts = self.tables[c]
probability = (counts.get(encoded, 0) + PRIOR * probability) / (total + PRIOR)
if len(self._prob) > 100000:
self._prob.clear()
self._prob[cache] = probability
return probability
def cost(self, action, context): return -math.log(self.probability(action, context))
def register_cost(self, number):
counts = self.value['registers']
return -math.log((counts.get(str(number // 12), 0) + PRIOR) / (sum(counts.values()) + PRIOR * 4))
def fit(examples, provenance):
tables = collections.defaultdict(collections.Counter)
rhythms = collections.Counter()
steps = collections.Counter()
degrees = collections.Counter()
registers = collections.Counter()
durations = set(DURATIONS)
for example in examples:
history = []
previous = None
for start, end, pitch in example['events']:
harmonic, root, _ = chord_context(example['harmony'], start)
action = token(end - start, pitch, previous, root)
context = contexts(harmonic, phase_at(example['analysis'], start - example['analysis'].body_offset),
source_feature(example['analysis'], start - example['analysis'].body_offset, root), tuple(history[-4:]))
for c in context:
tables[c][key(action)] += 1
durations.add(str(end - start))
rhythms[action[0] + ':' + action[1]] += 1
if pitch is not None:
steps[str(action[2])] += 1
degrees[str(action[3])] += 1
registers[str(pitch // 12)] += 1
previous = pitch
history.append(action)
return {'version': VERSION, 'provenance': provenance, 'prior': PRIOR,
'durations': sorted(durations, key=fractions.Fraction), 'rhythms': dict(rhythms), 'steps': dict(steps), 'degrees': dict(degrees),
'registers': dict(registers),
'contexts': {k: {'total': sum(v.values()), 'counts': dict(sorted(v.items()))} for k, v in sorted(tables.items())}}