+124
-98
@@ -6,127 +6,153 @@ 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
|
||||
import fractions
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.solo.types
|
||||
import generator.solo.profiles
|
||||
import generator.solo.expressions
|
||||
import generator.solo.recognition
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Roles:
|
||||
basic:frozenset
|
||||
specified:frozenset
|
||||
degrees:tuple
|
||||
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 degree_roles(kind, root, degrees):
|
||||
basic, specified, labels = set(), set(), {}
|
||||
for degree, alter in degrees:
|
||||
pc = int(root + generator.harmony.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())))
|
||||
if not chord or chord['kind'] == 'none':
|
||||
return Roles(frozenset(), frozenset(), ())
|
||||
root = chord['root']
|
||||
pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
|
||||
return degree_roles(chord['kind'], pc, tuple(sorted(generator.harmony.degrees(chord).items())))
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Span:
|
||||
start:F
|
||||
end:F
|
||||
roles:Roles
|
||||
chord:str
|
||||
key:dict
|
||||
pitches:frozenset
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
roles: Roles
|
||||
chord: str
|
||||
key: dict
|
||||
pitches: frozenset
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Result:
|
||||
valid:bool
|
||||
exposure:float
|
||||
pending:tuple=()
|
||||
error:str=''
|
||||
contracts:tuple=()
|
||||
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={}
|
||||
def __init__(self, spans, profile=None):
|
||||
self.profile = profile or generator.solo.profiles.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)
|
||||
r = roles(s.chord)
|
||||
key = frozenset((generator.model.NATURAL[l] + int(generator.model.fraction(a))) % 12 for l, a in zip(generator.model.LETTERS, s.key['alterations']))
|
||||
self.spans.append(Span(s.start, s.end, r, generator.harmony.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)
|
||||
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()
|
||||
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
|
||||
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 ''
|
||||
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
|
||||
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())
|
||||
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)
|
||||
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):
|
||||
notes = tuple(self.identified(n) for n in generator.solo.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=fractions.Fraction())
|
||||
permissions, pending, error = generator.solo.expressions.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()
|
||||
expressions, error = generator.solo.recognition.recognize(notes, self, expressions)
|
||||
if error:
|
||||
return Result(False, 0, error=error)
|
||||
permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, True)
|
||||
if error:
|
||||
return Result(False, 0, error=error)
|
||||
exposed = sounded = fractions.Fraction()
|
||||
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)]
|
||||
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(fractions.Fraction(1, 1000), sounded)), pending, contracts=tuple(expressions))
|
||||
def describe(self, notes, expressions=()):
|
||||
notes = tuple(self.identified(n) for n in generator.solo.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)]
|
||||
|
||||
Reference in New Issue
Block a user