"""Typed, target-driven harmonic expression over a strictly mapped theme.""" from __future__ import annotations import dataclasses from fractions import Fraction as F from generator import harmony as chord_model, model, pitch as spelling, vocabulary from generator.solo import types, drafts, enclosures, placement, policy def quality(chord): degrees=chord_model.degrees(chord) if degrees.get(3)==0 and degrees.get(7)==-1:return 'dominant' if degrees.get(3)==-1:return 'half_diminished' if degrees.get(5)==-1 else 'minor' if degrees.get(3)==0 and degrees.get(5)==0:return 'major' return 'other' def degree_pitch(chord,degree,alter,near): root={**chord['root'],'octave':4} p=spelling.shift_pitch(root,degree-1,chord_model.degree_semitones(degree,alter)) value=types.Pitch(p['letter'],model.alteration(p['alter']),p['octave']) return dataclasses.replace(value,octave=value.octave+round((near-value.number())/12)) def chromatic(target,delta): # Chromatic neighbors are spelled relative to their exact destination. if delta in (-2,-1,1,2): step=1 if delta>0 else -1 root={'letter':target.letter,'alter':model.rational(target.alter),'octave':target.octave} p=spelling.shift_pitch(root,step,delta) return types.Pitch(p['letter'],model.alteration(p['alter']),p['octave']) raise ValueError('unsupported approach interval') def eligible_color(rule,span,harmony): chord=chord_model.parse_symbol(span.chord) degrees=chord_model.degrees(chord) if quality(chord) not in rule['qualities']:return False degree=rule['degree']; simple=(degree-1)%7+1 # Explicit alterations and omissions take precedence over optional colors. if any((d['value']-1)%7+1==simple and (d['type']=='subtract' or model.alteration(d['alter'])!=rule['alter']) for d in chord.get('degrees',[])):return False if degree in degrees and degrees[degree]!=rule['alter']:return False following=next((s for s in harmony.spans if s.start>=span.end and s.chord!=span.chord and s.scale),None) root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))%12 next_chord=chord_model.parse_symbol(following.chord) if following else None next_root=int(model.NATURAL[next_chord['root']['letter']]+model.alteration(next_chord['root']['alter']))%12 if next_chord else None function=rule.get('function') if function=='resolving_dominant' and (next_root is None or (root-next_root)%12!=7):return False if function=='tonic_or_predominant': tonic=model.parse_pitch_name(span.scale.root) tonic_pc=int(model.NATURAL[tonic['letter']]+model.alteration(tonic['alter']))%12 if root!=tonic_pc and not (next_chord and quality(next_chord)=='dominant' and (next_root-root)%12==5):return False return True def validate(notes,contracts,harmony): """Return event permissions only after checking complete actual gestures.""" actual={n.event_id:n for n in notes};approved={} def same(expected): n=actual.get(expected.event_id) return n and (n.start,n.end,n.pitch)==(expected.start,expected.end,expected.pitch) for c in contracts: if not c.events or not all(same(n) for n in (*c.events,c.target)): 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) if not span or not span.scale or span.chord!=c.chord:return {},c.id+': wrong target harmony' 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' arrival=c.boundary if c.boundary is not None else target.start if c.boundary is not None and not any(a.kind=='anticipation' and a.target==target and a.boundary==c.boundary for a in contracts):return {},c.id+': future target lacks anticipation contract' if target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,arrival,target.end):return {},c.id+': invalid destination hold' gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F()) if target.start-cursor>F(vocabulary.data('lead_ins')['max_approach']):return {},c.id+': approach too long' for n,step,duration in zip(c.events,rule['semitones'],rule['durations']): if n.start!=cursor or n.end!=cursor+F(duration) or n.pitch.number()!=target.pitch.number()+step:return {},c.id+': wrong register, interval, or timing' cursor=n.end for index in rule.get('mapped_indices',[]): n=c.events[index] if n.pitch.number()%12 not in types.permitted(harmony.choices,n.start,n.end):return {},c.id+': declared diatonic neighbor is outside the mapped collection' if any(n.startc.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=='color': rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None) if not rule:return {},c.id+': unknown color policy' 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 not region.scale or not eligible_color(rule,region,harmony):return {},c.id+': incompatible color harmony/function' p=degree_pitch(chord_model.parse_symbol(region.chord),rule['degree'],rule['alter'],n.pitch.number()) if p!=n.pitch:return {},c.id+': color degree/spelling mismatch' if not rule['outside'] and n.pitch.number()%12 not in region.scale.pitches:return {},c.id+': color requires mapped collection' last=c.events[-1] if rule.get('resolve'): if target.event_id==last.event_id or not 0<=target.start-last.end<=F(vocabulary.data('harmony')['resolution_gap']) or not 0F(rule['max_delay']) or target.start!=held.end or target.pitch.number()-held.pitch.number() not in rule['steps']: return {},c.id+': missed suspension resolution' if target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': invalid suspension target' if held.pitch.number()%12 not in types.permitted(harmony.choices,held.start,held.end):return {},c.id+': suspension outside mapped collections' elif c.kind=='anticipation': if c.rule!='early_chord_tone' or len(c.events)!=1 or c.target!=c.events[0] or span.start!=c.boundary: return {},c.id+': unknown anticipation or non-boundary target' n,=c.events if c.boundary is None or not n.startF(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipation interval' if n.pitch.number()%12 not in harmony.members(c.boundary,n.end) or n.pitch.number()%12 not in types.permitted(harmony.choices,c.boundary,n.end):return {},c.id+': invalid future destination' elif c.kind=='blues': if len(c.events)!=1:return {},c.id+': blues treatment requires one inflected event' rule=next((r for r in vocabulary.data('lead_ins')['blues'] if r['id']==c.rule),None) n,=c.events chord=chord_model.parse_symbol(span.chord) if not rule or n.end-n.start>F(1,2) or n.end!=target.start or target.pitch.number()%12 not in span.roles.basic:return {},c.id+': unresolved blues inflection' expected=degree_pitch(chord,rule['degree'],rule['alter'],n.pitch.number()) resolved=degree_pitch(chord,rule['target_degree'],0,target.pitch.number()) if n.pitch!=expected or target.pitch!=resolved or target.pitch.number()-n.pitch.number()!=1:return {},c.id+': unsupported blues destination' if not c.preparation or c.preparation.end!=n.start or abs(c.preparation.pitch.number()-n.pitch.number())>2:return {},c.id+': unprepared blues gesture' else:return {},c.id+': unknown expression kind' for n in c.events: covered=n.start for region,a,b in harmony.segments(n): if a!=covered or not region.scale:return {},c.id+': missing harmony' covered=b if covered!=n.end:return {},c.id+': incomplete harmonic coverage' approved[n.event_id]=c return approved,'' def validate_frontier(notes,contracts,harmony,until,complete): """Validate heard prefixes and bounded future obligations separately. Future contract events are used ONLY to establish rule feasibility. They do not count as heard resolution; the contract stays pending until its entire target hold is present. Missing past events and expired deadlines fail. """ if complete: approved,error=validate(notes,contracts,harmony) return approved,(),error actual={n.event_id:n for n in notes};future=dict(actual);pending=[] for c in contracts: required=tuple(dict.fromkeys((*c.events,c.target,*((c.preparation,) if c.preparation else ())))) incomplete=False for expected in required: heard=actual.get(expected.event_id) if expected.start>=until: future[expected.event_id]=expected;incomplete=True elif heard and heard.pitch==expected.pitch and heard.start==expected.start and heard.end==min(until,expected.end): future[expected.event_id]=expected incomplete |= heard.endF(rule['max_beats']):continue p=degree_pitch(chord_model.parse_symbol(span.chord),rule['degree'],rule['alter'],n.pitch.number()) if abs(p.number()-n.pitch.number())>4 or p==n.pitch:continue color=dataclasses.replace(n,pitch=p,role='color') resolution=target if rule.get('resolve') else color contract=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(color,),resolution,harmony.at(resolution.start).chord) yield (*notes[:i],color,*notes[i+1:]),contract for rule in vocabulary.data('lead_ins')['blues']: if i==0 or n.end-n.start>F(1,2):continue p=degree_pitch(chord_model.parse_symbol(span.chord),rule['degree'],rule['alter'],n.pitch.number()) changed=dataclasses.replace(n,pitch=p,role='blues') yield (*notes[:i],changed,*notes[i+1:]),types.ExpressionContract(n.event_id+':'+rule['id'],'blues',rule['id'],(changed,),target,harmony.at(target.start).chord,preparation=notes[i-1]) for i,target in enumerate(notes): if i==0 or target.start not in {s.start for s in harmony.spans}:continue start=target.start-F(1,2) if not enclosures.editable(draft,start,target.start):continue n=dataclasses.replace(target,start=start,role='anticipation') actual=enclosures.replace_window(notes,start,target.start,()) if actual: actual=tuple(n if a.event_id==target.event_id else a for a in actual) yield actual,types.ExpressionContract(target.event_id+':anticipate','anticipation','early_chord_tone',(n,),n,harmony.at(target.start).chord,boundary=target.start) for i,(before,target) in enumerate(zip(notes,notes[1:])): boundary=target.start if before.end!=boundary or boundary not in {s.start for s in harmony.spans}:continue if not enclosures.editable(draft,before.start,target.end):continue for rule in vocabulary.data('harmony').get('suspensions',[]): delay=min(F(1,2),F(rule['max_delay'])) if target.end<=boundary+delay:continue held=dataclasses.replace(before,end=boundary+delay,role='suspension') resolved=dataclasses.replace(target,start=boundary+delay) yield (*notes[:i],held,resolved,*notes[i+2:]),types.ExpressionContract(before.event_id+':suspend', 'suspension',rule['id'],(held,),resolved,harmony.at(boundary).chord,boundary=boundary) def joint_candidates(phrases,incoming,harmony,tempo,context,*,bar=F(4)): """Expression alternatives compete BEFORE complete-phrase selection. This rewrites provisional gestures, never the selected chorus. The direct realization remains available and every alternative uses the same evaluator. """ from generator.solo import realization, sequences, phrasing, groups result=list(phrases) trace=context.diagnostics.setdefault('expression_operations',{}) for phrase in phrases: proposed={} for actual,c in proposals(phrase,harmony): counts=trace.setdefault(c.kind,dict(proposed=0,valid=0,retained=0,rejected_by_rule=0)) counts['proposed']+=1 old_pattern=tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in phrase.draft.notes) new_pattern=tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in actual) if groups.valid(old_pattern) and not groups.valid(new_pattern): counts['rejected_by_rule']+=1;continue if not drafts.contract_valid(phrase.draft,actual)[0]: counts['rejected_by_rule']+=1;continue checked=harmony.evaluate(actual,until=phrase.plan.end,complete=True,expressions=(*phrase.draft.expressions,c)) if not checked.valid: counts['rejected_by_rule']+=1;continue counts['valid']+=1 prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None) join=abs(prior.pitch.number()-c.events[0].pitch.number()) if prior else 0 rank=max(0,join-5)+bool(prior and prior.pitch==c.events[0].pitch) proposed.setdefault(c.kind,[]).append((rank,actual,c)) # One representative per treatment kind, then a bounded second sweep. options=sequences.diverse([v for values in proposed.values() for v in values], lambda v:(v[0],policy.stable_rank(phrase.plan.seed,v[2].id)),lambda v:v[2].kind,8) for _,actual,c in options: transforms=tuple(dataclasses.replace(t, events=tuple(n.event_id for n in actual if t.start<=n.start2 and join<=3) recent=sum(kind==c.kind for kind in history[-2:]) cost=.08*max(0,join-3)+.18*duplicated+.12*recent+.3*(enclosures.movement(actual)-enclosures.movement(original))-.12*prepares if c.kind=='anticipation':cost-=.05 decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in actual if d.start<=n.start