Files
product-vamp/generator/solo/harmonic.py
T
khodowany ffdbe48258
ci / checks (push) Failing after 43m34s
Validation
2026-10-05 20:33:14 -05:00

133 lines
6.8 KiB
Python

"""Written chord, source-key motion, and bounded expressive treatments.
There is no preselected scale map. Optional colors never rewrite a chord;
permission follows the actual sounded interval and validated resolution.
"""
from __future__ import annotations
import dataclasses
import functools
from fractions import Fraction as F
from generator import harmony as chords, model
from generator.solo import types
@dataclasses.dataclass(frozen=True)
class Roles:
basic:frozenset
specified:frozenset
degrees:tuple
@functools.lru_cache(maxsize=512)
def degree_roles(kind,root,degrees):
basic,specified,labels=set(),set(),{}
for degree,alter in degrees:
pc=int(root+chords.degree_semitones(degree,alter))%12
structural=degree in (1,3,5,6,7) or degree in (2,4) and kind in ('suspended-second','suspended-fourth')
(basic if structural else specified).add(pc);labels.setdefault(pc,[]).append(degree)
return Roles(frozenset(basic),frozenset(specified-basic),tuple((p,tuple(v)) for p,v in sorted(labels.items())))
def roles(chord):
if not chord or chord['kind']=='none':return Roles(frozenset(),frozenset(),())
root=chord['root'];pc=model.NATURAL[root['letter']]+model.alteration(root['alter'])
return degree_roles(chord['kind'],pc,tuple(sorted(chords.degrees(chord).items())))
@dataclasses.dataclass(frozen=True)
class Span:
start:F
end:F
roles:Roles
chord:str
key:dict
pitches:frozenset
@dataclasses.dataclass(frozen=True)
class Result:
valid:bool
exposure:float
pending:tuple=()
error:str=''
contracts:tuple=()
class Harmony:
def __init__(self,spans,profile=None):
from generator.solo.profiles import UNRESTRICTED
self.profile=profile or UNRESTRICTED
self.source_spans=spans;self.spans=[];self.color_eligibility={}
for s in spans:
r=roles(s.chord)
key=frozenset((model.NATURAL[l]+int(model.fraction(a)))%12 for l,a in zip(model.LETTERS,s.key['alterations']))
self.spans.append(Span(s.start,s.end,r,chords.symbol(s.chord) if s.chord else 'N.C.',s.key,key|r.basic|r.specified))
self.spans=tuple(self.spans)
@functools.lru_cache(maxsize=2048)
def at(self,time):return next((s for s in self.spans if s.start<=time<s.end),None)
def segments(self,n):return tuple((s,max(n.start,s.start),min(n.end,s.end)) for s in self.spans if s.start<n.end and s.end>n.start)
@functools.lru_cache(maxsize=8192)
def members(self,start,end):
spans=[s for s in self.spans if s.start<end and s.end>start]
return frozenset.intersection(*(s.roles.basic|s.roles.specified for s in spans)) if spans else frozenset()
@functools.lru_cache(maxsize=8192)
def permitted(self,start,end):
spans=[s for s in self.spans if s.start<end and s.end>start]
return frozenset.intersection(*(s.pitches if s.chord!='N.C.' else frozenset() for s in spans)) if spans else frozenset()
def function(self,number,span):
pc=number%12
return 'basic' if pc in span.roles.basic else 'specified_extension' if pc in span.roles.specified else 'diatonic_motion' if pc in span.pitches else 'chromatic'
def step(self,a,b,time):return 0<abs(a-b)<=2
def connected_motion(self,notes,index):
if not 0<index<len(notes)-1:return ''
a,n,b=notes[index-1:index+2]
if a.end!=n.start or n.end!=b.start:return ''
left=n.pitch.number()-a.pitch.number();right=b.pitch.number()-n.pitch.number()
return 'neighbor' if 0<abs(left)<=2 and left==-right else 'passing' if 0<abs(left)<=2 and 0<abs(right)<=2 and left*right>0 else ''
@functools.lru_cache(maxsize=8192)
def _ordinary(self,start,end,number):
covered=start
for s in self.spans:
a,b=max(start,s.start),min(end,s.end)
if a>=b:continue
if a!=covered or s.chord=='N.C.':return False
covered=b;pc=number%12
if pc not in s.roles.basic|s.roles.specified and (pc not in s.pitches or end-start>=2):return False
return covered==end and end>start
def ordinary(self,n):return self._ordinary(n.start,n.end,n.pitch.number())
@staticmethod
def identified(n):
return n if n.event_id else dataclasses.replace(n,event_id=f'event:{n.start}:{n.end}:{n.pitch.label()}')
def evaluate(self,notes,*,until=None,complete=False,expressions=(),infer=True):
from generator.solo.expressions import validate_frontier
notes=tuple(self.identified(n) for n in types.sounding_notes(notes))
expressions=tuple(dataclasses.replace(c,events=tuple(self.identified(n) for n in c.events),
target=self.identified(c.target),preparation=self.identified(c.preparation) if c.preparation else None) for c in expressions)
frontier=until if until is not None else max((n.end for n in notes),default=F())
permissions,pending,error=validate_frontier(notes,expressions,self,frontier,complete or until is None)
if error:return Result(False,0,error=error)
if infer and not pending and (complete or until is None) and any(not self.ordinary(n) and n.event_id not in permissions for n in notes):
from generator.solo.recognition import recognize
expressions,error=recognize(notes,self,expressions)
if error:return Result(False,0,error=error)
permissions,pending,error=validate_frontier(notes,expressions,self,frontier,True)
if error:return Result(False,0,error=error)
exposed=sounded=F()
for n in notes:
covered=n.start;duration=n.end-n.start;sounded+=duration
for s,a,b in self.segments(n):
if a!=covered or s.chord=='N.C.':return Result(False,0,error=f'{n.event_id}: missing written/carried harmony')
covered=b;permission=permissions.get(n.event_id)
if permission and permission.kind=='anticipation' and a>=permission.boundary:permission=None
pc=n.pitch.number()%12
if pc in s.roles.basic|s.roles.specified or permission:continue
if pc not in s.pitches:return Result(False,0,error=f'{n.event_id}: unsupported chromatic event')
if duration>=2:return Result(False,0,error=f'{n.event_id}: sustained nonchord tone needs an explicit treatment')
exposed+=(b-a)/4
if covered!=n.end:return Result(False,0,error=f'{n.event_id}: incomplete harmonic coverage')
return Result(True,float(exposed/max(F(1,1000),sounded)),pending,contracts=tuple(expressions))
def describe(self,notes,expressions=()):
notes=tuple(self.identified(n) for n in types.sounding_notes(notes))
checked=self.evaluate(notes,expressions=expressions,complete=True)
if not checked.valid:raise ValueError(checked.error)
permissions={n.event_id:c for c in checked.contracts for n in c.events}
return [{'start':str(a),'end':str(b),'attack':str(n.start),'sounding_duration':str(n.end-n.start),
'pitch':n.pitch.label(),'chord':s.chord,'function':self.function(n.pitch.number(),s),
'expression':permissions[n.event_id].kind if n.event_id in permissions else '',
'expression_rule':permissions[n.event_id].rule if n.event_id in permissions else '',
'protected':n.role=='quote'} for n in notes for s,a,b in self.segments(n)]