This commit is contained in:
khodowany
2026-10-04 15:51:18 -05:00
parent 405066eb43
commit f234dd6d51
54 changed files with 2104 additions and 330 deletions
+21 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
import json
import pathlib
import shutil
import tempfile
@@ -44,6 +45,8 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--composition-jobs", type=int, default=2, choices=range(1, 17), metavar="1-16")
parser.add_argument("--composition-cache", type=pathlib.Path)
parser.add_argument("--export-solo-xml", action="store_true", help="Export completed solo roles as comparison MusicXML, without recomposing.")
parser.add_argument("--trace-level",choices=("summary","full"),default="summary",help="Diagnostic detail only; does not seed composition.")
parser.add_argument("--profile-output",type=pathlib.Path,help="Write build and per-role elapsed timings (not a search timeout).")
parser.add_argument("--progress", action="store_true")
args = parser.parse_args()
if not args.renderer_fingerprint and (args.data_dir is None or args.output_dir is None):
@@ -65,8 +68,12 @@ def generate_assets(args: argparse.Namespace) -> None:
raise ValueError('composition cache and generated assets must not overlap')
progress = getattr(args, 'progress', False)
started = time.monotonic()
timings=[];stages=[];last_stage=started
def report(message):
nonlocal last_stage
now=time.monotonic()
stages.append({'completed_before':message,'seconds':now-last_stage});last_stage=now
if progress:
print(f'Assets [{time.monotonic() - started:.0f}s]: {message}', flush=True)
@@ -90,7 +97,14 @@ def generate_assets(args: argparse.Namespace) -> None:
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,
composition_jobs=getattr(args,'composition_jobs',2), cache_dir=getattr(args,'composition_cache',None), progress=progress)
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'):
roles=json.loads(path.read_text())
for role in roles.values():
trace=role.get('details',{}).get('selection_trace',{})
trace.pop('evaluations',None);trace.pop('source_candidate_pools',None)
path.write_text(json.dumps(roles,sort_keys=True,indent=2))
if getattr(args, 'export_solo_xml', False):
from generator.solo.export import export_tree
export_tree(staged)
@@ -110,6 +124,12 @@ def generate_assets(args: argparse.Namespace) -> None:
with modulus.filesystem.publish_directories([(staged, output_dir)]):
pass
report('publishing complete')
profile_path=getattr(args,'profile_output',None)
if profile_path:
import platform,os
profile_path.parent.mkdir(parents=True,exist_ok=True)
profile_path.write_text(json.dumps({'total_seconds':time.monotonic()-started,'stages':stages,'roles':timings,
'platform':platform.platform(),'cpus':os.cpu_count(),'workers':getattr(args,'composition_jobs',2)},indent=2))
print(f'Generated Product Vamp assets: {performance_count} performance scores.')
if args.check:
print('Generator check passed.')
+2 -2
View File
@@ -143,6 +143,6 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
reports.append(report)
return result
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms=None, scales=None, sequences=None, composition_jobs=2, cache_dir=None, progress=False) -> int:
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms=None, scales=None, sequences=None, composition_jobs=2, cache_dir=None, progress=False, timings=None) -> int:
from generator.production import generate_tree
return generate_tree(source_dir, output_dir, jobs=composition_jobs, cache_dir=cache_dir, progress=progress)
return generate_tree(source_dir, output_dir, jobs=composition_jobs, cache_dir=cache_dir, progress=progress,timings=timings)
+1 -1
View File
@@ -26,6 +26,6 @@ def main():
from generator.solo.export import write_role
manifest=write_role(result,args.solo_xml)
args.solo_xml.with_suffix('.manifest.solo').write_text(json.dumps(manifest,indent=2)+'\n')
print(f'{args.role}: {time.monotonic()-start:.2f}s wall; cached={result["cache_hit"]}; original composition {result["seconds"]:.2f}s')
print(f'{args.role}: {time.monotonic()-start:.2f}s wall; cached={result["cache_hit"]}; role generation/cache lookup {result["seconds"]:.2f}s')
if __name__=='__main__':main()
+37
View File
@@ -0,0 +1,37 @@
"""Composition read boundary, independent of optional audition/reference tools."""
from __future__ import annotations
import contextlib
import contextvars
import os
from pathlib import Path
import sys
ACTIVE=contextvars.ContextVar('composition_reads',default=None)
def _read(event,args):
scope=ACTIVE.get()
if scope is None or event!='open':return
name,mode,flags=args
if not isinstance(name,(str,bytes,os.PathLike)):return
# 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
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
if not allowed:raise ValueError('Forbidden composition input: '+str(path))
reads.add(str(path))
sys.addaudithook(_read)
@contextlib.contextmanager
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))
try:yield reads
finally:ACTIVE.reset(token)
+17 -4
View File
@@ -43,7 +43,7 @@ class SourceAnalysis:
staff.update(id='source', name='Source')
melodic = analysis.analyze(prepared)
content = {'melody': [(str(n.start), str(n.end), dataclasses.asdict(n.pitch)) for n in melodic.melody],
'measures': [{k: m.get(k) for k in ('duration', 'meter', 'key', 'harmonies', 'tempos', 'section_label', 'implicit')} for m in staff['measures']]}
'measures': [{k: m.get(k) for k in ('duration', 'time_signature', 'key', 'harmonies', 'tempos', 'section_label', 'implicit')} for m in staff['measures']]}
# Fraction/string spelling is canonical and never includes filesystem IDs.
key = hashlib.sha256(json.dumps(content, sort_keys=True, default=str).encode()).hexdigest()
return cls(json.dumps(prepared, sort_keys=True), key)
@@ -148,6 +148,18 @@ def _fill(notes, length, prefix, voice='voice_arranged'):
def generate_role(source, role, data_dir, settings=None, cache_dir=None):
from generator.input_audit import composition
started=time.monotonic()
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['observed_reads']=sorted(reads)
result['seconds']=time.monotonic()-started
return result
def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
if role not in ROLES: raise ValueError('Unknown generation role: '+role)
data_dir=Path(data_dir); resources=vocabulary.load(data_dir/'composition')
settings=settings or {}
@@ -190,7 +202,7 @@ def generate_role(source, role, data_dir, settings=None, cache_dir=None):
output=notation.measures(result['staves'][0]['measures'][first:first+count],solo.notes(),'solo_'+part)
notation.replace_region(result,first,output);annotate(result,solo,offset)
details=report.structure(solo)
solo_report=report.describe(solo,scale_library,first,offset)
solo_report=report.describe(solo,scale_library,first,offset,detail=details)
elif role.endswith('_accompaniment'):
canonical=comping.Canonical(document,part,seed)
result=generate._arrange_document(document,part,{},owned={'accompaniment'},accompaniment=canonical)
@@ -251,7 +263,7 @@ def _job(request):
return generate_role(source,role,directory,settings,cache)
def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=False):
def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=False, timings=None):
if type(jobs) is not int or not 1<=jobs<=16:
raise ValueError('composition workers must be between 1 and 16')
source_dir,output_dir=Path(source_dir),Path(output_dir)
@@ -263,6 +275,7 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
results={p:{} for p in sources}
def accept(path,role,value):
results[path][role]=value
if timings is not None:timings.append({'source':str(path),'role':role,'seconds':value['seconds'],'cache_hit':value['cache_hit'],'observed_reads':value.get('observed_reads',[])})
if progress:print(f'Composition: {path.stem}/{role}: '+('cached' if value['cache_hit'] else f'{value["seconds"]:.1f}s'),flush=True)
if jobs==1:
for path,role,args in requests:
@@ -283,6 +296,6 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
analysis_dir=output_dir.parent/'analysis';analysis_dir.mkdir(parents=True,exist_ok=True)
(analysis_dir/(score.slugify(path.stem)+'.txt')).write_text('\n'.join(results[path][r]['report'] for r in ROLES))
# Not a score file: diagnostics are deliberately outside the songs schema.
stable={role:{k:v for k,v in value.items() if k not in ('seconds','cache_hit')} for role,value in results[path].items()}
stable={role:{k:v for k,v in value.items() if k not in ('seconds','cache_hit','observed_reads')} for role,value in results[path].items()}
(analysis_dir/(score.slugify(path.stem)+'.roles')).write_text(json.dumps(stable,sort_keys=True,indent=2))
return len(sources)*3
+4 -1
View File
@@ -31,11 +31,14 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
raise ValueError(f'{part} solo, source measure {choice.span.source_measure}, beat {choice.span.beat}, {generator.harmony.symbol(choice.span.chord)}: No exact match within the permitted catalog')
from generator.solo import leads
context = context if context is not None else generator.solo.realization.SearchContext()
context.expressions_enabled = enclosures_enabled
options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode)
result = options[0][1]
result.diagnostics['strict_composition_seconds'] = time.monotonic()-started
from generator.solo import expressions
result = expressions.refine(result,enclosures_enabled and stage=='elaborated' and mode=='thematic')
if mode!='thematic':result = expressions.refine(result,False)
result.diagnostics['enclosures_enabled']=enclosures_enabled
result.diagnostics['expressions_selected']=[e.kind for p in result.phrases for e in p.draft.expressions]
validate(result)
return result
+106
View File
@@ -89,6 +89,17 @@ def validate(notes,contracts,harmony):
last=c.events[-1]
if rule.get('resolve'):
if target is last 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'
elif c.kind=='suspension':
rule=next((r for r in vocabulary.data('harmony').get('suspensions',[]) if r['id']==c.rule),None)
if not rule or len(c.events)!=1 or c.boundary is None:return {},c.id+': unsupported suspension'
held,=c.events
if not held.start<=c.boundary-F(rule['preparation'])<c.boundary<held.end:
return {},c.id+': insufficient sounding preparation'
if held.pitch.number()%12 not in harmony.members(held.start,c.boundary):return {},c.id+': nonchordal preparation'
if held.end-c.boundary>F(rule['max_delay']) or target.start!=held.end or target.pitch.number()-held.pitch.number() not in rule['steps']:
return {},c.id+': missed suspension resolution'
if target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': invalid suspension target'
if held.pitch.number()%12 not in types.permitted(harmony.choices,held.start,held.end):return {},c.id+': suspension outside mapped collections'
elif c.kind=='anticipation':
if c.rule!='early_chord_tone' or len(c.events)!=1 or c.target!=c.events[0] or span.start!=c.boundary:
return {},c.id+': unknown anticipation or non-boundary target'
@@ -116,6 +127,34 @@ def validate(notes,contracts,harmony):
return approved,''
def validate_frontier(notes,contracts,harmony,until,complete):
"""Validate heard prefixes and bounded future obligations separately.
Future contract events are used ONLY to establish rule feasibility. They do
not count as heard resolution; the contract stays pending until its entire
target hold is present. Missing past events and expired deadlines fail.
"""
if complete:
approved,error=validate(notes,contracts,harmony)
return approved,(),error
actual={n.event_id:n for n in notes};future=dict(actual);pending=[]
for c in contracts:
required=tuple(dict.fromkeys((*c.events,c.target,*((c.preparation,) if c.preparation else ()))))
incomplete=False
for expected in required:
heard=actual.get(expected.event_id)
if expected.start>=until:
future[expected.event_id]=expected;incomplete=True
elif heard and heard.pitch==expected.pitch and heard.start==expected.start and heard.end==min(until,expected.end):
future[expected.event_id]=expected
incomplete |= heard.end<expected.end
elif not heard or (heard.start,heard.end,heard.pitch)!=(expected.start,expected.end,expected.pitch):
return {},(),c.id+': missing/changed event before resolution deadline'
if incomplete:pending.append(c)
approved,error=validate(tuple(sorted(future.values(),key=lambda n:(n.start,n.event_id))),contracts,harmony)
return {key:c for key,c in approved.items() if key in actual},tuple(pending),error
def proposals(phrase,harmony):
draft=phrase.draft
if not draft or any(n.continuation for n in draft.notes):return
@@ -160,6 +199,73 @@ def proposals(phrase,harmony):
if actual:
actual=tuple(n if a.event_id==target.event_id else a for a in actual)
yield actual,types.ExpressionContract(target.event_id+':anticipate','anticipation','early_chord_tone',(n,),n,harmony.at(target.start).chord,boundary=target.start)
for i,(before,target) in enumerate(zip(notes,notes[1:])):
boundary=target.start
if before.end!=boundary or boundary not in {s.start for s in harmony.spans}:continue
if not enclosures.editable(draft,before.start,target.end):continue
for rule in vocabulary.data('harmony').get('suspensions',[]):
delay=min(F(1,2),F(rule['max_delay']))
if target.end<=boundary+delay:continue
held=dataclasses.replace(before,end=boundary+delay,role='suspension')
resolved=dataclasses.replace(target,start=boundary+delay)
yield (*notes[:i],held,resolved,*notes[i+2:]),types.ExpressionContract(before.event_id+':suspend',
'suspension',rule['id'],(held,),resolved,harmony.at(boundary).chord,boundary=boundary)
def joint_candidates(phrases,incoming,harmony,tempo,context,*,bar=F(4)):
"""Expression alternatives compete BEFORE complete-phrase selection.
This rewrites provisional gestures, never the selected chorus. The direct
realization remains available and every alternative uses the same evaluator.
"""
from generator.solo import realization, sequences, phrasing, groups
result=list(phrases)
trace=context.diagnostics.setdefault('expression_operations',{})
for phrase in phrases:
proposed={}
for actual,c in proposals(phrase,harmony):
counts=trace.setdefault(c.kind,dict(proposed=0,valid=0,retained=0,rejected_by_rule=0))
counts['proposed']+=1
old_pattern=tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in phrase.draft.notes)
new_pattern=tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in actual)
if groups.valid(old_pattern) and not groups.valid(new_pattern):
counts['rejected_by_rule']+=1;continue
if not drafts.contract_valid(phrase.draft,actual)[0]:
counts['rejected_by_rule']+=1;continue
checked=harmony.evaluate(actual,until=phrase.plan.end,complete=True,expressions=(c,))
if not checked.valid:
counts['rejected_by_rule']+=1;continue
counts['valid']+=1
prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None)
join=abs(prior.pitch.number()-c.events[0].pitch.number()) if prior else 0
rank=max(0,join-5)+bool(prior and prior.pitch==c.events[0].pitch)
proposed.setdefault(c.kind,[]).append((rank,actual,c))
# One representative per treatment kind, then a bounded second sweep.
options=sequences.diverse([v for values in proposed.values() for v in values],
lambda v:(v[0],policy.stable_rank(phrase.plan.seed,v[2].id)),lambda v:v[2].kind,8)
for _,actual,c in options:
transforms=tuple(dataclasses.replace(t,
events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end))
for t in phrase.draft.transformations)
changed=dataclasses.replace(phrase.draft,id=phrase.draft.id+':'+c.rule,
notes=actual,expressions=(c,),transformations=transforms)
candidate=realization.checked_phrase(phrase.plan,changed,actual,incoming.treatments,incoming,harmony,tempo,phrase.edits)
if candidate is None:continue
trace[c.kind]['retained']+=1
state=dataclasses.replace(phrase.state,notes=candidate.state.notes,last=candidate.state.last,
motifs=candidate.state.motifs,travel=candidate.state.travel)
history=incoming.history
for t in transforms:
ns=tuple(n for n in actual if n.event_id in t.events)
if ns:history=phrasing.remember(history,phrasing.heard(ns,t.operation,bar=bar),bar)
state=dataclasses.replace(state,history=history)
kept_routes=tuple(r for r in phrase.routes if all(any((n.event_id,n.start,n.end,n.pitch)==
(a.event_id,a.start,a.end,a.pitch) for a in actual) for n in r.notes))
original=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes))
result.append(dataclasses.replace(candidate,state=state,initial=phrase.initial,
refined=phrase.refined,before_rhythm=phrase.before_rhythm,before_enclosure=original,routes=kept_routes))
return result
def refine(solo,enabled=True):
+39 -1
View File
@@ -4,7 +4,44 @@ from __future__ import annotations
import dataclasses
from fractions import Fraction as F
from generator.solo import drafts, harmonic, types
from generator.solo import drafts, harmonic, types, groups
def join_variants(draft,harmony,library):
"""Recompose both sides of an editable internal join as one complete route.
Keep endpoints and exact span. A provisional held tail can reopen into two
binary attacks; a committed hold or identifying event is never consumed.
Alternatives are returned to the authoritative phrase evaluator.
"""
from generator.solo import routes, policy
fixed=set(draft.protected)|set(draft.structural)|{e for f in draft.features for e in f.events}
produced=0
for join in (t.start for t in draft.transformations[1:]):
inside=tuple(n for n in draft.notes if join-2<=n.start and n.end<=join+2)
if len(inside)<3 or any(n.event_id in fixed or n.role=='quote' for n in inside[1:-1]):continue
if any(t.start<inside[-1].end and t.end>inside[0].start for t in draft.sustained_tails):continue
pattern=tuple((n.start-inside[0].start,n.end-inside[0].start) for n in inside)
if not groups.valid(pattern):continue
alternatives=[inside]
for i,n in enumerate(inside[1:-1],1):
if n.end==join and n.end-n.start>=1 and (n.start*2).denominator==1:
cut=n.end-F(1,2)
alternatives.append((*inside[:i],dataclasses.replace(n,end=cut),
dataclasses.replace(n,start=cut,event_id=n.event_id+':extend'),*inside[i+1:]))
break
for template in alternatives:
for route in routes.candidates(template,{template[-1].event_id},harmony,library,harmony.profile.register,'two-sided join'):
if route.notes==inside:continue
actual=tuple(n for n in draft.notes if n.end<=inside[0].start)+route.notes+tuple(n for n in draft.notes if n.start>=inside[-1].end)
if not drafts.contract_valid(draft,actual)[0] or not harmony.evaluate(actual,until=draft.end,complete=True).valid:continue
transforms=tuple(dataclasses.replace(t,events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end)) for t in draft.transformations)
changed=dataclasses.replace(draft,id=draft.id+':join:'+route.id,notes=actual,transformations=transforms)
yield changed,types.DraftEdit('two_sided_join',inside[0].start,inside[-1].end,
tuple(n.event_id for n in inside),tuple(n.event_id for n in route.notes),'complete route over both sides; endpoints and contracts preserved'),route
produced+=1
if produced>=policy.DEFAULT.join_alternatives:return
def fragmentation(notes):
@@ -71,6 +108,7 @@ def revise(draft, harmony, treatments=()):
if len(proposals)>=8: break
valid=[]
for actual in proposals[:8]:
if not groups.valid(tuple((n.start-draft.start,n.end-draft.start) for n in actual)):continue
if fragmentation(actual)>=fragmentation(notes): continue
if not drafts.contract_valid(draft,actual)[0]: continue
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True).valid: continue
+68
View File
@@ -0,0 +1,68 @@
"""Exact rhythmic groups for generated material; imported music is not quantized.
Binary boundaries may be displaced together. Tuplet runs must return to their
entry subdivision phase before binary material resumes. A search boundary is
not a rhythmic boundary and must not be used to clip a group.
"""
from __future__ import annotations
import dataclasses
from fractions import Fraction as F
@dataclasses.dataclass(frozen=True)
class RhythmGroup:
start: F
end: F
family: str
events: tuple[tuple[F, F], ...]
displacement: F = F()
source: str = ''
def describe(pattern, *, origin=F(), source='', imported=False):
if not pattern:return ()
if imported:
return (RhythmGroup(origin, origin+pattern[-1][1], 'source', pattern, source=source),)
result=[]; i=0
while i<len(pattern):
a,b=pattern[i]
if b<=a or i and a<pattern[i-1][1]:raise ValueError('overlapping rhythm group')
# A complete ternary figure can be intentionally displaced on an eighth.
if (b-a).denominator%3==0:
if (a*2).denominator!=1:raise ValueError('unexplained tuplet entry phase')
start=i; cursor=a
while i<len(pattern):
x,y=pattern[i]
if x!=cursor or (y-x).denominator not in (3,6):break
cursor=y;i+=1
if (cursor-a).denominator==1:break
if i==start or (cursor-a).denominator!=1:
raise ValueError('incomplete tuplet group')
group=pattern[start:i]
result.append(RhythmGroup(origin+a,origin+cursor,'triplet',group,a%1,source))
else:
if (a*4).denominator!=1 or (b*4).denominator!=1:
raise ValueError('unexplained binary subdivision phase')
family='sixteenth' if (a*2).denominator!=1 or (b*2).denominator!=1 else 'binary'
result.append(RhythmGroup(origin+a,origin+b,family,((a,b),),a%1,source));i+=1
return tuple(result)
def valid(pattern):
try:describe(pattern);return True
except ValueError:return False
def signature(notes):
"""Translation-invariant duration, inter-onset and rest vocabulary."""
return tuple((n.end-n.start, notes[i+1].start-n.end if i+1<len(notes) else F())
for i,n in enumerate(notes))
def broaden(pattern, arrival, release):
"""Reopen an editable binary tail; never slice a tuplet or inherit its phase."""
if (arrival*2).denominator!=1 or (release*2).denominator!=1:return ()
for group in describe(pattern):
if group.family=='triplet' and group.start<arrival<group.end:return ()
result=tuple((a,min(b,arrival)) for a,b in pattern if a<arrival)+((arrival,release),)
return result if valid(result) else ()
+5 -5
View File
@@ -150,15 +150,15 @@ class Harmony:
def evaluate(self, notes, treatments=(), *, until=None, complete=False, enclosures=(), expressions=()):
notes = generator.solo.types.sounding_notes(notes)
from generator.solo.expressions import validate
permissions, expression_error = validate(notes, expressions, self)
if expression_error:
return Result(False, 0, error=expression_error)
from generator.solo.expressions import validate_frontier
explicit_frontier=until is not None
until = until if until is not None else max((n.end for n in notes), default=fractions.Fraction())
permissions, expression_pending, expression_error = validate_frontier(notes,expressions,self,until,complete or not explicit_frontier)
if expression_error:return Result(False,0,error=expression_error)
ornaments, error = self.enclosure_events(notes, enclosures, until) if enclosures else ({}, '')
if error:
return Result(False, 0, error=error)
pending, valid_contracts = [], []
pending, valid_contracts = list(expression_pending), []
for treatment in treatments:
if treatment.start >= until:
continue
+15 -11
View File
@@ -27,13 +27,17 @@ def shift(notes, octave):
def available(harmony, start, end, *, members=False):
span = harmony.at(start)
allowed = types.permitted(harmony.choices, start, end)
if members:
allowed &= harmony.members(start, end)
if not span or not span.scale:
return ()
return tuple(p for i in range(-35, 28) if harmony.profile.register[0] <= (p := types.scale_pitch(span.scale, i)).number() <= harmony.profile.register[1] and p.number()%12 in allowed and placement.positions(p.number(),harmony.profile))
if not hasattr(harmony,'available_pitches'):harmony.available_pitches={}
span=harmony.at(start)
allowed=types.permitted(harmony.choices,start,end)
if members:allowed &= harmony.members(start,end)
if not span or not span.scale:return ()
key=(span.scale,allowed,harmony.profile)
if key not in harmony.available_pitches:
harmony.available_pitches[key]=tuple(p for i in range(-35,28)
if harmony.profile.register[0]<=(p:=types.scale_pitch(span.scale,i)).number()<=harmony.profile.register[1]
and p.number()%12 in allowed and placement.positions(p.number(),harmony.profile))
return harmony.available_pitches[key]
def adapt(notes, harmony):
@@ -350,6 +354,7 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
if elapsed>start:
meter=measure['time_signature'];break
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
harmony.bar=bar
for source in sources:
key=('phrasing',source,incoming,part,end,stage,seed,id(library),id(rhythms),policy.DEFAULT,harmony.profile)
if key in context.results:
@@ -367,11 +372,10 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
context.results[key]=pool
context.diagnostics.setdefault('source_candidate_pools',{}).setdefault(source.id,[]).extend(
{'id':p.draft.id,'score':p.score,'attacks':len(p.draft.notes),'theme':p.state.home.id if p.state.home else None,
'ending':str(p.anchors[-1].duration) if p.anchors else None,'returns':len(p.state.returns)} for p in pool)
'ending':str(p.anchors[-1].duration) if p.anchors else None,'returns':len(p.state.returns),'evaluation':dataclasses.asdict(p.evaluation) if p.evaluation else None} for p in pool)
for candidate in pool:
span=float((source.end-source.start)/end)
total=cost+span*(candidate.score+phrasing.endpoint_cost(source.end,start,end,incoming.closures,harmony,bar))
total+=transition(history[-1] if history else None,candidate)
total=cost+span*candidate.score
following.append((total,(*history,candidate)))
if not pool:failures.append(f'{source.id}: planned sustained destination, feature or incoming continuation')
if not following:
@@ -385,7 +389,7 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
finished=[(cost,history) for cost,history in states if history[-1].plan.end==end]
if not finished:raise realization.SearchExhausted(f'{part}: phrase-boundary search budget exhausted; not proof of incompatibility')
return tuple((rank((cost,phrases))[0],types.Solo(part,choices,phrases,analysis.tempo,end,
{**context.diagnostics,'construction':'thematic_recomposition','policy':policy.describe()['fingerprint']},
{**context.diagnostics,'selection_total':rank((cost,phrases))[0], 'selection_scope':'duration-weighted complete phrase evaluations; partial frontiers use elapsed-duration rates', 'construction':'thematic_recomposition','policy':policy.describe()['fingerprint']},
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases),harmony.profile)) for cost,phrases in finished)
+9 -15
View File
@@ -57,9 +57,13 @@ def register_goal(incoming, part, fallback, profile=None):
if profile and profile.reference is not None:
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
center = round(sum(recent)/len(recent)) if recent else profile.reference
trajectory=generator.vocabulary.role(part+'_solo').get('register_motion',[0,4,-3,0])
phase=min(len(trajectory)-1,incoming.closures%len(trajectory))
goal=profile.reference+trajectory[phase]
# Follow a whole-gesture excursion with a possible return, not an
# instrument-specific octave or a repeating register cycle.
if center>profile.reference+7:goal=profile.reference
elif center<profile.reference-7:goal=profile.reference
else:
motion=incoming.direction*4 if incoming.home else 0
goal=profile.reference+motion
return max(profile.register[0]+3,min(profile.register[1]-3,round((center+goal)/2)))
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
if not recent:
@@ -127,7 +131,7 @@ def endings(source, incoming, part, harmony, bar, *, limit=8, final=False):
for pitch in available(harmony,arrival,arrival+hold,members=True):
if abs(pitch.number()-goal)>12:continue
anchor=types.Anchor(f'{source.id}:close:{arrival}:{pitch.number()}',arrival,arrival,pitch,
harmony.members(arrival,arrival+hold),harmony.profile.register,hold,'terminal',harmony.at(arrival).chord,
harmony.members(arrival,arrival+hold),harmony.profile.register,hold,'terminal' if final or ending_effect(arrival,harmony)=='cadential_arrival' else 'intermediate',harmony.at(arrival).chord,
hold_until=arrival+hold,effect=ending_effect(arrival,harmony,final))
cadence=sum(h.ending==(pitch.number()%12,arrival%bar,hold) for h in incoming.history)
rank=harmony.profile.cost(pitch.number())+destination_cost(pitch,arrival,arrival+hold,harmony,incoming,goal,True)+.16*min(2,cadence)+.035*abs(float(hold)-3)
@@ -145,6 +149,7 @@ def appearance_cost(operation, incoming, start, end):
def activity(source, ending):
if ending is None:return (types.ActivitySpan(source.start,source.end,'active',F(5,4),F()),)
spans=[]
if ending.earliest>source.start:
spans.append(types.ActivitySpan(source.start,ending.earliest,'active',F(5,4),F()))
@@ -154,17 +159,6 @@ def activity(source, ending):
return tuple(spans)
def evaluate(notes, incoming, harmony, start, end, bar):
"""Final musical cost uses a complete sounding phrase, never cell totals."""
notes=types.sounding_notes(notes)
checked=harmony.evaluate(notes,until=end,complete=True)
if not checked.valid or not notes:return float('inf')
density=4*len(notes)/float(end-start)
entry=max(0,abs(notes[0].pitch.number()-incoming.last.pitch.number())-5)/12 if incoming.last else 0.
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1)
history_cost=repetition(heard(notes,'develop'),incoming.history,bar)
return harmony.profile.cost(profiles.weighted_center(notes))+2*checked.exposure+entry+leaps+.8*history_cost
@functools.lru_cache(maxsize=512)
def defining_members(symbol):
+1 -1
View File
@@ -50,7 +50,7 @@ class Policy:
enclosure_routes: int = 4
enclosure_frontier: int = 8
enclosure_max_per_32_bars: int = 4
composition_version: str = 'purposeful-phrases-2'
composition_version: str = 'joint-gestures-1'
# Preserve the former default root seeds while decoupling them from policy edits.
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
+6 -7
View File
@@ -47,10 +47,9 @@ def for_part(part, analysis=None):
strings = (1, 2, 3, 4, 5, 6)
if analysis is None or not analysis.melody:
return Instrument(part, strings)
# Analysis contains the prepared concert source. Electric melody is written
# two octaves up and sounds one down (parts.build_measure), hence +12.
# Use the source voice, never the highest added harmonization voice.
shift = generator.vocabulary.role(part + '_solo').get('register_offset', 12 if part == 'electric' else 0)
notes = tuple(n for n in analysis.melody if n.end > analysis.body_offset)
return Instrument(part, strings, weighted_center(notes) + shift,
(min(n.pitch.number() for n in notes) + shift, max(n.pitch.number() for n in notes) + shift))
# Source identity is independent of sounding register. Roles have soft,
# overlapping centers; all legal positions remain available to both guitars.
center=generator.vocabulary.role(part+'_solo').get('register_center',57 if part=='acoustic' else 62)
notes=tuple(n for n in analysis.melody if n.end>analysis.body_offset)
return Instrument(part,strings,center,
(min(n.pitch.number() for n in notes),max(n.pitch.number() for n in notes)))
+35 -29
View File
@@ -510,39 +510,45 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit
except ValueError:
return None
complete = plan.end == (plan.passage_end or plan.end)
checked = harmony.evaluate(canonical, contracts, until=plan.end, complete=complete, enclosures=draft.enclosures)
checked = harmony.evaluate(canonical, contracts, until=plan.end, complete=complete, enclosures=draft.enclosures, expressions=draft.expressions)
valid, _ = generator.solo.harmonic.anchors_valid(draft.anchors, canonical, plan.end, complete=complete, closure=plan.closure)
if not checked.valid or not valid or not target_ok(notes, plan.target):
return None
shared = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
score_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
evaluation_key = (plan, draft.anchors)
if evaluation_key not in harmony.evaluations:
harmony.evaluations[evaluation_key] = generator.solo.scoring.Evaluation(evaluation_plan(plan, draft.anchors), harmony.choices, plan.end)
evaluation = harmony.evaluations[evaluation_key]
if not evaluation.feasible(score_notes):
return None
features = evaluation.features(score_notes, previous=incoming.last if not shared else None, movement=travel + (incoming.travel if shared else 0))
features.update(generator.solo.drafts.relationship_features(draft, notes))
if draft.lead_identity or draft.source_phrase:
supported = {e for feature in draft.features for e in feature.events}
motions = [(a, b) for a, b in zip(notes, notes[1:]) if not (a.event_id in supported and b.event_id in supported)]
features['contour'] = sum(min(1, max(0, abs(b.pitch.number() - a.pitch.number()) - 5) / 12) for a, b in motions) / max(1, len(motions))
# Fidelity is checked against explicit transformed identities, not against
# the source head as one undifferentiated phrase-long quotation.
features.pop('fidelity', None)
features['harmonic_exposure'] = checked.exposure
intended_repeats = 0
if draft.home:
reference = draft.home.notes
for transform in draft.transformations:
actual = tuple(n for n in notes if n.event_id in transform.events)
for i, (a, b, c) in enumerate(zip(actual, actual[1:], actual[2:])):
expected = tuple(reference[j % len(reference)].pitch for j in (i, i + 1, i + 2)) if reference else ()
if a.pitch == b.pitch == c.pitch and len(expected) == 3 and expected[0] == expected[1] == expected[2]:
intended_repeats += 1
features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(score_notes) - 2))
score = generator.solo.scoring.value(features)
musical = None
if plan.treatment == 'thematic_recomposition':
musical = generator.solo.scoring.musical(plan,draft,notes,incoming,harmony,travel=travel)
if not musical.valid:return None
score = musical.total
else:
evaluation_key = (plan, draft.anchors)
if evaluation_key not in harmony.evaluations:
harmony.evaluations[evaluation_key] = generator.solo.scoring.Evaluation(evaluation_plan(plan, draft.anchors), harmony.choices, plan.end)
evaluation = harmony.evaluations[evaluation_key]
if not evaluation.feasible(score_notes):
return None
features = evaluation.features(score_notes, previous=incoming.last if not shared else None, movement=travel + (incoming.travel if shared else 0))
features.update(generator.solo.drafts.relationship_features(draft, notes))
if draft.lead_identity or draft.source_phrase:
supported = {e for feature in draft.features for e in feature.events}
motions = [(a, b) for a, b in zip(notes, notes[1:]) if not (a.event_id in supported and b.event_id in supported)]
features['contour'] = sum(min(1, max(0, abs(b.pitch.number() - a.pitch.number()) - 5) / 12) for a, b in motions) / max(1, len(motions))
# Fidelity is checked against explicit transformed identities, not against
# the source head as one undifferentiated phrase-long quotation.
features.pop('fidelity', None)
features['harmonic_exposure'] = checked.exposure
intended_repeats = 0
if draft.home:
reference = draft.home.notes
for transform in draft.transformations:
actual = tuple(n for n in notes if n.event_id in transform.events)
for i, (a, b, c) in enumerate(zip(actual, actual[1:], actual[2:])):
expected = tuple(reference[j % len(reference)].pitch for j in (i, i + 1, i + 2)) if reference else ()
if a.pitch == b.pitch == c.pitch and len(expected) == 3 and expected[0] == expected[1] == expected[2]:
intended_repeats += 1
features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(score_notes) - 2))
score = generator.solo.scoring.value(features)
motifs = list(incoming.motifs) if shared else []
decisions = []
for transform in draft.transformations:
@@ -568,7 +574,7 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit
(notes[-1].pitch.number() > notes[0].pitch.number()) - (notes[-1].pitch.number() < notes[0].pitch.number()) if notes else incoming.direction,
last, 0, (), travel + (incoming.travel if shared else 0), home, motifs[-1] if motifs else incoming.development, themes, draft.anchors, plan.ending)
actual_plan = dataclasses.replace(plan, relationship=draft.relationship)
return generator.solo.types.Phrase(actual_plan, tuple(decisions), score, state, draft.anchors, draft, edits)
return generator.solo.types.Phrase(actual_plan, tuple(decisions), score, state, draft.anchors, draft, edits, evaluation=musical)
def legacy_target_plan(plan, choices):
if not plan.target or plan.anchor_options:
+10 -4
View File
@@ -16,13 +16,13 @@ import generator.solo.similarity
import generator.solo.features
import generator.solo.placement
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> str:
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0, *, detail=None) -> str:
region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo')
positioned = tuple(dataclasses.replace(c, span=dataclasses.replace(c.span, start=c.span.start + time_offset, end=c.span.end + time_offset, measure=c.span.measure + measure_offset)) for c in solo.choices)
lines = [generator.scales.report.passage(solo.part, region, positioned, scales), f'Phrase composition: {solo.part}', f'Placement reference tempo: {solo.tempo} quarter notes/minute. Runtime tempo changes are not a comfort guarantee.', 'Times below are quarter-note offsets from the start of this solo; pitches are sounding.']
if solo.diagnostics:
lines.append('Composition diagnostics: ' + json.dumps({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}, sort_keys=True))
detail = structure(solo)
lines.append('Composition diagnostics: ' + json.dumps({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}, sort_keys=True,default=str))
detail = structure(solo) if detail is None else detail
lines.append("Sustained phrasing: " + json.dumps(detail["phrasing"], sort_keys=True))
for passage in detail['passages']:
lines.append(f"Melodic passage {passage['id']}: {passage['start']}-{passage['end']}; phrases {', '.join(passage['phrases'])}")
@@ -126,9 +126,15 @@ def structure(solo):
entry['head_comparison_elaborated']=generator.solo.similarity.compare(tuple(n for d in phrase.decisions for n in d.notes),local_source)
if phrase.state and phrase.state.home:
entry['theme_relationship']=generator.solo.features.relationship(phrase.refined.notes,phrase.state.home)
entry['evaluation'] = rational(dataclasses.asdict(phrase.evaluation)) if phrase.evaluation else None
from generator.solo import groups
try:
entry['rhythm_groups']=rational([dataclasses.asdict(g) for g in groups.describe(tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in phrase.draft.notes),origin=phrase.plan.start)]) if phrase.draft else []
except ValueError as error:
entry['rhythm_groups']=[{'family':'source','reason':str(error)}]
entry['score'] = phrase.score
phrases.append(entry)
return {'instrument_profile': rational(dataclasses.asdict(solo.profile)) if solo.profile else None, 'phrasing': phrasing_report(solo), 'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'expressions': rational([dataclasses.asdict(e) for e in expressions]), 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures,expressions), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
return {'schema':'composer-report-2','selection_trace':rational({k:v for k,v in solo.diagnostics.items() if not k.endswith('_seconds')}), 'instrument_profile': rational(dataclasses.asdict(solo.profile)) if solo.profile else None, 'phrasing': phrasing_report(solo), 'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'expressions': rational([dataclasses.asdict(e) for e in expressions]), 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures,expressions), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
def phrasing_report(solo):
+6 -1
View File
@@ -89,7 +89,7 @@ def _candidates(notes, fixed, harmony, library, register, purpose='connect'):
if not span or not span.scale:
return ()
contexts=tuple((harmony.at(n.start).scale if harmony.at(n.start) else None,
types.permitted(harmony.choices,n.start,n.end),
types.permitted(harmony.choices,n.start,n.end) & (harmony.members(n.start,n.end) if n.end-n.start>=2 else frozenset(range(12))),
n.pitch.number() if n.event_id in fixed or i==len(notes)-1 else None) for i,n in enumerate(notes))
if not hasattr(harmony,'pitch_routes'):harmony.pitch_routes={}
key=(first.pitch,contexts,id(library),register,harmony.profile)
@@ -175,6 +175,11 @@ def pitch_paths(first, contexts, library, register, profile=None):
low,high=generator.fretboard.single_register((register[0]-7,register[1]+5))
low, high = max(low, profile.register[0]), min(high, profile.register[1])
result=[]
# Reattacks are an explicit primitive, separate from interval catalog paths
# (whose zero steps are deliberately excluded). Tied holds remain one event.
if all(first.number()%12 in allowed and (fixed is None or fixed==first.number())
for _,allowed,fixed in contexts):
result.append(((first,)*len(contexts),('primitive:repeated_attack',)))
for path in paths:
realized=[first]
for (scale,allowed,fixed),step in zip(contexts[1:],path.steps):
+73
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import fractions
import dataclasses
import generator.solo.policy
import generator.solo.types
@@ -116,3 +117,75 @@ def value(features):
def feasible(plan, notes, choices, until):
return Evaluation(plan, choices, until).feasible(notes)
@dataclasses.dataclass(frozen=True)
class MusicalEvaluation:
version: str
scope: tuple
valid: bool
pending: tuple
raw: dict
denominators: dict
weights: dict
contributions: dict
total: float
error: str = ''
MUSICAL_WEIGHTS = {'rhythm':3., 'destination':2., 'harmony':2.,
'register':1., 'repetition':1., 'activity':1., 'difficulty':.5}
def musical(plan, draft, notes, incoming, harmony, *, travel=0, weights=None, bar=None):
"""The sole final evaluator of the active thematic composer.
Scores are normalized on complete sounding events, never notation fragments.
Contracts are validity requirements, not additional similarity rewards.
Retrieval estimates are deliberately not added to this result.
"""
from generator.solo import drafts, groups, harmonic, phrasing, types
notes=types.sounding_notes(notes)
bar=getattr(harmony,'bar',fractions.Fraction(4)) if bar is None else bar
from generator import vocabulary
weights=dict(vocabulary.data('grammar').get('evaluation',{}).get('weights',MUSICAL_WEIGHTS) if weights is None else weights)
if set(weights)!=set(MUSICAL_WEIGHTS):raise ValueError('unknown or missing evaluation contribution')
checked=harmony.evaluate(notes, incoming.treatments, until=plan.end, complete=True,
enclosures=draft.enclosures, expressions=draft.expressions)
identity,identity_error=drafts.contract_valid(draft,notes)
anchors,anchor_error=harmonic.anchors_valid(draft.anchors,notes,plan.end,complete=True,closure=plan.closure)
feasible=Evaluation(plan,harmony.choices).feasible(notes)
valid=checked.valid and identity and anchors and feasible
error=checked.error or identity_error or anchor_error or ('' if feasible else 'activity intent not fulfilled')
span=float(plan.end-plan.start); sounded=sum(float(n.end-n.start) for n in notes)
islands=sum(a.end<b.start and b.end<c.start and b.end-b.start<=fractions.Fraction(1,2)
for a,b,c in zip(notes,notes[1:],notes[2:]))
# Rests at the edges can be entrance/release. Long internal gaps need a
# completed gesture, rather than receiving a universal occupancy penalty.
settled={a.hold_until for a in draft.anchors}
broken=sum(max(0,float(b.start-a.end)-1) for a,b in zip(notes,notes[1:])
if a.end not in settled and a.end-a.start<2)
entry=max(0,abs(notes[0].pitch.number()-incoming.last.pitch.number())-7)/12 if notes and incoming.last else 0.
rhythm=min(1.,islands/max(1,len(notes)-2)+broken/max(1,span)+entry/2)
# Explicit source groups retain their imported timing contract. Generated
# groups are checked before construction, not retroactively quantized here.
destinations=[]
for anchor in draft.anchors:
approach=[n for n in notes if n.start<anchor.earliest][-3:]
if approach:
distances=[abs(n.pitch.number()-anchor.pitch.number()) for n in approach]
# A broad approach may turn; large last-second jumps are a preference,
# not a nearest-note mandate for all intermediate events.
destinations.append(min(1,max(0,distances[-1]-5)/12))
goal=harmony.profile.reference
register=sum(float(n.end-n.start)*min(1,max(0,abs(n.pitch.number()-goal)-9)/12)
for n in notes)/max(sounded,.001) if goal is not None else 0.
history=phrasing.repetition(phrasing.heard(notes,draft.relationship),incoming.history,bar) if notes else 0.
activity=Evaluation(plan,harmony.choices).features(notes)['activity']
raw={'rhythm':rhythm,'destination':sum(destinations)/max(1,len(destinations)),
'harmony':checked.exposure,'register':register,'repetition':min(1,history),
'activity':activity,'difficulty':min(1,travel/max(1,len(notes))/2000)}
contributions={name:weights[name]*value for name,value in raw.items()}
return MusicalEvaluation('joint-musical-1',(plan.start,plan.end),valid,checked.pending,raw,
{'span':span,'sounding':sounded,'attacks':len(notes),'destinations':len(destinations)},weights,
contributions,sum(contributions.values()) if valid else float('inf'),error)
+12 -3
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import dataclasses
import pathlib
import types
import functools
import generator.solo.policy
@@ -13,6 +14,7 @@ import generator.musicxml
class Sequence:
id: str
steps: tuple[int, ...]
@functools.lru_cache(maxsize=32768)
def offsets(self) -> tuple[int, ...]:
result = [0]
for step in self.steps:
@@ -26,12 +28,14 @@ class Sequence:
class Path:
steps: tuple[int, ...]
sources: tuple[str, ...]
@functools.lru_cache(maxsize=32768)
def offsets(self):
result = [0]
for step in self.steps:
result.append(result[-1] + step)
return tuple(result)
@functools.lru_cache(maxsize=32768)
def family(steps):
if not steps or all(s == 0 for s in steps):
return 'repeated'
@@ -47,14 +51,19 @@ def family(steps):
def diverse(items, rank, category, limit):
"""Round-robin eligible musical families before a destructive cutoff."""
groups = {}
for item in sorted(items, key=rank):
groups.setdefault(category(item), []).append(item)
# Rank once: hashing full event provenance and classifying interval paths
# used to be repeated for every retained layer. Preserve stable tie order.
decorated = sorted(((rank(item), i, item) for i,item in enumerate(items)),key=lambda v:v[:2])
for value in decorated:
groups.setdefault(category(value[2]), []).append(value)
result, index = [], 0
while len(result) < limit:
layer = [values[index] for values in groups.values() if len(values) > index]
if not layer:
break
result.extend(sorted(layer, key=rank)[:limit - len(result)])
# Equal ranks retain category insertion order, just as the original stable
# layer sort did; the input index is only for the initial sort.
result.extend(v[2] for v in sorted(layer,key=lambda v:v[0])[:limit-len(result)])
index += 1
return tuple(result)
+150 -167
View File
@@ -11,7 +11,7 @@ from fractions import Fraction as F
import generator.model
import generator.harmony
import generator.vocabulary
from generator.solo import types, policy, sequences, routes, features as references, similarity, phrasing, profiles
from generator.solo import types, policy, sequences, routes, features as references, similarity, phrasing, profiles, groups as rhythm_groups
def source_notes(analysis):
@@ -71,13 +71,16 @@ def seeds(analysis, bar, seed):
identity=policy.stable_rank(seed,(group,treatment))[:12]
groups.append(types.SourceReference(identity,parent,group[0].start,group[-1].end,local,treatment))
def rank(ref):
rhythm={(n.start,n.end-n.start) for n in ref.notes}
rhythm=set(rhythm_groups.signature(ref.notes))
intervals={b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])}
preferred=generator.vocabulary.role().get('preferred_features',[])
preference=preferred.index(ref.treatment) if ref.treatment in preferred else 0
turn=any(a*b<0 for a,b in zip(ref.notes and tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])),tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:]))[1:]))
return (-min(4,len(intervals))-.3*turn+.15*preference,-min(3,len(rhythm)),ref.id)
return sequences.diverse(groups,rank,lambda r:(r.treatment,int(max(0,r.start)//(4*bar))),policy.DEFAULT.theme_seed_limit)
# Preserve different contracts first. Combining treatment with form position
# created many categories of the preferred treatment and could crowd every
# rhythm/contour seed out before a single pitch was constructed.
return sequences.diverse(groups,rank,lambda r:r.treatment,policy.DEFAULT.theme_seed_limit)
def feature(reference, actual, *, treatment=None):
@@ -87,7 +90,7 @@ def feature(reference, actual, *, treatment=None):
'repeat_with_harmonic_adaptation':('rhythm','contour'),'answer':('contour',),
'extend':('contour',),'fragment':('rhythm',),'callback':('rhythm','pitches'),
'return':('rhythm','intervals'), 'develop_rhythm':('rhythm',),
'develop_contour':('intervals',), 'continue':('contour',)}[treatment]
'develop_contour':('intervals',), 'continue':(), 'connect':(), 'extension':('contour',)}[treatment]
original=reference.notes
authored=next((r for r in generator.vocabulary.data('grammar')['transformations'] if r['id']==treatment),None)
if authored:required=tuple(authored['preserve'])
@@ -103,7 +106,7 @@ def rhythmic_purpose(operation, terminal=False):
if terminal:return 'arrival'
if operation in ('introduce','callback','return','repeat_with_harmonic_adaptation'):return 'entrance'
if operation in ('answer','fragment'):return 'answer'
return 'connect' if operation=='continue' else 'continue'
return 'connect' if operation in ('continue','connect') else 'continue'
def rhythm_cost(pattern,length,purpose):
@@ -145,12 +148,12 @@ def rhythm_windows(library,meter,length,context,purpose='continue'):
for offset in sorted({F(),*(a for a,_ in pattern)}):
if offset+length>total or any(a<offset<b or a<offset+length<b for a,b in pattern):continue
selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length)
if selected and len(selected)<=16:patterns.setdefault(selected,identifier)
if selected and len(selected)<=16 and rhythm_groups.valid(selected):patterns.setdefault(selected,identifier)
# Authored cells can start late and continue over computational barlines.
for cell in generator.vocabulary.rhythm_cells(tempo,purpose):
identifier,pattern,total=cell.id,cell.pattern,cell.duration
if total>length:continue
for offset in (F(),F(1,2),max(F(),length-total)):
for offset in ((F(),) if cell.entry=='pickup' else (F(),F(1,2),max(F(),length-total))):
if offset+total>length:continue
head=tuple((a+offset,b+offset) for a,b in pattern)
# Keep representative compatible continuations; never let dictionary
@@ -159,8 +162,10 @@ def rhythm_windows(library,meter,length,context,purpose='continue'):
lambda p:(len(p),p[-1][1]-p[-1][0]),2) or ((),)
for tail in tails:
if any(a<offset+total<b for a,b in tail):continue
combined=(*head,*((a,b) for a,b in tail if a>=offset+total))
if 2<=len(combined)<=16:patterns.setdefault(combined,'authored:'+identifier)
continuation=tuple((a,b) for a,b in tail if a>=offset+total)
if cell.exit=='continue' and offset+total<length and (not continuation or continuation[0][0]!=offset+total):continue
combined=(*head,*continuation)
if 2<=len(combined)<=16 and rhythm_groups.valid(combined):patterns.setdefault(combined,'authored:'+identifier)
context.pools[key]=sequences.diverse(tuple(patterns.items()),
lambda x:(rhythm_cost(x[0],length,purpose)+(0 if x[1].startswith('authored:') else .05),policy.stable_rank('rhythm-window',x[0])),
lambda x:(x[1].startswith('authored:'),len(x[0]),x[0][0][0],any(a[1]!=b[0] for a,b in zip(x[0],x[0][1:]))),8)
@@ -193,7 +198,7 @@ def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operatio
if b-a==1:
values[(*original[:i],(a,a+F(1,2)),(a+F(1,2),b),*original[i+1:])]=label+'; diminution'
for i,((a,b),(c,d)) in enumerate(zip(original,original[1:])):
if b!=c:continue
if b!=c or any((v*2).denominator!=1 for v in (a,b,c,d)):continue
for split in (a+F(1,2),a+F(3,2),d-F(1,2)):
if a<split<d:
values[(*original[:i],(a,split),(split,d),*original[i+2:])]=label+'; rhythmic alteration'
@@ -201,10 +206,12 @@ def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operatio
arrivals={}
arrival=ending.earliest-start
for original,label in values.items():
head=tuple((a,min(b,arrival)) for a,b in original if a<arrival)
if len(head)>=len(ref.notes):
arrivals[(*head,(arrival,ending.hold_until-start))]=label+'; planned sustained close'
try: revised=rhythm_groups.broaden(original,arrival,ending.hold_until-start)
except ValueError: revised=()
if revised and len(revised)>=len(ref.notes):
arrivals[revised]=label+'; planned sounding destination'
values=arrivals
values={p:label for p,label in values.items() if rhythm_groups.valid(p) or fixed_rhythm and p==prefix}
return sequences.diverse(tuple(values.items()),lambda x:(rhythm_cost(x[0],length,purpose),policy.stable_rank(ref.id,x[0])),
lambda x:(len(x[0]),tuple(b-a for a,b in x[0][:3]),x[0][0][0]),policy.DEFAULT.theme_rhythm_alternatives)
@@ -233,61 +240,6 @@ def relative_steps(notes,harmony):
return tuple(result)
def instantiate(templates,first,steps,harmony):
result=[dataclasses.replace(templates[0],pitch=first)]
for note,step in zip(templates[1:],steps):
span=harmony.at(note.start)
if not span or not span.scale:return ()
options=routes.advance(span.scale,result[-1].pitch.number(),step)
if not options:return ()
p=options[0]
if not harmony.profile.permits(p.number()) or p.number()%12 not in types.permitted(harmony.choices,note.start,note.end):return ()
result.append(dataclasses.replace(note,pitch=p))
return tuple(result) if len(result)==len(templates) else ()
def prefix_paths(templates,ref,first,harmony,library):
if not hasattr(harmony,'theme_prefixes'):harmony.theme_prefixes={}
key=(templates[:len(ref.notes)],ref,first,id(library))
if key not in harmony.theme_prefixes:
harmony.theme_prefixes[key]=_prefix_paths(templates,ref,first,harmony,library)
return harmony.theme_prefixes[key]
def _prefix_paths(templates,ref,first,harmony,library):
count=len(ref.notes); original=ref.notes
if count==1:return ((dataclasses.replace(templates[0],pitch=first),),)
if ref.treatment in ('opening_fragment','characteristic_interval','callback'):
delta=first.number()-original[0].pitch.number()
if ref.treatment in ('opening_fragment','callback') and delta%12:return ()
result=[]
from generator.solo.leads import available
for template,n in zip(templates,original):
target=n.pitch.number()+delta
p=next((p for p in available(harmony,template.start,template.end,members=template.end-template.start>=2) if p.number()==target),None)
if p is None:return ()
result.append(dataclasses.replace(template,pitch=p))
return (tuple(result),)
paths=[]
if ref.treatment == 'return':
from generator.solo.leads import available
delta=first.number()-original[0].pitch.number()
direct=[]
for template,n in zip(templates,original):
p=next((p for p in available(harmony,template.start,template.end,members=template.end-template.start>=2) if p.number()==n.pitch.number()+delta),None)
if p is None: break
direct.append(dataclasses.replace(template,pitch=p))
if len(direct)==count: paths.append(tuple(direct))
end_template=templates[count-1]
source_motion=original[-1].pitch.number()-original[0].pitch.number()
for target in pitches(harmony,end_template,first.number()+source_motion,limit=4):
template=tuple(dataclasses.replace(n,pitch=first if i==0 else target) for i,n in enumerate(templates[:count]))
paths.extend(r.notes for r in routes.candidates(template,{template[-1].event_id},harmony,library,harmony.profile.register,'seed '+ref.id,purpose='entrance'))
valid=[p for p in paths if not references.feature_error(feature(ref,p),p)]
return sequences.diverse(valid,lambda ns:(routes.path_cost(ns),policy.stable_rank(ref.id,ns)),
lambda ns:routes.path_family(ns,harmony),4)
@dataclasses.dataclass(frozen=True)
class Unit:
notes: tuple[types.Note,...]
@@ -303,81 +255,86 @@ class Unit:
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None):
# History is evaluated by the caller after retrieving these concrete options.
# Every state component used to construct this pool is in its cache key.
if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',(harmony.choices,harmony.profile))
key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,incoming.history,home,operation,ending,goal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT)
"""Joint whole-gesture pitch/rhythm actions, conditioned on the actual entry.
A complete route is proposed before thematic evidence is checked. No motif
prefix is spliced to an independently cheapest suffix. Ordinary search ends
carry no chord-tone obligation; long-note and destination contracts still do.
"""
from generator.solo import leads
key=('joint-gesture-1',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,
home,operation,ending,goal,batch,id(harmony),id(library),id(rhythms))
if key in context.results:
context.diagnostics['cache_hits']+=1;return context.results[key]
result=[]; count=len(ref.notes)
trace=context.diagnostics.setdefault('operations',{}).setdefault(operation,
dict(eligible=0,proposed=0,valid=0,retained=0,jobs=0,pruned_jobs=0,rejected_by_rule=0))
trace['eligible']+=1
require_feature=operation not in ('continue','connect')
count=len(ref.notes) if require_feature else 0
jobs=[]
for pattern,label in patterns(ref,start,finish,rhythms,meter,context,home=home,operation=operation,terminal=terminal,ending=ending):
if len(pattern)<count or len(pattern)>16:continue
templates=tuple(types.Note(start+a,start+b,ref.notes[0].pitch,tuple(s for n in ref.notes for s in n.sources),
'theme',event_id=f'{ref.id}:{start}:{i}') for i,(a,b) in enumerate(pattern))
base=incoming.last.pitch.number() if incoming.last else harmony.profile.reference if harmony.profile.reference is not None else ref.notes[0].pitch.number()
firsts=pitches(harmony,templates[0],base,limit=6 if batch else 3)
targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,members=True,limit=policy.DEFAULT.theme_destinations,incoming=incoming)
if len(pattern)<max(2,count) or len(pattern)>16:continue
provenance=tuple(dict.fromkeys(s for n in ref.notes for s in n.sources))
templates=tuple(types.Note(start+a,start+b,ref.notes[0].pitch,provenance,
'theme' if require_feature else 'connection',event_id=f'{ref.id}:{operation}:{start}:{i}') for i,(a,b) in enumerate(pattern))
base=incoming.last.pitch.number() if incoming.last else harmony.profile.reference or ref.notes[0].pitch.number()
firsts=pitches(harmony,templates[0],base,limit=6)
if incoming.last is None:
offsets=generator.vocabulary.role().get('register_alternatives',[-7,0,7])
firsts=tuple(dict.fromkeys(p for offset in offsets for p in pitches(harmony,templates[0],base+offset,limit=2)))
targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,
members=False,limit=policy.DEFAULT.theme_destinations,incoming=incoming)
for first in firsts:
for target in targets:
# Rank joint jobs, retaining rhythm and destination coverage before
# expensive path instantiation. The budget is spent on complete routes.
rank=harmony.profile.cost(first.number())+max(0,abs(first.number()-base)-5)/12+phrasing.destination_cost(target,templates[-1].start,templates[-1].end,harmony,incoming,goal if goal is not None else base,terminal)
motion=ref.notes[-1].pitch.number()-ref.notes[0].pitch.number()
if operation=='answer':motion=-motion
# Rank purposeful direction separately from entry continuity. No reward
# for minimizing every interval or for repeatedly visiting the midpoint.
rank=max(0,abs(first.number()-base)-7)/12
rank+=.1*min(1,abs((target.number()-first.number())-motion)/12)
jobs.append((rank,templates,label,first,target))
jobs.sort(key=lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4]))))
selected=[];rhythm_seen=set();target_seen=set();entry_seen=set();degrees_seen=set()
for job in jobs:
family=phrasing.destination_family(job[4],job[1][-1].start,harmony)
if family not in degrees_seen:
selected.append(job);degrees_seen.add(family)
for job in jobs:
rhythm=tuple((n.start,n.end) for n in job[1]);target=job[4].number();entry=job[3].number()
if rhythm not in rhythm_seen or target not in target_seen or entry not in entry_seen:
if job not in selected:selected.append(job)
rhythm_seen.add(rhythm);target_seen.add(target);entry_seen.add(entry)
job_limit=policy.DEFAULT.theme_route_jobs*(batch+1)
for job in jobs:
if len(selected)>=job_limit:break
if job not in selected:selected.append(job)
for _,templates,label,first,target in selected[:job_limit]:
proposed=[]
if home and operation in ('repeat_with_harmonic_adaptation','return') and len(templates)==len(home.notes):
heard=instantiate(templates,first,home.degree_steps,harmony)
if heard and heard[-1].pitch==target:proposed.append((heard,()))
for prefix in prefix_paths(templates,ref,first,harmony,library)[:2+batch]:
if len(prefix)==len(templates):
if prefix[-1].pitch==target:proposed.append((prefix,()))
continue
tail=(prefix[-1],*templates[count:])
tail=(*tail[:-1],dataclasses.replace(tail[-1],pitch=target))
for route in routes.candidates(tail,{tail[-1].event_id},harmony,library,harmony.profile.register,operation+' '+ref.id,purpose=rhythmic_purpose(operation,terminal))[:3+batch]:
proposed.append(((*prefix[:-1],*route.notes),(route,)))
seen=set()
for actual,connections in proposed:
identity=tuple(n.pitch.number() for n in actual)
if identity in seen:continue
seen.add(identity)
core=actual[:count]; contract=feature(ref,core,treatment=operation if operation!='introduce' else None)
if references.feature_error(contract,actual):continue
last=actual[-1]
if last.pitch.number()%12 not in harmony.members(last.start,last.end):continue
checked=harmony.evaluate(actual,until=finish,complete=True)
if not checked.valid:continue
entry=max(0,abs(first.number()-incoming.last.pitch.number())-5)/12 if incoming.last else 0.
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(actual,actual[1:]))/max(1,len(actual)-1)
direction=min(1,abs(last.pitch.number()-goal)/12) if goal is not None else 0.
# Activity belongs to the complete phrase; contracts are pass/fail.
score=harmony.profile.cost(profiles.weighted_center(actual))+2*checked.exposure+entry+leaps+phrasing.destination_cost(last.pitch,last.start,last.end,harmony,incoming,goal,terminal)
anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),harmony.profile.register,
last.end-last.start,'intermediate',harmony.at(last.start).chord,hold_until=last.end)
score+=.25*rhythm_cost(tuple((n.start-start,n.end-start) for n in actual),finish-start,rhythmic_purpose(operation,terminal))
result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections,start,finish))
selected=sequences.diverse(jobs,lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4]))),
lambda j:(tuple(n.end-n.start for n in j[1]),j[3].number()//12,
(j[4].number()>j[3].number())-(j[4].number()<j[3].number())),policy.DEFAULT.theme_route_jobs*(batch+1))
trace['jobs']+=len(jobs);trace['pruned_jobs']+=len(jobs)-len(selected)
result=[]
for _,templates,label,first,target in selected:
timed=(dataclasses.replace(templates[0],pitch=first),*templates[1:-1],dataclasses.replace(templates[-1],pitch=target))
proposed=list(routes.candidates(timed,{timed[-1].event_id},harmony,library,harmony.profile.register,
operation+' '+ref.id,purpose=rhythmic_purpose(operation,terminal)))
# A substantial literal return is a musical action, not merely the hope
# that a general interval search will accidentally reproduce the theme.
if require_feature and len(templates)==len(ref.notes):
delta=first.number()-ref.notes[0].pitch.number()
actual=[]
for template,n in zip(templates,ref.notes):
options=leads.available(harmony,template.start,template.end,members=template.end-template.start>=2)
p=next((p for p in options if p.number()==n.pitch.number()+delta),None)
if p is None:break
actual.append(dataclasses.replace(template,pitch=p))
if len(actual)==len(templates) and actual[-1].pitch==target:
proposed.append(types.RouteCandidate('theme:'+ref.id,tuple(actual),actual[-1],(),operation,(ref.id,)))
for route in proposed:
trace['proposed']+=1
actual=route.notes
contract=feature(ref,actual[:count],treatment=operation if operation!='introduce' else None) if count else None
if contract and references.feature_error(contract,actual):
trace['rejected_by_rule']+=1;continue
checked=harmony.evaluate(actual,until=finish,complete=True)
if not checked.valid:trace['rejected_by_rule']+=1;continue
trace['valid']+=1
entry=max(0,abs(first.number()-incoming.last.pitch.number())-7)/12 if incoming.last else 0.
score=2*checked.exposure+entry+.1*routes.path_cost(actual)
score+=.25*rhythm_cost(tuple((n.start-start,n.end-start) for n in actual),finish-start,rhythmic_purpose(operation,terminal))
result.append(Unit(actual,contract,ref,operation,ending,score,label,(route,),start,finish))
unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result}
selected=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)),
lambda u:(u.notes[0].pitch.number()//12,u.notes[-1].pitch.number(),tuple(n.end-n.start for n in u.notes[:count])),policy.DEFAULT.theme_unit_alternatives)
context.diagnostics['generated_candidates']+=len(selected)
context.results[key]=selected
return selected
chosen=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)),
lambda u:(routes.path_family(u.notes,harmony),u.notes[0].pitch.number()//12,
tuple(n.end-n.start for n in u.notes)),policy.DEFAULT.theme_unit_alternatives)
trace['retained']+=len(chosen)
context.diagnostics['generated_candidates']+=len(chosen)
context.results[key]=chosen
return chosen
def home_reference(home,operation):
@@ -400,6 +357,18 @@ def make_home(unit,harmony,start=None,end=None):
return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony),signature=home.notes[:len(unit.reference.notes)])
def operation_choices(home,incoming,cursor,phrase_start,closing):
available=generator.vocabulary.data('grammar')['operations']
selected=[]
for rule in available:
op=rule['id']
if rule.get('entry_only') and cursor!=phrase_start:continue
if rule.get('after_statement') and not home:continue
if rule.get('needs_recent') and not incoming.notes:continue
selected.append(op)
return tuple(selected)
def unit_end(source,cursor,home,ending,bar):
"""Bound search by time without cutting the heard signature at a barline."""
length=2*bar
@@ -409,7 +378,7 @@ def unit_end(source,cursor,home,ending,bar):
length+=home.signature[-1].end
length=min(2*bar,max(bar,length))
finish=min(source.end,cursor+length)
if source.end-finish<bar+2 or ending.earliest<finish:
if source.end-finish<bar+2 or ending and ending.earliest<finish:
finish=source.end
return finish
@@ -424,7 +393,8 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
seed_key=('source-seeds',analysis,bar,seed)
if seed_key not in context.pools:context.pools[seed_key]=seeds(analysis,bar,seed)
roots=context.pools[seed_key]
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=8*(batch+1),final=final)
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=6*(batch+1),final=final)
if not final:ending_options=(None,*ending_options)
states=[(0.,(),incoming.home,incoming,ending) for ending in ending_options]
while states and any(not units or units[-1].end<source.end for _,units,_,_,_ in states):
next_states=[]
@@ -438,15 +408,12 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
if introducing:
requests=[(r,'introduce') for r in roots]
else:
operations=['continue'] if finish==source.end and cursor>source.start else ['develop_contour','develop_rhythm']
if cursor==source.start:operations.append('return')
operations=operation_choices(home,state,cursor,source.start,finish==source.end)
requests=[(home_reference(home,op),op) for op in operations]
# A uniform duration alone is not identifying musical evidence.
requests=[(r,op) for r,op in requests if op!='develop_rhythm' or len({n.end-n.start for n in r.notes})>1 or any(a.end!=b.start for a,b in zip(r.notes,r.notes[1:]))]
if cursor>=8*bar and cursor==source.start and incoming.callbacks<2:
requests.extend((dataclasses.replace(r,treatment='callback'),'callback') for r in roots[:1] if r.end-r.start<=bar)
for ref,operation in requests:
if introducing and source.end-source.start<2*bar and ending.earliest-cursor<ref.notes[-1].end:
if introducing and ending and source.end-source.start<2*bar and ending.earliest-cursor<ref.notes[-1].end:
# A missing-harmony boundary can leave less than one full statement.
# Declare a smaller contour seed; never pretend it proves the full motif.
count=min(3,int(2*(ending.earliest-cursor)),len(ref.notes))
@@ -465,9 +432,10 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
if len(reduced)<2:continue
ref=dataclasses.replace(ref,notes=reduced,treatment='contour_seed')
operation='continue'
terminal=finish==source.end
terminal=bool(ending and finish==source.end)
base=state.last.pitch.number() if state.last else ref.notes[0].pitch.number()
goal=ending.pitch.number() if terminal else round(base+(ending.pitch.number()-base)*float((finish-cursor)/(source.end-cursor)))
destination=ending.pitch.number() if ending else phrasing.register_goal(state,part,base,harmony.profile)
goal=destination if finish==source.end else round(base+(destination-base)*float((finish-cursor)/(source.end-cursor)))
options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation,
terminal=terminal,batch=batch,ending=ending if terminal else None,goal=goal)
for unit in options:
@@ -487,22 +455,19 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
elapsed=units[-1].end-source.start
return cost/max(.001,float(elapsed)),policy.stable_rank(seed,tuple(u.notes for u in units))
states=sequences.diverse(next_states,rank,
lambda v:(v[2].id,v[4].pitch.number(),v[4].duration,len(v[1][-1].notes),v[1][-1].operation),policy.DEFAULT.theme_continuations)
lambda v:(v[2].id,(v[4].pitch.number(),v[4].duration) if v[4] else 'open',len(v[1][-1].notes),v[1][-1].operation),policy.DEFAULT.theme_continuations)
if not states:return ()
result=[]
for cost,units,home,state,ending in states:
plain=tuple(n for u in units for n in u.notes)
if similarity.near_copy(plain,head,source.start,source.end)['near_copy']:
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
features=tuple(u.feature for u in units)
features=tuple(u.feature for u in units if u.feature)
# Cell tails are not compulsory melodic arrivals. Only the selected close
# has a sounding contract; intermediate turns remain editable.
anchors=(ending,)
tails=(types.SustainedTail(ending.earliest,ending.hold_until,ending.pitch.number()),)
anchors=(ending,) if ending else ()
tails=(types.SustainedTail(ending.earliest,ending.hold_until,ending.pitch.number()),) if ending else ()
options=[(plain,(),())]
if stage=='elaborated':
for actual,edit,route in leads.elaborate(plain,features,harmony,library,rhythms,meter,source,sustain_entry=True,tails=tails):
options.append((actual,(edit,),(route,)))
for notes,edits,selected_routes in options:
identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,leads.signature(notes))[:12]
transforms=tuple(types.Transformation(identifier+f':{i}',u.reference.id,home.id,u.operation,u.notes[0].start,u.notes[-1].end,
@@ -512,9 +477,11 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
features=features,reference=source_ref.id,reference_motif=home,source_phrase=source,source_reference=source_ref,
source_links=tuple((n.event_id,n.sources) for n in notes),sustained_tails=tails)
motif=types.Motif(home.id,home.notes,home.duration,(), 'realized solo theme from '+source_ref.id)
landmark=types.Landmark(ending.earliest,ending.latest,ending.pitch.number(),ending.pitch.number(),0,'sustained destination')
landmarks=(types.Landmark(ending.earliest,ending.latest,ending.pitch.number(),ending.pitch.number(),0,'sounding destination'),) if ending else ()
closed=bool(ending and ending.role=='terminal')
plan=types.PhrasePlan(identifier,source.start,source.end,motif,draft.relationship,'thematic_recomposition',None,harmony.profile.register,F(5,4),
source.end-ending.hold_until,phrasing.activity(source,ending),(landmark,), 'sustain','closed',seed,
source.end-ending.hold_until if ending else F(),phrasing.activity(source,ending),landmarks,
'sustain' if ending else 'connected','closed' if closed else 'open',seed,
source.id,source.start,source.end,(anchors,))
before_rhythm=draft
from generator.solo import gesture_rhythm
@@ -523,20 +490,12 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
notes=draft.notes;edits=(*edits,*rhythm_edits);transforms=draft.transformations
phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits)
if phrase is None:continue
score=phrasing.evaluate(notes,incoming,harmony,source.start,source.end,bar)+.1*gesture_rhythm.fragmentation(notes)
score+=sum(phrasing.destination_cost(u.anchor.pitch,u.anchor.earliest,u.anchor.hold_until,harmony,incoming,phrasing.register_goal(incoming,part,u.notes[0].pitch.number(),harmony.profile),u.anchor.role=='terminal') for u in units)/len(units)
appearance_state=incoming
for unit in units:
score+=phrasing.appearance_cost(unit.operation,appearance_state,unit.notes[0].start,solo_end)/len(units)
if unit.operation=='return':
appearance_state=dataclasses.replace(appearance_state,returns=(*appearance_state.returns,unit.notes[0].start))
score+=.05*phrase.state.travel/max(1,len(notes))/2000
history=incoming.history
for transform in transforms:
group=tuple(n for n in notes if n.event_id in transform.events)
if group:history=phrasing.remember(history,phrasing.heard(group,transform.operation,ending if group[-1].end==ending.hold_until else None,bar),bar)
current=dataclasses.replace(phrase.state,next_destination=phrasing.next_destination(ending,harmony),home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units),
history=history,returns=state.returns,closures=incoming.closures+1,register_peak=max(state.register_peak,*(n.pitch.number() for n in notes)))
if group:history=phrasing.remember(history,phrasing.heard(group,transform.operation,ending if ending and group[-1].end==ending.hold_until else None,bar),bar)
current=dataclasses.replace(phrase.state,next_destination=phrasing.next_destination(ending,harmony) if ending else incoming.next_destination,home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units),
history=history,returns=state.returns,closures=incoming.closures+int(closed),register_peak=max(state.register_peak,*(n.pitch.number() for n in notes)))
if not incoming.home:
introduced=tuple(n for n in notes if n.start<units[0].notes[-1].end)
heard=dataclasses.replace(home,notes=tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start,position=None) for n in introduced),
@@ -545,6 +504,30 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
source_control=leads.shift(source.notes,next((o for o in (0,1,-1,2,-2) if all(41<=n.pitch.number()+12*o<=76 for n in source.notes)),0))
original=dataclasses.replace(draft,id=identifier+':source',notes=source_control,features=(),anchors=(),structural=(),sustained_tails=())
refined=dataclasses.replace(draft,id=identifier+':plain',notes=plain)
result.append(dataclasses.replace(phrase,score=score,state=current,initial=original,refined=refined,before_rhythm=before_rhythm,routes=(*tuple(r for u in units for r in u.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in notes) for n in r.notes)),*selected_routes)))
return sequences.diverse(result,lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
lambda p:(p.state.home.id,p.state.last.pitch.number(),p.anchors[-1].duration,len(p.state.returns)),policy.DEFAULT.phrase_alternatives)
result.append(dataclasses.replace(phrase,state=current,initial=original,refined=refined,before_rhythm=before_rhythm,routes=(*tuple(r for u in units for r in u.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in notes) for n in r.notes)),*selected_routes)))
from generator.solo import expressions,gesture_rhythm
if stage=='elaborated':
revisions=[]
for base in result:
for changed,edit,route in gesture_rhythm.join_variants(base.draft,harmony,library):
revised=realization.checked_phrase(base.plan,changed,changed.notes,incoming.treatments,incoming,harmony,tempo,(*base.edits,edit))
if revised is None:continue
state=dataclasses.replace(base.state,notes=revised.state.notes,last=revised.state.last,
motifs=revised.state.motifs,travel=revised.state.travel)
history=incoming.history
for t in changed.transformations:
heard=tuple(n for n in changed.notes if n.event_id in t.events)
if heard:history=phrasing.remember(history,phrasing.heard(heard,t.operation,bar=bar),bar)
state=dataclasses.replace(state,history=history)
retained=tuple(r for r in base.routes if not any(n.start<edit.end and n.end>edit.start for n in r.notes))
revisions.append(dataclasses.replace(revised,state=state,initial=base.initial,refined=base.refined,
before_rhythm=base.draft,routes=(*retained,route)))
context.diagnostics['join_revision_candidates']=context.diagnostics.get('join_revision_candidates',0)+len(revisions)
result.extend(revisions)
if stage=='elaborated' and getattr(context,'expressions_enabled',True):
result=expressions.joint_candidates(result,incoming,harmony,tempo,context,bar=bar)
selected=sequences.diverse(result,lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
lambda p:(p.state.home.id,p.plan.closure,p.state.last.pitch.number(),tuple(e.kind for e in p.draft.expressions)),policy.DEFAULT.phrase_alternatives)
context.diagnostics.setdefault('evaluations',[]).extend(
{'id':p.draft.id,'retained':p in selected,'evaluation':dataclasses.asdict(p.evaluation)} for p in sorted(result,key=lambda p:p.score)[:12] if p.evaluation)
return selected
+2
View File
@@ -361,6 +361,8 @@ class Phrase:
routes: tuple[RouteCandidate, ...] = ()
before_enclosure: MelodicDraft | None = None
before_rhythm: MelodicDraft | None = None
evaluation: object | None = None
rhythm_groups: tuple = ()
@dataclasses.dataclass(frozen=True)
class Solo:
+8 -1
View File
@@ -79,6 +79,9 @@ class RhythmCell:
duration: F
purpose: str
difficulty: str | None = None
subdivision: str = 'binary'
entry: str = 'attack'
exit: str = 'continue'
def rhythm_cells(tempo, purpose=None):
@@ -100,4 +103,8 @@ def rhythm_cells(tempo, purpose=None):
cursor += duration
if difficulty and len(notes) > limits['burst_attacks']:
continue
yield RhythmCell(cell['id'], tuple(notes), cursor, cell['purpose'], difficulty)
from generator.solo import groups
realized=groups.describe(tuple(notes),source=cell['id'])
family=cell.get('subdivision','binary')
if family=='triplet' and not any(g.family=='triplet' for g in realized):raise ValueError(cell['id']+': missing complete tuplet group')
yield RhythmCell(cell['id'],tuple(notes),cursor,cell['purpose'],difficulty,family,cell.get('entry','attack'),cell.get('exit','continue'))