Cleanup
This commit is contained in:
@@ -84,19 +84,13 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
if source.parent != data_dir / 'songs':
|
||||
raise ValueError(f'{source}: MusicXML sources belong directly under data/songs')
|
||||
generator.parts.source_paths(data_dir / 'songs')
|
||||
report('loading rhythm snippets')
|
||||
rhythms = generator.rhythm.load_library(data_dir / 'rhythms')
|
||||
report('loading solo scale patterns')
|
||||
scales = generator.scales.library.load_library(data_dir / 'scales')
|
||||
report('loading solo interval sequences')
|
||||
sequences = generator.solo.sequences.load_library(data_dir / 'sequences')
|
||||
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix='.vamp_assets_', dir=output_dir.parent) as temporary:
|
||||
staged = pathlib.Path(temporary) / 'assets'
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
copy_runtime_fonts(static_assets_dir, staged)
|
||||
report('generating performance parts')
|
||||
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences,
|
||||
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs',
|
||||
composition_jobs=getattr(args,'composition_jobs',2), cache_dir=getattr(args,'composition_cache',None), progress=progress,timings=timings)
|
||||
if getattr(args,'trace_level','summary')=='summary':
|
||||
for path in (staged/'analysis').glob('*.roles'):
|
||||
|
||||
@@ -17,10 +17,13 @@ def _read(event,args):
|
||||
# r+ and w+ can read; they must obey the same boundary as a plain read.
|
||||
if flags & os.O_WRONLY:return
|
||||
path=Path(os.fsdecode(name)).resolve()
|
||||
data,code,cache,reads=scope
|
||||
data,code,cache,compiled,reads=scope
|
||||
python_runtime=any(path.is_relative_to(Path(root).resolve()) for root in (sys.base_prefix,sys.prefix))
|
||||
allowed=path.is_relative_to(data) or path.is_relative_to(code) or python_runtime
|
||||
if cache and path.is_relative_to(cache):allowed=True
|
||||
# Only the content-addressed generic catalog directory is shared by roles.
|
||||
# Concrete musical caches remain confined to the requested role.
|
||||
if compiled and path.is_relative_to(compiled):allowed=True
|
||||
if not allowed:raise ValueError('Forbidden composition input: '+str(path))
|
||||
reads.add(str(path))
|
||||
|
||||
@@ -32,6 +35,7 @@ sys.addaudithook(_read)
|
||||
def composition(data,role,cache=None):
|
||||
reads=set()
|
||||
code=Path(__file__).resolve().parent
|
||||
token=ACTIVE.set((Path(data).resolve(),code,Path(cache).resolve()/role if cache else None,reads))
|
||||
token=ACTIVE.set((Path(data).resolve(),code,Path(cache).resolve()/role if cache else None,
|
||||
Path(cache).resolve()/'_resources' if cache else None,reads))
|
||||
try:yield reads
|
||||
finally:ACTIVE.reset(token)
|
||||
|
||||
+26
-17
@@ -17,7 +17,7 @@ import tempfile
|
||||
import time
|
||||
from fractions import Fraction as F
|
||||
|
||||
from generator import accompaniment, comping, fretboard, model, musicxml, parts, rhythm, score, vocabulary
|
||||
from generator import accompaniment, comping, fretboard, model, musicxml, parts, rhythm, score, vocabulary, resources as catalogs
|
||||
from generator.arrange import form, generate, pickup
|
||||
from generator.solo import analysis, composer, notation, report, sequences
|
||||
from generator.scales import library as scales
|
||||
@@ -66,13 +66,13 @@ def windows(document, part):
|
||||
a, _, old = result[-1]; result[-1] = (a, start, old)
|
||||
result.append((start, cursor, part+'_'+role))
|
||||
end = cursor+duration
|
||||
result.append((cursor, end, part+'_'+role))
|
||||
if part == 'electric' and section.role == 'slash' and score.body_start_measure_index(source):
|
||||
attacks = [model.fraction(n['onset']) for n in score.measure_notes(source[0]) if n['pitches']]
|
||||
if attacks:
|
||||
cutoff = end-model.fraction(source[0]['duration'])+min(attacks)
|
||||
result[-1] = (cursor, cutoff, part+'_solo')
|
||||
result.append((cutoff, end, part+'_rest'))
|
||||
start=cursor
|
||||
if section.role == 'slash' and score.body_start_measure_index(source) and result:
|
||||
# Allocate the source's complete pickup bar before composition. This
|
||||
# does not inspect either generated solo; a role may leave it silent.
|
||||
start=cursor-model.fraction(source[0]['duration'])
|
||||
a,_,old=result[-1];result[-1]=(a,start,old)
|
||||
result.append((start, end, part+'_'+role))
|
||||
cursor = end
|
||||
return tuple(result)
|
||||
|
||||
@@ -89,7 +89,7 @@ def _code_key(role):
|
||||
paths += list((root/'arrange').glob('*.py'))
|
||||
if role.endswith('_solo'):
|
||||
paths += [p for p in (root/'solo').glob('*.py') if 'audition' not in p.name and p.name != 'references.py']
|
||||
paths += list((root/'scales').glob('*.py')) + [root/'rhythm.py']
|
||||
paths += list((root/'scales').glob('*.py')) + [root/'rhythm.py', root/'resources.py', root/'musicxml.py']
|
||||
elif role.endswith('_accompaniment'):
|
||||
paths += [root/n for n in ('comping.py', 'accompaniment.py', 'guitar.py')]
|
||||
elif role == 'electric_melody':
|
||||
@@ -102,8 +102,8 @@ def _resource_key(directory, role):
|
||||
return {name: vocabulary.fingerprint([(p.relative_to(directory/name).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted((directory/name).rglob('*.xml'))]) for name in names}
|
||||
|
||||
@functools.lru_cache(maxsize=4)
|
||||
def _solo_libraries(directory, fingerprints):
|
||||
return scales.load_library(directory/'scales'),sequences.load_library(directory/'sequences'),rhythm.load_library(directory/'rhythms')
|
||||
def _solo_libraries(directory, fingerprints, cache_dir=None):
|
||||
return catalogs.load(directory,dict(fingerprints),cache_dir)
|
||||
|
||||
|
||||
def _mask(document, allowed):
|
||||
@@ -153,7 +153,7 @@ def generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
with composition(data_dir,role,cache_dir) as reads:
|
||||
result=_generate_role(source,role,data_dir,settings,cache_dir)
|
||||
# Observed reads are diagnostics, never cache identity or musical seeds.
|
||||
result['trace']={**result['trace'],'input_boundary':'data, generator code, Python runtime, own role cache'}
|
||||
result['trace']={**result['trace'],'input_boundary':'data, generator code, Python runtime, own role cache, content-addressed immutable catalogs'}
|
||||
result['observed_reads']=sorted(reads)
|
||||
result['seconds']=time.monotonic()-started
|
||||
return result
|
||||
@@ -194,15 +194,24 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
effective=generate.section_source(authored,section,form.full_arrangement_sections(authored,part)[si+1])
|
||||
template=copy.deepcopy(effective[section.source_measure_index:section.source_measure_index+count])
|
||||
initial=accompaniment.carried_chords(effective)[section.source_measure_index]
|
||||
end=next(b for a,b,r in windows(document,part) if r==role)-offset
|
||||
scale_library,sequence_library,rhythm_library=_solo_libraries(data_dir,tuple(sorted(inputs['catalogs'].items())))
|
||||
start,end=next((a-offset,b-offset) for a,b,r in windows(document,part) if r==role)
|
||||
notation_first=first
|
||||
if start<0:
|
||||
# The preceding arrangement harmony comes from the source form. Do
|
||||
# not invent harmony from a comparison score's unlabelled lead-in.
|
||||
template.insert(0,copy.deepcopy(result['staves'][0]['measures'][first-1]))
|
||||
notation_first-=1
|
||||
initial=accompaniment.carried_chords(result['staves'][0]['measures'])[first-1]
|
||||
scale_library,sequence_library,rhythm_library=_solo_libraries(data_dir,tuple(sorted(inputs['catalogs'].items())),cache_dir)
|
||||
solo=composer.compose(analysis.analyze(document),template,part,end,
|
||||
initial=initial,scales=scale_library,sequences=sequence_library,rhythms=rhythm_library,
|
||||
expressions_enabled=vocabulary.role().get('expressions',True))
|
||||
expressions_enabled=vocabulary.role().get('expressions',True),start=start)
|
||||
composition_timings={k:v for k,v in solo.diagnostics.items() if k.endswith('_seconds')}
|
||||
output=notation.measures(result['staves'][0]['measures'][first:first+count],solo.notes(),'solo_'+part)
|
||||
notation.replace_region(result,first,output);annotate(result,solo,offset)
|
||||
relative=tuple(dataclasses.replace(n,start=n.start-start,end=n.end-start) for n in solo.notes())
|
||||
output=notation.measures(result['staves'][0]['measures'][notation_first:first+count],relative,'solo_'+part)
|
||||
notation.replace_region(result,notation_first,output);annotate(result,solo,offset)
|
||||
details=report.structure(solo)
|
||||
details['allocation']={'start':str(start),'end':str(end),'lead_in_measures':first-notation_first}
|
||||
solo_report=report.describe(solo,scale_library,first,offset,detail=details)
|
||||
elif role.endswith('_accompaniment'):
|
||||
canonical=comping.Canonical(document,part,seed)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Content-addressed, non-executable catalog compilation.
|
||||
|
||||
Only validated production XML is compiled here. The cache contains no song,
|
||||
role, candidate, reference, or fitted musical result. A checksum and decoder
|
||||
version protect stale/corrupt payloads; no pickle or executable serialization.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from generator import rhythm, vocabulary
|
||||
from generator.scales import library as scales
|
||||
from generator.solo import sequences
|
||||
|
||||
VERSION = 'catalog-json-1'
|
||||
|
||||
|
||||
def identity(directory):
|
||||
directory=Path(directory)
|
||||
return {name:vocabulary.fingerprint([(p.relative_to(directory/name).as_posix(),
|
||||
hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted((directory/name).rglob('*.xml'))])
|
||||
for name in ('scales','sequences','rhythms')}
|
||||
|
||||
|
||||
def encode(libraries):
|
||||
scale,sequence,rhythms=libraries
|
||||
return {'scales':[dataclasses.asdict(p) for p in scale.patterns],
|
||||
'sequences':[dataclasses.asdict(p) for values in sequence.paths.values() for p in values],
|
||||
'rhythms':[dataclasses.asdict(p) for values in rhythms.snippets.values() for p in values]}
|
||||
|
||||
|
||||
def decode(value):
|
||||
scale=scales.Library(tuple(scales.Pattern(p['family'],p['mode'],tuple(p['intervals'])) for p in value['scales']))
|
||||
sequence=sequences.Library(tuple(sequences.Sequence(p['id'],tuple(p['steps'])) for p in value['sequences']))
|
||||
snippets=[]
|
||||
for p in value['rhythms']:
|
||||
bars=[]
|
||||
for b in p['bars']:
|
||||
events=tuple(rhythm.Event(F(e['onset']),F(e['duration']),e['sounding'],e['written_type'],e['dots'],
|
||||
tuple(tuple(v) for v in e['beams']),e['continuation'],F(e['ratio'])) for e in b['events'])
|
||||
bars.append(rhythm.Bar(events,F(b['length'])))
|
||||
snippets.append(rhythm.Snippet(p['id'],p['meter'],tuple(bars)))
|
||||
return scale,sequence,rhythm.Library(tuple(snippets))
|
||||
|
||||
|
||||
def load(directory, fingerprints=None, cache_dir=None):
|
||||
directory=Path(directory)
|
||||
fingerprints=fingerprints or identity(directory)
|
||||
# Code edits invalidate compiled importer semantics, but do not reseed music.
|
||||
root=Path(__file__).parent
|
||||
code=[root/n for n in ('resources.py','rhythm.py','musicxml.py','model.py','pitch.py','harmony.py','solo/sequences.py','scales/library.py')]
|
||||
key=vocabulary.fingerprint({'version':VERSION,'inputs':fingerprints,
|
||||
'decoder':[(p.relative_to(root).as_posix(),hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted(code)]})
|
||||
return _load(str(directory.resolve()),key,str(Path(cache_dir).resolve()) if cache_dir else '')
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4)
|
||||
def _load(directory,key,cache_dir):
|
||||
path=Path(cache_dir)/'_resources'/(key+'.json') if cache_dir else None
|
||||
if path and path.is_file() and not path.is_symlink():
|
||||
try:
|
||||
entry=json.loads(path.read_text())
|
||||
if entry['key']==key and vocabulary.fingerprint(entry['value'])==entry['digest']:
|
||||
return decode(entry['value'])
|
||||
except (ValueError,KeyError,TypeError,OverflowError):
|
||||
pass
|
||||
root=Path(directory)
|
||||
libraries=(scales.load_library(root/'scales'),sequences.load_library(root/'sequences'),rhythm.load_library(root/'rhythms'))
|
||||
if path:
|
||||
value=json.loads(json.dumps(encode(libraries),default=str))
|
||||
path.parent.mkdir(parents=True,exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile('w',dir=path.parent,delete=False) as out:
|
||||
json.dump({'key':key,'digest':vocabulary.fingerprint(value),'value':value},out,separators=(',',':'))
|
||||
temporary=out.name
|
||||
os.replace(temporary,path)
|
||||
return libraries
|
||||
@@ -6,15 +6,16 @@ from generator import harmony as chords, vocabulary
|
||||
from generator.scales import timeline, selection
|
||||
from generator.solo import types, profiles, harmonic, construction, placement, policy
|
||||
|
||||
def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,expressions_enabled=True):
|
||||
def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,expressions_enabled=True,start=F()):
|
||||
began=time.monotonic()
|
||||
choices=selection.select(timeline.read({'measures':measures},initial=initial),timeline.Solo(F(),end,part.capitalize()+' Solo'),scales)
|
||||
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)
|
||||
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.
|
||||
@@ -23,22 +24,22 @@ def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,e
|
||||
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':'complete_phrases','version':policy.DEFAULT.composition_version,
|
||||
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'joint_actions','version':policy.DEFAULT.composition_version,
|
||||
'construction_seconds':time.monotonic()-began,**builder.timings,'operation_coverage':{k:dict(v) for k,v in builder.coverage.items()},
|
||||
'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile)
|
||||
'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile,start)
|
||||
validate(result)
|
||||
return result
|
||||
|
||||
def validate(solo):
|
||||
previous=None
|
||||
for note in solo.notes():
|
||||
if note.start<0 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.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)
|
||||
if not checked.valid:raise ValueError(checked.error)
|
||||
cursor=F()
|
||||
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
|
||||
|
||||
+130
-217
@@ -1,230 +1,143 @@
|
||||
"""Construct complete phrases directly from harmonic/rhythmic purposes.
|
||||
"""Bounded search of joint musical actions; one normal production path.
|
||||
|
||||
The head is optional material, never a compulsory prefix or rhythmic template.
|
||||
No reference score, completed part, or previous published output enters here.
|
||||
Selection compares complete actions by cost per quarter. Actions are indivisible;
|
||||
cross-bar paths and resolution contracts never restart at search boundaries.
|
||||
"""
|
||||
import dataclasses
|
||||
import time
|
||||
from __future__ import annotations
|
||||
from collections import Counter
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary, harmony as chords, model
|
||||
from generator.solo import types, expressions, groups, routes, rhythm, scoring, placement, policy
|
||||
import time
|
||||
|
||||
from generator import vocabulary
|
||||
from generator.solo import types, groups, operators, selection, sequences, scoring
|
||||
|
||||
class SearchExhausted(ValueError):pass
|
||||
|
||||
def choose(values,seed,domain):
|
||||
return min(values,key=lambda v:policy.stable_rank(seed,(domain,v)))
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class State:
|
||||
total: F = F()
|
||||
recent: tuple = ()
|
||||
home: tuple = ()
|
||||
last_kind: str = ''
|
||||
parent: object = None
|
||||
action: object = None
|
||||
evaluation: object = None
|
||||
tie: tuple = ()
|
||||
pending: tuple = ()
|
||||
alternatives: tuple = ()
|
||||
|
||||
class Builder:
|
||||
def __init__(self,analysis,measures,harmony,sequences,rhythms,end,seed,enabled=True):
|
||||
self.analysis=analysis;self.harmony=harmony;self.end=end;self.seed=seed;self.enabled=enabled
|
||||
self.rules=vocabulary.data('solo');self.budgets=self.rules['search'];self.routes=routes.Routes(harmony,sequences)
|
||||
self.rhythms=rhythm.Rhythms(rhythms,analysis.tempo);self.windows=[];cursor=F()
|
||||
for m in measures:
|
||||
length=model.fraction(m['duration']);self.windows.append((cursor,cursor+length,m['time_signature']));cursor+=length
|
||||
self.coverage={a:Counter() for a in self.rules['actions']};self.failures=Counter();self.cache={};self.decisions=[];self.timings=Counter()
|
||||
def __init__(self,analysis,measures,harmony,sequences,rhythms,end,seed,enabled=True,*,start=F()):
|
||||
self.analysis=analysis;self.harmony=harmony;self.start=start;self.end=end
|
||||
self.operators=operators.Operators(harmony,analysis,end,start,enabled,sequences,rhythms)
|
||||
self.selector=selection.Selector(harmony,analysis,harmony.profile.reference or 60)
|
||||
self.rules=vocabulary.data('solo')['operators'];self.decisions=[];self.failures=Counter();self.coverage={};self.timings=Counter()
|
||||
|
||||
def at(self,time):return next((m for m in self.windows if m[0]<=time<m[1]),self.windows[-1])
|
||||
def candidates(self,at,state):
|
||||
candidates=self.operators.at(at)
|
||||
if state.home and state.last_kind!='return':
|
||||
candidates+=self.operators.related(at,state.home,'return')+self.operators.related(at,state.home,'answer')
|
||||
evaluated=[]
|
||||
for a in candidates:
|
||||
if state.pending and self.advance(state,None,a) is None:continue
|
||||
cost=self.selector.cost(a,state.recent,state.home,state.last_kind)
|
||||
evaluated.append((cost,a))
|
||||
# Rank complete actions by cost per quarter before bounded repair.
|
||||
# Accumulated prefix totals are diagnostic, not a global search objective.
|
||||
retained=sequences.diverse(evaluated,
|
||||
lambda v:(v[0],v[1].key),
|
||||
lambda v:(v[1].kind,v[1].end-v[1].start),self.rules['per_state'])
|
||||
for _,a in retained:self.operators.coverage['retained:'+a.kind]+=1
|
||||
return tuple((self.selector.evaluate(a,state.recent,state.home,state.last_kind),a) for _,a in retained)
|
||||
|
||||
def actions(self,history):
|
||||
result=['arpeggio','scalar','neighbor']
|
||||
if history:
|
||||
result+=['develop','answer']
|
||||
if len(history)>1 and history[-1].purpose!='return':result.append('return')
|
||||
if self.analysis.melody and len(history)>2:result.append('head')
|
||||
preferred=vocabulary.role().get('preferred_actions',[])
|
||||
return sorted(result,key=lambda a:(a not in preferred,preferred.index(a) if a in preferred else 0,a))
|
||||
|
||||
def propose(self,start,history,variant,batch=0):
|
||||
incoming=next((p.notes[-1] for p in reversed(history) if p.notes),None)
|
||||
home=next((p for p in history if len(p.notes)>=4),None)
|
||||
seed=policy.stable_rank(self.seed,('phrase',start,tuple(p.id for p in history[-2:]),variant,batch))
|
||||
purpose=self.actions(history)[variant%len(self.actions(history))]
|
||||
self.coverage[purpose]['proposed']+=1
|
||||
_,_,meter=self.at(start);bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
||||
span=self.harmony.at(start)
|
||||
if not span or not span.scale:return None
|
||||
limit=min(self.end,start+self.rules['max_phrase_bars']*bar)
|
||||
# Source harmony boundaries and available time supply opportunities, not mandatory closes.
|
||||
opportunities=sorted({min(self.end,start+2*bar),*(s.start for s in self.harmony.spans if start+2<=s.start<limit),*(s.end for s in self.harmony.spans if start+2<=s.end<limit),self.end if self.end<=limit else limit})
|
||||
boundary=choose(opportunities,seed,'destination time')
|
||||
ending=choose(('open','settle','complete'),seed,'ending')
|
||||
if boundary==self.end:ending='handoff'
|
||||
elif history and history[-1].ending=='open':ending=choose(('settle','complete'),seed,'complete continuation')
|
||||
offset=choose(tuple(map(F,self.rules['arrival_offsets'])) if self.enabled else (F(),F(1,2)),seed,'arrival')
|
||||
arrival=boundary+offset
|
||||
if ending=='open':arrival=min(boundary,self.end)-F(1,2)
|
||||
if ending=='handoff':arrival=self.end-choose((F(2),F(5,2),F(3)),seed,'handoff hold')
|
||||
arrival=max(start+(F(1) if ending=='handoff' else F(3,2)),min(arrival,self.end-F(1,2)))
|
||||
if arrival<=start:return None
|
||||
anticipated=arrival<boundary and ending!='open' and any(s.start==boundary for s in self.harmony.spans) and boundary-arrival<=F(vocabulary.data('harmony')['anticipation_max'])
|
||||
harmonic_time=boundary if anticipated else arrival
|
||||
target_span=self.harmony.at(harmonic_time)
|
||||
if not target_span or not target_span.scale:return None
|
||||
hold=F(1,2) if ending=='open' else choose(tuple(map(F,self.rules['holds'])),seed,'hold')
|
||||
if ending in ('complete','handoff'):hold=max(F(2),hold)
|
||||
release=min(self.end,arrival+hold)
|
||||
if anticipated and release<=boundary:return None
|
||||
if ending=='handoff':release=self.end
|
||||
breath=F() if ending=='open' else choose(tuple(map(F,self.rules['breaths'])),seed,'breath')
|
||||
finish=min(self.end,release+breath)
|
||||
if 0<self.end-finish<3:return None
|
||||
if finish==self.end and ending!='open':
|
||||
if release-arrival<min(F(2),self.end-start-1):return None
|
||||
ending='handoff'
|
||||
center=self.harmony.profile.reference or 60
|
||||
# A coherent excursion and return; no per-note octave folding or string quota.
|
||||
shift=choose(tuple(vocabulary.role().get('register_alternatives',(-7,0,7))),seed,'register')
|
||||
if incoming and incoming.pitch.number()>center+7:shift=min(0,shift)
|
||||
if incoming and incoming.pitch.number()<center-7:shift=max(0,shift)
|
||||
goal=center+shift
|
||||
permitted=types.permitted(self.harmony.choices,harmonic_time,release)
|
||||
members=self.harmony.members(harmonic_time,release)
|
||||
targets=[types.scale_pitch(target_span.scale,i) for i in range(-21,22)]
|
||||
targets=[p for p in targets if p.number()%12 in permitted and (ending=='open' or p.number()%12 in members) and self.harmony.profile.permits(p.number()) and abs(p.number()-goal)<=7]
|
||||
if not targets:return None
|
||||
# Choose among actual chord degrees and registers, without always picking the nearest fifth/root.
|
||||
target_pitch=choose(targets,seed,'destination pitch')
|
||||
target=types.Note(arrival,release,target_pitch,role='arrival' if ending!='open' else 'motion',event_id=seed[:16]+':target')
|
||||
contracts=[]
|
||||
if anticipated:contracts.append(types.ExpressionContract(seed[:16]+':anticipation','anticipation','early_chord_tone',(target,),target,target_span.chord,boundary=boundary))
|
||||
lead=();movement_end=arrival
|
||||
# Shape-changing expression is planned before either rhythm or route is realized.
|
||||
treatment=choose(('direct','approach','color','blues','suspension'),seed,'expression') if self.enabled and ending!='open' else 'direct'
|
||||
if treatment=='approach':
|
||||
rule=choose(vocabulary.data('lead_ins')['patterns'],seed,'approach rule')
|
||||
movement_end=arrival-F(rule['gap'])-sum(map(F,rule['durations']),F());cursor=movement_end;events=[]
|
||||
for i,(step,duration) in enumerate(zip(rule['semitones'],rule['durations'])):
|
||||
events.append(types.Note(cursor,cursor+F(duration),expressions.chromatic(target_pitch,step),role=rule['kind'],event_id=seed[:16]+':approach:'+str(i)));cursor+=F(duration)
|
||||
lead=tuple(events)
|
||||
contracts.append(types.ExpressionContract(seed[:16]+':approach',rule['kind'],rule['id'],lead,target,target_span.chord,boundary=boundary if anticipated else None))
|
||||
elif treatment=='color' and not anticipated:
|
||||
region=self.harmony.at(arrival-1)
|
||||
eligible=[r for r in vocabulary.data('harmony')['colors'] if region and expressions.eligible_color(r,region,self.harmony)]
|
||||
if eligible:
|
||||
rule=choose(eligible,seed,'color rule');p=expressions.degree_pitch(chords.parse_symbol(region.chord),rule['degree'],rule['alter'],target_pitch.number())
|
||||
if not rule.get('resolve') and choose((True,False),seed,'sustained color'):
|
||||
target=dataclasses.replace(target,pitch=p,role='color');target_pitch=p
|
||||
contracts.append(types.ExpressionContract(seed[:16]+':color','color',rule['id'],(target,),target,target_span.chord))
|
||||
else:
|
||||
color=types.Note(arrival-1,arrival,p,role='color',event_id=seed[:16]+':color')
|
||||
if rule.get('resolve') and not 0<abs(target_pitch.number()-p.number())<=2:return None
|
||||
lead=(color,);movement_end=color.start
|
||||
contracts.append(types.ExpressionContract(seed[:16]+':color','color',rule['id'],lead,target if rule.get('resolve') else color,target_span.chord if rule.get('resolve') else region.chord))
|
||||
elif treatment=='blues' and not anticipated:
|
||||
eligible=[r for r in vocabulary.data('lead_ins')['blues'] if expressions.degree_pitch(chords.parse_symbol(target_span.chord),r['target_degree'],0,target_pitch.number())==target_pitch]
|
||||
if eligible:
|
||||
rule=choose(eligible,seed,'blues rule');p=expressions.degree_pitch(chords.parse_symbol(target_span.chord),rule['degree'],rule['alter'],target_pitch.number())
|
||||
lead=(types.Note(arrival-F(1,2),arrival,p,role='blues',event_id=seed[:16]+':blues'),);movement_end=lead[0].start
|
||||
contracts.append(types.ExpressionContract(seed[:16]+':blues','blues',rule['id'],lead,target,target_span.chord))
|
||||
elif treatment=='suspension' and offset>0 and arrival==boundary+offset:
|
||||
previous=self.harmony.at(boundary-F(1,2));rule=vocabulary.data('harmony')['suspensions'][0]
|
||||
if previous and previous.scale:
|
||||
candidates=[p for p in self.routes.pitches(boundary-F(1,2),arrival) if p.number()%12 in previous.roles.basic and target_pitch.number()-p.number() in rule['steps']]
|
||||
if candidates:
|
||||
p=choose(candidates,seed,'suspension pitch');lead=(types.Note(boundary-F(1,2),arrival,p,role='suspension',event_id=seed[:16]+':suspension'),);movement_end=lead[0].start
|
||||
contracts.append(types.ExpressionContract(seed[:16]+':suspension','suspension',rule['id'],lead,target,target_span.chord,boundary=boundary))
|
||||
entrance=F() if history and history[-1].ending=='open' else choose(tuple(map(F,self.rules['entrances'])),seed,'entrance')
|
||||
entry=start+entrance
|
||||
if movement_end-entry<F(1):return None
|
||||
hint=home.notes[:4] if home else ()
|
||||
if purpose=='head':
|
||||
candidates=[self.analysis.melody[i:i+4] for i in range(max(0,len(self.analysis.melody)-3))]
|
||||
candidates=[ns for ns in candidates if ns[-1].end-ns[0].start<=movement_end-entry]
|
||||
if not candidates:return None
|
||||
hint=choose(candidates,seed,'head reference')
|
||||
rhythm_purpose=purpose if purpose in ('arpeggio','scalar','neighbor','develop','answer') else 'develop'
|
||||
rhythm_began=time.monotonic()
|
||||
timing,rhythm_groups,sources=self.rhythms.realize(entry,movement_end,rhythm_purpose,seed,meter)
|
||||
self.timings['rhythm_seconds']+=time.monotonic()-rhythm_began
|
||||
if not timing:return None
|
||||
if purpose in ('return','head'):
|
||||
if len(hint)<3:return None
|
||||
duration=hint[-1].end-hint[0].start
|
||||
if entry+duration>movement_end:return None
|
||||
pattern=tuple((entry+n.start-hint[0].start,entry+n.end-hint[0].start) for n in hint)
|
||||
tail,tail_groups,tail_sources=self.rhythms.realize(entry+duration,movement_end,'develop',seed,meter)
|
||||
timing=(*pattern,*tail)
|
||||
if not groups.valid(tuple((a-entry,b-entry) for a,b in timing)):return None
|
||||
rhythm_groups=(*groups.describe(tuple((a-entry,b-entry) for a,b in pattern),origin=entry,source='heard_theme' if purpose=='return' else 'head'),*tail_groups)
|
||||
sources=(purpose,*tail_sources)
|
||||
self.coverage[purpose]['rhythm_valid']+=1
|
||||
first_span=self.harmony.at(entry)
|
||||
starting=[p for p in self.routes.pitches(*timing[0]) if p.number()%12 in first_span.roles.basic and abs(p.number()-(incoming.pitch.number() if incoming else goal))<=7]
|
||||
if not starting:return None
|
||||
first=choose(starting,seed,'entry pitch')
|
||||
notes=tuple(types.Note(a,b,first,role=purpose,event_id=seed[:16]+':'+str(i)) for i,(a,b) in enumerate(timing))
|
||||
all_notes=(*notes,*lead,target)
|
||||
fixed={0,*range(len(notes),len(all_notes))}
|
||||
direction=choose(self.rules['directions'],seed,'contour')
|
||||
route_began=time.monotonic()
|
||||
paths=self.routes.realize(all_notes,fixed,purpose,direction,seed,goal,incoming,hint)
|
||||
self.timings['route_seconds']+=time.monotonic()-route_began
|
||||
self.coverage[purpose]['complete_routes']+=len(paths)
|
||||
for actual in paths:
|
||||
bound=[]
|
||||
for c in contracts:
|
||||
prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None)
|
||||
bound.append(dataclasses.replace(c,preparation=prior if c.kind in ('connection','blues') else c.preparation))
|
||||
validation_began=time.monotonic()
|
||||
checked=self.harmony.evaluate(actual,until=finish,complete=True,expressions=tuple(bound))
|
||||
self.timings['harmonic_seconds']+=time.monotonic()-validation_began
|
||||
if not checked.valid:self.failures[checked.error.split(':')[-1]]+=1;continue
|
||||
anchor=None if ending=='open' else types.Anchor(seed[:16]+':destination',arrival,arrival,target_pitch,members|frozenset({target_pitch.number()%12}),self.harmony.profile.register,release-arrival,'terminal' if ending in ('complete','handoff') else 'intermediate',target_span.chord,False,release,ending)
|
||||
phrase=types.Phrase(seed[:16],start,finish,actual,purpose,direction,ending,anchor,tuple(bound),rhythm_groups,home.id if home else seed[:16],sources=sources)
|
||||
evaluation_began=time.monotonic()
|
||||
evaluation=scoring.evaluate(phrase,incoming,history,center)
|
||||
self.timings['evaluation_seconds']+=time.monotonic()-evaluation_began
|
||||
self.coverage[purpose]['valid']+=1
|
||||
return dataclasses.replace(phrase,score=evaluation.total,evaluation=evaluation)
|
||||
return None
|
||||
|
||||
def candidates(self,start,history):
|
||||
key=(start,len(history),next(((p.id,p.notes) for p in history if len(p.notes)>=4),None),tuple((p.id,p.purpose,p.ending,p.notes,p.expressions) for p in history[-4:]))
|
||||
if key in self.cache:return self.cache[key]
|
||||
span=self.harmony.at(start)
|
||||
if not span or not span.scale:
|
||||
finish=min(self.end,span.end if span else self.end)
|
||||
result=(types.Phrase('space:'+str(start),start,finish,(),'space','none','open'),)
|
||||
self.cache[key]=result;return result
|
||||
for action in self.actions(history):self.coverage[action]['eligible_contexts']+=1
|
||||
proposals=[]
|
||||
for batch in range(self.budgets['replans']+1):
|
||||
for variant in range(self.budgets['proposals']):
|
||||
p=self.propose(start,history,variant,batch)
|
||||
if p:proposals.append(p)
|
||||
if proposals:break
|
||||
if not proposals:raise SearchExhausted(f'at beat {start}: no complete phrase within deterministic budget; rejections {self.failures.most_common(3)}; not proof of harmonic incompatibility')
|
||||
from generator.solo.sequences import diverse
|
||||
finals=diverse(proposals,lambda p:(p.score,policy.stable_rank(self.seed,p.id)),lambda p:(p.purpose,p.ending,p.direction),self.budgets['finalists'])
|
||||
placed=[];incoming=next((p.notes[-1] for p in reversed(history) if p.notes),None)
|
||||
for p in finals:
|
||||
self.coverage[p.purpose]['finalists']+=1
|
||||
placement_began=time.monotonic()
|
||||
options=placement.alternatives(p.notes,self.analysis.tempo,incoming,self.budgets['fingerings'],self.harmony.profile)
|
||||
self.timings['placement_seconds']+=time.monotonic()-placement_began
|
||||
if options:
|
||||
# Physical economy selects a fingering of this same melody, not the tune itself.
|
||||
placed.append(dataclasses.replace(p,notes=options[0][1]));self.coverage[p.purpose]['placed']+=1
|
||||
self.cache[key]=tuple(placed)
|
||||
return self.cache[key]
|
||||
def advance(self,state,evaluation,action):
|
||||
for contract in state.pending:
|
||||
target=contract.target
|
||||
if action.start<=target.start<action.end:
|
||||
# Reject a missed committed attack/hold before revalidating all the
|
||||
# harmonic relationships. Partial target evidence remains possible.
|
||||
if not any(n.event_id==target.event_id and n.start==target.start
|
||||
and n.end==min(action.end,target.end) and n.pitch==target.pitch for n in action.notes):return None
|
||||
obligations=(*state.pending,*action.contracts);pending=()
|
||||
if not state.pending:
|
||||
pending=tuple(c for c in action.contracts if c.target.end>action.end)
|
||||
elif obligations:
|
||||
heard={n.event_id:n for c in state.pending for n in c.events if n.start<action.start}
|
||||
heard.update({n.event_id:n for n in action.notes})
|
||||
check=self.harmony.evaluate(tuple(sorted(heard.values(),key=lambda n:n.start)),
|
||||
expressions=obligations,until=action.end,complete=action.end==self.end)
|
||||
if not check.valid:return None
|
||||
pending=check.pending
|
||||
recent=(*state.recent,*action.notes)[-8:];home=state.home
|
||||
if not home and len(recent)>=4:
|
||||
proposed=recent[:4]
|
||||
if proposed[-1].end-proposed[0].start<=8:home=proposed
|
||||
return State(state.total+(evaluation.total if evaluation else 0),recent,home,action.kind,state,action,evaluation,(),pending)
|
||||
|
||||
def compose(self):
|
||||
history=[];start=F()
|
||||
while start<self.end:
|
||||
options=self.candidates(start,tuple(history));ranked=[]
|
||||
for p in options[:self.budgets['frontier']]:
|
||||
future=()
|
||||
if p.end<self.end:
|
||||
try:future=self.candidates(p.end,(*history,p))
|
||||
except SearchExhausted:continue
|
||||
if not future:continue
|
||||
next_score=min((f.score for f in future),default=p.score)
|
||||
total=(p.score+next_score)/2
|
||||
ranked.append((total,policy.stable_rank(self.seed,p.id),p))
|
||||
if p.purpose in self.coverage:self.coverage[p.purpose]['lookahead_valid']+=1
|
||||
if not ranked:raise SearchExhausted(f'at beat {start}: no legal following phrase within lookahead budget')
|
||||
_,_,chosen=min(ranked,key=lambda v:v[:2]);history.append(chosen);start=chosen.end
|
||||
if chosen.purpose in self.coverage:self.coverage[chosen.purpose]['selected']+=1
|
||||
self.decisions.append({'start':str(chosen.start),'selected':chosen.id,'candidates':[{'id':p.id,'purpose':p.purpose,'end':str(p.end),'evaluation':dataclasses.asdict(p.evaluation) if p.evaluation else {},'lookahead_total':next((v[0] for v in ranked if v[2].id==p.id),None)} for p in options]})
|
||||
return tuple(history)
|
||||
self.decisions=[];began=time.monotonic();backtracks=0
|
||||
# Compare full actions by cost per exact quarter, with a bounded repair
|
||||
# frontier. This is a local policy, not a claim of globally optimal melody.
|
||||
stack=[(self.start,State(),None)];rankings={}
|
||||
while stack:
|
||||
at,state,iterator=stack[-1]
|
||||
if at==self.end:final=state;break
|
||||
if iterator is None:
|
||||
options=sorted(self.candidates(at,state),key=lambda v:(v[0].total,v[1].key))
|
||||
rankings[id(state)]=tuple({'kind':a.kind,'start':str(a.start),'end':str(a.end),
|
||||
'notes':[(str(n.start),str(n.end),n.pitch.number()) for n in a.notes],
|
||||
'evaluation':dataclasses.asdict(e)} for e,a in options[:4])
|
||||
iterator=iter(options);stack[-1]=(at,state,iterator)
|
||||
for evaluation,action in iterator:
|
||||
next_state=self.advance(state,evaluation,action)
|
||||
if next_state is not None:
|
||||
next_state=dataclasses.replace(next_state,alternatives=rankings[id(state)])
|
||||
stack.append((action.end,next_state,None));break
|
||||
else:
|
||||
stack.pop();backtracks+=1
|
||||
if backtracks>self.rules['backtracks']:raise SearchExhausted(f'at {at}: action repair budget exhausted')
|
||||
else:raise SearchExhausted('no complete action path')
|
||||
self.timings['backtracks']=backtracks
|
||||
self.timings['action_search_seconds']=time.monotonic()-began
|
||||
path=[];state=final
|
||||
while state.action:
|
||||
path.append(state);state=state.parent
|
||||
path.reverse();phrases=[];pending=[]
|
||||
self.selected_actions=tuple(s.action for s in path)
|
||||
for state in path:
|
||||
action=state.action;pending.append(state)
|
||||
self.operators.coverage['selected:'+action.kind]+=1
|
||||
self.decisions.append({'start':str(action.start),'end':str(action.end),'selected':action.kind,
|
||||
'evaluation':dataclasses.asdict(state.evaluation),'prefix_total':state.total,'alternatives':state.alternatives})
|
||||
# Reporting groups the selected thought; it does not insert events.
|
||||
held=bool(action.notes and action.notes[-1].end-action.notes[-1].start>=2)
|
||||
breath=not action.notes and action.end-action.start>=1
|
||||
if held or breath or action.end==self.end:
|
||||
phrases.append(self.phrase(pending,len(phrases)));pending=[]
|
||||
for name,count in self.operators.coverage.items():
|
||||
stage,kind=name.split(':',1);self.coverage.setdefault(kind,{})[stage]=count
|
||||
self.timings['selected_cost']=final.total
|
||||
return tuple(phrases)
|
||||
|
||||
def phrase(self,states,index):
|
||||
actions=tuple(s.action for s in states);ns=tuple(n for a in actions for n in a.notes)
|
||||
start,end=actions[0].start,actions[-1].end
|
||||
last=ns[-1] if ns else None
|
||||
held=last and last.end-last.start>=2
|
||||
ending='handoff' if end==self.end else 'settle' if held else 'open'
|
||||
anchor=None
|
||||
if held:
|
||||
region=self.harmony.at(last.start)
|
||||
anchor=types.Anchor(f'gesture_{index}:arrival',last.start,last.start,last.pitch,
|
||||
frozenset({last.pitch.number()%12}),self.harmony.profile.register,last.end-last.start,
|
||||
'terminal' if ending=='handoff' else 'intermediate',region.chord,False,last.end,ending)
|
||||
raw=Counter();contributions=Counter();weights={}
|
||||
for s in states:
|
||||
raw.update(s.evaluation.raw);contributions.update(s.evaluation.contributions);weights.update(s.evaluation.weights)
|
||||
evaluation=scoring.Evaluation(selection.VERSION,True,dict(raw),weights,dict(contributions),sum(s.evaluation.total for s in states),
|
||||
{'span':str(end-start),'attacks':len(ns),'ticks_per_quarter':12})
|
||||
direction='up' if len(ns)>1 and ns[-1].pitch.number()>ns[0].pitch.number() else 'down'
|
||||
rhythm=tuple(g for a in actions for g in groups.describe(tuple((n.start-a.start,n.end-a.start) for n in a.notes),origin=a.start,source=a.kind))
|
||||
return types.Phrase(f'gesture_{index}',start,end,ns,actions[0].kind,direction,ending,anchor,
|
||||
tuple(c for a in actions for c in a.contracts),rhythm,'home',evaluation.total,evaluation,tuple(a.source or a.kind for a in actions))
|
||||
|
||||
@@ -53,6 +53,8 @@ def write_role(result, path):
|
||||
part = role.split('_')[0]
|
||||
document = result['document']
|
||||
first, count = region(document, part)
|
||||
leadin=int(result.get('details',{}).get('allocation',{}).get('lead_in_measures',0))
|
||||
first-=leadin;count+=leadin
|
||||
selected = excerpt(document, first, count)
|
||||
numbers = [m['number'] for m in selected['staves'][0]['measures']]
|
||||
for i, measure in enumerate(selected['staves'][0]['measures'], 1):
|
||||
@@ -60,7 +62,7 @@ def write_role(result, path):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
musicxml_writer.write_musicxml(path, selected, tablature=True)
|
||||
return {'role': role, 'file': path.name, 'form_start_measure': 1,
|
||||
return {'role': role, 'file': path.name, 'form_start_measure': 1+leadin,
|
||||
'measure_count': count, 'arrangement_measures': numbers,
|
||||
'arrangement_start_beat': str(sum((model.fraction(m['duration']) for m in document['staves'][0]['measures'][:first]), F())),
|
||||
'pitch_convention': 'guitar-written; explicit octave transposition -1',
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
"""Typed, target-driven harmonic expression over a strictly mapped theme."""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
from generator import harmony as chord_model, model, pitch as spelling, vocabulary
|
||||
from generator.solo import types
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def parsed_chord(symbol):
|
||||
"""Private read-only chord analysis shared by pure expression operations."""
|
||||
return chord_model.parse_symbol(symbol)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def degree_spelling(letter,accidental,degree,alter):
|
||||
root={'letter':letter,'alter':model.rational(accidental),'octave':4}
|
||||
p=spelling.shift_pitch(root,degree-1,chord_model.degree_semitones(degree,alter))
|
||||
return types.Pitch(p['letter'],model.alteration(p['alter']),p['octave'])
|
||||
|
||||
|
||||
def quality(chord):
|
||||
degrees=chord_model.degrees(chord)
|
||||
@@ -15,16 +28,15 @@ def quality(chord):
|
||||
|
||||
|
||||
def degree_pitch(chord,degree,alter,near):
|
||||
root={**chord['root'],'octave':4}
|
||||
p=spelling.shift_pitch(root,degree-1,chord_model.degree_semitones(degree,alter))
|
||||
value=types.Pitch(p['letter'],model.alteration(p['alter']),p['octave'])
|
||||
value=degree_spelling(chord['root']['letter'],model.alteration(chord['root']['alter']),degree,alter)
|
||||
return dataclasses.replace(value,octave=value.octave+round((near-value.number())/12))
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1024)
|
||||
def chromatic(target,delta):
|
||||
# Chromatic neighbors are spelled relative to their exact destination.
|
||||
if delta in (-2,-1,1,2):
|
||||
step=1 if delta>0 else -1
|
||||
if delta in (-3,-2,-1,1,2,3):
|
||||
step=(1 if delta>0 else -1)*(2 if abs(delta)==3 else 1)
|
||||
root={'letter':target.letter,'alter':model.rational(target.alter),'octave':target.octave}
|
||||
p=spelling.shift_pitch(root,step,delta)
|
||||
return types.Pitch(p['letter'],model.alteration(p['alter']),p['octave'])
|
||||
@@ -32,7 +44,14 @@ def chromatic(target,delta):
|
||||
|
||||
|
||||
def eligible_color(rule,span,harmony):
|
||||
chord=chord_model.parse_symbol(span.chord)
|
||||
key=(tuple(rule['qualities']),rule['degree'],rule['alter'],rule.get('function'),span)
|
||||
cache=harmony.color_eligibility
|
||||
if key not in cache:cache[key]=_eligible_color(rule,span,harmony)
|
||||
return cache[key]
|
||||
|
||||
|
||||
def _eligible_color(rule,span,harmony):
|
||||
chord=parsed_chord(span.chord)
|
||||
degrees=chord_model.degrees(chord)
|
||||
if quality(chord) not in rule['qualities']:return False
|
||||
degree=rule['degree']; simple=(degree-1)%7+1
|
||||
@@ -41,9 +60,10 @@ def eligible_color(rule,span,harmony):
|
||||
if degree in degrees and degrees[degree]!=rule['alter']:return False
|
||||
following=next((s for s in harmony.spans if s.start>=span.end and s.chord!=span.chord and s.scale),None)
|
||||
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))%12
|
||||
next_chord=chord_model.parse_symbol(following.chord) if following else None
|
||||
next_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
|
||||
function=rule.get('function')
|
||||
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)
|
||||
@@ -70,27 +90,43 @@ def validate(notes,contracts,harmony):
|
||||
if not rule or len(c.events)!=len(rule['semitones']):return {},c.id+': unknown approach formula'
|
||||
arrival=c.boundary if c.boundary is not None else target.start
|
||||
if c.boundary is not None and not any(a.kind=='anticipation' and a.target==target and a.boundary==c.boundary for a in contracts):return {},c.id+': future target lacks anticipation contract'
|
||||
if target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,arrival,target.end):return {},c.id+': invalid destination hold'
|
||||
gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F())
|
||||
planned_color=any(a.kind=='color' and a.target==target and a.events==(target,) for a in contracts)
|
||||
if not planned_color and (target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,arrival,target.end)):return {},c.id+': invalid destination hold'
|
||||
gaps=tuple(map(F,rule.get('internal_gaps',['0']*(len(c.events)-1))))
|
||||
if len(gaps)!=len(c.events)-1 or any(g<0 for g in gaps):return {},c.id+': invalid internal approach gaps'
|
||||
gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F())-sum(gaps,F())
|
||||
if target.start-cursor>F(vocabulary.data('lead_ins')['max_approach']):return {},c.id+': approach too long'
|
||||
for n,step,duration in zip(c.events,rule['semitones'],rule['durations']):
|
||||
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
|
||||
cursor=n.end+(gaps[i] if i<len(gaps) else 0)
|
||||
for index in rule.get('mapped_indices',[]):
|
||||
n=c.events[index]
|
||||
if n.pitch.number()%12 not in types.permitted(harmony.choices,n.start,n.end):return {},c.id+': declared diatonic neighbor is outside the mapped collection'
|
||||
if any(n.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':
|
||||
rule=vocabulary.data('lead_ins').get('delayed_approach',{})
|
||||
if c.rule!=rule.get('id') or len(c.events)!=1:return {},c.id+': unknown delayed approach'
|
||||
n,=c.events
|
||||
if n.end-n.start>F(rule['max_note']) or not n.end<=target.start<=n.start+F(rule['deadline']):return {},c.id+': expired delayed approach'
|
||||
if target.pitch.number()-n.pitch.number() not in rule['steps']:return {},c.id+': wrong delayed target register'
|
||||
if target.pitch.number()%12 not in harmony.members(target.start,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,target.start,target.end):return {},c.id+': invalid delayed destination'
|
||||
elif c.kind=='color':
|
||||
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
|
||||
if not rule:return {},c.id+': unknown color policy'
|
||||
colored=False
|
||||
for n in c.events:
|
||||
if n.end-n.start>F(rule['max_beats']):return {},c.id+': excessive color duration'
|
||||
for region,a,b in harmony.segments(n):
|
||||
if n.pitch.number()%12 in region.roles.basic:continue
|
||||
if not region.scale or not eligible_color(rule,region,harmony):return {},c.id+': incompatible color harmony/function'
|
||||
p=degree_pitch(chord_model.parse_symbol(region.chord),rule['degree'],rule['alter'],n.pitch.number())
|
||||
if p!=n.pitch:return {},c.id+': color degree/spelling mismatch'
|
||||
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'):
|
||||
if target.event_id==last.event_id or not 0<=target.start-last.end<=F(vocabulary.data('harmony')['resolution_gap']) or not 0<abs(target.pitch.number()-last.pitch.number())<=2 or target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': unresolved color'
|
||||
@@ -105,17 +141,29 @@ def validate(notes,contracts,harmony):
|
||||
return {},c.id+': missed suspension resolution'
|
||||
if target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': invalid suspension target'
|
||||
if held.pitch.number()%12 not in types.permitted(harmony.choices,held.start,held.end):return {},c.id+': suspension outside mapped collections'
|
||||
elif c.kind=='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'
|
||||
n,=c.events
|
||||
if not n.start<c.boundary<n.end or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipated color interval'
|
||||
arrived=dataclasses.replace(n,start=c.boundary)
|
||||
target=arrived if c.target==n else c.target
|
||||
inner=types.ExpressionContract(c.id+':arrived','color',rule['id'],(arrived,),target,span.chord)
|
||||
_,error=validate((arrived,*((target,) if target!=arrived else ())), (inner,),harmony)
|
||||
if error:return {},error
|
||||
elif c.kind=='anticipation':
|
||||
if c.rule!='early_chord_tone' or len(c.events)!=1 or c.target!=c.events[0] or span.start!=c.boundary:
|
||||
if c.rule not in ('early_chord_tone','early_reattack') or len(c.events)!=1 or span.start!=c.boundary:
|
||||
return {},c.id+': unknown anticipation or non-boundary target'
|
||||
n,=c.events
|
||||
if c.boundary is None or not n.start<c.boundary<n.end or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipation interval'
|
||||
if n.pitch.number()%12 not in harmony.members(c.boundary,n.end) or n.pitch.number()%12 not in types.permitted(harmony.choices,c.boundary,n.end):return {},c.id+': invalid future destination'
|
||||
if c.boundary is None or not n.start<c.boundary or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipation interval'
|
||||
if c.rule=='early_chord_tone' and (n!=target or not c.boundary<n.end):return {},c.id+': invalid held anticipation'
|
||||
if c.rule=='early_reattack' and (n.end!=c.boundary or target.start!=c.boundary or target.event_id==n.event_id or target.pitch.number()!=n.pitch.number()):return {},c.id+': invalid anticipated reattack'
|
||||
if target.pitch.number()%12 not in harmony.members(c.boundary,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,c.boundary,target.end):return {},c.id+': invalid future destination'
|
||||
elif c.kind=='blues':
|
||||
if len(c.events)!=1:return {},c.id+': blues treatment requires one inflected event'
|
||||
rule=next((r for r in vocabulary.data('lead_ins')['blues'] if r['id']==c.rule),None)
|
||||
n,=c.events
|
||||
chord=chord_model.parse_symbol(span.chord)
|
||||
chord=parsed_chord(span.chord)
|
||||
if not rule or n.end-n.start>F(1,2) or n.end!=target.start or target.pitch.number()%12 not in span.roles.basic:return {},c.id+': unresolved blues inflection'
|
||||
expected=degree_pitch(chord,rule['degree'],rule['alter'],n.pitch.number())
|
||||
resolved=degree_pitch(chord,rule['target_degree'],0,target.pitch.number())
|
||||
|
||||
@@ -58,6 +58,7 @@ class Harmony:
|
||||
self._segments = {}
|
||||
self._members = {}
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Shared, enumerable musical actions for composition and derivation checking.
|
||||
|
||||
An action owns its complete rhythm and path. A rest is an action; an ordinary
|
||||
attack is not an implied cadence. Compound actions retain approach targets and
|
||||
whole tuplet groups. This module has no training/reference-file interface.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
from collections import Counter
|
||||
|
||||
from generator import harmony as chords, vocabulary
|
||||
from generator.solo import types, expressions, groups, rhythm
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Action:
|
||||
start: F
|
||||
end: F
|
||||
notes: tuple
|
||||
kind: str
|
||||
contracts: tuple = ()
|
||||
source: str = ''
|
||||
|
||||
@functools.cached_property
|
||||
def key(self):
|
||||
return (self.start,self.end,self.kind,tuple((n.start,n.end,n.pitch.number()) for n in self.notes),
|
||||
tuple((c.rule,str(c.boundary),c.target.start,c.target.end,c.target.pitch.number()) for c in self.contracts))
|
||||
|
||||
|
||||
class Operators:
|
||||
def __init__(self,harmony,analysis,end,start=F(),enabled=True,sequences=None,rhythms=None):
|
||||
self.harmony=harmony;self.analysis=analysis;self.start=start;self.end=end;self.enabled=enabled
|
||||
self.rules=vocabulary.data('solo')['operators'];self.cache={};self.pitch_cache={};self.coverage=Counter()
|
||||
self.ordinary_cache={}
|
||||
self.sequences=sequences
|
||||
self.patterns=[]
|
||||
if rhythms is not None:
|
||||
library=rhythm.Rhythms(rhythms,analysis.tempo)
|
||||
# Purposeful curated groups are peers; exhaustive catalogs contribute
|
||||
# representatives, not a probability proportional to XML file counts.
|
||||
candidates=library.groups
|
||||
for meter,values in library.catalog.items():
|
||||
if any(b-a==F(meter.split('/')[0])*4/int(meter.split('/')[1]) for a,b in zip(analysis.boundaries,analysis.boundaries[1:])):
|
||||
candidates+=list(values)
|
||||
seen=set()
|
||||
for name,pattern,length,_ in candidates:
|
||||
if 2<=len(pattern)<=4 and length<=4 and (pattern,length) not in seen:
|
||||
seen.add((pattern,length));self.patterns.append((name,pattern,length))
|
||||
|
||||
def pitches(self,time):
|
||||
span=self.harmony.at(time)
|
||||
if not span or not span.scale:return ()
|
||||
if span.scale not in self.pitch_cache:
|
||||
self.pitch_cache[span.scale]=tuple(p for i in range(-28,29)
|
||||
if self.harmony.profile.permits((p:=types.scale_pitch(span.scale,i)).number()))
|
||||
return self.pitch_cache[span.scale]
|
||||
|
||||
def note(self,start,end,pitch,role):
|
||||
# Identity follows exact musical content, not an output filename or hash.
|
||||
return types.Note(start,end,pitch,role=role,event_id=f'{start}:{end}:{pitch.number()}')
|
||||
|
||||
def degree_pitches(self,chord,rule):
|
||||
spelling=expressions.degree_pitch(chord,rule['degree'],rule['alter'],60)
|
||||
# Enumerate octaves, not repeated rounding from C-octave centers. Halfway
|
||||
# pitches (e.g. F#) otherwise disappear because round uses even ties.
|
||||
return tuple(p for octave in range(1,6)
|
||||
if self.harmony.profile.permits((p:=dataclasses.replace(spelling,octave=octave)).number()))
|
||||
|
||||
def _accepted(self,action):
|
||||
self.coverage['proposed:'+action.kind]+=1
|
||||
if action.end>self.end or action.end<=action.start:
|
||||
self.coverage['rejected_by_allocation:'+action.kind]+=1;return False
|
||||
if any(not self.harmony.profile.permits(n.pitch.number()) for n in action.notes):
|
||||
self.coverage['rejected_by_physical_domain:'+action.kind]+=1;return False
|
||||
if not groups.valid(tuple((n.start-action.start,n.end-action.start) for n in action.notes)):
|
||||
self.coverage['rejected_by_rhythm:'+action.kind]+=1;return False
|
||||
if action.contracts:
|
||||
valid=self.harmony.evaluate(action.notes,expressions=action.contracts,complete=False,until=action.end).valid
|
||||
else:
|
||||
valid=True
|
||||
for n in action.notes:
|
||||
key=(n.start,n.end,n.pitch.number())
|
||||
if key not in self.ordinary_cache:self.ordinary_cache[key]=self.harmony.evaluate((n,),complete=True,until=n.end).valid
|
||||
if not self.ordinary_cache[key]:valid=False;break
|
||||
if not valid:
|
||||
self.coverage['rejected_by_rule:'+action.kind]+=1
|
||||
return False
|
||||
self.coverage['valid:'+action.kind]+=1
|
||||
return True
|
||||
|
||||
def _color(self,n,span):
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
if rule.get('resolve') or not expressions.eligible_color(rule,span,self.harmony):continue
|
||||
expected=expressions.degree_pitch(expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],n.pitch.number())
|
||||
if expected.number()!=n.pitch.number():continue
|
||||
c=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(n,),n,span.chord)
|
||||
yield Action(n.start,n.end,(n,),'color',(c,),rule['id'])
|
||||
|
||||
def at(self,time,eligible=None,first=None):
|
||||
"""Unpruned legal actions. Incoming-state ranking is a separate operation."""
|
||||
if eligible is None and first is None and time in self.cache:return self.cache[time]
|
||||
span=self.harmony.at(time);values=[]
|
||||
if not span or not span.scale:
|
||||
end=min(self.end,span.end if span else self.end)
|
||||
result=(Action(time,end,(),'space'),)
|
||||
if eligible is not None:return tuple(a for a in result if eligible(a))
|
||||
self.cache[time]=result;return result
|
||||
def add(action):
|
||||
if eligible is not None and not eligible(action):return
|
||||
if self._accepted(action):values.append(action)
|
||||
def entry(a,b,pitch):
|
||||
return first is None or first==(a,b,pitch.number())
|
||||
durations=tuple(map(F,self.rules['durations']))
|
||||
if self.analysis.tempo>vocabulary.data('roles')['difficulty']['sixteenth_max_bpm']:
|
||||
durations=tuple(d for d in durations if d>=F(1,2))
|
||||
for duration in durations:
|
||||
if time+duration>self.end:continue
|
||||
if (time*(4 if duration==F(1,4) else 2)).denominator==1:
|
||||
add(Action(time,time+duration,(),'rest'))
|
||||
for pitch in self.pitches(time):
|
||||
if not entry(time,time+duration,pitch):continue
|
||||
n=self.note(time,time+duration,pitch,'motion')
|
||||
if pitch.number()%12 in types.permitted(self.harmony.choices,time,n.end):
|
||||
add(Action(time,n.end,(n,),'tone'))
|
||||
if self.enabled:
|
||||
delayed=vocabulary.data('lead_ins').get('delayed_approach')
|
||||
if delayed:
|
||||
for delay in map(F,delayed['arrivals']):
|
||||
arrival=time+delay
|
||||
for hold in map(F,delayed['holds']):
|
||||
if arrival+hold>self.end:continue
|
||||
region=self.harmony.at(arrival)
|
||||
if not region:continue
|
||||
for p in self.pitches(arrival):
|
||||
if p.number()%12 not in self.harmony.members(arrival,arrival+hold):continue
|
||||
target=self.note(arrival,arrival+hold,p,'arrival')
|
||||
for step in delayed['steps']:
|
||||
n=self.note(time,time+F(delayed['max_note']),expressions.chromatic(p,-step),'delayed_approach')
|
||||
if not entry(n.start,n.end,n.pitch):continue
|
||||
if n.pitch.number()%12 in types.permitted(self.harmony.choices,n.start,n.end):continue
|
||||
c=types.ExpressionContract(n.event_id+':delayed','delayed_approach',delayed['id'],(n,),target,region.chord)
|
||||
add(Action(time,n.end,(n,),'delayed_approach',(c,),delayed['id']))
|
||||
# Optional colors retain functional degree identity independently of
|
||||
# the reference/exporter's enharmonic notation. No scale is widened.
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
if not expressions.eligible_color(rule,span,self.harmony):continue
|
||||
for p in self.degree_pitches(expressions.parsed_chord(span.chord),rule):
|
||||
if not self.harmony.profile.permits(p.number()):continue
|
||||
for duration in durations:
|
||||
if duration>F(rule['max_beats']) or time+duration>self.end:continue
|
||||
if not entry(time,time+duration,p):continue
|
||||
n=self.note(time,time+duration,p,'color')
|
||||
if not rule.get('resolve'):
|
||||
c=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(n,),n,span.chord)
|
||||
add(Action(time,n.end,(n,),'color',(c,),rule['id']))
|
||||
else:
|
||||
for gap in (F(),F(1,2),F(1)):
|
||||
for q in self.pitches(n.end+gap):
|
||||
if not 0<abs(q.number()-p.number())<=2:continue
|
||||
for hold in map(F,self.rules['approach_holds']):
|
||||
target=self.note(n.end+gap,n.end+gap+hold,q,'arrival')
|
||||
target_span=self.harmony.at(target.start)
|
||||
if not target_span or target.end>self.end:continue
|
||||
c=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(n,),target,target_span.chord)
|
||||
add(Action(time,n.end,(n,),'color_resolution',(c,),rule['id']))
|
||||
for boundary in (s.start for s in self.harmony.spans if time<s.start<=time+F(vocabulary.data('harmony')['anticipation_max'])):
|
||||
region=self.harmony.at(boundary)
|
||||
for hold in durations:
|
||||
for p in self.pitches(boundary):
|
||||
n=self.note(time,boundary,p,'anticipation')
|
||||
if not entry(n.start,n.end,p):continue
|
||||
target=self.note(boundary,boundary+hold,p,'arrival')
|
||||
c=types.ExpressionContract(n.event_id+':anticipation','anticipation','early_reattack',(n,),target,region.chord,boundary=boundary)
|
||||
add(Action(time,target.end,(n,target),'anticipation',(c,),c.rule))
|
||||
for duration in durations:
|
||||
if time+duration<=boundary:continue
|
||||
for p in self.pitches(boundary):
|
||||
if not entry(time,time+duration,p):continue
|
||||
n=self.note(time,time+duration,p,'anticipation')
|
||||
c=types.ExpressionContract(n.event_id+':anticipation','anticipation','early_chord_tone',(n,),n,region.chord,boundary=boundary)
|
||||
add(Action(time,n.end,(n,),'anticipation',(c,),c.rule))
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
if not expressions.eligible_color(rule,region,self.harmony):continue
|
||||
for p in self.degree_pitches(expressions.parsed_chord(region.chord),rule):
|
||||
if not entry(time,time+duration,p):continue
|
||||
n=self.note(time,time+duration,p,'anticipated_color')
|
||||
if not rule.get('resolve'):
|
||||
c=types.ExpressionContract(n.event_id+':anticipated_color','anticipated_color',rule['id'],(n,),n,region.chord,boundary=boundary)
|
||||
add(Action(time,n.end,(n,),'anticipated_color',(c,),rule['id']))
|
||||
else:
|
||||
for q in self.pitches(n.end):
|
||||
if not 0<abs(q.number()-p.number())<=2:continue
|
||||
for hold in map(F,self.rules['approach_holds']):
|
||||
target=self.note(n.end,n.end+hold,q,'arrival')
|
||||
c=types.ExpressionContract(n.event_id+':anticipated_color','anticipated_color',rule['id'],(n,),target,region.chord,boundary=boundary)
|
||||
add(Action(time,target.end,(n,target),'anticipated_color',(c,),rule['id']))
|
||||
for rule in vocabulary.data('lead_ins')['patterns']:
|
||||
if rule.get('difficulty') and self.analysis.tempo>vocabulary.data('roles')['difficulty'][rule['difficulty']+'_max_bpm']:continue
|
||||
# A connection's preceding event is explicit in the action. It cannot
|
||||
# obtain a fictitious preparation from a label or from a future note.
|
||||
if rule['kind']=='connection':
|
||||
for preparation in map(F,self.rules['connection_preparations']):
|
||||
if first is not None and (first[0]!=time or first[1]!=time+preparation):continue
|
||||
arrival=time+preparation+sum(map(F,rule['durations']),F())+F(rule['gap'])
|
||||
for hold in map(F,self.rules['approach_holds']):
|
||||
if arrival+hold>self.end:continue
|
||||
for p in self.pitches(arrival):
|
||||
region=self.harmony.at(arrival)
|
||||
target=self.note(arrival,arrival+hold,p,'arrival')
|
||||
cursor=time+preparation;notes=[]
|
||||
for step,length in zip(rule['semitones'],map(F,rule['durations'])):
|
||||
notes.append(self.note(cursor,cursor+length,expressions.chromatic(p,step),'connection'));cursor+=length
|
||||
for prep_pitch in self.pitches(time):
|
||||
if not entry(time,time+preparation,prep_pitch) or not 0<abs(prep_pitch.number()-notes[0].pitch.number())<=2:continue
|
||||
prep=self.note(time,time+preparation,prep_pitch,'preparation')
|
||||
c=types.ExpressionContract(prep.event_id+':'+rule['id'],'connection',rule['id'],tuple(notes),target,region.chord,preparation=prep)
|
||||
add(Action(time,target.end,(prep,*notes,target),'connection',(c,),rule['id']))
|
||||
continue
|
||||
gaps=tuple(map(F,rule.get('internal_gaps',[])))
|
||||
arrival=time+sum(map(F,rule['durations']),F())+sum(gaps,F())+F(rule['gap'])
|
||||
for hold in map(F,self.rules['approach_holds']):
|
||||
for p in self.pitches(arrival):
|
||||
if not entry(time,time+F(rule['durations'][0]),expressions.chromatic(p,rule['semitones'][0])):continue
|
||||
region=self.harmony.at(arrival)
|
||||
if not region:continue
|
||||
target=self.note(arrival,arrival+hold,p,'arrival')
|
||||
cursor=time;notes=[]
|
||||
for i,(step,length) in enumerate(zip(rule['semitones'],map(F,rule['durations']))):
|
||||
notes.append(self.note(cursor,cursor+length,expressions.chromatic(p,step),rule['kind']));cursor+=length+(gaps[i] if i<len(gaps) else 0)
|
||||
c=types.ExpressionContract(notes[0].event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(notes),target,region.chord)
|
||||
add(Action(time,target.end,(*notes,target),rule['kind'],(c,),rule['id']))
|
||||
for colored in self._color(target,region):
|
||||
add(Action(time,target.end,(*notes,target),rule['kind'],(c,*colored.contracts),rule['id']))
|
||||
for upcoming in self.harmony.spans:
|
||||
if not arrival<upcoming.start<target.end or upcoming.start-arrival>F(vocabulary.data('harmony')['anticipation_max']):continue
|
||||
anticipation=types.ExpressionContract(target.event_id+':early','anticipation','early_chord_tone',(target,),target,upcoming.chord,boundary=upcoming.start)
|
||||
approach=dataclasses.replace(c,chord=upcoming.chord,boundary=upcoming.start)
|
||||
add(Action(time,target.end,(*notes,target),rule['kind'],(approach,anticipation),rule['id']))
|
||||
# Complete directional groups use exact scale indexing at each harmonic
|
||||
# time. A changed collection retains actual pitches, never degree labels.
|
||||
lengths_list=[(F(1,2),)*2,(F(1,2),)*4,(F(1),F(1,2),F(1,2)),(F(1,2),F(1),F(1,2))]
|
||||
difficulty=vocabulary.data('roles')['difficulty']
|
||||
if self.analysis.tempo<=difficulty['triplet_max_bpm']:lengths_list.append((F(1,3),)*3)
|
||||
if self.analysis.tempo<=difficulty['sixteenth_max_bpm']:lengths_list.extend(((F(1,4),)*4,(F(1,4),)*2))
|
||||
patterns=list(self.patterns)
|
||||
for lengths in lengths_list:
|
||||
cursor=F();pattern=[]
|
||||
for d in lengths:pattern.append((cursor,cursor+d));cursor+=d
|
||||
patterns.append(('complete_subdivision',tuple(pattern),cursor))
|
||||
for name,pattern,length in patterns:
|
||||
if time+length>self.end:continue
|
||||
if first is not None and (time+pattern[0][0],time+pattern[0][1])!=first[:2]:continue
|
||||
for stride in (-2,-1,1,2):
|
||||
# Catalog licenses relative paths, but never sets family probability.
|
||||
if self.sequences and not any(s.steps==(stride,)*(len(pattern)-1) for s in self.sequences.paths.get(len(pattern),())):continue
|
||||
for initial in self.pitches(time):
|
||||
if not entry(time+pattern[0][0],time+pattern[0][1],initial):continue
|
||||
cursor=time;p=initial;notes=[]
|
||||
for index,(a,b) in enumerate(pattern):
|
||||
cursor=time+a
|
||||
region=self.harmony.at(cursor)
|
||||
if not region or not region.scale:break
|
||||
if index:
|
||||
try:p=types.scale_pitch(region.scale,types.scale_index(region.scale,p.number())+stride)
|
||||
except ValueError:break
|
||||
notes.append(self.note(cursor,time+b,p,'scalar' if abs(stride)==1 else 'arpeggio'))
|
||||
if len(notes)==len(pattern):add(Action(time,time+length,tuple(notes),'scalar' if abs(stride)==1 else 'arpeggio',source=f'{name}:degree_stride:{stride}'))
|
||||
# Equivalent musical actions need one stable derivation, not extra votes.
|
||||
unique={}
|
||||
for a in values:unique.setdefault(a.key,a)
|
||||
result=tuple(unique[k] for k in sorted(unique))
|
||||
if eligible is not None or first is not None:return result
|
||||
if len(self.cache)>=32:self.cache.pop(next(iter(self.cache)))
|
||||
if len(self.ordinary_cache)>8192:self.ordinary_cache.clear()
|
||||
self.cache[time]=result
|
||||
return result
|
||||
|
||||
def related(self,time,home,kind):
|
||||
"""Construct a heard-theme return or answer as a whole group, never a prefix."""
|
||||
if len(home)<3:return ()
|
||||
pattern=tuple((n.start-home[0].start,n.end-home[0].start) for n in home)
|
||||
if not groups.valid(pattern):return ()
|
||||
values=[]
|
||||
for p in self.pitches(time):
|
||||
notes=[]
|
||||
for n,(a,b) in zip(home,pattern):
|
||||
number=p.number()+(n.pitch.number()-home[0].pitch.number())*(1 if kind=='return' else -1)
|
||||
q=next((q for q in self.pitches(time+a) if q.number()==number),None)
|
||||
if q is None:break
|
||||
notes.append(self.note(time+a,time+b,q,kind))
|
||||
if len(notes)==len(home):
|
||||
action=Action(time,time+pattern[-1][1],tuple(notes),kind,source='realized_theme')
|
||||
if self._accepted(action):values.append(action)
|
||||
return tuple(values)
|
||||
@@ -4,7 +4,7 @@ import dataclasses, hashlib
|
||||
class Policy:
|
||||
long_note: float = 2.
|
||||
stopping_note: float = 1.
|
||||
composition_version: str = 'phrase-composer-1'
|
||||
composition_version: str = 'joint-actions-1'
|
||||
DEFAULT=Policy()
|
||||
def stable_rank(seed, value):
|
||||
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
|
||||
|
||||
@@ -25,16 +25,18 @@ def structure(solo):
|
||||
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),'attacks_per_four_beats':4*len(ns)/float(solo.end) if solo.end else 0,
|
||||
'occupancy':sum(float(n.end-n.start) for n in ns)/float(solo.end) if solo.end else 0},
|
||||
'phrasing':{'endings':endings,'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_exposure':checked.exposure,'unresolved_treatments':plain(checked.pending)}
|
||||
|
||||
def describe(solo,scales,measure_offset=0,time_offset=0,*,detail=None):
|
||||
detail=detail or structure(solo)
|
||||
lines=[f'Independent complete-phrase solo: {solo.part}', 'Times are exact quarter beats from the solo start; pitches sound one octave below guitar notation.',
|
||||
lines=[f'Independent joint-action 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}')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""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, policy
|
||||
from generator.solo import groups
|
||||
|
||||
class Rhythms:
|
||||
def __init__(self, library, tempo):
|
||||
@@ -31,32 +31,3 @@ class Rhythms:
|
||||
representatives.setdefault(descriptor,(item.id,pattern,bar.length,('develop','answer')))
|
||||
self.catalog[meter]=tuple(representatives.values())
|
||||
self.cache={}
|
||||
|
||||
def realize(self,start,end,purpose,seed,meter):
|
||||
key=(start,end,purpose,seed,meter)
|
||||
if key in self.cache:return self.cache[key]
|
||||
cursor=start;notes=[];sources=[];chosen=[];home=None;uses=0
|
||||
while cursor<end:
|
||||
available=[g for g in self.groups if purpose in g[3] and g[2]<=end-cursor]
|
||||
# Catalog is a separate family, not thousands of duplicate votes.
|
||||
if purpose in ('develop','answer'):
|
||||
available += sorted((g for g in self.catalog.get(meter,()) if g[2]<=end-cursor),key=lambda g:policy.stable_rank(seed,('catalog',cursor,g[0])))[:1]
|
||||
available=[g for g in available if ((end-cursor-g[2])*2).denominator==1]
|
||||
if not available:return (),(),()
|
||||
# Prefer complete multi-event groups; single values finish an exact remainder.
|
||||
large=[g for g in available if g[2]>=1]
|
||||
if home is not None and uses<2 and home in available:
|
||||
selected=home
|
||||
else:
|
||||
# Establish one rhythmic idea before reshaping it. A complete phrase
|
||||
# is not a fresh unrelated catalog draw at every short boundary.
|
||||
selected=min(large or available,key=lambda g:policy.stable_rank(seed,('rhythm',len(chosen)//4,g[0])))
|
||||
home=selected;uses=0
|
||||
uses+=1
|
||||
name,pattern,length,_=selected
|
||||
events=tuple((cursor+a,cursor+b) for a,b in pattern)
|
||||
notes.extend(events);sources.append(name)
|
||||
chosen.extend(groups.describe(pattern,origin=cursor,source=name))
|
||||
cursor+=length
|
||||
result=(tuple(notes),tuple(chosen),tuple(sources));self.cache[key]=result
|
||||
return result
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Complete pitch paths for joint phrase proposals, with explicit structural motion."""
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary
|
||||
from generator.solo import types, policy, sequences
|
||||
|
||||
class Routes:
|
||||
def __init__(self,harmony,library):
|
||||
self.harmony=harmony;self.library=library;self.pitch_cache={};self.cache={}
|
||||
# Every short relative path is reachable by attack count and exact displacement.
|
||||
self.index={}
|
||||
self.steps=frozenset(s.steps[0] for s in library.paths.get(2,()))
|
||||
for values in library.paths.values():
|
||||
for value in values:self.index.setdefault((len(value.steps)+1,value.offsets()[-1]),[]).append(value)
|
||||
|
||||
def pitches(self,start,end):
|
||||
key=(start,end)
|
||||
if key not in self.pitch_cache:
|
||||
span=self.harmony.at(start)
|
||||
allowed=types.permitted(self.harmony.choices,start,end)
|
||||
if end-start>=2:allowed &= self.harmony.members(start,end)| (span.roles.specified if span else frozenset())
|
||||
self.pitch_cache[key]=tuple(types.scale_pitch(span.scale,i) for i in range(-28,29)
|
||||
if types.scale_pitch(span.scale,i).number()%12 in allowed and self.harmony.profile.permits(types.scale_pitch(span.scale,i).number())) if span and span.scale else ()
|
||||
return self.pitch_cache[key]
|
||||
|
||||
def realize(self,notes,fixed,purpose,direction,seed,center,incoming=None,hint=()):
|
||||
"""Only completed paths leave this function; internal frontier cuts have no cadence."""
|
||||
if not notes:return ()
|
||||
# Identical musical work is cached without event/provenance IDs.
|
||||
key=(tuple((n.start,n.end,n.pitch if i in fixed else None) for i,n in enumerate(notes)),tuple(sorted(fixed)),purpose,direction,seed,center,incoming.pitch if incoming else None,tuple((n.start,n.end,n.pitch) for n in hint))
|
||||
if key in self.cache:
|
||||
return tuple(tuple(dataclasses.replace(n,pitch=p) for n,p in zip(notes,path)) for path in self.cache[key])
|
||||
width=vocabulary.data('solo')['search']['route_frontier'];states=[(0.,())];count=len(notes)
|
||||
for i,n in enumerate(notes):
|
||||
options=(n.pitch,) if i in fixed else self.pitches(n.start,n.end)
|
||||
region=self.harmony.at(n.start)
|
||||
pitch_order={p:policy.stable_rank(seed,('pitch',i,p)) for p in options}
|
||||
following=[]
|
||||
for cost,path in states:
|
||||
prior=path[-1] if path else incoming.pitch if incoming else None
|
||||
phase=i/max(1,count-1)
|
||||
sign=1 if direction=='up' or direction=='arch' and phase<.5 or direction=='valley' and phase>=.5 else -1
|
||||
target=next((notes[j].pitch for j in sorted(fixed) if j>i),None)
|
||||
choices=[]
|
||||
for pitch in options:
|
||||
number=pitch.number();motion=number-prior.number() if prior else 0
|
||||
if not self.harmony.profile.permits(number) or prior and abs(motion)>12:continue
|
||||
if prior and i not in fixed:
|
||||
try:
|
||||
displacement=types.scale_index(region.scale,number)-types.scale_index(region.scale,prior.number())
|
||||
if displacement and displacement not in self.steps:continue
|
||||
except ValueError:
|
||||
pass # Changed collection: retain the actual incoming pitch.
|
||||
local=max(0,abs(number-center)-10)/12
|
||||
if i not in fixed:
|
||||
local+=max(0,abs(motion)-7)/3
|
||||
if prior:
|
||||
span=region
|
||||
chordal=number%12 in span.roles.basic
|
||||
# Arpeggios expand during the gesture; the last portion connects to its goal.
|
||||
if purpose=='arpeggio' and phase<.65:local+=(0 if chordal else 1)+(.4 if abs(motion)<3 else 0)
|
||||
elif purpose=='neighbor':local+=max(0,abs(motion)-2)/2
|
||||
else:local+=max(0,abs(motion)-3)/3
|
||||
desired=sign
|
||||
if hint and 0<i<len(hint):
|
||||
step=hint[i].pitch.number()-hint[i-1].pitch.number()
|
||||
if purpose=='answer':step=-step
|
||||
desired=(step>0)-(step<0)
|
||||
if purpose in ('return','head') and ((motion>0)-(motion<0))!=desired:continue
|
||||
local+=abs(abs(motion)-abs(step))/12
|
||||
if purpose=='neighbor':desired=sign if i%2 else -sign
|
||||
local+=.35 if motion*desired<0 else .2 if motion==0 else 0
|
||||
if len(path)>=2 and path[-2]==prior==pitch:local+=1
|
||||
if target and phase>.6:
|
||||
remaining=max(1,next(j for j in sorted(fixed) if j>i)-i)
|
||||
local+=max(0,abs(target.number()-number)-remaining*2)/3
|
||||
choices.append((local,pitch))
|
||||
# Preserve distinct register/motion alternatives, not merely two nearest notes.
|
||||
for local,pitch in sorted(choices,key=lambda v:(v[0],pitch_order[v[1]]))[:width]:
|
||||
following.append((cost+local,(*path,pitch)))
|
||||
if not following:return ()
|
||||
states=sequences.diverse(following,lambda v:(round(v[0],8),policy.stable_rank(seed,('path',v[1]))),
|
||||
lambda v:((v[1][-1].number()-v[1][0].number()>0),v[1][-1].number()),width)
|
||||
# Where applicable, add complete catalog routes as peers of the contextual route.
|
||||
if 2<=count<=4 and fixed=={0,count-1}:
|
||||
span=self.harmony.at(notes[0].start)
|
||||
if span and span.scale and all(self.harmony.at(n.start).scale==span.scale for n in notes):
|
||||
try:
|
||||
origin=types.scale_index(span.scale,notes[0].pitch.number());displacement=types.scale_index(span.scale,notes[-1].pitch.number())-origin
|
||||
for route in self.index.get((count,displacement),())[:32]:
|
||||
path=tuple(types.scale_pitch(span.scale,origin+x) for x in route.offsets())
|
||||
if all(p in self.pitches(n.start,n.end) for p,n in zip(path,notes)):states=(*states,(0.,path))
|
||||
except ValueError:pass
|
||||
paths=[]
|
||||
for _,path in sorted(states,key=lambda v:(v[0],policy.stable_rank(seed,v[1]))):
|
||||
if path not in paths:paths.append(path)
|
||||
if len(paths)==2:break
|
||||
self.cache[key]=tuple(paths)
|
||||
return tuple(tuple(dataclasses.replace(n,pitch=p) for n,p in zip(notes,path)) for path in paths)
|
||||
@@ -1,8 +1,6 @@
|
||||
"""One authoritative evaluation of completed phrases, never guitar travel."""
|
||||
"""The authoritative action-evaluation record, independent of guitar travel."""
|
||||
from dataclasses import dataclass
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary
|
||||
from generator.solo import types
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Evaluation:
|
||||
@@ -11,38 +9,5 @@ class Evaluation:
|
||||
raw: dict
|
||||
weights: dict
|
||||
contributions: dict
|
||||
total: float
|
||||
total: F
|
||||
denominators: dict
|
||||
|
||||
def signature(notes):
|
||||
ns=types.sounding_notes(notes)
|
||||
if not ns:return ()
|
||||
origin=ns[0].start;pitch=ns[0].pitch.number()
|
||||
return tuple((n.start-origin,n.end-n.start,n.pitch.number()-pitch) for n in ns)
|
||||
|
||||
def evaluate(phrase,incoming,history,center,weights=None):
|
||||
ns=types.sounding_notes(phrase.notes); span=float(phrase.end-phrase.start)
|
||||
weights=dict(vocabulary.data('solo')['weights'] if weights is None else weights)
|
||||
if set(weights)!={'purpose','continuity','repetition','register'}:raise ValueError('Unknown musical contribution')
|
||||
if not ns:
|
||||
valid=phrase.purpose=='space';raw=dict.fromkeys(weights,0.)
|
||||
else:
|
||||
valid=True
|
||||
movement=[b.pitch.number()-a.pitch.number() for a,b in zip(ns,ns[1:])]
|
||||
# Declared expansion permits arpeggio leaps; avoid unprepared octave flailing.
|
||||
purpose=sum(max(0,abs(v)-7)/12 for v in movement)/max(1,len(movement))
|
||||
oscillation=sum(a==-b==c and a!=0 for a,b,c in zip(movement,movement[1:],movement[2:]))
|
||||
purpose+=max(0,oscillation-1)/max(1,len(movement))
|
||||
if phrase.destination:
|
||||
prior=next((n for n in reversed(ns) if n.end<=phrase.destination.earliest),None)
|
||||
if prior:purpose+=max(0,abs(prior.pitch.number()-phrase.destination.pitch.number())-3)/12
|
||||
continuity=max(0,abs(ns[0].pitch.number()-incoming.pitch.number())-5)/12 if incoming else 0.
|
||||
current=signature(ns[:6]); repetition=0.
|
||||
for age,old in enumerate(reversed(history[-4:])):
|
||||
if len(current)>=3 and current==signature(old.notes[:6]):repetition+=1/(age+1)
|
||||
if phrase.purpose=='return':repetition=max(0,repetition-.5)
|
||||
duration=sum(float(n.end-n.start) for n in ns)
|
||||
register=sum(float(n.end-n.start)*max(0,abs(n.pitch.number()-center)-10)/12 for n in ns)/max(duration,.001)
|
||||
raw={'purpose':min(1,purpose),'continuity':min(1,continuity),'repetition':min(1,repetition),'register':min(1,register)}
|
||||
contributions={k:raw[k]*weights[k] for k in weights}
|
||||
return Evaluation('phrase-musical-1',valid,raw,weights,contributions,sum(contributions.values()) if valid else float('inf'),{'span':span,'attacks':len(ns),'sounding':sum(float(n.end-n.start) for n in ns)})
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Interpretable shared action ranking; no song identity or absolute bar features.
|
||||
|
||||
Complete actions compete by exact cost per quarter, with bounded backtracking.
|
||||
The final report exposes the exact same contributions used by search.
|
||||
Role-specific learned integer coefficients can augment these general priors.
|
||||
"""
|
||||
from collections import Counter
|
||||
from fractions import Fraction as F
|
||||
import dataclasses
|
||||
from generator import vocabulary, harmony as chords, model
|
||||
from generator.solo import expressions, scoring, types
|
||||
|
||||
VERSION='action-selection-1'
|
||||
DEFAULT={'excessive_leap':12,'register_distance':1,'oscillation':6,'repeated_attack':3,
|
||||
'unprepared_space':12,'rest_after_rest':24,'source_relation':-2,'theme_relation':-4,
|
||||
'sustained_arrival':-1,'directed_motion':-1}
|
||||
|
||||
|
||||
def validate_weights(weights):
|
||||
durations={str(F(i,4)).replace('/','_') for i in range(1,17)}|{'1_3','2_3'}
|
||||
kinds={'tone','color','color_resolution','anticipation','anticipated_color','approach','enclosure','connection','delayed_approach','space','rest','scalar','arpeggio','return','answer'}
|
||||
allowed={f'kind/{k}' for k in kinds}|{f'duration/{d}' for d in durations}
|
||||
allowed|={f'harmony/{q}/{p}/{d}' for q in ('major','minor','dominant','half_diminished','other') for p in range(12) for d in durations}
|
||||
allowed|={f'motion/{p}/{d}' for p in range(-12,13) for d in durations}
|
||||
allowed|={f'metric/{m}/{d}' for m in ('barline','beat','eighth','subdivision') for d in durations}
|
||||
allowed|={f'register/{i}' for i in range(-4,5)}
|
||||
allowed|={f'obligation/step/{i}' for i in range(-12,13)}
|
||||
allowed|={f'obligation/delay/{str(F(i,4)).replace("/","_")}' for i in range(1,21)}
|
||||
allowed|={f'obligation/hold/{d}' for d in durations}
|
||||
allowed|={f'continuation/{a}/{b}' for a in ('up','down','repeat') for b in ('up','down','repeat')}
|
||||
allowed|={'source/rhythm_match','theme/realized_identity',*(f'space/{s}' for s in ('unharmonized','breath','after_arrival','delayed_entrance'))}
|
||||
for key,value in weights.items():
|
||||
if key not in allowed or not isinstance(value,int) or isinstance(value,bool):
|
||||
raise ValueError('Unsupported fitted feature (only shared musical categories): '+key)
|
||||
if any(c not in 'abcdefghijklmnopqrstuvwxyz_0123456789/-' for c in key):
|
||||
raise ValueError('Noncanonical fitted feature: '+key)
|
||||
return dict(weights)
|
||||
|
||||
|
||||
def signature(notes):
|
||||
if not notes:return ()
|
||||
first=notes[0]
|
||||
return tuple((n.start-first.start,n.end-n.start,n.pitch.number()-first.pitch.number()) for n in notes)
|
||||
|
||||
|
||||
class Selector:
|
||||
def __init__(self,harmony,analysis,center,weights=None):
|
||||
self.harmony=harmony;self.analysis=analysis;self.center=center
|
||||
self.weights=validate_weights(weights if weights is not None else vocabulary.role().get('selection_weights',{}))
|
||||
self.source_intervals=Counter(b.pitch.number()-a.pitch.number() for a,b in zip(analysis.melody,analysis.melody[1:]))
|
||||
self.source_pairs=Counter((b.pitch.number()-a.pitch.number(),c.pitch.number()-b.pitch.number())
|
||||
for a,b,c in zip(analysis.melody,analysis.melody[1:],analysis.melody[2:]))
|
||||
self.source_rhythms=Counter(n.end-n.start for n in analysis.melody)
|
||||
self.static={}
|
||||
|
||||
def base(self,action):
|
||||
if action.key in self.static:return self.static[action.key]
|
||||
values=Counter();prior=Counter();ticks=int((action.end-action.start)*12)
|
||||
values['kind/'+action.kind]=ticks
|
||||
for contract in action.contracts:
|
||||
if contract.target.start>=action.end and contract.events:
|
||||
# Pending destinations must be visible to selection. Identical heard
|
||||
# attacks with different future obligations are different actions.
|
||||
last=contract.events[-1];target=contract.target
|
||||
values[f'obligation/step/{max(-12,min(12,target.pitch.number()-last.pitch.number()))}']+=ticks
|
||||
values['obligation/delay/'+str(target.start-last.start).replace('/','_')]+=ticks
|
||||
values['obligation/hold/'+str(target.end-target.start).replace('/','_')]+=ticks
|
||||
if not action.notes:
|
||||
values['space/'+('unharmonized' if action.kind=='space' else 'breath')]=ticks
|
||||
for n in action.notes:
|
||||
duration=n.end-n.start;length=int(duration*12)
|
||||
label=str(duration).replace('/','_')
|
||||
values['duration/'+label]+=length
|
||||
beat=(n.start%1)
|
||||
metric='barline' if n.start+self.analysis.body_offset in self.analysis.boundaries else 'beat' if beat==0 else 'eighth' if beat==F(1,2) else 'subdivision'
|
||||
values['metric/'+metric+'/'+label]+=length
|
||||
band=max(-4,min(4,(n.pitch.number()-self.center+6)//12))
|
||||
values[f'register/{band}']+=length
|
||||
region=self.harmony.at(n.start)
|
||||
chord=expressions.parsed_chord(region.chord)
|
||||
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))
|
||||
degree=(n.pitch.number()-root)%12
|
||||
values[f'harmony/{expressions.quality(chord)}/{degree}/{label}']+=length
|
||||
prior['register_distance']+=length*max(0,abs(n.pitch.number()-self.center)-7)
|
||||
if duration>=2 and n.pitch.number()%12 in self.harmony.members(n.start,n.end):
|
||||
prior['sustained_arrival']+=length
|
||||
values['source/rhythm_match']+=length if duration in self.source_rhythms else 0
|
||||
self.static[action.key]=(values,prior)
|
||||
if len(self.static)>8192:self.static.pop(next(iter(self.static)))
|
||||
return values,prior
|
||||
|
||||
def features(self,action,recent=(),home=(),previous_kind=''):
|
||||
if any(n.continuation for n in action.notes):action=dataclasses.replace(action,notes=types.sounding_notes(action.notes))
|
||||
static,base=self.base(action);values=Counter(static);prior=Counter(base)
|
||||
if not action.notes:
|
||||
if action.kind=='space':return values,prior
|
||||
ticks=int((action.end-action.start)*12)
|
||||
if previous_kind in ('rest','space'):prior['rest_after_rest']+=ticks
|
||||
elif recent and recent[-1].end-recent[-1].start>=F(3,2):values['space/after_arrival']+=ticks
|
||||
elif recent:prior['unprepared_space']+=ticks
|
||||
else:values['space/delayed_entrance']+=ticks
|
||||
return values,prior
|
||||
previous=recent[-1] if recent else None
|
||||
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(recent,recent[1:])]
|
||||
for n in action.notes:
|
||||
length=int((n.end-n.start)*12)
|
||||
if previous:
|
||||
delta=n.pitch.number()-previous.pitch.number()
|
||||
token=max(-12,min(12,delta));label=str(n.end-n.start).replace('/','_')
|
||||
values[f'motion/{token}/{label}']+=length
|
||||
if intervals:
|
||||
# Direction changes are contextual, not a blanket leap prohibition.
|
||||
sign=lambda x: 'up' if x>0 else 'down' if x<0 else 'repeat'
|
||||
values[f'continuation/{sign(intervals[-1])}/{sign(delta)}']+=length
|
||||
if len(intervals)>=2 and intervals[-2]==-intervals[-1]==delta and delta:prior['oscillation']+=length
|
||||
prior['excessive_leap']+=length*max(0,abs(delta)-7)
|
||||
prior['repeated_attack']+=length if delta==0 and n.start==previous.end else 0
|
||||
# Ordered relationships, not the set of available intervals. Weight
|
||||
# by sounding duration so subdivisions cannot multiply a reward.
|
||||
if intervals and (intervals[-1],delta) in self.source_pairs:prior['source_relation']+=length
|
||||
if 0<abs(delta)<=2 and intervals and delta*intervals[-1]>0:prior['directed_motion']+=length
|
||||
intervals.append(delta)
|
||||
previous=n
|
||||
if action.kind=='return' and len(home)>=3:
|
||||
if signature(action.notes)==signature(home):
|
||||
recently_heard=any(signature(recent[i:i+len(home)])==signature(home)
|
||||
for i in range(max(0,len(recent)-len(home)+1)))
|
||||
if not recently_heard:prior['theme_relation']+=int((action.end-action.start)*12)
|
||||
if recent and home:
|
||||
joined=(*recent,*action.notes)
|
||||
count=min(len(home),len(joined))
|
||||
if count>=3 and signature(joined[-count:])==signature(home[:count]):
|
||||
values['theme/realized_identity']+=12
|
||||
return values,prior
|
||||
|
||||
def evaluate(self,action,recent=(),home=(),previous_kind=''):
|
||||
features,prior=self.features(action,recent,home,previous_kind)
|
||||
contributions={'prior/'+k:v*DEFAULT[k] for k,v in prior.items() if v}
|
||||
contributions.update({k:v*self.weights.get(k,0) for k,v in features.items() if v})
|
||||
weights={'prior/'+k:v for k,v in DEFAULT.items()}|self.weights
|
||||
raw={'prior/'+k:v for k,v in prior.items()}|dict(features)
|
||||
span=action.end-action.start
|
||||
contributions={k:F(v)/span for k,v in contributions.items()}
|
||||
raw={k:F(v)/span for k,v in raw.items()}
|
||||
total=sum(contributions.values(),F())
|
||||
return scoring.Evaluation(VERSION,True,raw,weights,contributions,total,
|
||||
{'span':str(action.end-action.start),'ticks_per_quarter':12,'attacks':len(types.sounding_notes(action.notes))})
|
||||
|
||||
def cost(self,action,recent=(),home=(),previous_kind=''):
|
||||
"""Identical selection total without serializing every rejected breakdown."""
|
||||
features,prior=self.features(action,recent,home,previous_kind)
|
||||
numerator=sum(v*DEFAULT[k] for k,v in prior.items())
|
||||
numerator+=sum(v*self.weights.get(k,0) for k,v in features.items())
|
||||
return F(numerator)/(action.end-action.start)
|
||||
@@ -172,5 +172,6 @@ class Solo:
|
||||
end: fractions.Fraction
|
||||
diagnostics: dict = dataclasses.field(default_factory=dict, compare=False)
|
||||
profile: generator.solo.profiles.Instrument | None = None
|
||||
start: fractions.Fraction = fractions.Fraction()
|
||||
def notes(self):
|
||||
return sounding_notes(n for p in self.phrases for n in p.notes)
|
||||
|
||||
@@ -32,12 +32,10 @@ def _load(contents):
|
||||
cells=result['rhythms']['cells']+result['solo']['cells']
|
||||
if len({c['id'] for c in cells})!=len(cells):raise ValueError('Duplicate rhythm identity')
|
||||
rules=result['solo']
|
||||
if set(rules['actions'])!={'arpeggio','scalar','neighbor','develop','answer','return','head'}:raise ValueError('Unknown/missing phrase action')
|
||||
if set(rules['weights'])!={'purpose','continuity','repetition','register'}:raise ValueError('Unknown/missing musical contribution')
|
||||
if any(v<=0 for v in rules['search'].values()):raise ValueError('Invalid deterministic search budget')
|
||||
if any(type(rules['operators'][k]) is not int or rules['operators'][k]<=0 for k in ('backtracks','per_state')):raise ValueError('Invalid deterministic search budget')
|
||||
if any(F(d)<=0 for d in rules['operators']['durations']):raise ValueError('Invalid action duration')
|
||||
for group in rules['groups']:
|
||||
if not group['values'] or any(F(v.removeprefix('r:'))<=0 for v in group['values']):raise ValueError('Invalid rhythm group')
|
||||
if set(group['purpose'])-set(rules['actions']):raise ValueError('Unreachable rhythm purpose')
|
||||
return result
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
|
||||
Reference in New Issue
Block a user