+26
-11
@@ -11,7 +11,25 @@ import json
|
||||
from pathlib import Path
|
||||
from fractions import Fraction as F
|
||||
from generator import musicxml,parts,training_io,harmonic_time
|
||||
from generator.solo import analysis,types,placement,profiles
|
||||
from generator.solo import analysis,types,placement,profiles,harmonic
|
||||
|
||||
|
||||
def sounding(evidence,offset=F()):
|
||||
result=[]
|
||||
for i,event in enumerate(evidence['sounding_events']):
|
||||
p=event['pitch'];number=F(event['sounding_midi'])
|
||||
if number.denominator!=1:raise ValueError('Microtonal guitar solo is unsupported')
|
||||
pitch=types.Pitch(p['step'].lower(),F(p['alter']),p['octave']+(int(number)-int(F(event['encoded_midi'])))//12)
|
||||
result.append(types.Note(F(event['start'])-offset,F(event['end'])-offset,pitch,role='authored',event_id=f'authored_{i}'))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def validate_evidence(document,evidence):
|
||||
"""Training uses the same source-form harmonic context as solo playback."""
|
||||
h=harmonic.Harmony(harmonic_time.solo_source(document['staves'][0]))
|
||||
checked=h.evaluate(sounding(evidence),complete=True)
|
||||
if not checked.valid:raise ValueError('Unsupported harmonic relationship in authored solo: '+checked.error)
|
||||
return checked
|
||||
|
||||
|
||||
def lookup(source,role,data_dir):
|
||||
@@ -41,23 +59,20 @@ def realize(entry,source_analysis,measures,part,start,end,initial=None):
|
||||
if evidence['discrepancies']:raise ValueError(entry['id']+': invalid authored timing/ties: '+str(evidence['discrepancies']))
|
||||
expected=[(a,b-a) for a,b in zip(source_analysis.boundaries,source_analysis.boundaries[1:])]
|
||||
if [(F(m['start']),F(m['duration'])) for m in evidence['measures']]!=expected:raise ValueError(entry['id']+': source/solo forms differ; register and retrain the correct pair')
|
||||
notes=[]
|
||||
for i,event in enumerate(evidence['sounding_events']):
|
||||
a,b=F(event['start'])-source_analysis.body_offset,F(event['end'])-source_analysis.body_offset
|
||||
if a<start or b>end:raise ValueError(f'{entry["id"]}: authored event {a}–{b} conflicts with arrangement window {start}–{end}; no clipping applied')
|
||||
p=event['pitch'];number=F(event['sounding_midi'])
|
||||
if number.denominator!=1:raise ValueError('Microtonal guitar solo is unsupported')
|
||||
pitch=types.Pitch(p['step'].lower(),F(p['alter']),p['octave']+(int(number)-int(F(event['encoded_midi'])))//12)
|
||||
notes.append(types.Note(a,b,pitch,('authored:'+entry['id'],),'authored',event_id=f'authored_{i}'))
|
||||
notes=sounding(evidence,source_analysis.body_offset)
|
||||
for n in notes:
|
||||
if n.start<start or n.end>end:raise ValueError(f'{entry["id"]}: authored event {n.start}–{n.end} conflicts with arrangement window {start}–{end}; no clipping applied')
|
||||
profile=profiles.for_part(part,source_analysis)
|
||||
illegal=next((n for n in notes if not placement.positions(n.pitch.number(),profile)),None)
|
||||
if illegal:raise ValueError(f'{entry["id"]}: {illegal.pitch.label()} at {illegal.start} is outside strings 1–6/frets 1–12; authored solo left unchanged')
|
||||
placed=placement.place(tuple(notes),source_analysis.tempo,profile=profile)
|
||||
if placed is None:raise ValueError(entry['id']+': no legal complete guitar placement')
|
||||
spans=tuple(dataclasses.replace(s,start=s.start+start,end=s.end+start) for s in harmonic_time.read({'measures':measures},initial=initial))
|
||||
phrase=types.Phrase('authored',start,end,placed[1],'authored','authored','handoff',theme=entry['id'])
|
||||
checked=harmonic.Harmony(spans).evaluate(placed[1],complete=True)
|
||||
if not checked.valid:raise ValueError(entry['id']+': '+checked.error)
|
||||
phrase=types.Phrase('authored',start,end,placed[1],'authored','authored','handoff',theme=entry['id'],expressions=checked.contracts)
|
||||
return types.Solo(part,spans,(phrase,),source_analysis.tempo,end,
|
||||
{'construction':'authored_repertoire','authored_id':entry['id'],'authored_sha256':entry['sha256'],
|
||||
'pitch_convention':entry['pitch_convention'],'exact_sounding_events':True,
|
||||
'note':'Authored music is preserved, not reclassified as generated harmonic exceptions.',
|
||||
'note':'Authored music is preserved and passes automatic contextual harmonic validation.',
|
||||
'annotations':evidence['annotations']},profile,start)
|
||||
|
||||
+105
-18
@@ -46,7 +46,7 @@ class Decoder:
|
||||
self.durations=tuple(d for d in model.durations if 0<d<=4 and d.denominator in (1,2,4) and
|
||||
(d.denominator!=4 or analysis.tempo<=limits['sixteenth_max_bpm']))
|
||||
self.allow_triplets=analysis.tempo<=limits['triplet_max_bpm']
|
||||
self._actions={};self._context={};self._pitches={};self.trace=[];self.counts=collections.Counter()
|
||||
self._actions={};self._context={};self._pitches={};self._colors={};self._permissions={};self.trace=[];self.counts=collections.Counter()
|
||||
|
||||
def context(self,time):
|
||||
if time not in self._context:
|
||||
@@ -80,7 +80,37 @@ class Decoder:
|
||||
pitch=p if isinstance(p,types.Pitch) else self.pitch(p,a)
|
||||
return types.Note(a,b,pitch,role=role,event_id=f'{a}:{b}:{pitch.number()}')
|
||||
|
||||
def color_bases(self,n):
|
||||
key=(n.start,n.end,n.pitch.number())
|
||||
if key in self._colors:return self._colors[key]
|
||||
bases=[]
|
||||
regions=[(None,n)]
|
||||
regions.extend((s.start,dataclasses.replace(n,start=s.start)) for s in self.harmony.spans
|
||||
if n.start<s.start<n.end and s.start-n.start<=F(vocabulary.data('harmony')['anticipation_max']))
|
||||
for boundary,arrived in regions:
|
||||
exposed=[s for s,_,_ in self.harmony.segments(arrived) if n.pitch.number()%12 not in s.roles.basic|s.roles.specified]
|
||||
if not exposed:continue
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
if arrived.end-arrived.start>F(rule['max_beats']):continue
|
||||
if any(s.chord=='N.C.' or not expressions.eligible_color(rule,s,self.harmony) or
|
||||
expressions.degree_pitch(expressions.parsed_chord(s.chord),rule['degree'],rule['alter'],n.pitch.number()).number()!=n.pitch.number()
|
||||
for s in exposed):continue
|
||||
bases.append((rule,boundary))
|
||||
self._colors[key]=tuple(bases)
|
||||
return self._colors[key]
|
||||
|
||||
def color_contract(self,n,target,rule,boundary):
|
||||
span=self.harmony.at(boundary if boundary is not None else n.start)
|
||||
if not span:return None
|
||||
kind='anticipated_color' if boundary is not None else 'color'
|
||||
return types.ExpressionContract(n.event_id+':'+rule['id']+':'+str(boundary),kind,rule['id'],(n,),target,span.chord,boundary)
|
||||
|
||||
def permission(self,n):
|
||||
key=(n.start,n.end,n.pitch.number())
|
||||
if key not in self._permissions:self._permissions[key]=self._permission(n)
|
||||
return self._permissions[key]
|
||||
|
||||
def _permission(self,n):
|
||||
"""Find justified color/anticipation support, never authorize by label."""
|
||||
# Source spans are contiguous. These are precisely the ordinary-event
|
||||
# branches of Harmony.evaluate, without rebuilding an empty contract map.
|
||||
@@ -90,23 +120,64 @@ class Decoder:
|
||||
if not self.expressions_enabled:return None
|
||||
span=self.harmony.at(n.start)
|
||||
if not span or span.chord=='N.C.':return None
|
||||
exposed=tuple(region for region,_,_ in self.harmony.segments(n)
|
||||
if n.pitch.number()%12 not in region.roles.basic|region.roles.specified)
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
if rule.get('resolve') or n.end-n.start>F(rule['max_beats']):continue
|
||||
# A held chord member can become a color only after a chord change.
|
||||
# Test the regions needing permission, not just the attack's harmony.
|
||||
if any(region.chord=='N.C.' or not expressions.eligible_color(rule,region,self.harmony) or
|
||||
expressions.degree_pitch(expressions.parsed_chord(region.chord),rule['degree'],rule['alter'],n.pitch.number()).number()!=n.pitch.number()
|
||||
for region in exposed):continue
|
||||
c=types.ExpressionContract(n.event_id+':color','color',rule['id'],(n,),n,span.chord)
|
||||
if self.harmony.evaluate((n,),complete=True,expressions=(c,)).valid:return (c,)
|
||||
for rule,boundary in self.color_bases(n):
|
||||
if rule.get('resolve'):continue
|
||||
c=self.color_contract(n,n,rule,boundary)
|
||||
if self.harmony.evaluate((n,),complete=True,expressions=(c,),infer=False).valid:return (c,)
|
||||
for arriving in self.harmony.spans:
|
||||
if n.start<arriving.start<n.end and arriving.start-n.start<=F(vocabulary.data('harmony')['anticipation_max']):
|
||||
c=types.ExpressionContract(n.event_id+':anticipation','anticipation','early_chord_tone',(n,),n,arriving.chord,arriving.start)
|
||||
if self.harmony.evaluate((n,),complete=True,expressions=(c,)).valid:return (c,)
|
||||
if self.harmony.evaluate((n,),complete=True,expressions=(c,),infer=False).valid:return (c,)
|
||||
return None
|
||||
|
||||
def tails(self,n,target):
|
||||
"""Finite resolution timing: direct, real space, or one valid intervening note."""
|
||||
gap=target.start-n.end
|
||||
if gap<0:return
|
||||
if gap==0:
|
||||
yield ((n.start,n.end,n.pitch.number()),(target.start,target.end,target.pitch.number())),(n,target)
|
||||
return
|
||||
yield ((n.start,n.end,n.pitch.number()),(n.end,target.start,None),(target.start,target.end,target.pitch.number())),(n,target)
|
||||
windows={(n.end,target.start)} if gap<=1 else set()
|
||||
if gap>=1:windows|={(n.end,n.end+F(1,2)),(target.start-F(1,2),target.start)}
|
||||
for a,b in sorted(windows):
|
||||
for number in range(max(self.low,n.pitch.number()-3),min(self.high,n.pitch.number()+3)+1):
|
||||
mid=self.note(a,b,number)
|
||||
if number in (n.pitch.number(),target.pitch.number()) or not self.harmony.ordinary(mid):continue
|
||||
events=[(n.start,n.end,n.pitch.number())]
|
||||
if a>n.end:events.append((n.end,a,None))
|
||||
events.append((a,b,number))
|
||||
if b<target.start:events.append((b,target.start,None))
|
||||
events.append((target.start,target.end,target.pitch.number()))
|
||||
yield tuple(events),(n,mid,target)
|
||||
|
||||
def resolutions(self,n):
|
||||
"""Joint color/delayed-approach actions; the destination is never optional."""
|
||||
requests=[]
|
||||
for rule,boundary in self.color_bases(n):
|
||||
if rule.get('resolve'):
|
||||
for gap in (F(),F(1,2),F(1)):
|
||||
if gap<=F(vocabulary.data('harmony')['resolution_gap']):requests.append(('color',rule,boundary,n.end+gap,(-2,-1,1,2)))
|
||||
delay=vocabulary.data('lead_ins')['delayed_approach']
|
||||
if n.end-n.start<=F(delay['max_note']) and n.pitch.number()%12 not in self.harmony.permitted(n.start,n.end):
|
||||
for arrival in map(F,delay['arrivals']):
|
||||
if n.end<=n.start+arrival<=n.start+F(delay['deadline']):requests.append(('delayed',delay,None,n.start+arrival,delay['steps']))
|
||||
for kind,rule,boundary,arrival,steps in requests:
|
||||
for delta in steps:
|
||||
number=n.pitch.number()+delta
|
||||
if not self.low<=number<=self.high:continue
|
||||
for hold in (F(1,2),F(1),F(2)):
|
||||
if arrival+hold>self.end or number%12 not in self.harmony.members(arrival,arrival+hold):continue
|
||||
target=self.note(arrival,arrival+hold,number,'destination')
|
||||
c=self.color_contract(n,target,rule,boundary) if kind=='color' else types.ExpressionContract(n.event_id+':delayed','delayed_approach',rule['id'],(n,),target,self.harmony.at(arrival).chord)
|
||||
# Color and delayed-approach contracts permit this intervening
|
||||
# window. Validate the invariant endpoints once; tails() checks
|
||||
# every inserted note's ordinary full-duration permission itself.
|
||||
if not self.harmony.evaluate((n,target),complete=True,expressions=(c,),infer=False).valid:continue
|
||||
for events,ns in self.tails(n,target):
|
||||
if not groups.valid(tuple((a,b) for a,b,_ in events)):continue
|
||||
yield events,ns,(c,)
|
||||
|
||||
def actions(self,time):
|
||||
if time in self._actions:return self._actions[time]
|
||||
result=[]
|
||||
@@ -118,6 +189,19 @@ class Decoder:
|
||||
for number in range(self.low,self.high+1):
|
||||
n=self.note(time,end,number);support=self.permission(n)
|
||||
if support is not None:add(((time,end,number),),(n,),support)
|
||||
if self.expressions_enabled and not self.harmony.ordinary(n):
|
||||
for events,ns,contracts in self.resolutions(n):add(events,ns,contracts)
|
||||
rule=vocabulary.data('lead_ins')['passing']
|
||||
if duration<=F(rule['max_note']):
|
||||
for step in rule['steps']:
|
||||
p=number+step
|
||||
if not self.low<=p<=self.high:continue
|
||||
for hold in (F(1,2),F(1),F(2)):
|
||||
if end+hold>self.end:continue
|
||||
target=self.note(end,end+hold,p);extra=self.permission(target)
|
||||
if extra is None or any(c.kind not in ('color','anticipated_color') for c in extra):continue
|
||||
c=types.ExpressionContract(n.event_id+':passing','passing',rule['id'],(n,),target,self.harmony.at(end).chord)
|
||||
add(((time,end,number),(end,end+hold,p)),(n,target),(c,*extra))
|
||||
if self.expressions_enabled:
|
||||
limits=vocabulary.data('roles')['difficulty']
|
||||
for rule in vocabulary.data('lead_ins')['patterns']:
|
||||
@@ -147,7 +231,7 @@ class Decoder:
|
||||
if rule['kind']=='connection':
|
||||
# Validation waits for the actual incoming event.
|
||||
add((*events,(target_time,end,number)),ns,(contract,))
|
||||
elif self.harmony.evaluate(ns,complete=True,expressions=(contract,)).valid:
|
||||
elif self.harmony.evaluate(ns,complete=True,expressions=(contract,),infer=False).valid:
|
||||
add((*events,(target_time,end,number)),ns,(contract,))
|
||||
# Complete ordinary triplet groups; do not leave a third-beat residual.
|
||||
if self.allow_triplets and time+1<=self.end:
|
||||
@@ -161,7 +245,7 @@ class Decoder:
|
||||
numbers.append(nxt);cursor+=F(1,3)
|
||||
if len(numbers)!=3:continue
|
||||
ns=tuple(self.note(time+i*F(1,3),time+(i+1)*F(1,3),p) for i,p in enumerate(numbers))
|
||||
if self.harmony.evaluate(ns,complete=True).valid:add(tuple((n.start,n.end,n.pitch.number()) for n in ns),ns)
|
||||
if self.harmony.evaluate(ns,complete=True,infer=False).valid:add(tuple((n.start,n.end,n.pitch.number()) for n in ns),ns)
|
||||
self._actions[time]=tuple(result);self.counts['valid_structures']+=len(result)
|
||||
return self._actions[time]
|
||||
|
||||
@@ -171,10 +255,13 @@ class Decoder:
|
||||
# rest-to-rest transition absent from that event representation.
|
||||
if state.events and state.events[-1][2] is None and events[0][2] is None:return None
|
||||
if notes and state.previous is not None and abs(notes[0].pitch.number()-state.previous)>12:return None
|
||||
if contracts and contracts[0].kind=='connection':
|
||||
if any(c.kind in ('connection','passing') and c.preparation is None for c in contracts):
|
||||
if not state.notes:return None
|
||||
c=dataclasses.replace(contracts[0],preparation=state.notes[-1]);contracts=(c,)
|
||||
_,error=expressions.validate((state.notes[-1],*notes),contracts,self.harmony)
|
||||
contracts=tuple(dataclasses.replace(c,preparation=state.notes[-1]) if c.kind in ('connection','passing') and c.preparation is None else c for c in contracts)
|
||||
if self.harmony.ordinary(state.notes[-1]):
|
||||
_,error=expressions.validate((state.notes[-1],*notes),contracts,self.harmony)
|
||||
else:
|
||||
_,error=expressions.validate((*state.notes,*notes),(*state.contracts,*contracts),self.harmony)
|
||||
if error:return None
|
||||
history=state.history;previous=state.previous;cost=state.cost
|
||||
for a,b,p in events:
|
||||
|
||||
@@ -91,8 +91,11 @@ def validate(notes,contracts,harmony):
|
||||
return {},c.id+': missing or changed expression event/target'
|
||||
if c.preparation and not same(c.preparation):return {},c.id+': changed preparation'
|
||||
if any(n.event_id in approved for n in c.events):return {},c.id+': overlapping expression permissions'
|
||||
target=c.target; span=harmony.at(c.boundary if c.boundary is not None else target.start)
|
||||
target=c.target; span=harmony.at(c.boundary if c.boundary is not None else c.events[0].start if c.kind=='color' else target.start)
|
||||
if not span or span.chord=='N.C.' or span.chord!=c.chord:return {},c.id+': wrong target harmony'
|
||||
if c.kind in ('anticipation','anticipated_color'):
|
||||
previous=next((s for s in harmony.spans if s.end==c.boundary),None)
|
||||
if not previous or previous.chord.strip('()')==span.chord.strip('()'):return {},c.id+': anticipation requires a harmonic change'
|
||||
if c.kind in ('approach','enclosure','connection'):
|
||||
rule=next((r for r in vocabulary.data('lead_ins')['patterns'] if r['id']==c.rule and r['kind']==c.kind),None)
|
||||
if not rule or len(c.events)!=len(rule['semitones']):return {},c.id+': unknown approach formula'
|
||||
@@ -112,6 +115,16 @@ def validate(notes,contracts,harmony):
|
||||
if n.pitch.number()%12 not in harmony.permitted(n.start,n.end):return {},c.id+': declared diatonic neighbor is outside source-key/chord motion'
|
||||
if any(n.start<c.target.start and n.end>c.events[0].start and n.event_id not in {e.event_id for e in c.events} for n in notes):return {},c.id+': interrupted approach'
|
||||
if c.kind=='connection' and (not c.preparation or c.preparation.end!=c.events[0].start or abs(c.preparation.pitch.number()-c.events[0].pitch.number())>2):return {},c.id+': chromatic connection lacks its incoming motion'
|
||||
elif c.kind=='passing':
|
||||
rule=vocabulary.data('lead_ins')['passing']
|
||||
if c.rule!=rule['id'] or len(c.events)!=1 or not c.preparation:return {},c.id+': unsupported passing figure'
|
||||
n,=c.events;before=c.preparation
|
||||
left=n.pitch.number()-before.pitch.number();right=target.pitch.number()-n.pitch.number()
|
||||
if before.end!=n.start or n.end!=target.start or n.end-n.start>F(rule['max_note']) or left!=right or left not in rule['steps']:
|
||||
return {},c.id+': interrupted/nonstepwise passing figure'
|
||||
def supported(endpoint):
|
||||
return harmony.ordinary(endpoint) or any(a.kind in ('color','anticipated_color') and endpoint in a.events for a in contracts)
|
||||
if not supported(before) or not supported(target):return {},c.id+': unsupported passing endpoints'
|
||||
elif c.kind=='delayed_approach':
|
||||
rule=vocabulary.data('lead_ins').get('delayed_approach',{})
|
||||
if c.rule!=rule.get('id') or len(c.events)!=1:return {},c.id+': unknown delayed approach'
|
||||
@@ -126,7 +139,7 @@ def validate(notes,contracts,harmony):
|
||||
for n in c.events:
|
||||
if n.end-n.start>F(rule['max_beats']):return {},c.id+': excessive color duration'
|
||||
for region,a,b in harmony.segments(n):
|
||||
if n.pitch.number()%12 in region.roles.basic:continue
|
||||
if n.pitch.number()%12 in region.roles.basic|region.roles.specified:continue
|
||||
if region.chord=='N.C.' or not eligible_color(rule,region,harmony):return {},c.id+': incompatible color harmony/function'
|
||||
p=degree_pitch(parsed_chord(region.chord),rule['degree'],rule['alter'],n.pitch.number())
|
||||
# The rule retains the functional degree/spelling. Encoded
|
||||
|
||||
@@ -45,6 +45,7 @@ class Result:
|
||||
exposure:float
|
||||
pending:tuple=()
|
||||
error:str=''
|
||||
contracts:tuple=()
|
||||
|
||||
class Harmony:
|
||||
def __init__(self,spans,profile=None):
|
||||
@@ -77,12 +78,34 @@ class Harmony:
|
||||
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 evaluate(self,notes,*,until=None,complete=False,expressions=()):
|
||||
@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=types.sounding_notes(notes)
|
||||
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
|
||||
@@ -93,14 +116,15 @@ class Harmony:
|
||||
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')
|
||||
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)
|
||||
return Result(True,float(exposed/max(F(1,1000),sounded)),pending,contracts=tuple(expressions))
|
||||
def describe(self,notes,expressions=()):
|
||||
from generator.solo.expressions import validate
|
||||
permissions,error=validate(notes,expressions,self)
|
||||
if error:raise ValueError(error)
|
||||
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 '',
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Recognize bounded harmonic relationships in actual sounded music.
|
||||
|
||||
No filenames, annotations, learned examples or role exemptions enter here.
|
||||
Existing expression validators prove each candidate; deterministic set-cover
|
||||
search chooses a mutually consistent explanation, never edits the notes.
|
||||
"""
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary
|
||||
from generator.solo import expressions, types
|
||||
|
||||
|
||||
def signature(c):
|
||||
return (c.kind,c.rule,tuple(n.event_id for n in c.events),c.target.event_id,
|
||||
str(c.boundary),c.preparation.event_id if c.preparation else '')
|
||||
|
||||
|
||||
def merge(*groups):
|
||||
result={}
|
||||
for group in groups:
|
||||
for c in group:result.setdefault(signature(c),c)
|
||||
return tuple(result.values())
|
||||
|
||||
|
||||
def recognize(notes,harmony,provided=(),*,budget=4096):
|
||||
required={n.event_id for n in notes if not harmony.ordinary(n)}
|
||||
fixed={n.event_id for c in provided for n in c.events}
|
||||
if required<=fixed:return tuple(provided),''
|
||||
candidates={};single={};by_id={n.event_id:n for n in notes}
|
||||
if len(by_id)!=len(notes):return (), 'Harmonic analysis requires unique sounding-event IDs'
|
||||
|
||||
def contract(kind,rule,events,target,boundary=None,preparation=None):
|
||||
span=harmony.at(boundary if boundary is not None else target.start)
|
||||
if not span or span.chord=='N.C.':return None
|
||||
identifier=f'inferred:{kind}:{rule}:{events[0].event_id}:{target.event_id}:{boundary}'
|
||||
return types.ExpressionContract(identifier,kind,rule,tuple(events),target,span.chord,boundary,preparation)
|
||||
|
||||
def add(c,extras=()):
|
||||
if c is None:return False
|
||||
bundle=merge(extras,(c,));covered=frozenset(n.event_id for a in bundle for n in a.events)
|
||||
if not (covered & required):return False
|
||||
combined=merge(provided,bundle)
|
||||
_,error=expressions.validate(notes,combined,harmony)
|
||||
if error:return False
|
||||
candidates[tuple(signature(a) for a in bundle)]=(bundle,covered)
|
||||
return True
|
||||
|
||||
# First discover independently supported colors and harmonic anticipations.
|
||||
# These can also support a later approach's destination or passing endpoint.
|
||||
for i,n in enumerate(notes):
|
||||
if n.event_id not in required:continue
|
||||
future=[t for t in notes[i+1:] if n.end<=t.start<=n.end+F(vocabulary.data('harmony')['resolution_gap'])
|
||||
and 0<abs(t.pitch.number()-n.pitch.number())<=2]
|
||||
regions=[(None,harmony.at(n.start))]
|
||||
regions.extend((s.start,s) for s in harmony.spans if n.start<s.start<n.end
|
||||
and s.start-n.start<=F(vocabulary.data('harmony')['anticipation_max']))
|
||||
for boundary,span in regions:
|
||||
if not span or span.chord=='N.C.':continue
|
||||
if boundary is not None:
|
||||
c=contract('anticipation','early_chord_tone',(n,),n,boundary)
|
||||
if add(c):single.setdefault(n.event_id,[]).append((c,))
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
if not expressions.eligible_color(rule,span,harmony) and boundary is not None:continue
|
||||
kind='anticipated_color' if boundary is not None else 'color'
|
||||
for target in future if rule.get('resolve') else (n,):
|
||||
c=contract(kind,rule['id'],(n,),target,boundary)
|
||||
# Non-anticipated color permission is scoped to the note's initial
|
||||
# harmony, even when its resolution occurs after a chord boundary.
|
||||
if boundary is None and c:c=types.ExpressionContract(c.id,c.kind,c.rule,c.events,c.target,span.chord)
|
||||
if add(c):single.setdefault(n.event_id,[]).append((c,))
|
||||
if i+1<len(notes):
|
||||
target=notes[i+1]
|
||||
for s in harmony.spans:
|
||||
if n.start<s.start<n.end:
|
||||
for rule in vocabulary.data('harmony').get('suspensions',[]):
|
||||
add(contract('suspension',rule['id'],(n,),target,s.start))
|
||||
elif s.start==n.end==target.start and n.pitch.number()==target.pitch.number():
|
||||
add(contract('anticipation','early_reattack',(n,),target,s.start))
|
||||
|
||||
for i,target in enumerate(notes):
|
||||
targets=[(None,())]
|
||||
for support in single.get(target.event_id,()):
|
||||
c=support[0]
|
||||
# An approach into an anticipated color needs both permissions; that
|
||||
# combination is not silently interpreted as a chord-tone anticipation.
|
||||
if c.kind=='color':targets.append((None,support))
|
||||
elif c.kind=='anticipation':targets.append((c.boundary,support))
|
||||
for rule in vocabulary.data('lead_ins')['patterns']:
|
||||
size=len(rule['semitones'])
|
||||
if i<size:continue
|
||||
ns=notes[i-size:i]
|
||||
if not any(n.event_id in required for n in ns):continue
|
||||
if tuple(n.pitch.number()-target.pitch.number() for n in ns)!=tuple(rule['semitones']):continue
|
||||
preparation=notes[i-size-1] if i>size and rule['kind']=='connection' else None
|
||||
for boundary,extras in targets:
|
||||
add(contract(rule['kind'],rule['id'],ns,target,boundary,preparation),extras)
|
||||
# Delayed approaches may include real space or intervening valid music.
|
||||
# They still have an exact semitone target and a finite deadline.
|
||||
rule=vocabulary.data('lead_ins')['delayed_approach']
|
||||
for n in reversed(notes[:i]):
|
||||
if target.start-n.start>F(rule['deadline']):break
|
||||
if n.event_id in required and target.pitch.number()-n.pitch.number() in rule['steps']:
|
||||
add(contract('delayed_approach',rule['id'],(n,),target))
|
||||
if i>=2:
|
||||
before,n=notes[i-2:i]
|
||||
if n.event_id in required:
|
||||
for left in [(),*single.get(before.event_id,())]:
|
||||
for right in [(),*single.get(target.event_id,())]:
|
||||
add(contract('passing',vocabulary.data('lead_ins')['passing']['id'],(n,),target,preparation=before),merge(left,right))
|
||||
for rule in vocabulary.data('lead_ins')['blues']:
|
||||
add(contract('blues',rule['id'],(n,),target,preparation=before))
|
||||
|
||||
options={identifier:[] for identifier in sorted(required-fixed,key=lambda identifier:(by_id[identifier].start,identifier))}
|
||||
for bundle,covered in candidates.values():
|
||||
for identifier in covered & options.keys():options[identifier].append((bundle,covered))
|
||||
for identifier,choices in options.items():
|
||||
if not choices:
|
||||
n=by_id[identifier]
|
||||
return (),f'{identifier} at {n.start}: unsupported harmonic relationship for {n.pitch.label()}'
|
||||
choices.sort(key=lambda x:(sum((n.end-n.start for c in x[0] for n in c.events),F()),
|
||||
sum((max(F(),c.target.start-c.events[-1].end) for c in x[0]),F()),tuple(signature(c) for c in x[0])))
|
||||
|
||||
visits=0;failed=set()
|
||||
def solve(chosen,covered):
|
||||
nonlocal visits
|
||||
remaining=required-covered
|
||||
if not remaining:return chosen
|
||||
key=tuple(sorted(signature(c) for c in chosen))
|
||||
if key in failed or visits>=budget:return None
|
||||
visits+=1
|
||||
identifier=min(remaining,key=lambda x:(len(options[x]),by_id[x].start,x))
|
||||
for bundle,added in options[identifier]:
|
||||
combined=merge(chosen,bundle)
|
||||
_,error=expressions.validate(notes,combined,harmony)
|
||||
if error:continue
|
||||
result=solve(combined,covered|added)
|
||||
if result is not None:return result
|
||||
failed.add(key)
|
||||
return None
|
||||
result=solve(tuple(provided),fixed)
|
||||
if result is None:return (), 'Harmonic treatment search budget exhausted' if visits>=budget else 'No mutually consistent harmonic treatment coverage'
|
||||
return result,''
|
||||
@@ -14,7 +14,6 @@ def structure(solo):
|
||||
harmony=harmonic.Harmony(solo.harmony)
|
||||
contracts=tuple(c for p in solo.phrases for c in p.expressions)
|
||||
checked=harmony.evaluate(solo.notes(),until=solo.end,complete=True,expressions=contracts)
|
||||
authored=solo.diagnostics.get('construction')=='authored_repertoire'
|
||||
phrases=[]
|
||||
for p in solo.phrases:
|
||||
entry={'id':p.id,'start':str(p.start),'end':str(p.end),'purpose':p.purpose,'direction':p.direction,'ending':p.ending,
|
||||
@@ -29,7 +28,7 @@ def structure(solo):
|
||||
'phrasing':{'attacks':len(ns),'allocation_beats':str(duration),
|
||||
'attacks_per_four_beats':4*len(ns)/float(duration) if duration else 0,
|
||||
'occupancy':sum(float(n.end-n.start) for n in ns)/float(duration) if duration else 0},
|
||||
'harmonic_roles':([] if authored else harmony.describe(ns,expressions=contracts)),'harmonic_policy':'authored (generated policy diagnostic only)' if authored else 'generated', 'harmonic_valid':checked.valid,'harmonic_error':checked.error,
|
||||
'harmonic_roles':harmony.describe(ns,expressions=contracts),'harmonic_policy':'contextual (shared by authored and predicted music)', 'harmonic_valid':checked.valid,'harmonic_error':checked.error,
|
||||
'harmonic_exposure':checked.exposure,'unresolved_treatments':plain(checked.pending)}
|
||||
|
||||
def describe(solo,measure_offset=0,time_offset=0,*,detail=None):
|
||||
|
||||
Reference in New Issue
Block a user