Training Model
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
|
||||
@@ -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}')
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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())}}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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))})
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user