131 lines
5.9 KiB
Python
131 lines
5.9 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
|
|
from fractions import Fraction as F
|
|
from pathlib import Path
|
|
from generator import model
|
|
from generator.solo import 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=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=expressions.parsed_chord(future.chord) if future else None
|
|
def root(c):return int(model.NATURAL[c['root']['letter']]+model.alteration(c['root']['alter']))%12 if c else 0
|
|
return (expressions.quality(current) if current else 'unknown',
|
|
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(F,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(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=F),'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())}}
|