Training Model

This commit is contained in:
khodowany
2026-10-05 19:44:33 -05:00
parent db7966a430
commit e7180e557b
6603 changed files with 8919 additions and 523063 deletions
+2 -27
View File
@@ -1,20 +1,7 @@
from __future__ import annotations
import copy
import fractions
import generator.accompaniment
import generator.arrange.accompaniment
import generator.arrange.form
import generator.electric
import generator.fretboard
import generator.model
import generator.score
import generator.solo.analysis
import generator.solo.composer
import generator.solo.notation
import generator.solo.report
import generator.solo.types
import generator.model
import generator.arrange.form
def regions(document):
result, first, start = {}, 0, fractions.Fraction()
@@ -25,15 +12,3 @@ def regions(document):
first += section.measure_count
start += length
return result
def annotate(document, solo, offset):
spans = []
for choice in solo.choices:
if choice.scale is None:
continue
label = choice.scale.label()
if spans and spans[-1]['label'] == label and generator.model.fraction(spans[-1]['end']) == offset + choice.span.start:
spans[-1]['end'] = generator.model.rational(offset + choice.span.end)
else:
spans.append({'id': f'{solo.part}_scale_{len(spans) + 1}', 'start': generator.model.rational(offset + choice.span.start), 'end': generator.model.rational(offset + choice.span.end), 'label': label, 'matched': True})
document['staves'][0]['scale_spans'] = spans
+63
View File
@@ -0,0 +1,63 @@
"""Explicit registered repertoire. Authored playback is not model prediction.
Matching is by canonical source music and role, never title or filename. This
path intentionally reads the registered solo, following the user's exact-reuse
policy. Unknown song/role pairs go to the learned composer.
"""
from __future__ import annotations
import dataclasses
import hashlib
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
def lookup(source,role,data_dir):
from generator.production import SourceAnalysis
root=Path(data_dir);manifest=root/'training/manifest.json'
if not manifest.exists():return None
value=json.loads(manifest.read_text())
if value.get('version')!=1:raise ValueError('Unsupported training manifest')
matches=[]
for entry in value['examples']:
if entry['slot']!=(1 if role=='acoustic' else 2):continue
path=(manifest.parent/entry['source']).resolve()
if not path.is_relative_to((root/'songs').resolve()):raise ValueError('Registered sources must be in data/songs')
doc=parts.prepare_document(musicxml.load_musicxml(path))
if SourceAnalysis.read(doc).musical_key==source.musical_key:
target=(manifest.parent/entry['solo']).resolve()
if not target.is_relative_to(manifest.parent.resolve()):raise ValueError('Registered solo must be inside data/training')
evidence=training_io.read(target,role,pitch_convention=entry['pitch_convention'])
training_io.validate_harmony(doc,evidence,entry['id'])
matches.append({**entry,'path':target,'sha256':hashlib.sha256(target.read_bytes()).hexdigest()})
if len(matches)>1:raise ValueError('Multiple authored solos registered for the same source music and role')
return matches[0] if matches else None
def realize(entry,source_analysis,measures,part,start,end,initial=None):
evidence=training_io.read(entry['path'],part,pitch_convention=entry['pitch_convention'])
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}'))
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'])
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.',
'annotations':evidence['annotations']},profile,start)
+45 -38
View File
@@ -1,50 +1,57 @@
"""The single production entry point for independent phrase composition."""
from fractions import Fraction as F
import time
"""The sole production solo composer: frozen learned model plus music validity."""
import dataclasses
from generator import harmony as chords, vocabulary
from generator.scales import timeline, selection
from generator.solo import types, profiles, harmonic, construction, placement, policy
import time
from fractions import Fraction as F
from pathlib import Path
from generator import harmonic_time, vocabulary
from generator.solo import types, profiles, harmonic, placement, learned, decoder, groups
def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,expressions_enabled=True,start=F()):
def compose(analysis,measures,part,end,*,learned_model=None,initial=None,expressions_enabled=True,start=F()):
began=time.monotonic()
choices=selection.select(timeline.read({'measures':measures},initial=initial),timeline.Solo(F(),end-start,part.capitalize()+' Solo'),scales)
choices=tuple(dataclasses.replace(c,span=dataclasses.replace(c.span,start=c.span.start+start,end=c.span.end+start)) for c in choices)
for c in choices:
if c.span.chord and c.scale is None:raise ValueError(f'{part} solo, source measure {c.span.source_measure}, beat {c.span.beat}, {chords.symbol(c.span.chord)}: No exact match within the permitted catalog')
profile=profiles.for_part(part,analysis);harmony=harmonic.Harmony(choices,profile)
source=tuple((n.start,n.end,n.pitch) for n in analysis.melody)
seed=vocabulary.role().get('choice_seed') or policy.stable_rank(part,(source,tuple((c.span.start,c.span.end,c.span.chord) for c in choices),vocabulary.role(part+'_solo'),policy.DEFAULT.composition_version))
builder=construction.Builder(analysis,measures,harmony,sequences,rhythms,end,seed,expressions_enabled,start=start)
phrases=builder.compose()
# Resolve hand-position transitions over the selected whole melody. This
# cannot reselect pitches, rhythm or another independently generated part.
raw=tuple(n for p in phrases for n in p.notes)
placed=placement.place(raw,analysis.tempo,profile=profile) if raw else (0,())
if placed is None:raise construction.SearchExhausted('selected melody has no complete legal guitar path')
by_id={n.event_id:n for n in placed[1]}
phrases=tuple(dataclasses.replace(p,notes=tuple(by_id[n.event_id] for n in p.notes)) for p in phrases)
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'source_melodic_phrases','version':policy.DEFAULT.composition_version,
'construction_seconds':time.monotonic()-began,**builder.timings,'operation_coverage':{k:dict(v) for k,v in builder.coverage.items()},
'whole_stream_diagnostic':dataclasses.asdict(builder.winner_evaluation),
'opening_candidates':builder.opening_candidates,
'frontiers':builder.frontiers,'completed_candidates':[{'rank':i+1,'evaluation':{'selection_total': str(s.total / (end-start)), 'phrases': [dataclasses.asdict(p.evaluation) for p in s.phrases]},'events':[(str(n.start),str(n.end),n.pitch.number()) for n in s.notes]} for i,s in enumerate(builder.completed)],'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile,start)
validate(result)
return result
spans=tuple(dataclasses.replace(s,start=s.start+start,end=s.end+start) for s in harmonic_time.read({'measures':measures},initial=initial))
profile=profiles.for_part(part,analysis);harmony=harmonic.Harmony(spans,profile)
model=learned_model or learned.Model.load(Path(__file__).resolve().parents[2]/'data/models/solo.json')
settings=vocabulary.role(part+'_solo')
builder=decoder.Decoder(analysis,harmony,model,part,start,end,expressions_enabled=expressions_enabled,
attempts=settings.get('attempts',4),seed=settings.get('choice_seed',''))
selected=builder.compose();decoded=time.monotonic()
placed=placement.place(selected.notes,analysis.tempo,profile=profile)
if placed is None:raise decoder.SearchExhausted('learned solo has no complete legal guitar path')
# Phrase labels describe heard rests/arrivals. They do not impose a new
# endpoint or reset the learned history at barlines or arbitrary windows.
notes=placed[1];ends=[n.end for i,n in enumerate(notes[:-1]) if notes[i+1].start-n.end>=1]
boundaries=[start,*[t for t in ends if start<t<end],end];phrases=[]
home=tuple(n for n in notes if n.start<(ends[0] if ends else min(end,start+8)))
for i,(a,b) in enumerate(zip(boundaries,boundaries[1:])):
ns=tuple(n for n in notes if a<=n.start<b)
cs=tuple(c for c in selected.contracts if a<=c.target.start<b)
ending='handoff' if b==end else 'settle'
phrases.append(types.Phrase(f'learned_{i+1}',a,b,ns,'learned_sequence','learned',ending,
expressions=cs,groups=groups.describe(tuple((n.start,n.end) for n in ns)),
theme='introduced' if i==0 else 'conditional_history',identity=home if i==0 else ()))
result=types.Solo(part,spans,tuple(phrases),analysis.tempo,end,
{'construction':'learned_joint_events','version':learned.VERSION,'decode_seconds':decoded-began,
'placement_seconds':time.monotonic()-decoded,'selection_total':selected.cost,
'objective':'seeded sampling from validated learned joint-event likelihoods; score is diagnostic, not a complete-solo ranking',
'budget':{'attempts':builder.attempts},
'counts':dict(builder.counts),'frontiers':builder.trace,'completed_candidates':[
{'rank':i+1,'cost':s.cost,'events':[(str(a),str(b),p) for a,b,p in s.events]} for i,s in enumerate(builder.completed)],
'expressions_enabled':expressions_enabled},profile,start)
validate(result);return result
def validate(solo):
previous=None
for note in solo.notes():
if note.start<solo.start or note.end>solo.end or note.end<=note.start or previous and previous.end>note.start:raise ValueError('overlapping/out-of-bounds sounding event')
if note.position not in placement.positions(note.pitch.number(),solo.profile):raise ValueError('invalid guitar placement')
previous=note
expressions=tuple(c for p in solo.phrases for c in p.expressions)
checked=harmonic.Harmony(solo.choices).evaluate(solo.notes(),until=solo.end,complete=True,expressions=expressions)
for n in solo.notes():
if n.start<solo.start or n.end>solo.end or n.end<=n.start or previous and previous.end>n.start:raise ValueError('overlapping/out-of-bounds sounding event')
if n.position not in placement.positions(n.pitch.number(),solo.profile):raise ValueError('invalid guitar placement')
previous=n
checked=harmonic.Harmony(solo.harmony).evaluate(solo.notes(),until=solo.end,complete=True,
expressions=tuple(c for p in solo.phrases for c in p.expressions))
if not checked.valid:raise ValueError(checked.error)
cursor=solo.start
for p in solo.phrases:
if p.start!=cursor or p.end<=p.start:raise ValueError('phrase coverage gap or overlap')
cursor=p.end
valid,error=harmonic.anchors_valid(p.arrivals or ((p.destination,) if p.destination else ()),p.notes,p.end,complete=True,closure='closed' if p.ending in ('complete','handoff') else 'open')
if not valid:raise ValueError(error)
if cursor!=solo.end:raise ValueError('incomplete solo duration')
-104
View File
@@ -1,104 +0,0 @@
"""Bounded comparison of completed source-related phrases."""
import collections
import dataclasses
import fractions
import time
import generator.solo.phrases
import generator.solo.selection
import generator.solo.sequences
import generator.solo.policy
import generator.vocabulary
class SearchExhausted(ValueError):
pass
@dataclasses.dataclass(frozen=True)
class State:
phrases: tuple = ()
home: tuple = ()
total: fractions.Fraction = fractions.Fraction()
@property
def notes(self):
return tuple(n for p in self.phrases for n in p.notes)
class Builder:
def __init__(self,analysis,measures,harmony,sequences,rhythms,end,seed,enabled=True,*,start=fractions.Fraction()):
self.analysis,self.harmony = analysis,harmony
self.start,self.end,self.seed = start,end,seed
self.phrases = generator.solo.phrases.Phrases(analysis,harmony,sequences,rhythms,start,end,seed,enabled)
self.rules = generator.vocabulary.data('solo')['search']
self.timings = {}; self.frontiers = []; self.coverage = collections.defaultdict(collections.Counter); self.decisions = []
self.failures = self.phrases.rejections
self.opening_candidates = []
def candidates(self,start,end,state):
incoming = state.notes[-1] if state.notes else None
values = {}
for variant in range(self.rules['alternatives']):
value = self.phrases.make(start,end,incoming,state.home,state.phrases,variant)
if value is not None:
phrase,introduced = value
self.coverage[phrase.purpose]['valid'] += 1
key = tuple((n.start,n.end,n.pitch.number()) for n in phrase.notes)
values.setdefault(key,value)
for phrase,_ in values.values():
self.coverage[phrase.purpose]['distinct'] += 1
return tuple(values.values())
def rank(self,state):
return state.total,generator.solo.policy.stable_rank(self.seed,tuple((n.start,n.end,n.pitch.number()) for n in state.notes))
@staticmethod
def character(state):
phrase = state.phrases[-1]
ns = phrase.notes
# Diversity describes the realized phrase, not its constructor label.
return (tuple((n.start-phrase.start,n.end-n.start) for n in ns[:4]),
tuple((b.pitch.number()>a.pitch.number())-(b.pitch.number()<a.pitch.number()) for a,b in zip(ns[:3],ns[1:4])),
tuple((a.earliest%1,a.effect) for a in phrase.arrivals))
def compose(self):
began = time.monotonic(); self.frontiers = []; self.decisions = []
openings = []
states = (State(),); start = self.start
while start<self.end:
end = self.phrases.horizon(start)
successors = []
for state in states:
for phrase,introduced in self.candidates(start,end,state):
total = state.total+phrase.evaluation.total*(end-start)
successors.append(State((*state.phrases,phrase),state.home or (introduced if start>=0 and len(introduced)>=3 else ()),total))
if not successors:
raise SearchExhausted(f'No valid complete phrase at {start}–{end}; failures: {dict(self.failures)}')
if start>=0 and not openings:
openings = [s.phrases[-1] for s in successors]
successors.sort(key=self.rank)
states = tuple(generator.solo.sequences.diverse(successors,self.rank,
self.character,self.rules['histories']))
for state in states:
self.coverage[state.phrases[-1].purpose]['retained_histories'] += 1
self.frontiers.append({'start':str(start),'end':str(end),'completed':len(successors),'retained':len(states),'best':str(min(s.total for s in states))})
start = end
self.completed = tuple(sorted(states,key=self.rank))
winner = self.completed[0]
selected_opening = next((p for p in winner.phrases if p.start>=0),None)
if selected_opening is not None:
remaining = [p for p in openings if p.notes!=selected_opening.notes]
examples = (selected_opening,*generator.solo.sequences.diverse(remaining,
lambda p:(p.evaluation.total,p.id),lambda p:self.character(State((p,))),3))
self.opening_candidates = [{'selected_in_final_solo':p is selected_opening,'start':str(p.start),'end':str(p.end),
'purpose':p.purpose,'ending':p.ending,'evaluation':dataclasses.asdict(p.evaluation),
'events':[(str(n.start),str(n.end),n.pitch.number()) for n in p.notes]} for p in examples]
self.timings['phrase_search_seconds'] = time.monotonic()-began
self.winner_evaluation = generator.solo.selection.evaluate(winner.notes,self.start,self.end,self.harmony,
home=winner.home,contracts=tuple(c for p in winner.phrases for c in p.expressions),center=self.phrases.center)
for k,v in self.phrases.proposals.items():
self.coverage[k]['proposed'] = v
for p in winner.phrases:
self.coverage[p.purpose]['selected'] += 1
self.decisions = [{'start':str(p.start),'end':str(p.end),'purpose':p.purpose,'ending':p.ending,'evaluation':dataclasses.asdict(p.evaluation)} for p in winner.phrases]
return winner.phrases
+220
View File
@@ -0,0 +1,220 @@
"""Constrained, deterministically seeded autoregressive musical sampling.
The pooled model proposes joint note/rest, duration and pitch events. Complete
expression actions include their real resolution. Guitar and chord/function
validity precede sampling. No target access, hand-authored melody scorer or
legacy fallback. A bounded restart handles an exhausted/entirely silent sample.
"""
from __future__ import annotations
import collections
import dataclasses
import hashlib
import random
import math
from fractions import Fraction as F
from generator import model as music, vocabulary
from generator.solo import expressions, groups, learned, types
class SearchExhausted(ValueError):pass
@dataclasses.dataclass(frozen=True)
class State:
events:tuple=()
notes:tuple=()
contracts:tuple=()
history:tuple=()
previous:int|None=None
cost:float=0.
def event_key(events):return tuple((a,b,-1 if p is None else p) for a,b,p in events)
class Decoder:
def __init__(self,analysis,harmony,model,role,start,end,*,expressions_enabled=True,attempts=4,seed=''):
if type(attempts) is not int or not 1<=attempts<=16:raise ValueError('attempts must be between 1 and 16')
self.analysis,self.harmony,self.model,self.role=analysis,harmony,model,role
self.start,self.end=start,end
self.attempts=attempts
source=tuple((str(n.start),str(n.end),n.pitch.number()) for n in analysis.melody)
self.seed=seed or hashlib.sha256(repr((source,role,learned.VERSION)).encode()).hexdigest()
self.expressions_enabled=expressions_enabled
self.low,self.high=harmony.profile.register
limits=vocabulary.data('roles')['difficulty']
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()
def context(self,time):
if time not in self._context:
harmonic,root,future=learned.chord_context(self.harmony,time)
self._context[time]=(harmonic,root,learned.phase_at(self.analysis,time),learned.source_feature(self.analysis,time,root),future)
return self._context[time]
def pitch(self,number,time):
span=self.harmony.at(time)
identity=(number,span.start if span else None)
if identity not in self._pitches:self._pitches[identity]=self._spell(number,time)
return self._pitches[identity]
def _spell(self,number,time):
span=self.harmony.at(time)
if span and span.chord!='N.C.':
chord=expressions.parsed_chord(span.chord)
from generator import harmony as chords
for degree,alter in chords.degrees(chord).items():
p=expressions.degree_pitch(chord,degree,alter,number)
if p.number()==number:return p
for letter,alter in zip(music.LETTERS,span.key['alterations']):
accidental=music.fraction(alter)
if (music.NATURAL[letter]+accidental)%12==number%12:
return types.Pitch(letter,accidental,int((number-music.NATURAL[letter]-accidental)//12)-1)
names=(('C',0),('D',-1),('D',0),('E',-1),('E',0),('F',0),('F',1),('G',0),('A',-1),('A',0),('B',-1),('B',0))
letter,alter=names[number%12]
return types.Pitch(letter.lower(),F(alter),number//12-1)
def note(self,a,b,p,role='learned'):
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 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.
if self.harmony.at(n.start) and n.end<=self.harmony.spans[-1].end:
if n.pitch.number()%12 in self.harmony.members(n.start,n.end):return ()
if n.end-n.start<2 and n.pitch.number()%12 in self.harmony.permitted(n.start,n.end):return ()
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 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,)
return None
def actions(self,time):
if time in self._actions:return self._actions[time]
result=[]
def add(events,notes,contracts=()):result.append((events,notes,contracts))
for duration in self.durations:
end=time+duration
if end>self.end:continue
add(((time,end,None),),())
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:
limits=vocabulary.data('roles')['difficulty']
for rule in vocabulary.data('lead_ins')['patterns']:
difficulty=rule.get('difficulty')
if difficulty and self.analysis.tempo>limits[difficulty+'_max_bpm']:continue
# Connections require a concrete preceding note, checked at expansion.
durations=tuple(map(F,rule['durations']));gaps=tuple(map(F,rule.get('internal_gaps',['0']*(len(durations)-1))))
target_time=time+sum(durations,F())+sum(gaps,F())+F(rule['gap'])
span=self.harmony.at(target_time)
if not span or span.chord=='N.C.':continue
holds=(F(1,3),) if difficulty=='triplet' else (F(1,2),F(1),F(2))
for duration in holds:
end=target_time+duration
if end>self.end:continue
for number in range(self.low,self.high+1):
if number%12 not in self.harmony.members(target_time,end):continue
target=self.note(target_time,end,number,'destination');cursor=time;notes=[];events=[]
for i,(delta,d) in enumerate(zip(rule['semitones'],durations)):
n=self.note(cursor,cursor+d,expressions.chromatic(target.pitch,delta),rule['kind'])
notes.append(n);events.append((n.start,n.end,n.pitch.number()));cursor=n.end
gap=gaps[i] if i<len(gaps) else F(rule['gap'])
if gap:events.append((cursor,cursor+gap,None));cursor+=gap
if any(not self.low<=n.pitch.number()<=self.high for n in notes):continue
if not groups.valid(tuple((a,b) for a,b,p in (*events,(target_time,end,number)))):continue
contract=types.ExpressionContract(f'{time}:{number}:{rule["id"]}',rule['kind'],rule['id'],tuple(notes),target,span.chord)
ns=(*notes,target)
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:
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:
for number in range(self.low,self.high+1):
for direction in (-1,0,1):
numbers=[number];cursor=time
for _ in range(2):
nxt=next((p for p in range(numbers[-1]+direction,numbers[-1]+direction*4,direction or 1)
if self.low<=p<=self.high and p%12 in self.harmony.permitted(cursor+F(1,3),cursor+F(2,3))),None) if direction else number
if nxt is None:break
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)
self._actions[time]=tuple(result);self.counts['valid_structures']+=len(result)
return self._actions[time]
def extend(self,state,action):
events,notes,contracts=action
# Training tokenization merges contiguous silence. Never manufacture a
# 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 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)
if error:return None
history=state.history;previous=state.previous;cost=state.cost
for a,b,p in events:
harmonic,root,phase,source,_=self.context(a)
action=learned.token(b-a,p,previous,root)
context=learned.contexts(harmonic,phase,source,history)
cost+=self.model.cost(action,context)
if p is not None:
if previous is None:cost+=self.model.register_cost(p)
previous=p
history=(*history,action)[-4:]
return State((*state.events,*events),(*state.notes,*notes),(*state.contracts,*contracts),history,previous,cost)
def compose(self):
for attempt in range(self.attempts):
rng=random.Random(self.seed+':'+str(attempt));state=State();time=self.start
trace=[]
while time<self.end:
actions=self.actions(time)
options=[s for a in actions if (s:=self.extend(state,a)) is not None]
# Different operator names cannot multiply the probability of identical
# sounded music. Prefer plain permission when no exception is needed.
unique={}
for s in options:
signature=event_key(s.events[len(state.events):])
if signature not in unique or len(s.contracts)<len(unique[signature].contracts):unique[signature]=s
options=sorted(unique.values(),key=lambda s:event_key(s.events[len(state.events):]))
self.counts['scored_continuations']+=len(options)
if not options:break
low=min(s.cost-state.cost for s in options)
weights=[math.exp(-(s.cost-state.cost-low)) for s in options]
draw=rng.random()*sum(weights);selected=options[-1]
for s,w in zip(options,weights):
draw-=w
if draw<=0:selected=s;break
trace.append({'time':str(time),'valid_joint_actions':len(options),
'selected_events':[(str(a),str(b),p) for a,b,p in selected.events[len(state.events):]],
'negative_log_likelihood':selected.cost-state.cost})
state=selected;time=state.events[-1][1]
if time==self.end and state.notes:
self.trace=trace;self.completed=(state,);self.counts['attempts']=attempt+1
return state
raise SearchExhausted(f'{self.role}: no complete non-silent sample within {self.attempts} deterministic attempts; end={self.end}')
+16 -13
View File
@@ -1,4 +1,4 @@
"""Typed, target-driven harmonic expression over a strictly mapped theme."""
"""Typed, target-driven harmonic expression against written harmony and explicit musical relationships."""
from __future__ import annotations
import dataclasses
import functools
@@ -22,7 +22,10 @@ def degree_spelling(letter,accidental,degree,alter):
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)==-1:
if degrees.get(5)==-1:return 'half_diminished' if degrees.get(7)==-1 else 'diminished'
return 'minor'
if chord['kind'] in ('suspended-second','suspended-fourth'):return chord['kind']
if degrees.get(3)==0 and degrees.get(5)==0:return 'major'
return 'other'
@@ -47,8 +50,8 @@ def eligible_color(rule,span,harmony):
# An explicit no-chord span (or missing harmony) has no optional colors.
# In particular, looking ahead for an anticipation must not try to parse
# the display label N.C. as a written chord.
if not span or not span.scale or not span.chord:return False
key=(tuple(rule['qualities']),rule['degree'],rule['alter'],rule.get('function'),span)
if not span or span.chord=='N.C.' or not span.chord:return False
key=(tuple(rule['qualities']),rule['degree'],rule['alter'],rule.get('function'),span.start,span.end,span.chord)
cache=harmony.color_eligibility
if key not in cache:cache[key]=_eligible_color(rule,span,harmony)
return cache[key]
@@ -62,7 +65,7 @@ def _eligible_color(rule,span,harmony):
# 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)
following=next((s for s in harmony.spans if s.start>=span.end and s.chord!=span.chord and s.chord!='N.C.'),None)
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))%12
next_chord=parsed_chord(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
@@ -70,7 +73,8 @@ def _eligible_color(rule,span,harmony):
if function=='predominant_to_dominant' and not (next_chord and quality(next_chord)=='dominant' and (next_root-root)%12==5):return False
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=span.key.get('tonic')
if not tonic:return False
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
@@ -88,7 +92,7 @@ def validate(notes,contracts,harmony):
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 not span or span.chord=='N.C.' 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'
@@ -103,9 +107,9 @@ def validate(notes,contracts,harmony):
for i,(n,step,duration) in enumerate(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+(gaps[i] if i<len(gaps) else 0)
for index in rule.get('mapped_indices',[]):
for index in rule.get('diatonic_indices',[]):
n=c.events[index]
if n.pitch.number()%12 not in harmony.permitted(n.start,n.end):return {},c.id+': declared diatonic neighbor is outside the mapped collection'
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=='delayed_approach':
@@ -123,13 +127,12 @@ def validate(notes,contracts,harmony):
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 not region.scale or not eligible_color(rule,region,harmony):return {},c.id+': incompatible color harmony/function'
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
# enharmonic notation must not change sounding harmonic validity.
if p.number()!=n.pitch.number():return {},c.id+': color degree/register mismatch'
colored=True
if not rule['outside'] and n.pitch.number()%12 not in region.scale.pitches:return {},c.id+': color requires mapped collection'
if not colored:return {},c.id+': no interval expresses the declared color'
last=c.events[-1]
if rule.get('resolve'):
@@ -144,7 +147,7 @@ def validate(notes,contracts,harmony):
if held.end-c.boundary>F(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 harmony.permitted(held.start,held.end):return {},c.id+': suspension outside mapped collections'
if held.pitch.number()%12 not in harmony.permitted(held.start,held.end):return {},c.id+': suspension outside source-key/chord motion'
elif c.kind=='anticipated_color':
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
if not rule or len(c.events)!=1 or c.boundary!=span.start:return {},c.id+': unsupported anticipated color'
@@ -177,7 +180,7 @@ def validate(notes,contracts,harmony):
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'
if a!=covered or region.chord=='N.C.':return {},c.id+': missing harmony'
covered=b
if covered!=n.end:return {},c.id+': incomplete harmonic coverage'
approved[n.event_id]=c
+86 -235
View File
@@ -1,257 +1,108 @@
"""Chord function on exact sounding intervals, separate from scale permission."""
"""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 fractions
import functools
import generator.harmony
import generator.model
import generator.solo.policy
import generator.solo.types
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[int]
specified: frozenset[int]
degrees: tuple[tuple[int, tuple[int, ...]], ...]
basic:frozenset
specified:frozenset
degrees:tuple
@functools.lru_cache(maxsize=512)
def degree_roles(kind, root, degrees):
basic, specified, labels = set(), set(), {}
for degree, alteration in degrees:
pitch = int(root + generator.harmony.degree_semitones(degree, alteration)) % 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(pitch)
labels.setdefault(pitch, []).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+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']
number = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
return degree_roles(chord['kind'], number, tuple(sorted(generator.harmony.degrees(chord).items())))
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: fractions.Fraction
end: fractions.Fraction
roles: Roles
scale: object
chord: str
start:F
end:F
roles:Roles
chord:str
key:dict
pitches:frozenset
@dataclasses.dataclass(frozen=True)
class Result:
valid: bool
exposure: float
pending: tuple[generator.solo.types.HarmonicTreatment, ...] = ()
error: str = ''
valid:bool
exposure:float
pending:tuple=()
error:str=''
class Harmony:
def __init__(self, choices, profile=None):
def __init__(self,spans,profile=None):
from generator.solo.profiles import UNRESTRICTED
self.profile = profile or UNRESTRICTED
self.choices = choices
self.spans = tuple(Span(c.span.start, c.span.end, roles(c.span.chord), c.scale, generator.harmony.symbol(c.span.chord) if c.span.chord else 'N.C.') for c in choices)
self._at = {}
self._segments = {}
self._members = {}
self._permitted = {}
self.evaluations = {}
self.color_eligibility = {}
def at(self, time):
if time not in self._at:
self._at[time] = next((s for s in self.spans if s.start <= time < s.end), None)
return self._at[time]
def segments(self, note):
key = (note.start, note.end)
if key not in self._segments:
self._segments[key] = tuple((s, max(note.start, s.start), min(note.end, s.end)) for s in self.spans if s.start < note.end and s.end > note.start)
return self._segments[key]
def members(self, start, end):
key = (start, end)
if key not in self._members:
spans = [s for s in self.spans if s.start < end and s.end > start]
self._members[key] = frozenset.intersection(*(s.roles.basic for s in spans)) if spans else frozenset()
return self._members[key]
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):
key=(start,end)
if key not in self._permitted:
self._permitted[key]=generator.solo.types.permitted(self.choices,start,end)
return self._permitted[key]
def function(self, number, span):
pitch = number % 12
return 'basic' if pitch in span.roles.basic else 'specified_extension' if pitch in span.roles.specified else 'mapped_motion' if span.scale and pitch in span.scale.pitches else 'outside_collection'
def step(self, first, second, time):
span = self.at(time)
if not span or not span.scale:
return False
try:
return abs(generator.solo.types.scale_index(span.scale, first) - generator.solo.types.scale_index(span.scale, second)) == 1
except ValueError:
return False
def connected_motion(self, notes, index):
if not 0 < index < len(notes) - 1:
return ''
before, note, after = notes[index - 1:index + 2]
if before.end != note.start or note.end != after.start:
return ''
a, b, c = (n.pitch.number() for n in (before, note, after))
if self.step(a, b, note.start) and self.step(b, c, note.start):
return 'neighbor' if a == c else 'passing' if (b - a) * (c - b) > 0 else ''
return ''
def enclosure_events(self, notes, enclosures, until=None):
"""Validate whole gestures before granting any exception; never trust labels."""
notes = generator.solo.types.sounding_notes(notes)
events = {}
for note in notes:
if note.event_id:
if note.event_id in events:
return {}, 'duplicate sounding event ID in enclosure context'
events[note.event_id] = note
accepted, intervals = {}, []
for contract in enclosures:
upper, lower, target = contract.upper, contract.lower, contract.target
if len({n.event_id for n in (upper, lower, target)}) != 3 or not all(n.event_id for n in (upper, lower, target)):
return {}, contract.id + ': enclosure requires three distinct event IDs'
for expected in (upper, lower, target):
actual = events.get(expected.event_id)
if not actual or (actual.start, actual.end, actual.pitch) != (expected.start, expected.end, expected.pitch):
return {}, contract.id + ': missing or changed enclosure event ' + expected.event_id
approach_start = contract.approach_start if contract.approach_start is not None else upper.start
if not upper.start <= approach_start or upper.end-approach_start != fractions.Fraction(1,2) or lower.end-lower.start != fractions.Fraction(1,2) or upper.end != lower.start or lower.end != target.start:
return {}, contract.id + ': enclosure must be two contiguous eighths immediately before its target'
if not 0 < upper.pitch.number()-target.pitch.number() <= 3 or lower.pitch.number() != target.pitch.number()-1:
return {}, contract.id + ': enclosure does not surround the exact target register'
if any(n.start < target.start and n.end > upper.start and n.event_id not in (upper.event_id,lower.event_id) for n in notes):
return {}, contract.id + ': interrupted or overlapping enclosure'
if any(a < target.start and b > upper.start for a,b in intervals):
return {}, contract.id + ': overlapping enclosure contracts'
if until is not None and target.end > until:
return {}, contract.id + ': enclosure resolution exceeds the available arrangement'
span = self.at(target.start)
tail = contract.protected_tail
if not span or span.chord != contract.chord or target.pitch.number()%12 not in self.members(target.start,target.end):
return {}, contract.id + ': target is not a chord member throughout its hold'
if contract.target_role not in ('intermediate','terminal') or tail.pitch != target.pitch.number() or not target.start <= tail.start < tail.end <= target.end:
return {}, contract.id + ': invalid protected destination tail'
for note in (upper, lower, target):
covered=note.start
for region,start,end in self.segments(note):
if start != covered or not region.scale:
return {}, contract.id + ': missing harmony in enclosure'
if note is not lower and note.pitch.number()%12 not in region.scale.pitches:
return {}, contract.id + ': upper approach or target outside mapped collection'
covered=end
if covered != note.end:
return {}, contract.id + ': incomplete harmony in enclosure'
# The upper neighbor is the nearest common mapped pitch above T.
allowed=generator.solo.types.permitted(self.choices,upper.start,upper.end)
if any(p%12 in allowed for p in range(target.pitch.number()+1,upper.pitch.number())):
return {}, contract.id + ': upper approach is not the nearest mapped neighbor'
accepted[lower.event_id]=contract
accepted[upper.event_id]=contract
intervals.append((upper.start,target.start))
return accepted, ''
def evaluate(self, notes, treatments=(), *, until=None, complete=False, enclosures=(), expressions=()):
notes = generator.solo.types.sounding_notes(notes)
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 evaluate(self,notes,*,until=None,complete=False,expressions=()):
from generator.solo.expressions import validate_frontier
explicit_frontier=until is not None
until = until if until is not None else max((n.end for n in notes), default=fractions.Fraction())
permissions, expression_pending, expression_error = validate_frontier(notes,expressions,self,until,complete or not explicit_frontier)
if expression_error:return Result(False,0,error=expression_error)
ornaments, error = self.enclosure_events(notes, enclosures, until) if enclosures else ({}, '')
if error:
return Result(False, 0, error=error)
pending, valid_contracts = list(expression_pending), []
for treatment in treatments:
if treatment.start >= until:
continue
if treatment.kind == 'color':
spans = [s for s in self.spans if s.start < treatment.end and s.end > treatment.start]
if not spans or any(treatment.pitch % 12 not in s.roles.basic | s.roles.specified for s in spans):
return Result(False, 0, error=f'{treatment.id}: color is not specified by the overlapped harmony')
valid_contracts.append(treatment)
continue
if treatment.kind != 'suspension' or treatment.resolution is None or treatment.deadline is None:
return Result(False, 0, error=f'{treatment.id}: incomplete harmonic treatment')
held = next((n for n in notes if n.pitch.number() == treatment.pitch and n.start <= treatment.start - treatment.preparation and n.end > treatment.start), None)
if held is None:
return Result(False, 0, error=f'{treatment.id}: suspension lacks sounding preparation')
if treatment.pitch % 12 not in self.members(treatment.start - treatment.preparation, treatment.start):
return Result(False, 0, error=f'{treatment.id}: preparation is not a chord member')
resolution = next((n for n in notes if n.start == held.end and n.pitch.number() == treatment.resolution and n.start <= treatment.deadline), None)
if resolution:
span = self.at(resolution.start)
if not span or treatment.resolution % 12 not in span.roles.basic or not self.step(treatment.pitch, treatment.resolution, resolution.start):
return Result(False, 0, error=f'{treatment.id}: resolution is not an adjacent chord member')
elif complete or until > treatment.deadline or any(n.start >= held.end for n in notes if n is not held):
return Result(False, 0, error=f'{treatment.id}: unresolved suspension at deadline {treatment.deadline}')
else:
pending.append(treatment)
valid_contracts.append(treatment)
exposed, sounding = 0.0, 0.0
for index, note in enumerate(notes):
number = note.pitch.number()
duration = note.end - note.start
sounding += float(duration)
covered = note.start
for span, start, end in self.segments(note):
contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), None)
enclosure = ornaments.get(note.event_id)
expression = permissions.get(note.event_id)
exception = enclosure is not None and note.event_id == enclosure.lower.event_id or expression is not None and (expression.kind != 'anticipation' or end <= expression.boundary)
if start > covered or not span.scale or number % 12 not in span.scale.pitches and not exception:
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony')
covered = end
function = self.function(number, span)
brief_enclosure = enclosure and (exception or enclosure.approach_start is None or enclosure.approach_start == note.start)
if function == 'basic' or note.role == 'quote' or brief_enclosure or expression:
continue
if duration >= generator.solo.policy.DEFAULT.long_note and contract is None:
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end} over {span.chord}: sustained {function} without a planned treatment')
connected = duration < generator.solo.policy.DEFAULT.stopping_note and self.connected_motion(notes, index)
cost = 0 if contract or connected else 1 if duration >= generator.solo.policy.DEFAULT.stopping_note else 0.25
exposed += float(end - start) * cost
if covered < note.end:
return Result(False, 0, error=f'{note.pitch.label()} at {covered}: missing harmonic coverage')
return Result(True, exposed / max(sounding, 0.001), tuple(pending))
def describe(self, notes, treatments=(), enclosures=(), expressions=()):
notes = generator.solo.types.sounding_notes(notes)
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)
ornaments, error = self.enclosure_events(notes,enclosures) if enclosures else ({}, '')
if error:
raise ValueError(error)
result = []
for index, note in enumerate(notes):
for span, start, end in self.segments(note):
contracts = [t for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end]
expression = permissions.get(note.event_id)
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'enclosure': ornaments[note.event_id].id if note.event_id in ornaments else '', 'expression': expression.kind if expression else '', 'expression_rule': expression.rule if expression else '', 'scale_exception': bool(note.event_id in ornaments or expression) and self.function(note.pitch.number(), span) == 'outside_collection', 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]})
return result
def anchor_note(anchor, notes, *, until=None):
for note in notes:
if note.pitch.number() != anchor.pitch.number():
continue
arrival = note.start if anchor.onset_required else max(note.start, anchor.earliest)
needed = max(arrival + anchor.duration, anchor.hold_until or arrival)
if anchor.earliest <= arrival <= anchor.latest and note.start <= arrival < note.end and note.end >= min(needed, until if until is not None else needed):
return note
return None
def anchors_valid(anchors, notes, until, *, complete=False, closure='open'):
for anchor in anchors:
if anchor.earliest >= until and not complete:
continue
note = anchor_note(anchor, notes, until=None if complete else until)
if note is None and (complete or until > anchor.latest):
return False, f'{anchor.id}: required {anchor.role} arrival {anchor.earliest}-{anchor.latest} on {anchor.pitch.label()} not completed'
if note and anchor.role == 'terminal' and closure == 'closed' and any(n.start > note.start for n in notes):
return False, f'{anchor.id}: terminal anchor touched then abandoned'
return True, ''
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)]
+130
View File
@@ -0,0 +1,130 @@
"""Smoothed joint musical-event model. No examples or song lookup at inference.
Counts are sufficient statistics of short, relative contexts, not stored scores.
The hierarchical Dirichlet estimator uses eight parent pseudo-observations.
All paths through the decoder score the same sounding event stream identically.
"""
from __future__ import annotations
import collections
import json
import math
import functools
from fractions import Fraction as F
from pathlib import Path
from generator import model
from generator.solo import expressions
VERSION = 'pooled-events-1'
PRIOR = 8
DURATIONS = ('1/4','1/3','1/2','2/3','3/4','1','3/2','2','5/2','3','4')
def key(value):
return json.dumps(value,separators=(',',':'))
def chord_context(harmony,time):
span=harmony.at(time)
current=expressions.parsed_chord(span.chord) if span and span.chord!='N.C.' else None
future=next((s for s in harmony.spans if s.start>time and s.chord!='N.C.' and (not span or s.chord!=span.chord)),None)
following=expressions.parsed_chord(future.chord) if future else None
def root(c):return int(model.NATURAL[c['root']['letter']]+model.alteration(c['root']['alter']))%12 if c else 0
return (expressions.quality(current) if current else 'unknown',
expressions.quality(following) if following else 'end',
(root(following)-root(current))%12 if current and following else -1),root(current),future
def token(duration,pitch,previous,root):
return ('rest',str(duration),0,0) if pitch is None else ('note',str(duration),
pitch-previous if previous is not None else 'start',(pitch-root)%12)
def source_feature(analysis,time,root=0):
time+=analysis.body_offset
before=[n for n in analysis.melody if n.start<=time][-2:]
following=next((n for n in analysis.melody if n.start>time),None)
direction=0 if len(before)<2 else (before[-1].pitch.number()>before[-2].pitch.number())-(before[-1].pitch.number()<before[-2].pitch.number())
held=bool(before and before[-1].end>time)
upcoming=0 if not before or not following else (following.pitch.number()>before[-1].pitch.number())-(following.pitch.number()<before[-1].pitch.number())
return (direction,held,upcoming,str(before[-1].end-before[-1].start) if before else 'none',
(before[-1].pitch.number()-root)%12 if before else -1,
before[-1].pitch.number()-before[-2].pitch.number() if len(before)>1 else 0)
def phase_at(analysis,time):
absolute=time+analysis.body_offset
start=next((a for a,b in zip(analysis.boundaries,analysis.boundaries[1:]) if a<=absolute<b),analysis.boundaries[-2])
return absolute-start
@functools.lru_cache(maxsize=65536)
def contexts(harmonic,phase,source,history):
# No title, path, absolute bar number, progression ID, or target-derived ID.
base=(*harmonic,str(phase))
result=[(),base,(*base,source)]
# A complete four-event rhythmic/melodic relationship can influence the next
# event, while unseen histories smoothly return to broader musical evidence.
for count in range(1,min(4,len(history))+1):
result.append((*base,source,tuple(history[-count:])))
return tuple(key(c) for c in result)
class Model:
def __init__(self,value):
if value.get('version')!=VERSION:raise ValueError('Missing/incompatible solo model; run python -m generator.train')
self.value=value
self.tables={k:(v['total'],v['counts']) for k,v in value['contexts'].items()}
self.durations=tuple(sorted(map(F,value['durations'])))
self.rhythms=value['rhythms'];self.steps=value['steps'];self.degrees=value['degrees']
self._prob={}
self._totals={id(t):sum(t.values()) for t in (self.rhythms,self.steps,self.degrees)}
@classmethod
def load(cls,path):
try:return cls(json.loads(Path(path).read_text()))
except FileNotFoundError as e:raise ValueError('Solo model not found; run python -m generator.train') from e
def probability(self,action,context):
cache=(action,context)
if cache in self._prob:return self._prob[cache]
kind,duration,step,degree=action
def p(table,item,size):return (table.get(str(item),0)+1)/(self._totals[id(table)]+size)
probability=p(self.rhythms,kind+':'+duration,2*len(self.durations))
if kind=='note':probability*=p(self.steps,step,50)*p(self.degrees,degree,12)
encoded=key(action)
for c in context:
if c in self.tables:
total,counts=self.tables[c]
probability=(counts.get(encoded,0)+PRIOR*probability)/(total+PRIOR)
if len(self._prob)>100000:self._prob.clear()
self._prob[cache]=probability
return probability
def cost(self,action,context):return -math.log(self.probability(action,context))
def register_cost(self,number):
counts=self.value['registers']
return -math.log((counts.get(str(number//12),0)+PRIOR)/(sum(counts.values())+PRIOR*4))
def fit(examples,provenance):
tables=collections.defaultdict(collections.Counter)
rhythms=collections.Counter();steps=collections.Counter();degrees=collections.Counter()
registers=collections.Counter();durations=set(DURATIONS)
for example in examples:
history=[];previous=None
for start,end,pitch in example['events']:
harmonic,root,_=chord_context(example['harmony'],start)
action=token(end-start,pitch,previous,root)
context=contexts(harmonic,phase_at(example['analysis'],start-example['analysis'].body_offset),
source_feature(example['analysis'],start-example['analysis'].body_offset,root),tuple(history[-4:]))
for c in context:tables[c][key(action)]+=1
durations.add(str(end-start));rhythms[action[0]+':'+action[1]]+=1
if pitch is not None:
steps[str(action[2])]+=1;degrees[str(action[3])]+=1
registers[str(pitch//12)]+=1;previous=pitch
history.append(action)
return {'version':VERSION,'provenance':provenance,'prior':PRIOR,
'durations':sorted(durations,key=F),'rhythms':dict(rhythms),'steps':dict(steps),'degrees':dict(degrees),
'registers':dict(registers),
'contexts':{k:{'total':sum(v.values()),'counts':dict(sorted(v.items()))} for k,v in sorted(tables.items())}}
-656
View File
@@ -1,656 +0,0 @@
"""Source-related melodic sentences realized as whole pitch/rhythm routes.
Original song analysis and general vocabulary are the only musical inputs.
"""
import collections
import dataclasses
import fractions
import functools
import generator.model
import generator.solo.expressions
import generator.solo.groups
import generator.solo.policy
import generator.solo.rhythm
import generator.solo.sequences
import generator.solo.selection
import generator.solo.types
import generator.vocabulary
@dataclasses.dataclass(frozen=True)
class Idea:
rhythm: tuple
intervals: tuple
source: str
@property
def length(self):
return self.rhythm[-1][1]
def idea(notes, source):
first = notes[0]
return Idea(tuple((n.start-first.start,n.end-first.start) for n in notes),
tuple(n.pitch.number()-first.pitch.number() for n in notes),source)
def source_ideas(analysis):
found = {}
for i in range(len(analysis.melody)):
for count in (3,4,5,6):
notes = analysis.melody[i:i+count]
if len(notes)!=count or not 2<=notes[-1].end-notes[0].start<=6:
continue
value = idea(notes,'head:'+str(notes[0].start))
if not generator.solo.groups.valid(value.rhythm):
continue
key = (value.rhythm,value.intervals)
old,number = found.get(key,(value,0)); found[key] = (old,number+1)
def rank(row):
value,count = row
intervals = [b-a for a,b in zip(value.intervals,value.intervals[1:])]
turns = sum(a*b<0 for a,b in zip(intervals,intervals[1:]))
durations = len({b-a for a,b in value.rhythm})
return (-min(count,3),-durations,-min(turns,2),-min(7,max(value.intervals)-min(value.intervals)),value.source)
return tuple(v for v,_ in sorted(found.values(),key=rank)[:12])
@dataclasses.dataclass(frozen=True)
class Arrival:
note: generator.solo.types.Note
anchor: generator.solo.types.Anchor
contracts: tuple = ()
resume: fractions.Fraction = fractions.Fraction()
class Phrases:
def __init__(self,analysis,harmony,sequences,rhythms,start,end,seed,enabled):
self.analysis,self.harmony = analysis,harmony
self.start,self.end,self.seed,self.enabled = start,end,seed,enabled
self.sequences = sequences
self.ideas = source_ideas(analysis)
library = generator.solo.rhythm.Rhythms(rhythms,analysis.tempo)
self.patterns = tuple(library.groups)
self.authored_ids = frozenset(v[0] for v in self.patterns)
self.arrivals = tuple(sorted({(cell.pattern[-1][1]-cell.pattern[-1][0],cell.duration-cell.pattern[-1][1])
for cell in generator.vocabulary.rhythm_cells(analysis.tempo,'arrival') if cell.pattern and cell.pattern[-1][1]-cell.pattern[-1][0]>=1}))
# Catalog families contribute one peer per rhythmic function, not one
# vote per file. In particular the large 5/4 family cannot dominate 4/4.
representatives = {}
lengths = {b-a for a,b in zip(analysis.boundaries,analysis.boundaries[1:])}
for values in library.catalog.values():
for name,pattern,length,purposes in values:
if length not in lengths or not 2<=len(pattern)<=6:
continue
descriptor = generator.solo.groups.descriptor(pattern,length)
representatives.setdefault(descriptor,(name,pattern,length,purposes))
self.patterns += tuple(representatives.values())
self.patterns = tuple(dict((generator.solo.groups.descriptor(p,l),(name,p,l,roles))
for name,p,l,roles in self.patterns).values())
self.patterns_by_length = {}
for value in self.patterns:
self.patterns_by_length.setdefault(value[2],[]).append(value)
self.center = harmony.profile.reference or 60
self.rejections = collections.Counter()
self.proposals = collections.Counter()
def choose(self,values,domain,key):
return min(values,key=lambda x:generator.solo.policy.stable_rank(self.seed+domain,(key,x)))
def horizon(self,start):
region = self.harmony.at(start)
if not region or not region.scale:
return min(self.end,region.end if region else self.end)
gap = next((s.start for s in self.harmony.spans if start<s.start<self.end and not s.scale),self.end)
boundaries = [t-self.analysis.body_offset for t in self.analysis.boundaries if start<t-self.analysis.body_offset<=self.end]
if not boundaries:
return self.end
if start<0:
return min(self.end,fractions.Fraction())
candidates = boundaries[1:8] or boundaries
def cost(end):
previous = self.harmony.at(end-fractions.Fraction(1,8))
before = self.harmony.at(previous.start-fractions.Fraction(1,8)) if previous else None
cadence = False
if previous and before and previous.scale and before.scale:
a = generator.solo.expressions.parsed_chord(before.chord)
b = generator.solo.expressions.parsed_chord(previous.chord)
root = lambda c:int(generator.model.NATURAL[c['root']['letter']]+generator.model.alteration(c['root']['alter']))%12
cadence = generator.solo.expressions.quality(a)=='dominant' and (root(a)-root(b))%12==7
breath = any(n.end==end+self.analysis.body_offset and (i+1==len(self.analysis.melody) or self.analysis.melody[i+1].start>n.end) for i,n in enumerate(self.analysis.melody))
return (abs(boundaries.index(end)+1-4)-2*int(cadence)-int(breath),end)
return min(gap,min(candidates,key=cost))
@functools.lru_cache(maxsize=8192)
def pitches(self,start,end):
span = self.harmony.at(start)
if not span or not span.scale:
return ()
permitted = self.harmony.permitted(start,end)
return tuple(p for index in range(-28,29) if self.harmony.profile.permits((p:=generator.solo.types.scale_pitch(span.scale,index)).number()) and p.number()%12 in permitted)
@staticmethod
def note(start,end,pitch,role):
return generator.solo.types.Note(start,end,pitch,role=role,event_id=f'{start}:{end}:{pitch.number()}')
@functools.lru_cache(maxsize=4096)
def event_options(self,start,end,structural,enabled):
"""Validate the immutable harmonic interval once, not at every near pitch."""
result = []
span = self.harmony.at(start)
if not span or not span.scale:
return ()
for pitch in self.pitches(start,end):
note = self.note(start,end,pitch,'arrival' if structural else 'motion')
if self.harmony.evaluate((note,),complete=True).valid:
if not structural or pitch.number()%12 in self.harmony.members(start,end):
result.append((note,()))
if enabled and end-start>=1:
for rule in generator.vocabulary.data('harmony')['colors']:
if rule.get('resolve') or not generator.solo.expressions.eligible_color(rule,span,self.harmony):
continue
low,high = self.harmony.profile.register
for near in range(low,high+12,12):
pitch = generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],near)
if not self.harmony.profile.permits(pitch.number()):
continue
note = self.note(start,end,pitch,'color')
color = generator.solo.types.ExpressionContract(note.event_id+':'+rule['id'],'color',rule['id'],(note,),note,span.chord)
if self.harmony.evaluate((note,),expressions=(color,),complete=True).valid:
result.append((note,(color,)))
return tuple(dict.fromkeys(result))
def options(self,start,end,near,structural=False):
return tuple(v for v in self.event_options(start,end,structural,self.enabled) if abs(v[0].pitch.number()-near)<=12)
def arrival_options(self,onset,release,harmonic_time,center,effect,resume=None):
"""Choose the sounding target before realizing its incoming gesture."""
region = self.harmony.at(harmonic_time)
if not region or not region.scale or not onset<release:
return ()
result = []
if onset<harmonic_time:
if not self.enabled or region.start!=harmonic_time or not onset<harmonic_time<release:
return ()
# A future member need not be legal in the preceding collection. Only
# the bounded, validated anticipation authorizes that preceding interval.
candidates = [(p,()) for p in self.pitches(harmonic_time,release)
if p.number()%12 in self.harmony.members(harmonic_time,release)]
else:
candidates = [(n.pitch,cs) for n,cs in self.options(onset,release,center,True)]
for pitch,support in candidates:
if abs(pitch.number()-center)>10:
continue
note = self.note(onset,release,pitch,'arrival')
if onset<harmonic_time:
support = (generator.solo.types.ExpressionContract(note.event_id+':early','anticipation',
'early_chord_tone',(note,),note,region.chord,boundary=harmonic_time),)
elif support:
note = support[0].target
if not self.harmony.evaluate((note,),expressions=support,complete=True).valid:
continue
anchor = generator.solo.types.Anchor('arrival:'+str(onset),onset,onset,pitch,
frozenset({pitch.number()%12}),self.harmony.profile.register,release-onset,
'terminal' if effect in ('complete','handoff') else 'intermediate',region.chord,
False,release,effect)
result.append(Arrival(note,anchor,support,release if resume is None else resume))
return tuple(result)
def cadence(self,time):
current = self.harmony.at(time)
previous = self.harmony.at(time-fractions.Fraction(1,8))
if not current or not previous or not current.scale or not previous.scale or current.chord==previous.chord:
return False
a,b = (generator.solo.expressions.parsed_chord(s.chord) for s in (previous,current))
root = lambda c:int(generator.model.NATURAL[c['root']['letter']]+generator.model.alteration(c['root']['alter']))%12
return (generator.solo.expressions.quality(a)=='dominant' and
generator.solo.expressions.quality(b)!='dominant' and (root(a)-root(b))%12==7)
def destination_choice(self,options,center,domain,key):
# Each eligible harmonic degree has an octave realization close to the
# phrase's chosen hand/register region. Register exploration belongs to
# the phrase choice, not an unrelated octave lottery at every arrival.
members = {}
for value in options:
pc = value.note.pitch.number()%12
rank = (abs(value.note.pitch.number()-center),value.note.pitch.number())
if pc not in members or rank<members[pc][0]:
members[pc] = (rank,value)
return self.choose(tuple(v for _,v in sorted(members.values(),key=lambda v:v[0])),domain,key)
def destinations(self,start,end,center,variant):
"""Small connected alternatives in harmonic time, independent of a body."""
hold,breath = self.choose(self.arrivals,'arrival',(start,variant))
close = end==self.end or self.choose((True,False),'closure',(start,variant))
result = []
if close and end-start>=3:
release = end-breath
base = max(start+1,release-hold)
times = [(base,base)]
# Near a harmonic change compare anticipation, on-time and delayed
# entry, rather than retiming an already selected strict melody.
for span in self.harmony.spans:
if start+2<=span.start<release-1 and abs(span.start-base)<=2:
for offset in (fractions.Fraction(-1,2),fractions.Fraction(),fractions.Fraction(1,2)):
onset = span.start+offset
if start+1<=onset<release-1:
times.append((onset,span.start if offset<0 else onset))
onset,when = self.choose(tuple(dict.fromkeys(times)),'arrival_time',(start,variant))
effect = 'handoff' if end==self.end else 'complete' if any(self.cadence(s.start) for s in self.harmony.spans if onset<=s.start<release) or self.cadence(when) else 'settle'
options = self.arrival_options(onset,release,when,center,effect,end)
if not options:
return None
result.append(self.destination_choice(options,center,'destination',(start,variant)))
limit = result[0].note.start if result else end
regions = [s for i,s in enumerate(self.harmony.spans) if s.scale and i and
self.harmony.spans[i-1].chord!=s.chord and start+3<=s.start<limit-3]
if regions:
# Local repose is optional; it is not closure at every chord/cell.
region = self.choose((*regions,None),'local_settle',(start,variant))
if region:
offset = self.choose((fractions.Fraction(-1,2),fractions.Fraction(),fractions.Fraction(1,2)), 'local_time',(start,variant))
onset = region.start+offset
duration,space = self.choose(self.arrivals,'local_duration',(start,variant))
release = min(region.end,region.start+duration,limit-1)
resume = min(release+space,limit-1)
local_center = round((center+result[0].note.pitch.number())/2) if result else center
options = self.arrival_options(onset,release,region.start if offset<0 else onset,local_center,'settle',resume)
if options:
result.insert(0,self.destination_choice(options,local_center,'local_destination',(start,variant)))
return tuple(result)
@functools.lru_cache(maxsize=128)
def developments(self,motif,purpose):
if not motif:
return ()
if purpose=='return':
return (motif,)
values = []
# The characteristic pitch relationships can survive rhythmic recasting.
# Source holds remain source music; they do not become obligatory holds
# at the start of every generated development.
cursor = fractions.Fraction(); compact = []
for i,(a,b) in enumerate(motif.rhythm):
gap = a-motif.rhythm[i-1][1] if i else a
cursor += min(gap,fractions.Fraction(1,2))
length = min(b-a,fractions.Fraction(1))
compact.append((cursor,cursor+length)); cursor += length
if generator.solo.groups.valid(tuple(compact)):
values.append(Idea(tuple(compact),motif.intervals,motif.source+':rephrase'))
if purpose=='extend':
# Reopen the provisional ending and continue its motion. This is an
# edit to the existing idea, not a finished idea followed by a new one.
a,b = compact[-1]
stop = a+min(b-a,fractions.Fraction(1,2))
rhythm = (*compact[:-1],(a,stop),(stop,stop+fractions.Fraction(1,2)),(stop+fractions.Fraction(1,2),stop+1))
step = 2 if motif.intervals[-1]>=motif.intervals[-2] else -2
values.append(Idea(rhythm,(*motif.intervals,motif.intervals[-1]+step,motif.intervals[-1]+2*step),motif.source+':extend'))
for name,pattern,length,roles in self.patterns:
if (len(pattern)==len(motif.intervals) and 1<=length<=4 and
set(roles)&{'entrance','continue','connect','develop','answer','scalar'}):
values.append(Idea(pattern,motif.intervals,motif.source+':rhythm:'+name))
# A full rhythmic recurrence is still a legitimate candidate, not a duty.
values.append(motif)
return tuple(dict(((v.rhythm,v.intervals),v) for v in values).values())
@functools.lru_cache(maxsize=1024)
def rhythm_candidates(self,cursor,remaining,approaching,fast_allowed):
desired_roles = {'connect','continue','scalar'} if approaching else {'continue','connect','develop','answer','scalar','arpeggio'}
eligible = []
for length,values in sorted(self.patterns_by_length.items()):
if not min(remaining,2)<=length<=remaining:
continue
for value in values:
name,pattern,length,roles = value
fast = any((b-a).denominator not in (1,2) for a,b in pattern)
if (set(roles)&desired_roles and (not fast or fast_allowed) and
generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in pattern))):
eligible.append(value)
return tuple(eligible)
def scale_step(self,scale,number,step):
"""Continue from sounding pitch, never transfer a degree between maps."""
try:
index = generator.solo.types.scale_index(scale,number)
except ValueError:
# The preceding attack may belong to the previous collection. Its
# pitch stays untouched; enter the new one in the requested direction.
if not step:
return None
entries = [generator.solo.types.scale_pitch(scale,i) for i in range(-28,29)]
entries = [p for p in entries if (p.number()-number)*step>0]
if not entries:
return None
nearest = min(entries,key=lambda p:abs(p.number()-number))
index = generator.solo.types.scale_index(scale,nearest.number())
step -= 1 if step>0 else -1
return generator.solo.types.scale_pitch(scale,index+step)
def route(self,rhythm,contour,incoming,center,mode='develop',*,degree_path=False,target=None):
values = self.routes(rhythm,contour,incoming,center,mode,degree_path=degree_path,target=target,limit=1)
return values[0] if values else None
def routes(self,rhythm,contour,incoming,center,mode='develop',*,degree_path=False,target=None,limit=3):
return self._routes(rhythm,contour,incoming,center,mode,degree_path,target,limit,self.enabled)
@functools.lru_cache(maxsize=1024)
def _routes(self,rhythm,contour,incoming,center,mode,degree_path,target,limit,enabled):
"""Keep complete paths toward a known future event, not one next step."""
if not rhythm:
return ()
initial = incoming.pitch.number() if incoming else center
if incoming and mode in ('connect','arpeggio'):
direction = (1 if target.pitch.number()>=initial else -1) if target else (1 if contour[-1]>=contour[0] else -1)
center = initial+direction*2
options = self.options(*rhythm[0],center)
if mode=='arpeggio':
options = tuple(v for v in options if v[0].pitch.number()%12 in self.harmony.at(rhythm[0][0]).roles.basic)
starts = sorted(options,key=lambda v:(abs(v[0].pitch.number()-center)+max(0,abs(v[0].pitch.number()-initial)-5),v[0].pitch.number()))[:6]
states = [(abs(n.pitch.number()-initial)/4,(n,),c,n.pitch.number()) for n,c in starts]
def finish_distance(note):
if target is None:
return 0
distance = abs(note.pitch.number()-target.pitch.number())
return max(0,distance-2)*2+int(distance==0 and note.end==target.start)*2
for index,(a,b) in enumerate(rhythm[1:],1):
next_states = []
for cost,notes,contracts,origin in states:
wanted = origin+contour[min(index,len(contour)-1)]
expected = contour[min(index,len(contour)-1)]-contour[min(index-1,len(contour)-1)]
span = self.harmony.at(a)
if degree_path:
if not span or not span.scale:
continue
if target is not None:
# Use octave-aware indices when both pitches belong to this map.
# Across maps the target remains an actual sounding pitch.
try:
displacement = generator.solo.types.scale_index(span.scale,target.pitch.number())-generator.solo.types.scale_index(span.scale,notes[-1].pitch.number())
step = max(-2,min(2,round(displacement/(len(rhythm)-index+1))))
except ValueError:
step = 1 if target.pitch.number()>notes[-1].pitch.number() else -1
pitch = self.scale_step(span.scale,notes[-1].pitch.number(),step)
else:
pitch = self.scale_step(span.scale,notes[-1].pitch.number(),expected)
if pitch is None:
continue
wanted = pitch.number()
expected = wanted-notes[-1].pitch.number()
for note,support in self.options(a,b,wanted):
delta = note.pitch.number()-notes[-1].pitch.number()
if degree_path:
if target is None and note.pitch.number()!=wanted:
continue
if target is not None:
try:
step = generator.solo.types.scale_index(span.scale,note.pitch.number())-generator.solo.types.scale_index(span.scale,notes[-1].pitch.number())
if abs(step)>2:
continue
except ValueError:
if abs(delta)>3:
continue
if mode=='arpeggio' and (note.pitch.number()%12 not in span.roles.basic or delta==0):
continue
error = abs(note.pitch.number()-wanted)+3*int(delta==0 and expected!=0)+max(0,abs(delta)-7)*2
if b-a>=1 and note.pitch.number()%12 not in span.roles.basic and not support:
error += 2
next_states.append((cost+error,(*notes,note),(*contracts,*support),origin))
if not next_states:
return ()
def rank(state):
# Admissible reachability pressure uses the actual remaining attacks;
# a partial short route is never compared to a complete long route.
distance = max(0,abs(state[1][-1].pitch.number()-target.pitch.number())-3*(len(rhythm)-index)) if target else 0
return state[0]+distance,tuple(n.pitch.number() for n in state[1])
states = generator.solo.sequences.diverse(next_states,rank,
lambda s:(s[1][0].pitch.number(),s[1][-1].pitch.number()),8)
values = []
for cost,notes,contracts,_ in sorted(states,key=lambda s:(s[0]+finish_distance(s[1][-1]),tuple(n.pitch.number() for n in s[1]))):
if self.harmony.evaluate(notes,expressions=contracts,complete=True).valid:
values.append((notes,contracts))
return tuple(values[:limit])
def contour(self,count,direction,mode,motif,time):
if mode in ('statement','return','develop','answer') and motif:
values = motif.intervals
sign = 1
# Explicit reduction/extension; never cycle a route to fit its rhythm.
return tuple(sign*values[i] if i<len(values) else sign*values[-1]+direction*2*(i-len(values)+1) for i in range(count))
paths = self.sequences.connecting(count) if self.sequences else ()
paths = [p for p in paths if max(map(abs,p.steps))<=(3 if mode=='arpeggio' else 2) and (p.offsets()[-1]>0)==(direction>0) and sum(a*b<0 for a,b in zip(p.steps,p.steps[1:]))<=1]
span = self.harmony.at(time)
if paths and span and span.scale:
path = min(paths,key=lambda p:(abs(abs(p.offsets()[-1])-(count-1)),p.rank()))
if mode=='connect':
return path.offsets()
base = generator.solo.types.scale_pitch(span.scale,0)
return tuple(generator.solo.types.scale_pitch(span.scale,i).number()-base.number() for i in path.offsets())
if mode=='connect':
return tuple(direction*i for i in range(count))
return tuple(direction*i*(3 if mode=='arpeggio' else 2) for i in range(count))
def approach(self,arrival,start,variant):
"""Compose a complete optional lead-in before choosing the body rhythm."""
if not self.enabled:
return (),()
family = self.choose(('direct','approach','enclosure'),'approach_family',(start,variant,arrival.note.start))
if family=='direct':
return (),()
rules = [r for r in generator.vocabulary.data('lead_ins')['patterns'] if r['kind']==family and
(not r.get('difficulty') or self.analysis.tempo<=generator.vocabulary.data('roles')['difficulty'][r['difficulty']+'_max_bpm'])]
for rule in sorted(rules,key=lambda r:generator.solo.policy.stable_rank(self.seed+'lead_in',(start,variant,r['id']))):
gaps = tuple(map(fractions.Fraction,rule.get('internal_gaps',[])))
cursor = arrival.note.start-sum(map(fractions.Fraction,rule['durations']),fractions.Fraction())-sum(gaps,fractions.Fraction())-fractions.Fraction(rule['gap'])
if cursor<start+1:
continue
ns = []
for i,(step,length) in enumerate(zip(rule['semitones'],rule['durations'])):
pitch = generator.solo.expressions.chromatic(arrival.note.pitch,step)
ns.append(self.note(cursor,cursor+fractions.Fraction(length),pitch,family))
cursor = ns[-1].end+(gaps[i] if i<len(gaps) else 0)
if any(not self.harmony.profile.permits(n.pitch.number()) for n in ns):
continue
early = next((c.boundary for c in arrival.contracts if c.kind=='anticipation'),None)
c = generator.solo.types.ExpressionContract(arrival.note.event_id+':'+rule['id'],family,rule['id'],tuple(ns),
arrival.note,arrival.anchor.chord,boundary=early)
if self.harmony.evaluate((*ns,arrival.note),expressions=(*arrival.contracts,c),complete=True).valid:
return tuple(ns),(c,)
return (),()
def make(self,start,end,incoming,home,history,variant):
if not any(s.scale and s.start<end and s.end>start for s in self.harmony.spans):
self.proposals['space'] += 1
evaluation = generator.solo.selection.evaluate((),start,end,self.harmony)
return generator.solo.types.Phrase('space:'+str(start),start,end,(),'space','level','open',evaluation=evaluation),()
motif = idea(home,'realized_theme') if home else self.choose(self.ideas,'source',variant) if self.ideas else None
purpose = 'statement' if not home else self.choose(('return','develop','answer','extend'),'purpose',(start,variant))
if purpose=='return' and history and history[-1].purpose=='return':
purpose = 'develop'
self.proposals[purpose] += 1
center = self.center+self.choose((-5,0,5),'register',(start,variant))
if purpose=='answer' and home:
movement = home[-1].pitch.number()-home[0].pitch.number()
center -= 3 if movement>0 else -3 if movement<0 else 0
if incoming:
center = max(incoming.pitch.number()-7,min(incoming.pitch.number()+7,center))
destinations = self.destinations(start,end,center,variant)
if destinations is None:
self.rejections['destination_feasibility'] += 1; return None
ending = destinations[-1].anchor.effect if destinations and destinations[-1].resume==end else 'open'
terminal = destinations[-1].anchor if ending!='open' else None
developments = self.developments(motif,purpose)
notes,contracts,sources,rhythm_groups = [],[],[],[]
cursor = start
if start>=0 and purpose!='return':
cursor += self.choose((fractions.Fraction(),fractions.Fraction(1,2),fractions.Fraction(1)),'entrance',(start,variant))
signature_notes = (); group_index = 0; fast_groups = 0
landmarks = (*destinations,*((None,) if not destinations or destinations[-1].resume<end else ()))
for landmark in landmarks:
approach,support = self.approach(landmark,cursor,variant) if landmark else ((),())
stop = approach[0].start if approach else landmark.note.start if landmark else end
target = approach[0] if approach else landmark.note if landmark else None
while cursor<stop:
span = self.harmony.at(cursor)
if not span or not span.scale:
cursor = min(stop,span.end if span else stop); continue
remaining = stop-cursor
choices = [v for v in developments if v.length<=remaining and generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in v.rhythm))]
if group_index==0 and choices:
motif = self.choose(choices,'theme_rhythm',(start,variant))
pattern,length,source = motif.rhythm,motif.length,motif.source
mode = purpose if purpose!='extend' else 'develop'
else:
# Select complete rhythmic groups by their musical function, keeping
# ordered timing diversity until pitches and destination are known.
eligible = self.rhythm_candidates(cursor,remaining,bool(target and remaining<=4),
fast_groups<generator.vocabulary.data('solo')['subdivision_groups'])
if eligible:
# Families have equal standing; the combinatorial catalog's file
# count never determines how often its material is requested.
families = {}
for value in eligible:
family = 'catalog' if value[0] not in self.authored_ids else 'authored'
families.setdefault(family,[]).append(value)
family = self.choose(tuple(families),'rhythm_family',(start,variant,group_index))
source,pattern,length,_ = self.choose(families[family],'rhythm',(start,variant,group_index))
else:
# An explicit moving primitive, never an arbitrary long hold used
# to fill a leftover span. Tuplets may only enter as whole groups.
length = min(fractions.Fraction(1,2),remaining)
pattern,source = ((fractions.Fraction(),length),),'eighth_continuation'
if not generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in pattern)):
self.rejections['subdivision_remainder'] += 1; return None
mode = 'connect' if target and remaining<=4 else self.choose(('connect','develop','arpeggio'),'movement',(start,variant,group_index))
rhythm = tuple((cursor+a,cursor+b) for a,b in pattern)
previous = notes[-1] if notes else incoming
direction = 1 if target and target.pitch.number()> (previous.pitch.number() if previous else center) else -1 if target else self.choose((-1,1),'direction',(start,variant))
desired = self.contour(len(pattern),direction,mode,motif,cursor)
# Only the group immediately reaching a target must finish its approach.
# Earlier material sees the future register but can retain its identity.
next_target = target if cursor+length==stop else None
local_center = round((center+target.pitch.number())/2) if target else center
routes = self.routes(rhythm,desired,previous,local_center,mode,degree_path=mode=='connect',target=next_target)
if not routes:
self.rejections['route'] += 1; return None
ns,cs = routes[variant%len(routes)]
if not signature_notes and group_index==0 and len(ns)>=3:
signature_notes = ns
fast_groups += int(any((b-a).denominator not in (1,2) for a,b in pattern))
notes.extend(ns); contracts.extend(cs); sources.append(source)
rhythm_groups.append(generator.solo.groups.RhythmGroup(cursor,cursor+length,'gesture',rhythm,
cursor%1,source+':'+mode))
cursor += length; group_index += 1
if landmark:
if approach and notes and notes[-1].end==approach[0].start and notes[-1].pitch==approach[0].pitch:
# Recompose the join as a direct route in another candidate; never
# keep an accidental duplicated entrance because the label is valid.
self.rejections['duplicated_approach_entrance'] += 1; return None
notes.extend(approach); contracts.extend(support)
note = landmark.note
if notes and not approach and not landmark.contracts and notes[-1].end==note.start and notes[-1].pitch==note.pitch:
before = notes[-1]
used = {n.event_id for c in contracts for n in (*c.events,c.target)}
held = dataclasses.replace(note,start=before.start,event_id=note.event_id+':carried')
if before not in signature_notes and before.event_id not in used and self.harmony.evaluate((held,),complete=True).valid:
notes.pop(); note=held
notes.append(note); contracts.extend(landmark.contracts)
rhythm_groups.append(generator.solo.groups.RhythmGroup(
approach[0].start if approach else note.start,landmark.resume,'arrival',
tuple((n.start,n.end) for n in (*approach,note)),note.start%1,landmark.anchor.effect))
sources.append('destination:'+landmark.anchor.effect)
cursor = landmark.resume
if not notes:
return None
if self.enabled and variant%3:
# Only genuinely local refinements remain here. Every committed
# arrival and the treatment's identifying events are protected.
protected = (*signature_notes,*(n for n in notes if any(n.start<=d.note.start and n.end>=d.note.end and n.pitch==d.note.pitch for d in destinations)))
notes,contracts = self.expression(tuple(notes),tuple(contracts),protected,start,variant)
checked = self.harmony.evaluate(notes,expressions=contracts,until=end,complete=True)
if not checked.valid:
self.rejections[checked.error] += 1; return None
anchors = tuple(d.anchor for d in destinations)
evaluation = generator.solo.selection.evaluate(notes,start,end,self.harmony,incoming=(incoming,) if incoming else (),
home=home,purpose=purpose,contracts=contracts,center=center,destinations=anchors,history=history,identity=signature_notes)
if not evaluation.valid:
self.rejections['arrival_contract'] += 1; return None
key = generator.solo.policy.stable_rank(self.seed,(start,end,variant))[:12]
pattern = tuple((n.start-start,n.end-start) for n in notes)
try:
generator.solo.groups.describe(pattern,origin=start,source=purpose)
except ValueError:
self.rejections['rhythm_group'] += 1; return None
# Refine diagnostics against the chosen sounding events. Local expression
# and shared arrival sustains must not leave stale pre-edit rhythm reports.
rhythm_groups = tuple(dataclasses.replace(g,events=tuple((n.start,n.end) for n in notes
if g.start<=n.start<g.end)) for g in rhythm_groups)
direction = 'up' if notes[-1].pitch.number()>notes[0].pitch.number() else 'down' if notes[-1].pitch.number()<notes[0].pitch.number() else 'returning'
phrase = generator.solo.types.Phrase(key,start,end,tuple(notes),purpose,direction,ending,
terminal,tuple(contracts),rhythm_groups,'home' if home else key,float(evaluation.total),evaluation,tuple(sources),anchors,signature_notes)
return phrase,signature_notes
def expression(self,notes,contracts,protected,start,variant):
protected_ids = {n.event_id for n in protected}
used = {n.event_id for c in contracts for n in (*c.events,c.target)}
targets = [n for n in notes if n.event_id not in used|protected_ids and n.end-n.start>=1 and n.pitch.number()%12 in self.harmony.members(n.start,n.end)]
if not targets:
return notes,contracts
target = self.choose(targets,'expression_target',(start,variant))
previous = next((n for n in reversed(notes) if n.end<=target.start),None)
region = self.harmony.at(target.start)
editable = previous and previous.event_id not in used|protected_ids and previous.end==target.start
if editable and region.start==target.start and target is not notes[-1] and target.end-target.start>=fractions.Fraction(3,2):
for rule in generator.vocabulary.data('harmony').get('suspensions',[]):
if target.pitch.number()-previous.pitch.number() not in rule['steps']:
continue
held = dataclasses.replace(previous,end=target.start+fractions.Fraction(1,2),event_id=previous.event_id+':held',role='suspension')
resolved = dataclasses.replace(target,start=held.end,event_id=target.event_id+':resolved')
c = generator.solo.types.ExpressionContract(held.event_id,'suspension',rule['id'],(held,),resolved,region.chord,boundary=region.start)
revised = tuple(held if n is previous else resolved if n is target else n for n in notes)
if self.harmony.evaluate(revised,expressions=(*contracts,c),complete=True).valid:
return revised,(*contracts,c)
# Anticipation changes the actual rhythm before phrase selection. The
# original committed arrival interval remains sounding in its entirety.
if editable and region.start==target.start and previous.end-previous.start>=1:
early = dataclasses.replace(target,start=target.start-fractions.Fraction(1,2),event_id=target.event_id+':early')
before = dataclasses.replace(previous,end=early.start,event_id=previous.event_id+':short')
contract = generator.solo.types.ExpressionContract(early.event_id,'anticipation','early_chord_tone',(early,),early,region.chord,boundary=region.start)
revised = tuple(early if n is target else before if n is previous else n for n in notes)
if self.harmony.evaluate(revised,expressions=(*contracts,contract),complete=True).valid:
return revised,(*contracts,contract)
# Altered colors resolve to an existing destination; the rule, not the
# annotation or accidental, authorizes their complete exposed interval.
if editable and variant%2 and previous.end-previous.start<=1:
span = self.harmony.at(previous.start)
for rule in generator.vocabulary.data('harmony')['colors']:
if not rule.get('resolve') or not generator.solo.expressions.eligible_color(rule,span,self.harmony):
continue
pitch = generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],target.pitch.number())
color = dataclasses.replace(previous,pitch=pitch,event_id=previous.event_id+':color',role='color')
c = generator.solo.types.ExpressionContract(color.event_id,'color',rule['id'],(color,),target,region.chord)
revised = tuple(color if n is previous else n for n in notes)
if self.harmony.profile.permits(pitch.number()) and self.harmony.evaluate(revised,expressions=(*contracts,c),complete=True).valid:
return revised,(*contracts,c)
rules = [r for r in generator.vocabulary.data('lead_ins')['patterns'] if r['kind'] in ('approach','enclosure') and (not r.get('difficulty') or self.analysis.tempo<=generator.vocabulary.data('roles')['difficulty'][r['difficulty']+'_max_bpm'])]
rules = sorted(rules,key=lambda r:generator.solo.policy.stable_rank(self.seed+'expression',(start,variant,r['id'])))
for rule in rules:
gaps = tuple(map(fractions.Fraction,rule.get('internal_gaps',[])))
cursor = target.start-sum(map(fractions.Fraction,rule['durations']),fractions.Fraction())-sum(gaps,fractions.Fraction())-fractions.Fraction(rule['gap'])
affected = [n for n in notes if n.start<target.start and n.end>cursor]
if cursor<start or any(n.event_id in used|protected_ids or n.start<cursor for n in affected):
continue
gesture = []
for i,(delta,length) in enumerate(zip(rule['semitones'],rule['durations'])):
pitch = generator.solo.expressions.chromatic(target.pitch,delta)
gesture.append(self.note(cursor,cursor+fractions.Fraction(length),pitch,rule['kind']))
cursor += fractions.Fraction(length)+(gaps[i] if i<len(gaps) else 0)
if any(not self.harmony.profile.permits(n.pitch.number()) for n in gesture):
continue
before = next((n for n in reversed(notes) if n.end<=gesture[0].start),None)
if before and (abs(before.pitch.number()-gesture[0].pitch.number())>7 or before.end==gesture[0].start and before.pitch==gesture[0].pitch):
continue
revised = tuple(sorted((*[n for n in notes if n not in affected],*gesture),key=lambda n:n.start))
contract = generator.solo.types.ExpressionContract(target.event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(gesture),target,self.harmony.at(target.start).chord)
if self.harmony.evaluate(revised,expressions=(*contracts,contract),complete=True).valid:
return revised,(*contracts,contract)
return notes,contracts
+5 -10
View File
@@ -1,10 +1,5 @@
"""Physical/harmonic constants and semantic generation version."""
import dataclasses, hashlib
@dataclasses.dataclass(frozen=True)
class Policy:
long_note: float = 2.
stopping_note: float = 1.
composition_version: str = 'directed-phrases-2'
DEFAULT=Policy()
def stable_rank(seed, value):
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
"""Stable deterministic tie breaking shared with guitar and accompaniment."""
import hashlib
def stable_rank(seed,value):
return hashlib.sha256((seed+repr(value)).encode()).hexdigest()
+1 -6
View File
@@ -34,9 +34,4 @@ def for_part(part, analysis=None):
strings = (1, 2, 3, 4, 5, 6)
if analysis is None or not analysis.melody:
return Instrument(part, strings)
# Source identity is independent of sounding register. Roles have soft,
# overlapping centers; all legal positions remain available to both guitars.
center=generator.vocabulary.role(part+'_solo').get('register_center',57 if part=='acoustic' else 62)
notes=tuple(n for n in analysis.melody if n.end>analysis.body_offset)
return Instrument(part,strings,center,
(min(n.pitch.number() for n in notes),max(n.pitch.number() for n in notes)))
return Instrument(part,strings)
+9 -12
View File
@@ -11,33 +11,30 @@ def plain(value):
return value
def structure(solo):
harmony=harmonic.Harmony(solo.choices)
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)
phrases=[];endings=[]
authored=solo.diagnostics.get('construction')=='authored_repertoire'
phrases=[]
for p in solo.phrases:
anchor=p.destination
completed=anchor is None or harmonic.anchor_note(anchor,p.notes) is not None
entry={'id':p.id,'start':str(p.start),'end':str(p.end),'purpose':p.purpose,'direction':p.direction,'ending':p.ending,
'theme':p.theme,'notes':plain(p.notes),'rhythm_groups':plain(p.groups),'sources':p.sources,
'anchors':[{**plain(a),'pitch':a.pitch.label(),'completed':harmonic.anchor_note(a,p.notes) is not None} for a in p.arrivals or ((anchor,) if anchor else ())],
'identifying_events':plain(p.identity),
'ending_completed':completed,'evaluation':plain(p.evaluation),'score':p.score,'expressions':plain(p.expressions)}
'evaluation':plain(p.evaluation),'score':p.score,'expressions':plain(p.expressions)}
phrases.append(entry)
if anchor:endings.append({'phrase':p.id,'effect':p.ending,'arrival':str(anchor.earliest),'pitch':anchor.pitch.label(),'required_beats':str(anchor.duration),'completed':completed})
ns=solo.notes()
duration=solo.end-solo.start
return {'schema':'composer-report-3','selection_trace':plain({k:v for k,v in solo.diagnostics.items() if not k.endswith('_seconds')}),
'instrument_profile':plain(solo.profile),'phrases':phrases,'expressions':plain(contracts),'fretboard':fretboard_report(solo),
'phrasing':{'endings':endings,'attacks':len(ns),'allocation_beats':str(duration),
'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':harmony.describe(ns,expressions=contracts),'harmonic_valid':checked.valid,'harmonic_error':checked.error,
'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_exposure':checked.exposure,'unresolved_treatments':plain(checked.pending)}
def describe(solo,scales,measure_offset=0,time_offset=0,*,detail=None):
def describe(solo,measure_offset=0,time_offset=0,*,detail=None):
detail=detail or structure(solo)
lines=[f'Independent melodic-phrase solo: {solo.part}', 'Times are exact quarter beats from the chorus start; negative times are allocated lead-ins. Pitches sound one octave below guitar notation.',
lines=[f'{solo.diagnostics.get("construction","solo")}: {solo.part}', 'Times are exact quarter beats from the chorus start; negative times are allocated lead-ins. Pitches sound one octave below guitar notation.',
'Approximate fingering costs are not a biomechanical guarantee.',json.dumps(detail['phrasing'])]
for p in solo.phrases:
lines.append(f'{p.id}: {p.start}–{p.end}; {p.purpose}, {p.direction}, {p.ending}; theme {p.theme}; score {p.score:.4f}')
@@ -61,4 +58,4 @@ def fretboard_report(solo):
'duration_fraction_by_string':{s:d/max(.001,total) for s,d in sorted(durations.items())},
'first_string_forced':forced,'first_string_optional':optional,
'range_midi': [min(n.pitch.number() for n in notes),max(n.pitch.number() for n in notes)] if notes else [],
'cross_bar_sounding_events':sum(any(n.start < t < n.end for t in {c.span.start for c in solo.choices if c.span.beat == 1}) for n in notes)}
'cross_bar_sounding_events':sum(any(n.start < t < n.end for t in {c.start for c in solo.harmony if c.beat == 1}) for n in notes)}
-32
View File
@@ -1,32 +0,0 @@
"""Complete generated rhythm groups, indexed once, independent of source rhythms."""
from fractions import Fraction as F
from generator import vocabulary
from generator.solo import groups
class Rhythms:
def __init__(self, library, tempo):
self.groups=[]; limits=vocabulary.data('roles')['difficulty']
for item in vocabulary.data('solo')['groups']:
difficulty=item.get('difficulty')
if difficulty and tempo>limits[difficulty+'_max_bpm']:continue
cursor=F();pattern=[]
for value in item['values']:
duration=F(value.removeprefix('r:'))
if not value.startswith('r:'):pattern.append((cursor,cursor+duration))
cursor+=duration
self.groups.append((item['id'],tuple(pattern),cursor,tuple(item['purpose'])))
for cell in vocabulary.rhythm_cells(tempo):
# Imported authored cells remain whole. They are never sliced to fit.
if groups.valid(cell.pattern):self.groups.append((cell.id,cell.pattern,cell.duration,(cell.purpose,)))
# Meter-indexed catalog representatives: file counts do not set style frequency.
self.catalog={}
for meter,items in library.snippets.items():
representatives={}
for item in items:
for bar in item.bars:
pattern=tuple((e.onset,e.onset+e.duration) for e in bar.events if e.sounding)
if any(e.continuation for e in bar.events) or not pattern or not groups.valid(pattern):continue
if any(b-a<F(1,2) for a,b in pattern):continue
descriptor=groups.descriptor(pattern,bar.length)
representatives.setdefault(descriptor,(item.id,pattern,bar.length,('develop','answer')))
self.catalog[meter]=tuple(representatives.values())
-13
View File
@@ -1,13 +0,0 @@
"""The complete-phrase evaluation record, independent of guitar travel."""
from dataclasses import dataclass
from fractions import Fraction as F
@dataclass(frozen=True)
class Evaluation:
version: str
valid: bool
raw: dict
weights: dict
contributions: dict
total: F
denominators: dict
-102
View File
@@ -1,102 +0,0 @@
"""One complete-event evaluator, including the declared musical relationships.
The inputs are realized notes and independently planned obligations. Action
counts and constructor-family labels never award musical credit.
"""
import collections
import fractions
import generator.solo.harmonic
import generator.solo.scoring
import generator.solo.types
VERSION = 'directed-phrases-2'
WEIGHTS = {'continuity': 2, 'repetition': 3, 'interruption': 2, 'activity': 2,
'harmony': 2, 'register': 1, 'identity': 3, 'arrival': 2}
def signature(notes):
notes = generator.solo.types.sounding_notes(notes)
if not notes:
return ()
return tuple((n.start-notes[0].start, n.end-n.start, n.pitch.number()-notes[0].pitch.number()) for n in notes)
def theme_distance(home, sample, purpose):
if not home or purpose not in ('return','develop','answer','extend'):
return fractions.Fraction()
# Returns preserve the whole introduced signature; development preserves
# its selected opening intervals, allowing the rhythm and conclusion to change.
count = len(home) if purpose=='return' else min(3,len(home))
if count<3 or len(sample)<count:
return fractions.Fraction(2)
a,b = signature(home[:count]),signature(sample[:count])
intervals = [(x[i][2]-x[i-1][2]) for x in (a,b) for i in range(1,count)]
left,right = intervals[:count-1],intervals[count-1:]
pitch = sum(min(fractions.Fraction(1),fractions.Fraction(abs(x-y),4)) for x,y in zip(left,right))/(count-1)
rhythm = sum((x[0],x[1])!=(y[0],y[1]) for x,y in zip(a,b))/fractions.Fraction(count) if purpose=='return' else 0
return pitch+rhythm
def figures(notes):
return tuple(signature(notes[i:i+4]) for i in range(max(0,len(notes)-3)))
def evaluate(notes, start, end, harmony, *, incoming=(), home=(), purpose='develop', contracts=(), center=60,
destinations=(), history=(), identity=()):
notes = generator.solo.types.sounding_notes(notes)
duration = end-start
if duration <= 0:
raise ValueError('Evaluation requires a positive span')
checked = harmony.evaluate(notes, expressions=contracts, until=end, complete=True)
anchors_valid,_ = generator.solo.harmonic.anchors_valid(destinations,notes,end,complete=True,closure='closed')
sounding = sum((n.end-n.start for n in notes), fractions.Fraction())
pitches = [n.pitch.number() for n in notes]
joined = [n.pitch.number() for n in incoming[-1:]]+pitches
intervals = [b-a for a,b in zip(joined, joined[1:])]
continuity = fractions.Fraction(sum(max(0, abs(d)-7) for d in intervals),max(1,len(intervals)))
oscillation = sum(a == -b == c and a != 0 for a,b,c in zip(intervals,intervals[1:],intervals[2:]))
patterns = collections.Counter(figures(notes))
recent = collections.Counter(f for p in history[-3:] for f in figures(generator.solo.types.sounding_notes(p.notes)))
# A single clear return is allowed. Repeating the same figure several times
# still costs something; a "return" label is not unlimited immunity.
overuse = sum(max(0,n+recent[f]-2) for f,n in patterns.items())
repetition = fractions.Fraction(oscillation+sum(a==b for a,b in zip(pitches,pitches[1:]))+overuse,max(1,len(notes)))
arrival_ids = {n.event_id for a in destinations if (n:=generator.solo.harmonic.anchor_note(a,notes))}
interruption = fractions.Fraction(sum(a.end<b.start and a.end-a.start<1 and b.end-b.start<1
and a.event_id not in arrival_ids for a,b in zip(notes,notes[1:])),max(1,len(notes)))
density = fractions.Fraction(len(notes)*4,1)/duration
active = any(s.scale and s.start<end and s.end>start for s in harmony.spans)
activity = max(0,density-7)+(2 if active and not notes else 0)
if notes and sounding<duration/3:
activity += (duration/3-sounding)/duration
# Unplanned broad notes in the moving portion have a different function from
# a fulfilled arrival. This does not penalize the required held destination.
motion = [n for n in notes if n.event_id not in arrival_ids and n.role!='quote']
activity += sum((max(0,n.end-n.start-2) for n in motion),fractions.Fraction())/duration
identity_cost = theme_distance(home,identity or notes,purpose)
register = sum(( (n.end-n.start)*max(0,abs(n.pitch.number()-center)-10) for n in notes),fractions.Fraction())/max(1,sounding)
arrival_cost = fractions.Fraction()
for anchor in destinations:
target = generator.solo.harmonic.anchor_note(anchor,notes)
if target is None:
arrival_cost += 2
continue
before = [n for n in notes if n.end<=target.start][-3:]
if before:
distances = [abs(n.pitch.number()-target.pitch.number()) for n in before]+[0]
# A contour may turn. What matters here is whether the final approach
# prepares its already selected destination, rather than jumping to it.
arrival_cost += fractions.Fraction(max(0,distances[-2]-2),6)
arrival_cost += fractions.Fraction(sum(max(0,b-a) for a,b in zip(distances,distances[1:])),12)
if before[-1].end<target.start:
supported_gap = any(c.target.event_id==target.event_id and c.events[-1].end==before[-1].end for c in contracts)
if not supported_gap:
arrival_cost += min(1,target.start-before[-1].end)
arrival_cost /= max(1,len(destinations))
values = dict(continuity=continuity,repetition=repetition,interruption=interruption,
activity=activity,harmony=checked.exposure,register=register,identity=identity_cost,arrival=arrival_cost)
raw = {k:fractions.Fraction(str(v)).limit_denominator(1000000) for k,v in values.items()}
contributions = {k:v*WEIGHTS[k] for k,v in raw.items()}
return generator.solo.scoring.Evaluation(VERSION,checked.valid and anchors_valid,raw,dict(WEIGHTS),contributions,
sum(contributions.values(),fractions.Fraction()),{'span':str(duration),'attacks':len(notes),'sounding':str(sounding),
'destinations':len(destinations),'identity_events':len(identity or notes[:len(home)]),'history_phrases':min(3,len(history))})
-87
View File
@@ -1,87 +0,0 @@
from __future__ import annotations
import dataclasses
import pathlib
import types
import functools
import generator.model
import generator.musicxml
@dataclasses.dataclass(frozen=True)
class Sequence:
id: str
steps: tuple[int, ...]
@functools.lru_cache(maxsize=32768)
def offsets(self) -> tuple[int, ...]:
result = [0]
for step in self.steps:
result.append(result[-1] + step)
return tuple(result)
def rank(self) -> tuple:
offsets = self.offsets()
return max(map(abs, self.steps)), max(offsets) - min(offsets), sum(a * b < 0 for a, b in zip(self.steps, self.steps[1:])), self.id
def diverse(items, rank, category, limit):
"""Round-robin eligible musical families before a destructive cutoff."""
groups = {}
# Rank once: hashing full event provenance and classifying interval paths
# used to be repeated for every retained layer. Preserve stable tie order.
decorated = sorted(((rank(item), i, item) for i,item in enumerate(items)),key=lambda v:v[:2])
for value in decorated:
groups.setdefault(category(value[2]), []).append(value)
result, index = [], 0
while len(result) < limit:
layer = [values[index] for values in groups.values() if len(values) > index]
if not layer:
break
# Equal ranks retain category insertion order, just as the original stable
# layer sort did; the input index is only for the initial sort.
result.extend(v[2] for v in sorted(layer,key=lambda v:v[0])[:limit-len(result)])
index += 1
return tuple(result)
@dataclasses.dataclass(frozen=True, init=False)
class Library:
paths: types.MappingProxyType
def __init__(self, sequences: tuple[Sequence, ...]):
paths, identities = {}, set()
for sequence in sequences:
if sequence.id in identities or not 1 <= len(sequence.steps) <= 3 or any(not 1 <= abs(step) <= 7 for step in sequence.steps):
raise ValueError(f'{sequence.id}: invalid or duplicate sequence')
identities.add(sequence.id)
paths.setdefault(len(sequence.steps) + 1, []).append(sequence)
object.__setattr__(self, 'paths', types.MappingProxyType({count: tuple(sorted(items, key=lambda s: s.rank())) for count, items in paths.items()}))
def connecting(self, count: int, displacement: int | None = None) -> tuple[Sequence, ...]:
return tuple(s for s in self.paths.get(count, ()) if displacement is None or s.offsets()[-1] == displacement)
def read(path: pathlib.Path, identifier: str, count: int) -> Sequence:
document = generator.musicxml.load_musicxml(path)
measures = document['staves'][0]['measures']
if len(measures) != 1 or len(measures[0]['voices']) != 1 or measures[0]['time_signature'] != f'{count}/4':
raise ValueError(f'{path}: expected one {count}/4 sequence measure and one voice')
notes = measures[0]['voices'][0]['notes']
if len(notes) != count:
raise ValueError(f'{path}: expected {count} sequence notes')
degrees = []
for index, event in enumerate(notes):
if event.get('grace') or len(event['pitches']) != 1 or generator.model.fraction(event['onset']) != index or generator.model.fraction(event['duration']) != 1:
raise ValueError(f'{path}: sequence requires consecutive untied quarter notes')
pitch = event['pitches'][0]
if generator.model.alteration(pitch['alter']) or 'tie_from' in pitch or 'tie_to' in pitch:
raise ValueError(f'{path}: sequences must use untied C-major scale degrees')
degrees.append(7 * pitch['octave'] + generator.model.LETTERS.index(pitch['letter']))
if degrees[0] != 28:
raise ValueError(f'{path}: sequence must begin on C4')
return Sequence(identifier, tuple(b - a for a, b in zip(degrees, degrees[1:])))
def load_library(root: pathlib.Path) -> Library:
sequences = []
for path in sorted(root.rglob('*.xml')):
relative = path.relative_to(root)
if len(relative.parts) != 2 or relative.parts[0] not in ('2_4', '3_4', '4_4'):
raise ValueError(f'{path}: sequence sources belong under 2_4, 3_4, or 4_4')
sequences.append(read(path, relative.as_posix(), int(relative.parts[0][0])))
if not sequences:
raise ValueError(f'{root}: sequence library is empty or missing')
return Library(tuple(sequences))
+2 -89
View File
@@ -3,8 +3,6 @@ from __future__ import annotations
import dataclasses, fractions, functools
import generator.model
import generator.fretboard
import generator.scales.library
import generator.scales.selection
import generator.solo.profiles
@@ -15,7 +13,7 @@ class Pitch:
octave: int
def __post_init__(self):
if self.letter not in generator.model.NATURAL or self.alter.denominator != 1:
raise ValueError('solo pitches require spelled semitones from the permitted scales')
raise ValueError('solo pitches require spelled semitones')
@functools.lru_cache(maxsize=512)
def __repr__(self):
# Exactly the former dataclass representation: cache formatting without
@@ -52,19 +50,6 @@ def sounding_notes(notes):
result.append(note)
return tuple(result)
@dataclasses.dataclass(frozen=True)
class HarmonicTreatment:
id: str
kind: str
pitch: int
start: fractions.Fraction
end: fractions.Fraction
resolution: int | None = None
deadline: fractions.Fraction | None = None
preparation: fractions.Fraction = fractions.Fraction(1, 2)
event: str = ''
resolution_event: str = ''
@dataclasses.dataclass(frozen=True)
class ExpressionContract:
id: str
@@ -76,76 +61,6 @@ class ExpressionContract:
boundary: fractions.Fraction | None = None
preparation: Note | None = None
@dataclasses.dataclass(frozen=True)
class Anchor:
id: str
earliest: fractions.Fraction
latest: fractions.Fraction
pitch: Pitch
eligible: frozenset[int]
register: tuple[int, int]
duration: fractions.Fraction
role: str
chord: str
onset_required: bool = False
hold_until: fractions.Fraction | None = None
effect: str = 'local_settle'
@dataclasses.dataclass(frozen=True)
class SustainedTail:
start: fractions.Fraction
end: fractions.Fraction
pitch: int
@dataclasses.dataclass(frozen=True)
class EnclosureContract:
"""A complete, timed upper/lower approach to a previously chosen arrival.
These snapshots describe sounding events, independent of notation splits or
guitar placement. Only the lower event may receive a scale exception.
"""
id: str
upper: Note
lower: Note
target: Note
chord: str
target_role: str
protected_tail: SustainedTail
approach_start: fractions.Fraction | None = None
@functools.lru_cache(maxsize=4096)
def scale_pitch(scale: generator.scales.library.Scale, index: int, octave: int = 4) -> Pitch:
"""Index an extended scale, including negative degrees and octave crossings."""
root = generator.model.parse_pitch_name(scale.root)
root_number = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter']) + 12 * (octave + 1)
turns, degree = divmod(index, 7)
name = generator.model.parse_pitch_name(scale.notes[degree])
number = root_number + 12 * turns + scale.pattern.intervals[degree]
alter = generator.model.alteration(name['alter'])
return Pitch(name['letter'], alter, int((number - generator.model.NATURAL[name['letter']] - alter) // 12 - 1))
@functools.lru_cache(maxsize=4096)
def scale_index(scale: generator.scales.library.Scale, number: int, octave: int = 4) -> int:
"""Exact inverse by sounding pitch; spelling never moves a held note."""
root = scale_pitch(scale, 0, octave).number()
turns, chromatic = divmod(number - root, 12)
if chromatic not in scale.pattern.intervals:
raise ValueError(f'{number} is outside {scale.label()}')
return 7 * turns + scale.pattern.intervals.index(chromatic)
def permitted(choices: tuple[generator.scales.selection.Choice, ...], start: fractions.Fraction, end: fractions.Fraction) -> frozenset[int]:
covered = start
pitches = frozenset(range(12))
for choice in choices:
if choice.span.end <= start or choice.span.start >= end:
continue
if choice.span.start > covered or choice.scale is None:
return frozenset()
pitches &= choice.scale.pitches
covered = max(covered, choice.span.end)
return pitches if covered >= end else frozenset()
@dataclasses.dataclass(frozen=True)
class Phrase:
id: str
@@ -155,20 +70,18 @@ class Phrase:
purpose: str
direction: str
ending: str
destination: Anchor | None = None
expressions: tuple[ExpressionContract, ...] = ()
groups: tuple = ()
theme: str = ''
score: float = 0.
evaluation: object = None
sources: tuple[str, ...] = ()
arrivals: tuple[Anchor, ...] = ()
identity: tuple[Note, ...] = ()
@dataclasses.dataclass(frozen=True)
class Solo:
part: str
choices: tuple
harmony: tuple
phrases: tuple[Phrase, ...]
tempo: fractions.Fraction
end: fractions.Fraction