This commit is contained in:
khodowany
2026-10-04 12:27:43 -05:00
parent 8fdd736e13
commit 405066eb43
18 changed files with 810 additions and 71 deletions
+4
View File
@@ -43,6 +43,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--jobs", type=int, default=1, choices=range(1, 17), metavar="1-16")
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("--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):
@@ -90,6 +91,9 @@ def generate_assets(args: argparse.Namespace) -> None:
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)
if getattr(args, 'export_solo_xml', False):
from generator.solo.export import export_tree
export_tree(staged)
generator.score.validate_generated_tree(staged)
if args.check:
generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
+7
View File
@@ -12,13 +12,20 @@ def main():
parser.add_argument('--data-dir',type=Path,default=Path('data'))
parser.add_argument('--output',type=Path,required=True)
parser.add_argument('--cache-dir',type=Path)
parser.add_argument('--solo-xml',type=Path,help='Also export a completed solo role as MusicXML.')
parser.add_argument('--settings',type=Path)
args=parser.parse_args()
if args.solo_xml and not args.role.endswith('_solo'):
parser.error('--solo-xml requires a solo role')
source=production.SourceAnalysis.read(parts.prepare_document(musicxml.load_musicxml(args.song)))
start=time.monotonic()
result=production.generate_role(source,args.role,args.data_dir,json.loads(args.settings.read_text()) if args.settings else {},args.cache_dir)
args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps(result,indent=2))
if args.solo_xml:
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')
if __name__=='__main__':main()
+9 -5
View File
@@ -104,7 +104,7 @@ def write_harmony(parent, value: dict, divisions: int) -> None:
element(item, 'degree-type', degree['type'])
element(node, 'offset', int(generator.model.fraction(value['onset']) * divisions))
def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[dict]) -> None:
def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[dict], tablature=False) -> None:
if event['kind'] == 'spacer':
item = element(parent, 'forward')
element(item, 'duration', int(generator.model.fraction(event['duration']) * divisions))
@@ -170,6 +170,10 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
for beam in event.get('beams', []):
element(node, 'beam', beam['type'], number=beam['level'])
notations = element(node, 'notations')
if tablature and pitch and pitch.get('tablature'):
technical = element(notations, 'technical')
element(technical, 'string', pitch['tablature']['string'])
element(technical, 'fret', pitch['tablature']['fret'])
for field, action in (('tie_from', 'stop'), ('tie_to', 'start')):
if pitch and pitch.get(field):
element(notations, 'tied', type=action)
@@ -204,7 +208,7 @@ def write_barline(bar, measure, location):
node = element(bar, 'barline', location=location)
element(node, 'bar-style', measure.get('barlines', {}).get(location, 'regular'))
def musicxml_tree(document: dict) -> xml.etree.ElementTree.ElementTree:
def musicxml_tree(document: dict, *, tablature=False) -> xml.etree.ElementTree.ElementTree:
generator.model.validate_document(document)
root = xml.etree.ElementTree.Element('score-partwise', {'version': '4.0'})
metadata, staff = document['metadata'], document['staves'][0]
@@ -296,7 +300,7 @@ def musicxml_tree(document: dict) -> xml.etree.ElementTree.ElementTree:
onset = generator.model.fraction(event['onset'])
if onset > cursor:
element(element(bar, 'forward'), 'duration', int((onset - cursor) * divisions))
write_note(bar, event, voice['id'], divisions, staff.get('tuplets', []))
write_note(bar, event, voice['id'], divisions, staff.get('tuplets', []), tablature)
cursor = onset + generator.model.fraction(event['duration'])
if cursor < generator.model.fraction(measure['duration']):
element(element(bar, 'forward'), 'duration', int((generator.model.fraction(measure['duration']) - cursor) * divisions))
@@ -305,7 +309,7 @@ def musicxml_tree(document: dict) -> xml.etree.ElementTree.ElementTree:
absolute += generator.model.fraction(measure['duration'])
return xml.etree.ElementTree.ElementTree(root)
def write_musicxml(path: pathlib.Path, document: dict) -> None:
tree = musicxml_tree(document)
def write_musicxml(path: pathlib.Path, document: dict, *, tablature=False) -> None:
tree = musicxml_tree(document, tablature=tablature)
xml.etree.ElementTree.indent(tree, space=' ')
tree.write(path, encoding='utf-8', xml_declaration=True)
+6 -4
View File
@@ -78,7 +78,7 @@ def windows(document, part):
def dependencies(role, resources):
names = ('grammar', 'harmony', 'lead_ins', 'rhythms') if role.endswith('_solo') else ('rhythms',) if role.endswith('_accompaniment') else ()
names = ('grammar', 'harmony', 'lead_ins', 'rhythms', 'melodic_vocabulary') if role.endswith('_solo') else ('rhythms',) if role.endswith('_accompaniment') else ()
return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']},
**{name: resources[name] for name in names}}
@@ -151,7 +151,9 @@ 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 {}
inputs={'version':VERSION,'source':source.musical_key,'role':role,'settings':settings,
from generator.solo.policy import DEFAULT
algorithm=VERSION+'/'+DEFAULT.composition_version if role.endswith('_solo') else VERSION
inputs={'version':algorithm,'source':source.musical_key,'role':role,'settings':settings,
'vocabulary':dependencies(role,resources),'catalogs':_resource_key(data_dir,role),'code':_code_key(role)}
# Source notation/lyrics also invalidate cached score representation, never the musical seed.
key=vocabulary.fingerprint({'inputs':inputs,'notation':source.document_json})
@@ -165,8 +167,8 @@ def generate_role(source, role, data_dir, settings=None, cache_dir=None):
except (ValueError,KeyError,TypeError): pass
started=time.monotonic(); document=source.document(); part=role.split('_')[0]
trace={'source':source.musical_key,'role':role,'production_resources':inputs['catalogs'],
'vocabulary':vocabulary.fingerprint(inputs['vocabulary']),'settings':settings,'algorithm':VERSION,'code':inputs['code'],'cache_key':key}
seed=vocabulary.fingerprint({'source':source.musical_key,'role':role,'settings':settings,'data':inputs['vocabulary'],'version':VERSION})
'vocabulary':vocabulary.fingerprint(inputs['vocabulary']),'settings':settings,'algorithm':algorithm,'code':inputs['code'],'cache_key':key}
seed=vocabulary.fingerprint({'source':source.musical_key,'role':role,'settings':settings,'data':inputs['vocabulary'],'version':algorithm})
details={}; solo_report=''
with vocabulary.using(resources,role,settings|{'choice_seed':seed}):
if role.endswith('_solo'):
+84
View File
@@ -0,0 +1,84 @@
"""Optional MusicXML views of finished production roles; never composes."""
from __future__ import annotations
import copy
import json
from pathlib import Path
from fractions import Fraction as F
from generator import model, musicxml_writer, score
def region(document, part):
measures = document['staves'][0]['measures']
first = next(i for i, m in enumerate(measures) if m.get('section_label') == part.capitalize() + ' Solo')
end = next((i for i in range(first + 1, len(measures)) if measures[i].get('section_label')), len(measures))
return first, end - first
def excerpt(document, first, count):
result = copy.deepcopy(document)
staff = result['staves'][0]
original = staff['measures']
staff['measures'] = original[first:first + count]
if not staff['measures']:
raise ValueError('Empty solo export region')
notes = [n for m in staff['measures'] for n in score.measure_notes(m)]
identities = {p['id'] for n in notes for p in n['pitches']}
events = {n['id'] for n in notes}
for note in notes:
for pitch in note['pitches']:
for key in ('tie_from', 'tie_to'):
if pitch.get(key) not in identities:
pitch.pop(key, None)
staff['tuplets'] = [t for t in staff.get('tuplets', []) if t['start'] in events and t['end'] in events]
tuplets = {t['id'] for t in staff['tuplets']}
for note in notes:
if 'tuplet_ids' in note:
note['tuplet_ids'] = [i for i in note['tuplet_ids'] if i in tuplets]
offset = sum((model.fraction(m['duration']) for m in original[:first]), F())
end = offset + sum((model.fraction(m['duration']) for m in staff['measures']), F())
for key in ('scale_spans', 'octave_shifts'):
staff[key] = [{**s, 'start': model.rational(max(offset, model.fraction(s['start'])) - offset),
'end': model.rational(min(end, model.fraction(s['end'])) - offset)} for s in staff.get(key, [])
if model.fraction(s['start']) < end and model.fraction(s['end']) > offset]
model.validate_document(result)
return result
def write_role(result, path):
role = result['role']
if role not in ('acoustic_solo', 'electric_solo'):
raise ValueError('Solo XML export requires an acoustic_solo or electric_solo role')
part = role.split('_')[0]
document = result['document']
first, count = region(document, part)
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):
measure['number'] = str(i)
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,
'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',
'trace': result['trace'], 'phrase_report': result.get('details', {})}
def export_tree(assets, output=None):
assets = Path(assets)
output = Path(output) if output else assets / 'solo_xml'
output.mkdir(parents=True, exist_ok=True)
entries = {}
for path in sorted((assets / 'analysis').glob('*.roles')):
roles = json.loads(path.read_text())
for i, part in enumerate(('acoustic', 'electric'), 1):
name = path.stem.replace('_', '-') + f'-solo{i}.xml'
entries[path.stem + '/' + part] = write_role(roles[part + '_solo'], output / name)
manifest = {'version': 1, 'composition_inputs': False, 'solos': entries}
# Runtime score discovery treats *.json under assets as score documents.
# Diagnostic manifests deliberately use a non-score extension, like .roles.
(output / 'manifest.solo').write_text(json.dumps(manifest, sort_keys=True, indent=2) + '\n')
return manifest
+38 -2
View File
@@ -14,9 +14,45 @@ def fragmentation(notes):
return isolated + .1*gaps
def shared_arrival(draft,harmony,treatments=()):
"""A provisional endpoint may also begin a continuation, without reattack.
Retain the continuation event's identity. A rhythm/exact-quote contract or
a committed destination can forbid sharing; full context is revalidated.
"""
notes=draft.notes
joins={t.start for t in draft.transformations[1:] if t.operation=='continue'}
fixed=set(draft.protected)|set(draft.structural)
fixed.update(n.event_id for n in notes if n.role=='quote')
fixed.update(e for f in draft.features if 'rhythm' in f.preserves for e in f.events)
edits=[]
for join in sorted(joins):
for i,(a,b) in enumerate(zip(notes,notes[1:])):
if b.start!=join or a.end!=b.start or a.pitch!=b.pitch:continue
if a.event_id in fixed or b.event_id in fixed:continue
actual=(*notes[:i],dataclasses.replace(b,start=a.start),*notes[i+2:])
if not drafts.contract_valid(draft,actual)[0]:continue
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True).valid:continue
if not harmonic.anchors_valid(draft.anchors,actual,draft.end,complete=True,closure='closed')[0]:continue
edits.append(types.DraftEdit('shared_arrival',a.start,b.end,(a.event_id,b.event_id),(b.event_id,),
'reopen provisional endpoint as the same sounding continuation; preserve feature and arrival contracts'))
notes=actual
break
if not edits:return draft,()
by_id={n.event_id:n for n in notes}
transforms=[]
for t in draft.transformations:
group=tuple(by_id[e] for e in t.events if e in by_id)
if group:
transforms.append(dataclasses.replace(t,start=group[0].start,end=group[-1].end,
events=tuple(n.event_id for n in group),rhythm=tuple((n.start-group[0].start,n.end-group[0].start) for n in group)))
return dataclasses.replace(draft,notes=notes,transformations=tuple(transforms)),tuple(edits)
def revise(draft, harmony, treatments=()):
notes=draft.notes; edits=[]
if len(notes)<3: return draft, ()
draft,shared=shared_arrival(draft,harmony,treatments)
notes=draft.notes; edits=list(shared)
if len(notes)<3: return draft, tuple(edits)
protected=set(draft.protected)|set(draft.structural)|{n.event_id for n in notes if n.role=='quote'}
# An identifying rhythm also protects its internal rests.
protected.update(e for feature in draft.features if 'rhythm' in feature.preserves for e in feature.events)
+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 = 'independent-roles-1'
composition_version: str = 'purposeful-phrases-2'
# Preserve the former default root seeds while decoupling them from policy edits.
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
+72 -8
View File
@@ -9,6 +9,22 @@ import generator.solo.placement
import generator.solo.policy
import generator.solo.sequences
import generator.solo.types as types
import generator.vocabulary
def path_cost(notes):
"""Penalize unexplained extremes, not every third or arpeggio interval."""
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(notes,notes[1:])]
excess=sum(max(0,abs(v)-7) for v in intervals)/max(1,len(intervals))
oscillation=sum(a!=0 and a==-b and a==c and b==d for a,b,c,d in zip(intervals,intervals[1:],intervals[2:],intervals[3:]))
return excess+.1*oscillation
def path_family(notes,harmony):
intervals=tuple(b.pitch.number()-a.pitch.number() for a,b in zip(notes,notes[1:]))
chordal=all(n.pitch.number()%12 in harmony.members(n.start,n.end) for n in notes)
return ('arpeggio' if chordal and any(abs(v)>=3 for v in intervals) else 'mapped',
generator.solo.sequences.family(intervals))
def advance(scale, previous, step):
try:
@@ -25,7 +41,7 @@ def advance(scale, previous, step):
outgoing.reverse()
return tuple(outgoing[abs(step) - 1:abs(step)])
def candidates(notes, fixed, harmony, library, register, relationship='connect theme'):
def candidates(notes, fixed, harmony, library, register, relationship='connect theme', *, purpose='connect'):
# Cache concrete routes only with their endpoints, timing, scale context and
# vocabulary. The per-Harmony cache cannot leak across collection changes.
if not hasattr(harmony,'route_cache'):
@@ -33,7 +49,7 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
harmony.route_libraries={}
harmony.concrete_routes={}
harmony.route_libraries[id(library)]=library
concrete_key=(notes,frozenset(fixed),id(library),register,harmony.profile)
concrete_key=(notes,frozenset(fixed),id(library),register,harmony.profile,purpose)
if concrete_key in harmony.concrete_routes:
return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.concrete_routes[concrete_key])
# Event/source IDs and non-fixed placeholder pitches do not affect route
@@ -42,9 +58,9 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
fixed_indices=frozenset(i for i,n in enumerate(notes) if n.event_id in fixed)
canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i),role='connection',position=None,
pitch=n.pitch if i==0 or i==len(notes)-1 or i in fixed_indices else types.Pitch('c',fractions.Fraction(),0)) for i,n in enumerate(notes))
key=(canonical,fixed_indices,id(library),register,harmony.profile)
key=(canonical,fixed_indices,id(library),register,harmony.profile,purpose)
if key not in harmony.route_cache:
harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register)
harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register,purpose)
result=[]
for route in harmony.route_cache[key]:
actual=tuple(n if i==0 else dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==r.pitch.number() else r.pitch,position=None)
@@ -52,15 +68,15 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
identity=generator.solo.policy.stable_rank('complete-route',(actual,route.sources))[:16]
result.append(dataclasses.replace(route,id=identity,notes=actual,destination=notes[-1],relationship=relationship))
selected=generator.solo.sequences.diverse(result,
lambda r:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(r.notes,r.notes[1:])),r.id),
lambda r:generator.solo.sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(r.notes,r.notes[1:]))),
lambda r:(path_cost(r.notes),r.id),
lambda r:path_family(r.notes,harmony),
generator.solo.policy.DEFAULT.join_alternatives)
if len(harmony.concrete_routes)>=8192:harmony.concrete_routes.pop(next(iter(harmony.concrete_routes)))
if len(harmony.route_cache)>=8192:harmony.route_cache.pop(next(iter(harmony.route_cache)))
harmony.concrete_routes[concrete_key]=selected
return selected
def _candidates(notes, fixed, harmony, library, register):
def _candidates(notes, fixed, harmony, library, register, purpose='connect'):
"""Every returned candidate includes its entry, complete rhythm and destination.
Callers validate the whole surrounding sounding context and guitar path.
@@ -83,7 +99,24 @@ def _candidates(notes, fixed, harmony, library, register):
route_spans=tuple((max(first.start,c.span.start),min(destination.end,c.span.end),c.scale.label())
for c in harmony.choices if c.scale and c.span.start<destination.end and c.span.end>first.start)
result=[]
for pitches,sources in harmony.pitch_routes[key]:
paths=list(harmony.pitch_routes[key])
# These are traversal operators, not stored phrases. An arpeggio prefix
# adapts to the chord at each note and a complete scale route then connects
# it to this caller's actual destination. No endpoint is attacked twice.
for prefix,rule in chord_prefixes(notes,fixed,harmony,register,purpose):
i=len(prefix)-1
if i==len(notes)-1:
if prefix[-1].pitch.number()==destination.pitch.number():
paths.append((tuple(n.pitch for n in prefix),('chord-path:'+rule,)))
continue
suffix_key=(prefix[-1].pitch,contexts[i:],id(library),register,harmony.profile)
if suffix_key not in harmony.pitch_routes:
while len(harmony.pitch_routes)>=8192:
harmony.pitch_routes.pop(next(iter(harmony.pitch_routes)))
harmony.pitch_routes[suffix_key]=pitch_paths(prefix[-1].pitch,contexts[i:],library,register,harmony.profile)
for tail,sources in harmony.pitch_routes[suffix_key][:2]:
paths.append((tuple(n.pitch for n in prefix[:-1])+tail,('chord-path:'+rule,*sources)))
for pitches,sources in paths:
realized=(first,*(dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==p.number() else p,position=None)
for n,p in zip(notes[1:],pitches[1:])))
identity=generator.solo.policy.stable_rank('complete-route',(realized,sources))[:16]
@@ -91,6 +124,37 @@ def _candidates(notes, fixed, harmony, library, register):
return tuple(result)
def chord_prefixes(notes,fixed,harmony,register,purpose):
from generator.solo import phrasing
if len(notes)<2:return ()
values=[]
first=notes[0];destination=notes[-1]
for rule in generator.vocabulary.data('melodic_vocabulary')['chord_paths']:
if purpose not in rule['purpose']:continue
if rule['require_chord_entry'] and first.pitch.number()%12 not in harmony.members(first.start,first.end):continue
displacement=destination.pitch.number()-first.pitch.number()
direction=1 if rule['direction']=='up' else -1 if rule['direction']=='down' else (1 if displacement>=0 else -1)
for count in rule['prefix_attacks']:
if count>len(notes):continue
prefix=[first]
for i,n in enumerate(notes[1:count]):
span=harmony.at(n.start)
if not span or not span.scale:break
eligible=harmony.members(n.start,n.end)&types.permitted(harmony.choices,n.start,n.end)
if rule['members']=='defining':eligible=eligible&phrasing.defining_members(span.chord)
step=rule['member_steps'][i%len(rule['member_steps'])]
old=prefix[-1].pitch.number()
pitches=sorted((types.scale_pitch(span.scale,j) for j in range(-21,29)),key=lambda p:p.number(),reverse=direction*step<0)
options=[p for p in pitches if p.number()%12 in eligible and 0<(p.number()-old)*direction*step
and abs(p.number()-old)<=rule['max_interval'] and register[0]<=p.number()<=register[1] and harmony.profile.permits(p.number())]
if len(options)<abs(step):break
selected=options[abs(step)-1]
if (n.event_id in fixed or i+1==len(notes)-1) and selected.number()!=n.pitch.number():break
prefix.append(dataclasses.replace(n,pitch=selected))
if len(prefix)==count:values.append((tuple(prefix),rule['id']))
return tuple(values)
def pitch_paths(first, contexts, library, register, profile=None):
"""Time-independent pitch work; context includes every overlapped collection.
+75 -36
View File
@@ -99,10 +99,34 @@ def feature(reference, actual, *, treatment=None):
tuple(n.pitch.number()+octave for n in original))
def rhythm_windows(library,meter,length,context):
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'
def rhythm_cost(pattern,length,purpose):
"""Evaluate a rhythmic job, not resemblance to any target score or density."""
if not pattern:return float('inf')
gaps=[b[0]-a[1] for a,b in zip(pattern,pattern[1:])]
isolated=sum(b-a<=F(1,2) and prev[1]<a and b<nxt[0]
for prev,(a,b),nxt in zip(pattern,pattern[1:],pattern[2:]))
values={b-a for a,b in pattern}
uniform=.15 if len(pattern)>=4 and len(values)==1 else 0.
if purpose in ('continue','connect'):
# Silence may articulate an idea; disconnected tiny islands do not carry
# unfinished motion. No minimum occupancy or chorus attack quota.
return isolated+.25*float(sum(gaps,F())/length)+uniform
if purpose=='arrival':
return isolated+float(max(F(),F(1)- (pattern[-1][1]-pattern[-1][0])))/2+uniform
return isolated+uniform
def rhythm_windows(library,meter,length,context,purpose='continue'):
from generator.solo import drafts
tempo=getattr(context,'tempo',120)
key=('theme-rhythm',id(library),meter,length,tempo)
key=('theme-rhythm',id(library),meter,length,tempo,purpose)
if key not in context.pools:
patterns={}
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
@@ -112,8 +136,8 @@ def rhythm_windows(library,meter,length,context):
# than accidentally excluding eighths/dotted values by snippet length.
# Pitch paths and feature contracts span the assembled rhythm's seams.
split=bar
for left,left_id in rhythm_windows(library,meter,split,context):
for right,right_id in rhythm_windows(library,meter,length-split,context):
for left,left_id in rhythm_windows(library,meter,split,context,'continue' if purpose=='arrival' else purpose):
for right,right_id in rhythm_windows(library,meter,length-split,context,purpose):
combined=(*left,*((a+split,b+split) for a,b in right))
if len(combined)<=16:patterns.setdefault(combined,left_id+' + '+right_id)
for identifier,pattern,total in drafts.catalog(library,meter):
@@ -123,22 +147,29 @@ def rhythm_windows(library,meter,length,context):
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)
# Authored cells can start late and continue over computational barlines.
for identifier,pattern,total in generator.vocabulary.rhythm_cells(tempo):
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)):
if offset+total>length:continue
head=tuple((a+offset,b+offset) for a,b in pattern)
tails=tuple((a,b) for a,b in next(iter(patterns),()) if a>=offset+total)
combined=(*head,*tails)
if 2<=len(combined)<=16:patterns.setdefault(combined,'authored:'+identifier)
# Keep representative compatible continuations; never let dictionary
# insertion order secretly choose the only tail for an authored cell.
tails=sequences.diverse(tuple(patterns),lambda p:(rhythm_cost(p,length,purpose),policy.stable_rank('rhythm-tail',p)),
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)
context.pools[key]=sequences.diverse(tuple(patterns.items()),
lambda x:(len(x[0])>16,policy.stable_rank('rhythm-window',x[0])),
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)
return context.pools[key]
def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operation='introduce', terminal=False, ending=None):
length=finish-start; values={}
purpose=rhythmic_purpose(operation,terminal)
if home and operation in ('repeat_with_harmonic_adaptation','return'):
pattern=tuple((n.start,n.end) for n in home.notes if n.end<=length)
if pattern:values[pattern]='heard-theme'
@@ -148,12 +179,12 @@ def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operatio
duration=prefix[-1][1]
if duration==length:values[prefix]=ref.id
if duration<length:
for tail,label in rhythm_windows(rhythms,meter,length-duration,context):
for tail,label in rhythm_windows(rhythms,meter,length-duration,context,purpose):
values[(*prefix,*((a+duration,b+duration) for a,b in tail))]=ref.id+' + '+label
else:
values.update(rhythm_windows(rhythms,meter,length,context))
values.update(rhythm_windows(rhythms,meter,length,context,purpose))
if home and operation=='answer':
for p,label in rhythm_windows(rhythms,meter,length,context):values.setdefault(p,label)
for p,label in rhythm_windows(rhythms,meter,length,context,purpose):values.setdefault(p,label)
if not fixed_rhythm:
# Develop the heard timing, preserving total duration. Eighth/quarter/dotted
# alternatives are actual contracts, not failed copies of a rhythmic seed.
@@ -174,7 +205,7 @@ def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operatio
if len(head)>=len(ref.notes):
arrivals[(*head,(arrival,ending.hold_until-start))]=label+'; planned sustained close'
values=arrivals
return sequences.diverse(tuple(values.items()),lambda x:(len(x[0])>16,policy.stable_rank(ref.id,x[0])),
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)
@@ -251,10 +282,10 @@ def _prefix_paths(templates,ref,first,harmony,library):
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))
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:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(ns,ns[1:])),tuple(n.pitch for n in ns)),
lambda ns:sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ns,ns[1:]))),4)
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)
@@ -267,6 +298,8 @@ class Unit:
score: float
source: str
routes: tuple[types.RouteCandidate,...] = ()
start: F = F()
end: F = F()
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None):
@@ -317,7 +350,7 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
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)[:3+batch]:
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:
@@ -337,7 +370,8 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
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)
result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections))
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))
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)
@@ -366,12 +400,25 @@ 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 unit_end(source,cursor,home,ending,bar):
"""Bound search by time without cutting the heard signature at a barline."""
length=2*bar
if home and home.signature:
length=home.signature[-1].end
while 0<length<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:
finish=source.end
return finish
def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False,solo_end=None):
from generator.solo import leads,realization
if source.end-source.start<2:
return short_fragment(source,incoming,part,harmony,tempo,seed,final)
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
unit_length=2*bar
solo_end=solo_end or max(c.span.end for c in harmony.choices)
head=source_notes(analysis)
seed_key=('source-seeds',analysis,bar,seed)
@@ -379,17 +426,14 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
roots=context.pools[seed_key]
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=8*(batch+1),final=final)
states=[(0.,(),incoming.home,incoming,ending) for ending in ending_options]
cursor=source.start
while cursor<source.end and states:
finish=min(cursor+unit_length,source.end)
if cursor==source.start and source.end-source.start>=3*bar:
# Selected home rhythms finish as objects, not at the nearest barline.
signature=incoming.home.signature if incoming.home else ()
if signature and signature[-1].end>unit_length:
finish=min(source.end,cursor+signature[-1].end)
if source.end-finish<6 or any(ending.earliest<finish for *_,ending in states):finish=source.end
while states and any(not units or units[-1].end<source.end for _,units,_,_,_ in states):
next_states=[]
for cost,units,home,state,ending in states:
cursor=units[-1].end if units else source.start
if cursor>=source.end:
next_states.append((cost,units,home,state,ending))
continue
finish=unit_end(source,cursor,home,ending,bar)
introducing=home is None
if introducing:
requests=[(r,'introduce') for r in roots]
@@ -437,19 +481,14 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
following=dataclasses.replace(state,home=actual_home,last=unit.notes[-1],notes=unit.notes,source_reference=state.source_reference or ref,
history=history,returns=(*state.returns,*((cursor,) if operation=='return' else ())),
register_peak=max(state.register_peak,*(n.pitch.number() for n in unit.notes)))
next_states.append((cost+unit.score+.8*repetition+appearance,(*units,unit),actual_home,following,ending))
next_states.append((cost+float(finish-cursor)*(unit.score+.8*repetition+appearance),(*units,unit),actual_home,following,ending))
def rank(value):
cost,units,home,state,ending=value
attacks=sum(len(u.notes) for u in units)
remaining=max(F(),ending.earliest-finish)
possible=attacks+2*float(remaining)+(0 if finish==source.end else 1)
expected=float(source.end-source.start)
activity_bound=0.0 # Activity is a gesture/difficulty property, not a chorus quota.
return cost/max(1,len(units))+activity_bound,policy.stable_rank(seed,tuple(u.notes for u in units))
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)
if not states:return ()
cursor=finish
result=[]
for cost,units,home,state,ending in states:
plain=tuple(n for u in units for n in u.notes)
+31 -4
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import contextlib
import contextvars
import dataclasses
import functools
import hashlib
import json
@@ -14,7 +15,7 @@ ACTIVE = contextvars.ContextVar('composition_vocabulary', default=None)
def load(root=ROOT):
# Content, not just the directory, keys memoization. Long-lived individual
# role builds must observe edited production data without restarting Python.
names = ('roles', 'grammar', 'harmony', 'lead_ins', 'rhythms')
names = ('roles', 'grammar', 'harmony', 'lead_ins', 'rhythms', 'melodic_vocabulary')
return _load(tuple((name, (Path(root)/(name+'.json')).read_text()) for name in names))
@functools.lru_cache(maxsize=16)
@@ -28,6 +29,21 @@ def _load(contents):
if len({v['id'] for v in entries}) != len(entries):
raise ValueError(f'{name}: duplicate vocabulary ID')
result[name] = value
cells=result['rhythms']['cells']+result['melodic_vocabulary']['cells']
if len({c['id'] for c in cells})!=len(cells):
raise ValueError('Duplicate rhythmic identity across production vocabularies')
for c in cells:
if c['purpose'] not in ('entrance','continue','connect','answer','arrival'):
raise ValueError(c['id']+': unknown rhythmic purpose')
paths=result['melodic_vocabulary']['chord_paths']
if len({p['id'] for p in paths})!=len(paths):raise ValueError('Duplicate chord-path operator')
for p in paths:
if p['direction'] not in ('up','down','toward') or p['members'] not in ('basic','defining'):
raise ValueError(p['id']+': invalid chord traversal')
if not p['member_steps'] or any(type(s)!=int or not 1<=abs(s)<=3 for s in p['member_steps']):
raise ValueError(p['id']+': invalid chord-member step')
if not p['prefix_attacks'] or any(n not in (2,3,4) for n in p['prefix_attacks']) or not 1<=p['max_interval']<=12:
raise ValueError(p['id']+': unsupported bounded chord prefix')
return result
@functools.lru_cache(maxsize=1)
@@ -56,9 +72,20 @@ def using(resources, name, settings=None):
def fingerprint(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def rhythm_cells(tempo):
@dataclasses.dataclass(frozen=True)
class RhythmCell:
id: str
pattern: tuple
duration: F
purpose: str
difficulty: str | None = None
def rhythm_cells(tempo, purpose=None):
limits = data('roles')['difficulty']
for cell in data('rhythms')['cells']:
for cell in (*data('rhythms')['cells'],*data('melodic_vocabulary')['cells']):
if purpose is not None and cell['purpose'] != purpose:
continue
difficulty = cell.get('difficulty')
if difficulty and tempo > limits[difficulty + '_max_bpm']:
continue
@@ -73,4 +100,4 @@ def rhythm_cells(tempo):
cursor += duration
if difficulty and len(notes) > limits['burst_attacks']:
continue
yield cell['id'], tuple(notes), cursor
yield RhythmCell(cell['id'], tuple(notes), cursor, cell['purpose'], difficulty)