"""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='' 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<=timen.start) @functools.lru_cache(maxsize=8192) def members(self,start,end): spans=[s for s in self.spans if s.startstart] 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.startstart] 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 00 else '' def evaluate(self,notes,*,until=None,complete=False,expressions=()): from generator.solo.expressions import validate_frontier notes=types.sounding_notes(notes) 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) 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 and n.role!='quote':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) def describe(self,notes,expressions=()): from generator.solo.expressions import validate permissions,error=validate(notes,expressions,self) if error:raise ValueError(error) 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)]