Cleanup
This commit is contained in:
@@ -93,17 +93,17 @@ Artifacts are published to the khodo generic package registry's required `latest
|
||||
|
||||
Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live in `data/`. Generated score JSON is not committed.
|
||||
|
||||
`data/songs` supplies the original melody and harmony. The 21 scale patterns, 2,954 relative interval paths, meter-specific rhythm catalogs, and versioned rules in `data/composition` supply production vocabulary. The melodic vocabulary adds purpose-bearing rhythm cells and transposable chord-member traversal operators, followed by mapped-scale connections to the chosen destination. Audition references are outside that path and never participate in construction, scoring, caching or selection.
|
||||
`data/songs` supplies the original melody and harmony. The 21 scale patterns, 2,954 relative interval paths, meter-specific rhythm catalogs, and versioned rules in `data/composition` supply production vocabulary. Shared operators enumerate notes, rests, complete routes and typed harmonic gestures before contextual selection. Production does not read target solos or previous output. Explicit development fitting is separate and cannot automatically publish its coefficients.
|
||||
|
||||
Six roles generate independently from immutable source analysis: acoustic accompaniment/solo; electric accompaniment/melody/solo; vocal melody. A bounded process pool composes roles in parallel, with fully keyed per-role caches for incremental builds. `--composition-jobs 1` provides the serial equivalent; the default is two. There is no joint acoustic/electric selection, generated-solo-aware backing, or reuse of generated melody grips. Assembly respects source-defined role windows and the returning vocal pickup.
|
||||
|
||||
Each solo develops one realized theme from selected features across the actual head. Complete phrases coordinate chord-tone destinations, scale routes, mixed rhythms, space and binding sustained arrivals. Both solos can use all six strings at frets 1–12 without open strings, with different soft register and thematic preferences. Ordinary movement remains strictly mapped. Target-driven approaches, colors, anticipations and conservative blues treatments require explicit validated timing, harmonic and resolution contracts. Direct approaches remain available. No dynamics, accents or humanization have been added.
|
||||
Each solo retains an introduced realized theme and uses ordered relationships from the source head. The current selector ranks complete musical actions with bounded backtracking; it is not a whole-phrase global optimizer. Both solos can use all six strings at frets 1–12 without open strings, with overlapping soft registers. Ordinary movement stays mapped; approaches, colors and anticipations require typed timing, harmony and resolution contracts. Pending targets survive internal boundaries. No dynamics, accents or humanization have been added.
|
||||
|
||||
Each accompaniment is a source-derived canonical part, reused unchanged at corresponding form positions. Sparse shells/dyads and compact voicings retain essential chord information, with deliberate releases and varied rhythms. Neither backing changes in response to generated solos. Source vocals and electric melody retain their established behavior.
|
||||
|
||||
Optional solo exports are available with `-DVAMP_EXPORT_SOLO_XML=ON` in CMake (or `python -m generator --export-solo-xml ...`). They appear in `generated_assets/solo_xml/`: `*-solo1.xml` is acoustic and `*-solo2.xml` is electric. These contain the actual completed production notes, guitar octave transposition, and TAB positions; enabling export does not recompose music. The adjacent manifest records source and algorithm identities. A single-role build can add `--solo-xml build/example-solo1.xml`.
|
||||
|
||||
Reference comparison runs separately, after the build, through `python -m audition.solo_compare`. Its optional references never enter production selection or cache keys. For the four supplied scores, use `--reference-pitch guitar-written`: their notes sound one octave below the encoded pitches. Exact event differences are diagnostic, not a musical-quality score or a parameter-fitting objective.
|
||||
Reference comparison runs separately through `python -m audition.solo_compare`; `--require-exact` returns a failure for any sounding-event or rest difference. For the supplied targets, use `--reference-pitch guitar-written`: they sound one octave below encoded pitches. The separate `audition.fit_selection` command can diagnose target constructibility and fit shared, whitelisted theory coefficients during development. It never stores target phrases in production data. Exact reproduction remains an unmet acceptance condition unless all four exported comparisons pass; constructibility and passing technical tests do not establish it.
|
||||
|
||||
See [Independent Musical Generation](TECHNICAL_GUIDE.md#independent-musical-generation) for the dependency graph, data inventory/provenance, algorithms, constraints, single-role CLI, cache behavior and optional audition/reference commands. The four supplied scores and current production exports are compared in `build/solo_reference_comparison/index.html`; a compact previous-output snapshot is retained there. See [the current repair report](docs/composer/implementation_report.md). Comparison outputs never become production inputs.
|
||||
|
||||
|
||||
+2
-2
@@ -194,11 +194,11 @@ Production composes six roles independently from the original source and relevan
|
||||
|
||||
`production.generate_role` runs in a bounded spawn process pool, default two workers (`VAMP_COMPOSITION_JOBS` / `--composition-jobs`). Role-specific keyed caches support incremental rebuilds. Serial and parallel modes use the same deterministic musical choices; worker completion order is not an input. Actual composition reads are audited. Optional targets, prior scores and other roles' caches are excluded.
|
||||
|
||||
The active path is `composer.compose → leads.thematic_options → themes.phrase_candidates → themes.unit_candidates → gestures/routes → realization.checked_phrase → scoring.musical`. One source-related realized theme remains available for returns and development. Complete route candidates connect harmonic material through the mapped scales. New joint gesture proposals choose early/on-time/delayed arrivals, holds, releases and typed approaches/colors **before** pitch-route search. A complete approach can target an anticipated destination when both contracts validate. Binding targets and sounding tails survive revision; ordinary computational boundaries remain open.
|
||||
The active path is `composer.compose → construction.Builder → operators.Operators + selection.Selector → placement → validation → notation/export`. Complete action candidates include scalar/arpeggiated routes, heard-theme returns/answers, rests, colors, approaches and anticipations. Ordinary actions need no chord-tone endpoint. Pending contracts retain exact future targets through internal boundaries. The search ranks actions by cost per quarter with bounded repair; reporting groups are not evidence of global phrase optimization. See the [active-path audit](docs/composer/active_path_audit.md) for budgets and limitations.
|
||||
|
||||
Both solo roles use strings 1–6, frets 1–12, no open strings, with soft overlapping register preferences. Exact timing, source transposition, ties, harmonic interpretation and runtime representation remain unchanged. The 21 scale patterns govern ordinary movement. Exceptions need actual typed preparation/target/duration evidence. A tonic/predominant natural minor sixth may be a bounded stable color; mapped-upper enclosures must verify their upper neighbor's collection membership. No chord is rewritten to excuse an ornament.
|
||||
|
||||
The authoritative evaluator reports validity, normalized components, weights and their actual selected total. It does not use reference similarity. Rhythm/route retrieval heuristics are separate. Per-Harmony caches retain musical constraints and restore caller provenance; pure rhythm-group and legal-position checks are memoized.
|
||||
The authoritative evaluator reports validity, normalized components, weights and the actual selection total. It does not read reference events or compute runtime reference similarity. Development-only shared-weight fitting is explicit and bounded. Production catalogs have a checksummed non-executable JSON cache, separate from concrete role caches. Pure chord, expression, rhythm-group and legal-position analysis is memoized.
|
||||
|
||||
Accompaniment remains source-only canonical sparse comping, reused at corresponding form positions. It does not respond to generated lead notes or borrow their grips. No new dynamics, velocity shaping, random timing or instrument effects are introduced.
|
||||
|
||||
|
||||
@@ -29,7 +29,12 @@ def main():
|
||||
collect('targets_present_cold',{r:production.generate_role(source,r,data) for r in production.ROLES})
|
||||
for path in refs.glob('*.xml'):path.unlink()
|
||||
collect('targets_absent_cold',{r:production.generate_role(source,r,data) for r in production.ROLES})
|
||||
production.generate_role(source,'electric_solo',data,{'choice_seed':'different electric only'})
|
||||
# Exercise an operational musical setting, not merely a different cache
|
||||
# key. A stream seed alone need not change a deterministic winner.
|
||||
changed=production.generate_role(source,'electric_solo',data,{'register_center':72})
|
||||
changed_hash=vocabulary.fingerprint(changed['document'])
|
||||
assert changed_hash!=results['base']['electric_solo'],'electric setting was inert'
|
||||
evidence['changed_electric_register_hash']=changed_hash
|
||||
collect('after_electric_setting',{r:production.generate_role(source,r,data,cache_dir=cache) for r in production.ROLES})
|
||||
evidence['passed']=True
|
||||
(a.output/'dependency.json').write_text(json.dumps(evidence,indent=2))
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Explicit development-only shared-rule derivation and fitting.
|
||||
|
||||
Targets are read only by this command. Production imports neither this module
|
||||
nor its artifacts. A derivation uses the exact same enumerated Actions as the
|
||||
normal composer; accepting injected pitches is not a construction proof.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import bisect
|
||||
from collections import Counter
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
import hashlib
|
||||
import heapq
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
from audition.reference_review import read
|
||||
from audition.solo_compare import events, rests
|
||||
from generator import accompaniment, model, musicxml, parts, production, resources, score, vocabulary
|
||||
from generator.arrange import form, generate
|
||||
from generator.scales import selection, timeline
|
||||
from generator.solo import analysis, construction, harmonic, profiles, report, types
|
||||
|
||||
|
||||
def build(source_path,role,data_dir,cache_dir):
|
||||
"""Read original song/form and production data; never reference harmony."""
|
||||
document=parts.prepare_document(musicxml.load_musicxml(source_path))
|
||||
a=analysis.analyze(document);part=role.split('_')[0]
|
||||
source=score.source_staff(document)['measures']
|
||||
sections=form.full_arrangement_sections(source,part);si=3 if part=='acoustic' else 4
|
||||
section=sections[si]
|
||||
effective=generate.section_source(source,section,sections[si+1])
|
||||
measures=effective[section.source_measure_index:section.source_measure_index+section.measure_count]
|
||||
initial=accompaniment.carried_chords(effective)[section.source_measure_index]
|
||||
from generator.solo.arrangement import regions
|
||||
first,_,offset,_=regions(document)[si]
|
||||
start,end=next((a-offset,b-offset) for a,b,r in production.windows(document,part) if r==role)
|
||||
if start<0:
|
||||
template=generate._arrange_document(document,part,{},owned={'slash'})
|
||||
measures=[template['staves'][0]['measures'][first-1],*measures]
|
||||
initial=accompaniment.carried_chords(template['staves'][0]['measures'])[first-1]
|
||||
sc,seq,rh=resources.load(data_dir,cache_dir=cache_dir)
|
||||
choices=selection.select(timeline.read({'measures':measures},initial=initial),timeline.Solo(F(),end-start,role),sc)
|
||||
choices=tuple(dataclasses.replace(c,span=dataclasses.replace(c.span,start=c.span.start+start,end=c.span.end+start)) for c in choices)
|
||||
h=harmonic.Harmony(choices,profiles.for_part(part,a))
|
||||
return construction.Builder(a,measures,h,seq,rh,end,'development',start=start),document
|
||||
|
||||
|
||||
def canonical(notes):
|
||||
return tuple((n.start,n.end,n.pitch.number()) for n in notes)
|
||||
|
||||
|
||||
def derive(builder,target):
|
||||
"""Search grammar derivations constrained to the exact supplied events.
|
||||
|
||||
This is a development diagnostic, not normal production inference. Failure
|
||||
is recorded at its earliest uncovered event. No target action is injected.
|
||||
"""
|
||||
encoded=tuple((F(n['start']),F(n['end']),int(n['pitch'])) for n in events(target))
|
||||
starts=tuple(n[0] for n in encoded);encoded_set=set(encoded)
|
||||
queue=[builder.start];paths={builder.start:()};seen=set();furthest=builder.start
|
||||
while queue:
|
||||
at=heapq.heappop(queue)
|
||||
if at in seen:continue
|
||||
seen.add(at);furthest=max(furthest,at)
|
||||
if at==builder.end:
|
||||
path=paths[at]
|
||||
checked=builder.harmony.evaluate(tuple(n for a in path for n in a.notes),expressions=tuple(c for a in path for c in a.contracts),complete=True,until=at)
|
||||
return path,{'complete':checked.valid,'covered_until':str(at),'actions':len(path),
|
||||
'status':'constructible' if checked.valid else 'rejected_by_full_context','error':checked.error}
|
||||
left=bisect.bisect_left(starts,at)
|
||||
def matches(action):
|
||||
if any((c.target.start,c.target.end,c.target.pitch.number()) not in encoded_set for c in action.contracts):return False
|
||||
expected=encoded[left:bisect.bisect_left(starts,action.end)]
|
||||
return not (left and encoded[left-1][1]>at) and canonical(action.notes)==expected
|
||||
first=encoded[left] if left<len(encoded) else None
|
||||
for action in builder.operators.at(at,eligible=matches,first=first):
|
||||
if action.end not in paths:
|
||||
paths[action.end]=(*paths[at],action);heapq.heappush(queue,action.end)
|
||||
next_event=next((n for n in encoded if n[0]>=furthest),None)
|
||||
reasons=[]
|
||||
if next_event:
|
||||
start,end,pitch=next_event;region=builder.harmony.at(start)
|
||||
reasons=[{'pitch':pitch,'start':str(start),'end':str(end),
|
||||
'source_chord':region.chord if region else None,'mapped':bool(region and region.scale and pitch%12 in region.scale.pitches),
|
||||
'basic':bool(region and pitch%12 in region.roles.basic),
|
||||
'function':builder.harmony.function(pitch,region) if region else 'missing_harmony'}]
|
||||
return paths.get(furthest,()),{'complete':False,'covered_until':str(furthest),
|
||||
'status':'not_proposed_or_rejected_by_rule','next_events':reasons,
|
||||
'budget':'Exhaustive derivation through available unpruned grammar actions, not unrestricted music search.',
|
||||
'action_coverage':dict(builder.operators.coverage)}
|
||||
|
||||
|
||||
def fit(examples,passes,on_checkpoint=None):
|
||||
"""Integer early-update structured perceptron on shared musical features.
|
||||
|
||||
Each roll-in is normal source-only ranking until its first wrong action.
|
||||
Only the development command finds a compatible positive action and updates
|
||||
coefficients. Production never receives the positive prefix. A zero-error
|
||||
pass means every unrestricted roll-in produced the entire expected stream.
|
||||
"""
|
||||
weights={r:dict(b.selector.weights) for _,r,b,_ in examples};history=[];best=None;tables={}
|
||||
# Whole actions include pickup+hold and compound groups (e.g. 17/4).
|
||||
# Eight beats on the supported twelfth-beat lattice need numerators up to
|
||||
# 96. The previous LCM 1..16 covered note values, not complete actions.
|
||||
normalization=math.lcm(*range(1,97))
|
||||
def table(name,b,at,state):
|
||||
key=(name,at,canonical(state.recent),canonical(state.home),state.last_kind,state.pending)
|
||||
if key in tables:return tables[key]
|
||||
rows=[]
|
||||
actions=b.operators.at(at)
|
||||
if state.home and state.last_kind!='return':actions+=b.operators.related(at,state.home,'return')+b.operators.related(at,state.home,'answer')
|
||||
for action in actions:
|
||||
if b.advance(state,None,action) is None:continue
|
||||
features,prior=b.selector.features(action,state.recent,state.home,state.last_kind)
|
||||
factor=F(normalization)/(action.end-action.start)
|
||||
if factor.denominator!=1:raise ValueError('Unsupported training normalization span')
|
||||
factor=int(factor)
|
||||
rows.append((action,tuple((k,v*factor) for k,v in features.items() if v),
|
||||
sum(v*b.selector_default[k] for k,v in prior.items())*factor))
|
||||
if len(tables)>=24:tables.pop(next(iter(tables)))
|
||||
tables[key]=tuple(rows);return tables[key]
|
||||
from generator.solo.selection import DEFAULT
|
||||
for _,_,b,_ in examples:b.selector_default=DEFAULT
|
||||
for iteration in range(passes):
|
||||
updates={r:Counter() for r in weights};records=[];errors=0;covered=0
|
||||
for name,role,b,gold in examples:
|
||||
b.selector.weights=weights[role];state=construction.State();at=b.start
|
||||
encoded=canonical(tuple(n for a in gold for n in a.notes));encoded_set=set(encoded);starts=tuple(n[0] for n in encoded)
|
||||
while at<b.end:
|
||||
rows=table(name,b,at,state)
|
||||
rank=lambda row:(sum(weights[role].get(k,0)*v for k,v in row[1])+row[2],row[0].key)
|
||||
if not rows:raise ValueError('No feasible action during development roll-in')
|
||||
predicted=min(rows,key=rank)
|
||||
left=bisect.bisect_left(starts,at)
|
||||
def matches(row):
|
||||
action=row[0]
|
||||
return canonical(action.notes)==encoded[left:bisect.bisect_left(starts,action.end)] and all((c.target.start,c.target.end,c.target.pitch.number()) in encoded_set for c in action.contracts)
|
||||
if not matches(predicted):
|
||||
positives=sorted((row for row in rows if matches(row)),key=rank)
|
||||
# An alternate valid derivation can be learnable even when the
|
||||
# cheapest positive shares every feature with the wrong action.
|
||||
positive=next((row for row in positives if dict(row[1])!=dict(predicted[1])),positives[0] if positives else None)
|
||||
if positive is None:
|
||||
records.append({'case':name,'first_error':str(at),'status':'coverage_lost_after_alternate_derivation'})
|
||||
errors+=1;break
|
||||
updates[role].update(dict(predicted[1]));updates[role].subtract(dict(positive[1]))
|
||||
records.append({'case':name,'first_error':str(at),'predicted':predicted[0].kind,'positive':positive[0].kind,
|
||||
'identical_features':dict(predicted[1])==dict(positive[1]),'status':'selection_error'})
|
||||
errors+=1;break
|
||||
action=predicted[0]
|
||||
state=b.advance(state,b.selector.evaluate(action,state.recent,state.home,state.last_kind),action)
|
||||
at=action.end
|
||||
covered+=bisect.bisect_left(starts,at)
|
||||
if at==b.end:records.append({'case':name,'status':'exact_rollout','events':len(encoded)})
|
||||
complexity=sum(abs(v) for w in weights.values() for v in w.values())
|
||||
history.append({'pass':iteration+1,'incorrect_rollouts':errors,'matching_prefix_attacks':covered,'cases':records})
|
||||
checkpoint=(errors,-covered,complexity)
|
||||
if best is None or checkpoint<best[0]:best=(checkpoint,json.loads(json.dumps(weights)))
|
||||
if on_checkpoint:on_checkpoint({'weights':best[1],'history':history,'complete':errors==0})
|
||||
print(f'fit pass {iteration+1}: {errors} incorrect rollouts; {covered} matching prefix attacks',flush=True)
|
||||
if not errors:break
|
||||
for role,difference in updates.items():
|
||||
for feature,delta in difference.items():weights[role][feature]=weights[role].get(feature,0)+delta
|
||||
weights[role]={k:v for k,v in weights[role].items() if v}
|
||||
return {'weights':best[1],'history':history,'complete':history[-1]['incorrect_rollouts']==0,
|
||||
'method':'integer early-update structured perceptron, fixed case order and 200-pass maximum',
|
||||
'checkpoint_metric':'incorrect rollouts, then longest correct prefixes, then coefficient magnitude; full-output exact gate remains separate',
|
||||
'limitation':'Training examples are not unseen evaluation. Runtime independence does not imply development independence.'}
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('operation',choices=('explain','fit'))
|
||||
p.add_argument('--source',action='append',required=True,metavar='CASE=PATH')
|
||||
p.add_argument('--reference',action='append',required=True,metavar='CASE=PATH')
|
||||
p.add_argument('--role',action='append',required=True,metavar='CASE=acoustic_solo|electric_solo')
|
||||
p.add_argument('--form-start',action='append',default=[],metavar='CASE=MEASURE')
|
||||
p.add_argument('--data-dir',type=Path,default=Path('data'))
|
||||
p.add_argument('--cache-dir',type=Path)
|
||||
p.add_argument('--output',type=Path,required=True)
|
||||
p.add_argument('--passes',type=int,default=200)
|
||||
args=p.parse_args()
|
||||
if not 1<=args.passes<=200:p.error('passes must be 1–200')
|
||||
pairs=lambda values:dict(v.split('=',1) for v in values)
|
||||
sources,refs,roles=map(pairs,(args.source,args.reference,args.role));starts=pairs(args.form_start)
|
||||
if sources.keys()!=refs.keys() or sources.keys()!=roles.keys():p.error('source/reference/role cases must match')
|
||||
args.output.mkdir(parents=True,exist_ok=True);results={};examples=[]
|
||||
data=vocabulary.load(args.data_dir/'composition');began=time.monotonic()
|
||||
for name in sorted(sources):
|
||||
with vocabulary.using(data,roles[name]):
|
||||
b,_=build(sources[name],roles[name],args.data_dir,args.cache_dir)
|
||||
target=read(refs[name],name,int(starts[name]) if name in starts else None,'guitar-written')
|
||||
path,result=derive(b,target)
|
||||
results[name]={**result,'reference_sha256':target['sha256'],'source_sha256':hashlib.sha256(Path(sources[name]).read_bytes()).hexdigest(),
|
||||
'derivation':[{'kind':a.kind,'start':str(a.start),'end':str(a.end),'events':[(str(n.start),str(n.end),n.pitch.number()) for n in a.notes],'rules':[c.rule for c in a.contracts]} for a in path]}
|
||||
if args.operation=='fit' and result['complete']:examples.append((name,roles[name],b,path))
|
||||
print(name,result['status'],result['covered_until'],flush=True)
|
||||
result={'schema':'shared-rule-coverage-1','development_only':True,'coverage_seconds':time.monotonic()-began,'cases':results}
|
||||
if args.operation=='fit':
|
||||
if not all(r['complete'] for r in results.values()):result['fit_status']='blocked_by_construction_coverage'
|
||||
else:
|
||||
def checkpoint(value):
|
||||
path=args.output/'checkpoint.json';temporary=path.with_suffix('.tmp')
|
||||
temporary.write_text(json.dumps(value,indent=2)+'\n');temporary.replace(path)
|
||||
fitting_start=time.monotonic()
|
||||
result['fit']=fit(examples,args.passes,checkpoint)
|
||||
result['fit_seconds']=time.monotonic()-fitting_start
|
||||
result['total_seconds']=time.monotonic()-began
|
||||
(args.output/'coverage.json').write_text(json.dumps(result,indent=2)+'\n')
|
||||
return 0 if all(r['complete'] for r in results.values()) and result.get('fit',{'complete':True})['complete'] else 2
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -209,7 +209,7 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None
|
||||
rows=['<!doctype html><meta charset="utf-8"><title>Solo comparison</title>',
|
||||
'<style>body{font:16px system-ui;max-width:1100px;margin:2rem auto}td,th{padding:.5rem;text-align:left}pre{white-space:pre-wrap}</style>',
|
||||
'<h1>Completed solos and reference scores</h1><p>Differences are diagnostic. These files do not supply generation inputs or candidate scores. No listening verdict is claimed.</p>',
|
||||
'<p><a href="comparison.json">Exact timing, notes, harmony, annotations, placement, and phrase reports</a> · <a href="analysis/theory.md">Complete musical relationship review</a></p>']
|
||||
'<p><a href="comparison.json">Exact timing, notes, harmony, annotations, placement, and phrase reports</a></p>']
|
||||
for role,r in result['solos'].items():
|
||||
actual_path=generated_dir/manifest['solos'][role]['file']
|
||||
reference_path=Path(reference_paths[role])
|
||||
@@ -221,10 +221,10 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None
|
||||
selected=collections.Counter(c['kind'] for c in r['production_phrases'].get('expressions',[]))
|
||||
rows.append('<p>Selected, validated production treatments: '+html.escape(str(dict(selected)))+'. Ordinary mapped material remains separate.</p>')
|
||||
if r.get('reference_events_after_production_end'):
|
||||
rows.append('<p>Reference material extends beyond the production vocal-protection window: '+str(len(r['reference_events_after_production_end']))+' events. The reference and vocal timing remain unchanged.</p>')
|
||||
rows.append('<p>Reference material extends beyond this exported production allocation: '+str(len(r['reference_events_after_production_end']))+' events. The reference and source timing remain unchanged.</p>')
|
||||
funnel=r['production_phrases'].get('selection_trace',{}).get('operation_coverage',{})
|
||||
if funnel:
|
||||
stages=('eligible_contexts','proposed','rhythm_valid','complete_routes','valid','finalists','placed','lookahead_valid','selected')
|
||||
stages=tuple(dict.fromkeys(k for counts in funnel.values() for k in counts))
|
||||
rows.append('<details><summary>Construction coverage (not similarity scoring)</summary><table><tr><th>Purpose</th>'+''.join('<th>'+k.replace('_',' ')+'</th>' for k in stages)+'</tr>')
|
||||
for purpose,counts in sorted(funnel.items()):
|
||||
rows.append('<tr><td>'+html.escape(purpose)+'</td>'+''.join('<td>'+str(counts.get(k,0))+'</td>' for k in stages)+'</tr>')
|
||||
@@ -249,9 +249,15 @@ def main():
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--baseline-dir',type=Path,help='Optional previous completed solo exports; never generation inputs.')
|
||||
parser.add_argument('--assets',type=Path,help='Optional completed assets for neutral reference/generated audio with identical backing.')
|
||||
parser.add_argument('--require-exact',action='store_true',help='Fail unless every requested reference has identical complete sounding events and rests.')
|
||||
args=parser.parse_args()
|
||||
run(args.generated_dir,dict(x.split('=',1) for x in args.reference),
|
||||
if args.require_exact and not args.reference:
|
||||
parser.error('--require-exact requires at least one explicit --reference')
|
||||
result=run(args.generated_dir,dict(x.split('=',1) for x in args.reference),
|
||||
{k:int(v) for k,v in (x.split('=',1) for x in args.form_start)},args.reference_pitch,args.output,args.assets,args.baseline_dir)
|
||||
for role,review in result['solos'].items():
|
||||
print(f'{role}: exact={review["exact_musical_match"]}; matching attacks={review["exact_matching_attacks"]}; differing onsets={len(review["differences"])}; rests differ={review["rest_difference"]}')
|
||||
return 1 if args.require_exact and any(not r['exact_musical_match'] for r in result['solos'].values()) else 0
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"provenance": "Original conservative chord/function rules; not learned from supplied reference scores. Review of reference colors prompted the general distinction between a stable minor sixth and a resolving chromatic neighbor; no reference timing distribution was fitted.",
|
||||
"provenance": "Shared chord/function policies. Target-guided review exposed enharmonic and immediate-resolution restrictions; changes are general degree policies, not target phrases. Available tensions and delayed resolution are informed by Berklee faculty teaching; numerical duration limits are engineering settings.",
|
||||
"license": "MIT",
|
||||
"colors": [
|
||||
{
|
||||
@@ -43,7 +43,7 @@
|
||||
"degree": 9,
|
||||
"alter": 0,
|
||||
"max_beats": "4",
|
||||
"outside": false
|
||||
"outside": true
|
||||
},
|
||||
{
|
||||
"id": "minor_eleventh",
|
||||
@@ -76,7 +76,7 @@
|
||||
"degree": 9,
|
||||
"alter": 0,
|
||||
"max_beats": "4",
|
||||
"outside": false
|
||||
"outside": true
|
||||
},
|
||||
{
|
||||
"id": "dominant_thirteenth",
|
||||
@@ -86,7 +86,7 @@
|
||||
"degree": 13,
|
||||
"alter": 0,
|
||||
"max_beats": "4",
|
||||
"outside": false
|
||||
"outside": true
|
||||
},
|
||||
{
|
||||
"id": "dominant_flat_ninth",
|
||||
@@ -95,10 +95,11 @@
|
||||
],
|
||||
"degree": 9,
|
||||
"alter": -1,
|
||||
"max_beats": "2",
|
||||
"max_beats": "4",
|
||||
"outside": true,
|
||||
"resolve": true,
|
||||
"function": "resolving_dominant"
|
||||
"resolve": false,
|
||||
"function": "resolving_dominant",
|
||||
"continuation": "Bounded dominant color. May continue through other chord tones/tensions rather than resolve immediately by step. Written exclusions and harmonic function remain binding."
|
||||
},
|
||||
{
|
||||
"id": "dominant_sharp_ninth",
|
||||
@@ -107,10 +108,11 @@
|
||||
],
|
||||
"degree": 9,
|
||||
"alter": 1,
|
||||
"max_beats": "2",
|
||||
"max_beats": "4",
|
||||
"outside": true,
|
||||
"resolve": true,
|
||||
"function": "resolving_dominant"
|
||||
"resolve": false,
|
||||
"function": "resolving_dominant",
|
||||
"continuation": "Bounded dominant color. May continue through other chord tones/tensions rather than resolve immediately by step. Written exclusions and harmonic function remain binding."
|
||||
},
|
||||
{
|
||||
"id": "dominant_flat_thirteenth",
|
||||
@@ -119,10 +121,23 @@
|
||||
],
|
||||
"degree": 13,
|
||||
"alter": -1,
|
||||
"max_beats": "2",
|
||||
"max_beats": "4",
|
||||
"outside": true,
|
||||
"resolve": true,
|
||||
"function": "resolving_dominant"
|
||||
"resolve": false,
|
||||
"function": "resolving_dominant",
|
||||
"continuation": "Bounded dominant color. May continue through other chord tones/tensions rather than resolve immediately by step. Written exclusions and harmonic function remain binding."
|
||||
},
|
||||
{
|
||||
"id": "half_diminished_natural_sixth",
|
||||
"qualities": [
|
||||
"half_diminished"
|
||||
],
|
||||
"degree": 13,
|
||||
"alter": 0,
|
||||
"max_beats": "4",
|
||||
"outside": false,
|
||||
"function": "predominant_to_dominant",
|
||||
"continuation": "Mapped Locrian-natural-sixth color preparing the third of the following dominant; excluded when not present in the selected collection or when that harmonic function is absent."
|
||||
}
|
||||
],
|
||||
"resolution_gap": "1",
|
||||
@@ -137,5 +152,9 @@
|
||||
-2
|
||||
]
|
||||
}
|
||||
],
|
||||
"sources": [
|
||||
"https://www.berklee.edu/berklee-today/summer-2014/tension-and-resolution",
|
||||
"https://college.berklee.edu/berklee-today-55"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"provenance": "Original target-relative formulas; no reference-score licks or statistics. Reference analysis motivated independently parameterized mapped-neighbor variants; no reference phrases or positions are stored.",
|
||||
"provenance": "Generic target-relative approach/enclosure operators. Target-guided review motivated an explicitly separated enclosure and spelling-independent function. No pitches, chord progressions, bar schedules or extracted phrase templates are stored.",
|
||||
"license": "MIT",
|
||||
"patterns": [
|
||||
{
|
||||
@@ -119,6 +119,135 @@
|
||||
"mapped_indices": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "separated_lower_upper",
|
||||
"kind": "enclosure",
|
||||
"semitones": [
|
||||
-1,
|
||||
2
|
||||
],
|
||||
"durations": [
|
||||
"1/2",
|
||||
"1/2"
|
||||
],
|
||||
"internal_gaps": [
|
||||
"1/2"
|
||||
],
|
||||
"gap": "0",
|
||||
"mapped_indices": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chromatic_scalar_up",
|
||||
"kind": "approach",
|
||||
"semitones": [
|
||||
-3,
|
||||
-2
|
||||
],
|
||||
"durations": [
|
||||
"1/2",
|
||||
"1/2"
|
||||
],
|
||||
"gap": "0",
|
||||
"mapped_indices": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chromatic_scalar_down",
|
||||
"kind": "approach",
|
||||
"semitones": [
|
||||
3,
|
||||
2
|
||||
],
|
||||
"durations": [
|
||||
"1/2",
|
||||
"1/2"
|
||||
],
|
||||
"gap": "0",
|
||||
"mapped_indices": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "short_chromatic_up",
|
||||
"kind": "connection",
|
||||
"semitones": [
|
||||
-2,
|
||||
-1
|
||||
],
|
||||
"durations": [
|
||||
"1/4",
|
||||
"1/4"
|
||||
],
|
||||
"gap": "0",
|
||||
"difficulty": "sixteenth"
|
||||
},
|
||||
{
|
||||
"id": "short_chromatic_down",
|
||||
"kind": "connection",
|
||||
"semitones": [
|
||||
2,
|
||||
1
|
||||
],
|
||||
"durations": [
|
||||
"1/4",
|
||||
"1/4"
|
||||
],
|
||||
"gap": "0",
|
||||
"difficulty": "sixteenth"
|
||||
},
|
||||
{
|
||||
"id": "triplet_chromatic_up",
|
||||
"kind": "connection",
|
||||
"semitones": [
|
||||
-2,
|
||||
-1
|
||||
],
|
||||
"durations": [
|
||||
"1/3",
|
||||
"1/3"
|
||||
],
|
||||
"gap": "0",
|
||||
"difficulty": "triplet"
|
||||
},
|
||||
{
|
||||
"id": "triplet_chromatic_down",
|
||||
"kind": "connection",
|
||||
"semitones": [
|
||||
2,
|
||||
1
|
||||
],
|
||||
"durations": [
|
||||
"1/3",
|
||||
"1/3"
|
||||
],
|
||||
"gap": "0",
|
||||
"difficulty": "triplet"
|
||||
},
|
||||
{
|
||||
"id": "quarter_below",
|
||||
"kind": "approach",
|
||||
"semitones": [
|
||||
-1
|
||||
],
|
||||
"durations": [
|
||||
"1"
|
||||
],
|
||||
"gap": "0"
|
||||
},
|
||||
{
|
||||
"id": "quarter_above",
|
||||
"kind": "approach",
|
||||
"semitones": [
|
||||
1
|
||||
],
|
||||
"durations": [
|
||||
"1"
|
||||
],
|
||||
"gap": "0"
|
||||
}
|
||||
],
|
||||
"blues": [
|
||||
@@ -135,5 +264,27 @@
|
||||
"target_degree": 5
|
||||
}
|
||||
],
|
||||
"max_approach": "3/2"
|
||||
"max_approach": "3/2",
|
||||
"delayed_approach": {
|
||||
"id": "bounded_semitone_resolution",
|
||||
"max_note": "1/2",
|
||||
"deadline": "4",
|
||||
"steps": [
|
||||
-1,
|
||||
1
|
||||
],
|
||||
"arrivals": [
|
||||
"1",
|
||||
"3/2",
|
||||
"2",
|
||||
"5/2",
|
||||
"3",
|
||||
"7/2",
|
||||
"4"
|
||||
],
|
||||
"holds": [
|
||||
"1/2",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+12
-23
@@ -6,32 +6,21 @@
|
||||
"acoustic_solo": {
|
||||
"seed": "acoustic",
|
||||
"register_center": 57,
|
||||
"register_alternatives": [
|
||||
-7,
|
||||
0,
|
||||
7
|
||||
],
|
||||
"preferred_actions": [
|
||||
"arpeggio",
|
||||
"answer",
|
||||
"develop",
|
||||
"neighbor"
|
||||
]
|
||||
"selection_weights": {
|
||||
"kind/arpeggio": -2,
|
||||
"kind/answer": -2,
|
||||
"duration/1": -1,
|
||||
"space/after_arrival": -2
|
||||
}
|
||||
},
|
||||
"electric_solo": {
|
||||
"seed": "electric",
|
||||
"register_center": 62,
|
||||
"register_alternatives": [
|
||||
-7,
|
||||
0,
|
||||
7
|
||||
],
|
||||
"preferred_actions": [
|
||||
"scalar",
|
||||
"arpeggio",
|
||||
"develop",
|
||||
"answer"
|
||||
]
|
||||
"selection_weights": {
|
||||
"kind/scalar": -2,
|
||||
"metric/eighth/1_2": -1,
|
||||
"kind/enclosure": -1
|
||||
}
|
||||
},
|
||||
"acoustic_accompaniment": {
|
||||
"seed": "acoustic_comp",
|
||||
@@ -53,7 +42,7 @@
|
||||
"difficulty": {
|
||||
"name": "moderate",
|
||||
"triplet_max_bpm": 160,
|
||||
"sixteenth_max_bpm": 112,
|
||||
"sixteenth_max_bpm": 144,
|
||||
"burst_attacks": 4
|
||||
}
|
||||
}
|
||||
|
||||
+28
-54
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"provenance": "Independently authored compositional operators and grouping policies based on general arpeggio, scalar, thematic and tension-resolution principles. Earlier authored cells retained with their provenance. No reference phrases, extracted timing patterns, measurements or fitted weights.",
|
||||
"provenance": "General musical operators authored from chord-tone, scale-motion, rhythmic grouping and target-resolution principles. Shared ranking may be fitted in explicitly invoked development commands; no stored reference phrases or song-specific prescriptions are permitted.",
|
||||
"license": "MIT",
|
||||
"cells": [
|
||||
{
|
||||
@@ -278,58 +278,32 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
"arpeggio",
|
||||
"scalar",
|
||||
"neighbor",
|
||||
"develop",
|
||||
"answer",
|
||||
"return",
|
||||
"head"
|
||||
],
|
||||
"directions": [
|
||||
"up",
|
||||
"down",
|
||||
"arch",
|
||||
"valley"
|
||||
],
|
||||
"entrances": [
|
||||
"0",
|
||||
"1/2",
|
||||
"1",
|
||||
"3/2"
|
||||
],
|
||||
"arrival_offsets": [
|
||||
"-1/2",
|
||||
"0",
|
||||
"1/2"
|
||||
],
|
||||
"holds": [
|
||||
"1",
|
||||
"3/2",
|
||||
"2",
|
||||
"5/2",
|
||||
"3"
|
||||
],
|
||||
"breaths": [
|
||||
"0",
|
||||
"1/2",
|
||||
"1",
|
||||
"3/2"
|
||||
],
|
||||
"max_phrase_bars": 4,
|
||||
"weights": {
|
||||
"purpose": 2,
|
||||
"continuity": 2,
|
||||
"repetition": 1,
|
||||
"register": 1
|
||||
},
|
||||
"search": {
|
||||
"proposals": 32,
|
||||
"finalists": 8,
|
||||
"fingerings": 2,
|
||||
"frontier": 4,
|
||||
"replans": 1,
|
||||
"route_frontier": 8
|
||||
"operators": {
|
||||
"durations": [
|
||||
"1/4",
|
||||
"1/2",
|
||||
"1",
|
||||
"3/2",
|
||||
"2",
|
||||
"5/2",
|
||||
"3",
|
||||
"7/2",
|
||||
"4"
|
||||
],
|
||||
"approach_holds": [
|
||||
"1/2",
|
||||
"1",
|
||||
"3/2",
|
||||
"2",
|
||||
"5/2",
|
||||
"3",
|
||||
"1/3"
|
||||
],
|
||||
"per_state": 24,
|
||||
"connection_preparations": [
|
||||
"1/2",
|
||||
"1"
|
||||
],
|
||||
"backtracks": 16
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
# Active solo path after the rewrite
|
||||
# Active path audit
|
||||
|
||||
The replaced entry path was `production.generate_role → composer.compose → leads.thematic_options → themes.phrase_candidates/unit_candidates → routes/gestures → realization.checked_phrase → scoring.musical`. The same modules retained paraphrase, draft, cell-search, join-refinement, and older scoring APIs. Source-derived feature contracts could preserve a long head rhythm before a new tune existed.
|
||||
Starting revision: `0e68f53` (`Cleanup`). The earlier implementation report is preserved in [baseline_0e68f53.md](baseline_0e68f53.md); its test and timing results do not describe this change.
|
||||
|
||||
The sole normal path is now `production.generate_role → composer.compose → construction.Builder → rhythm.Rhythms + routes.Routes → scoring.evaluate → placement.alternatives/place → composer.validate → notation/export`. There is no legacy mode, silent fallback, pair selector, or reference argument.
|
||||
Before: `production.generate_role → composer.compose → construction.Builder.propose → rhythm.Rhythms.realize + routes.Routes.realize → scoring.evaluate → one-phrase lookahead → placement → validation → notation/export`.
|
||||
|
||||
| Finding | Before this rewrite | Resolution |
|
||||
After: `production.generate_role → composer.compose → construction.Builder → operators.Operators + selection.Selector → bounded action repair → whole-solo placement → validation → notation/export`.
|
||||
|
||||
| Finding | Verified starting behavior | Change / remaining limitation |
|
||||
|---|---|---|
|
||||
| Multiple generations of musical construction coexist | Confirmed by callers/imports and legacy mode branch | Thirteen obsolete construction/helper modules removed |
|
||||
| Mandatory source-feature influence | Confirmed in theme seed and feature-prefix construction | New melody is independent of head rhythm; head references are optional actions |
|
||||
| Final scorer overwritten / density omitted | Already repaired in the preceding active path; not reproduced as a current defect | Old duplicate evaluators retired; one completed-phrase evaluator |
|
||||
| Broad score often leaves fingering as decisive term | Observed in saved selected/alternative reports | No fingering term in musical selection; whole-melody placement runs after selection |
|
||||
| Expression only added after strict selection | Already partly repaired by joint-arrival proposals | All retained production expression construction now precedes route selection; post-passes removed |
|
||||
| Bar/cell ends act as mandatory cadences | Older paths contained such requirements; active path had some open behavior | No cells/plans/drafts with competing ending semantics; only actual phrase destinations |
|
||||
| Rhythm packets do not establish a recurring idea | Constructor selected separate short material repeatedly | Repeat a whole rhythm group before reshaping it; never slice tuplets |
|
||||
| Fixed ending/return counts | Historical settings remained | Those settings and scorers removed; no target counts |
|
||||
| Alternatives chosen by hashes before evaluation | `propose` hash-selected timing, destination, treatment, register and first pitch; 32 proposals per batch | Enumerate shared actions before contextual ranking. Stable content ordering breaks score ties. |
|
||||
| Complete target cannot pass allocation | Autumn acoustic lead-in was outside export; electric tail clipped before returning vocal | Source pickup measure allocated before composition. Full electric tail allowed with authorized vocal overlap; vocal unchanged. |
|
||||
| Scorer overwritten / density silently omitted | Not reproduced: only four named phrase terms remained | One rational action evaluator; no hidden density setting. |
|
||||
| Every unit closes | Not reproduced: old constructor had open alternatives, but handoff forced a 2–3 beat hold | Individual actions need no chord-tone stop; handoff can be short or silent. |
|
||||
| Answer unreachable | Not reproduced: old chooser requested answers | New answer transforms the actual heard group as a complete candidate. |
|
||||
| Expressions are post-processing only | Not reproduced: old proposals planned them jointly | Keep joint treatments; carry pending exact-target obligations across actions. |
|
||||
| Missing expressive coverage | Reproduced against actual enumerated operators | General delayed approaches, reattacked anticipations, anticipated colors and separated enclosures. |
|
||||
| Color octaves disappear | Midpoint rounding skipped alternating F-sharp octaves | Enumerate explicit legal octaves. |
|
||||
| Repeated catalog parsing | Parent parsed catalogs that independent workers parsed again | Parent parse removed; content-addressed immutable JSON catalogs shared by workers. |
|
||||
|
||||
Search limits are construction budgets, not musical quotas: 32 proposals per state, eight diverse finalists, two endpoint fingerings per melody, four candidates considered with one-phrase lookahead, one additional planning batch if the first yields nothing. A whole-solo hand-position pass resolves transitions without changing notes.
|
||||
`Operators.at` consumes source harmony, physical domain and versioned data. It records proposed, rejected-by-rule and valid counts. `Builder.candidates` retains 24 representatives by kind/duration; pending obligations filter candidates before this cutoff. `Builder.compose` ranks complete actions and allows 16 deterministic backtracks. There is no wall-clock winner, reference-reader import or old-constructor fallback.
|
||||
|
||||
Shared source analysis contains imported sounding events, tempo and exact measure boundaries. It does not extract compulsory head motifs or source phrase segmentation. Both roles receive the same physical domain and independent deterministic decision streams.
|
||||
This is a local policy, not a globally optimized complete-phrase model. An all-onset frontier experiment was abandoned because of excessive work. Phrase objects group selected actions for validation/reporting; that grouping does not mean whole-phrase alternatives competed. This limitation matters to exact selection.
|
||||
|
||||
The development-only `audition.fit_selection` constrains the same enumerator to an explicit target to diagnose representability. Its fitter rolls in with ordinary ranking and updates shared coefficients at the first mismatch. A valid derivation is not proof that production selects it. The release comparison checks the exported unrestricted winner separately.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Complete-phrase solo rewrite
|
||||
|
||||
Starting revision: `68df7292ee85918a0cdbd0965139ccb2ceecb130`. This revision replaces the active solo constructor. Source import, harmony, exact timing, typed expression validation, independent roles, guitar placement, notation, playback and atomic asset publication remain in use.
|
||||
|
||||
The selected direction is a new melody over the source changes, with optional head references. The source head no longer supplies a compulsory rhythmic prefix or protected initial hold. The four supplied solos remain outside production and unchanged; exact reproduction or listener preference is not claimed.
|
||||
|
||||
## Construction and simplification
|
||||
|
||||
The normal path is `production.generate_role → composer.compose → construction.Builder → rhythm.Rhythms/routes.Routes → scoring.evaluate → placement → validation → notation/export`.
|
||||
|
||||
Each proposal chooses a phrase purpose, rhythm groups, direction, harmonic destination, arrival timing, sounding duration and optional treatment together. Complete pitch paths realize those choices. Open continuations need no chord-tone stop; settling, completion and handoff have actual destination contracts. Anticipations, approaches, enclosures, connections, colors, blues treatments and prepared suspensions enter before route selection and must validate as complete relationships.
|
||||
|
||||
An introduced realized phrase supplies the home idea. Development, answer, deliberate return and optional head reference are distinct reachable actions. A return protects a short heard rhythm and directional signature with harmonic adaptation; ordinary phrases have no compulsory three-note prefix. This signature is not a perceptual guarantee of recognition.
|
||||
|
||||
One versioned evaluator reports purpose, incoming continuity, contextual repetition and broad register costs. Physical travel selects fingering, not the winning tune. One-phrase lookahead averages normalized completed-phrase scores across variable durations; it remains a bounded heuristic, not a whole-chorus optimum. Route construction has its own explicitly separate heuristic.
|
||||
|
||||
The solo package decreases from **6,232 to 1,729 Python lines**, and from 30 to 19 modules. Thirteen obsolete constructors/helpers were removed and two focused modules added. Old plan/draft/paraphrase API tests were retired; retained musical and representation contracts were migrated. Removed tests are not counted as passing tests. No legacy production fallback remains.
|
||||
|
||||
## Inputs and data
|
||||
|
||||
The 21 scales, 2,954 sequence paths and 3,538 rhythm snippets remain intact. `solo.json` contains independently authored parameterized actions, nine atomic rhythm groups and migrated authored cells. `grammar.json` and `melodic_vocabulary.json` were retired after their consumers migrated. Accompaniment fields and resources were not changed.
|
||||
|
||||
References: `build/all-of-me-solo-1.xml`, `all-of-me-solo-2.xml`, `autumn-leaves-solo-1.xml`, `autumn-leaves-solo-2.xml`. Solo 1 maps to acoustic; Solo 2 to electric. The optional reader applies the confirmed guitar-written octave convention and preserves raw evidence. Autumn Leaves aligns at the first Gm7 in reference measure 2; its preceding measure remains in the comparison. Four Autumn electric reference events extend beyond the source-defined vocal allocation. Neither the target nor the vocal timing is changed to force equality.
|
||||
|
||||
All six strings remain available on frets 1–12 without open strings. Ties retain one sounding event and one position. No performance-expression changes were introduced. The existing independently generated accompaniment, electric melody and vocals are intentionally preserved by this solo rewrite.
|
||||
|
||||
## Verification and review
|
||||
|
||||
The final Python command was the repository Python runner followed by `-m unittest discover -s tests -p 'test_*.py'`: **435 tests passed in 218.783 seconds**. During implementation, repeat-build tests caught profiling timings leaking into stable diagnostics. Timings now live only in the separate build profile; the final suite includes those repeat-build checks. Earlier interrupted/stale-import runs are not counted as passing verification.
|
||||
|
||||
The explicit dependency command passed all six roles on independently authored input: alone/cold, reverse/warm, parallel/cold, invalid optional targets present, targets removed, and after an electric-only setting change. Actual reads and canonical document hashes are retained. This synthetic test is not a whole-song quality assessment.
|
||||
|
||||
The optional transfer command passed all 18 role builds on evaluation-only four-bar excerpts from After You've Gone, Hindustan and Indiana. Their provenance remains under `tests/evaluation_sources/`. This demonstrates limited transfer; it does not establish arbitrary meter, form or chord support.
|
||||
|
||||
The comparison page includes full generated, previous and reference solo audio, common-backing mixes and opening excerpts. The same neutral renderer and constant comparison gains are used. Comparison reads happen only after generation. No ear-based audition or listener approval is claimed.
|
||||
|
||||
## Evidence locations
|
||||
|
||||
- [Active path and findings](active_path_audit.md)
|
||||
- [Resource/code migration and provenance](data_migration.md)
|
||||
- [Evaluation contract and search limitation](evaluation_contract.md)
|
||||
- [Capability matrix](musical_capability_matrix.md)
|
||||
- [Independent-input verification](dependency_proof.md)
|
||||
- [Reproduction commands](commands.md)
|
||||
|
||||
`build/solo_reference_comparison/` is the sole comparison location. `previous/` retains the immediate pre-rewrite output and input hashes; `generated/` contains the replacement. The complete operation/pruning trace and local/next-phrase evaluations are in the manifest. `analysis/selected_phrases.md` provides readable selected events, destinations, holds, expression evidence and alternative scores.
|
||||
|
||||
## Remaining boundaries
|
||||
|
||||
The reference solos are outside-review examples, not a numerical loss. The replacement output remains new music, not those four scores. Counts and matching attacks do not establish greater musical quality.
|
||||
|
||||
The search retains a small frontier and limited lookahead. It can choose another complete phrase, but does not perform arbitrary two-sided revision of committed music. Some exact thematic returns may still sound too weak; the selected rhythm and contour contract is less strict than exact intervals. Acceptable candidates can share a zero musical penalty, leaving the stable role key to break ties.
|
||||
|
||||
Unsupported written harmony produces an explicit diagnostic. Optional expression rules do not repair ambiguous source chords or authorize arbitrary chromatic notes. Legal TAB and coarse tempo limits are not full biomechanical certification. The next useful listening check is the four complete comparisons: whether their openings establish memorable ideas and whether later returns remain recognizable through the freer continuations.
|
||||
|
||||
## Published assets and measured cost
|
||||
|
||||
Full assets are published at `build/build_x86_debug/generated_assets/`; the four exported solos are in `solo_xml/`. All twelve musical role documents match between cold staged generation and cached publication. The eight non-solo documents are exactly equal to the preserved baseline. Every exported solo sounding event and TAB position matches the selected stream. All committed holds and typed harmonic contracts passed validation. All six source/reference file hashes are unchanged.
|
||||
|
||||
| Solo | New attacks | Sounding MIDI range | Occupancy |
|
||||
|---|---:|---|---:|
|
||||
| all_of_me/acoustic | 117 | 47–67 | 0.781 |
|
||||
| all_of_me/electric | 139 | 52–72 | 0.824 |
|
||||
| autumn_leaves/acoustic | 153 | 47–67 | 0.859 |
|
||||
| autumn_leaves/electric | 109 | 52–70 | 0.732 |
|
||||
|
||||
These descriptive measurements are not targets or proof of musical improvement. None of the four generated solos exactly matches its reference. All four normalized references have legal pitch/position paths within the existing guitar constraints; the Autumn electric vocal-allocation conflict remains explicit.
|
||||
|
||||
The native integration test `ctest --test-dir build/build_x86_debug -R '^product_vamp$' --output-on-failure` passed in **30.45 seconds**, with required localhost access. The optional comparison adapter suite passed **8 tests in 0.018 seconds** after the audio-directory update. All **36 comparison WAVs** passed PCM clipping checks, and all **50 page links** resolved. The signal review is not an ear-based audition.
|
||||
|
||||
The isolated final staged asset build took **107.15 seconds** wall time, including **91.31 seconds** in the performance-part stage. All twelve role caches missed. Two composition workers ran in separate processes; no tests or audition rendering overlapped. Engraving was warm and reused all six scores. The **27.57-second** normal CMake publication reused all twelve role caches and six engraving results.
|
||||
|
||||
| Cold solo role | Role seconds | Construction seconds | Route seconds |
|
||||
|---|---:|---:|---:|
|
||||
| all-of-me/electric_solo | 40.82 | 26.27 | 21.47 |
|
||||
| all-of-me/acoustic_solo | 44.38 | 29.96 | 24.90 |
|
||||
| autumn_leaves/electric_solo | 35.56 | 33.09 | 28.31 |
|
||||
| autumn_leaves/acoustic_solo | 44.04 | 41.71 | 35.80 |
|
||||
|
||||
Worker times overlap. Role time includes resource setup, notation and validation beyond construction. Route time is included in construction, not an additional cost. Hardware/process evidence: 12 logical CPUs, Linux x86-64, repository Python 3.13; `build-profile.json` contains actual reads, worker settings and stage times. The first worker use still spends time loading the full catalogs. Route search remains the dominant construction cost.
|
||||
|
||||
An isolated one-process All of Me acoustic build took **39.80 seconds** without cProfile. The diagnostic run before the performance-only optimization took 163.09 seconds **with cProfile enabled**, so those times are not a valid speedup ratio. Profiling exposed repeated pitch hashing/stringification and harmony lookups. Memoization and hoisted identical calculations preserved the complete musical document hash `2b6b35011e353d166d287a8bfff6074723e62559b5e64ff978084623e1520e67`, also matching final production. The rewrite changes music and search structure; its timing is not presented as a semantics-preserving speedup over the old composer.
|
||||
|
||||
Cleanup removed **151.2 MiB** of superseded comparison audio, stages, probes and temporary rendering tones during this rewrite. The immediate baseline, four user references, build toolchains, caches, published assets and useful verification evidence remain. Current audio is consolidated under `audio/` and linked from one `index.html`; `cleanup.json` records removed paths.
|
||||
+42
-24
@@ -1,35 +1,53 @@
|
||||
# Reproduction commands
|
||||
# Reproduction commands and release gate
|
||||
|
||||
Use this prefix from the repository root:
|
||||
Prefix from the repository root:
|
||||
|
||||
```sh
|
||||
build/build_common/python/bin/python3 build/build_tools/python_runner.py build/build_common/modulus
|
||||
```
|
||||
|
||||
Staged generation:
|
||||
|
||||
```sh
|
||||
-m generator --data-dir data --output-dir build/solo_reference_comparison/.staging --static-assets-dir package/assets --check --jobs 2 --composition-jobs 2 --composition-cache build/build_common/composition_cache --engraving-cache build/build_common/engraving_cache --export-solo-xml --trace-level full --profile-output build/solo_reference_comparison/build-profile.json --progress
|
||||
```
|
||||
|
||||
Publish normally with `cmake --build build/build_x86_debug --target vamp_generated_assets -j2`. Preserve the previous completed exports under `build/solo_reference_comparison/previous/solo_xml/` before replacing assets. Copy the checked stage's `solo_xml/` into `build/solo_reference_comparison/generated/` to retain full decision reports, and verify its musical/XML equality with normal publication.
|
||||
|
||||
```sh
|
||||
-m audition.solo_compare --generated-dir build/solo_reference_comparison/generated --baseline-dir build/solo_reference_comparison/previous/solo_xml --reference all_of_me/acoustic=build/all-of-me-solo-1.xml --reference all_of_me/electric=build/all-of-me-solo-2.xml --reference autumn_leaves/acoustic=build/autumn-leaves-solo-1.xml --reference autumn_leaves/electric=build/autumn-leaves-solo-2.xml --form-start autumn_leaves/acoustic=2 --form-start autumn_leaves/electric=2 --form-start all_of_me/acoustic=1 --form-start all_of_me/electric=1 --reference-pitch guitar-written --assets build/build_x86_debug/generated_assets --output build/solo_reference_comparison
|
||||
```
|
||||
|
||||
Full relationship/rule review:
|
||||
|
||||
```sh
|
||||
-m audition.theory_review --reference all_of_me/acoustic=build/all-of-me-solo-1.xml --reference all_of_me/electric=build/all-of-me-solo-2.xml --reference autumn_leaves/acoustic=build/autumn-leaves-solo-1.xml --reference autumn_leaves/electric=build/autumn-leaves-solo-2.xml --source all_of_me=data/songs/all-of-me.xml --source autumn_leaves=data/songs/autumn_leaves.xml --generated-dir build/solo_reference_comparison/generated --output build/solo_reference_comparison/analysis
|
||||
```
|
||||
|
||||
Tests:
|
||||
The commands below go after that prefix. They write only the consolidated repair directory. Existing published assets and the baseline remain separate.
|
||||
|
||||
```sh
|
||||
-m unittest discover -s tests -p 'test_*.py'
|
||||
-m audition.dependency_check --output build/solo_reference_comparison/dependency
|
||||
-m audition.transfer_review --output build/solo_reference_comparison/transfer
|
||||
-m audition.dependency_check --output build/solo_reference_comparison/repair/dependency
|
||||
```
|
||||
|
||||
Individual-role generation remains `-m generator.compose --song ... --role acoustic_solo --data-dir data --cache-dir ... --output ... --solo-xml ...`. No other part is generated as a musical input. The one comparison folder is downstream and disposable.
|
||||
Development-only derivation/fitting (use `explain` instead of `fit` for coverage alone):
|
||||
|
||||
```sh
|
||||
-m audition.fit_selection fit --passes 200 \
|
||||
--source all_me_a=data/songs/all-of-me.xml --source all_me_e=data/songs/all-of-me.xml \
|
||||
--source autumn_a=data/songs/autumn_leaves.xml --source autumn_e=data/songs/autumn_leaves.xml \
|
||||
--reference all_me_a=build/all-of-me-solo-1.xml --reference all_me_e=build/all-of-me-solo-2.xml \
|
||||
--reference autumn_a=build/autumn-leaves-solo-1.xml --reference autumn_e=build/autumn-leaves-solo-2.xml \
|
||||
--role all_me_a=acoustic_solo --role all_me_e=electric_solo \
|
||||
--role autumn_a=acoustic_solo --role autumn_e=electric_solo \
|
||||
--form-start all_me_a=1 --form-start all_me_e=1 --form-start autumn_a=2 --form-start autumn_e=2 \
|
||||
--cache-dir build/solo_reference_comparison/repair/cache --output build/solo_reference_comparison/repair/fitting
|
||||
```
|
||||
|
||||
Production stage (no reference arguments):
|
||||
|
||||
```sh
|
||||
-m generator --data-dir data --output-dir build/solo_reference_comparison/repair/staged \
|
||||
--static-assets-dir package/assets --check --jobs 2 --composition-jobs 2 \
|
||||
--composition-cache build/solo_reference_comparison/repair/cache \
|
||||
--engraving-cache build/build_common/engraving_cache --export-solo-xml --trace-level full \
|
||||
--profile-output build/solo_reference_comparison/repair/build-profile.json --progress
|
||||
```
|
||||
|
||||
Exact gate after generation:
|
||||
|
||||
```sh
|
||||
-m audition.solo_compare --generated-dir build/solo_reference_comparison/repair/staged/solo_xml \
|
||||
--reference all_of_me/acoustic=build/all-of-me-solo-1.xml --reference all_of_me/electric=build/all-of-me-solo-2.xml \
|
||||
--reference autumn_leaves/acoustic=build/autumn-leaves-solo-1.xml --reference autumn_leaves/electric=build/autumn-leaves-solo-2.xml \
|
||||
--form-start all_of_me/acoustic=1 --form-start all_of_me/electric=1 \
|
||||
--form-start autumn_leaves/acoustic=2 --form-start autumn_leaves/electric=2 \
|
||||
--reference-pitch guitar-written --output build/solo_reference_comparison/repair/comparison --require-exact
|
||||
```
|
||||
|
||||
`--require-exact` returns 1 on any sounding-event/rest mismatch; it does not reinterpret a similar style as success. Add `--assets <completed-assets>` for consistent neutral rendering. A failed fit or exact gate is not permission to copy targets, promote a target-constrained derivation, or publish a mismatched stage. Normal CMake publication is intentionally deferred until the approved acceptance gate passes.
|
||||
|
||||
Individual roles remain available through `-m generator.compose --song ... --role acoustic_solo --data-dir data --cache-dir ... --output ... --solo-xml ...`. Use `-m audition.benchmark --source ... --role ... --cache ... --output ...` for an isolated measurement after other builds/tests/fits stop. Profiling changes measured runtime; do not compare a cProfile run to an unprofiled build as a speedup ratio.
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
# Musical data and code migration
|
||||
# Data and consumer migration
|
||||
|
||||
No target XML, target-derived phrase, timing distribution, fitted weight or lookup by song title was added to production. The four references motivated qualitative musical requirements; the new rules are independently authored general operators. Existing sources and accompaniment resources are unchanged.
|
||||
The latest approved scope permits target-guided diagnosis of general rules and fitting shared theory coefficients. Production target reads, copied tunes, per-song recipes and hidden phrase banks remain prohibited. Policy provenance records this development influence; these four targets are not an unseen evaluation set. A development weight artifact is not automatically a production resource.
|
||||
|
||||
| Resource | Action | Live consumer / reason |
|
||||
| Path | Action | Live consumer / reason |
|
||||
|---|---|---|
|
||||
| `data/songs/` | Keep unchanged | Canonical source harmony, form and optional head material |
|
||||
| `data/scales/` | Keep 21 patterns | Existing scale mapper and full-duration permission |
|
||||
| `data/sequences/` | Keep 2,954 paths | Exact degree-step vocabulary and endpoint-indexed short routes; no uniform sampling |
|
||||
| `data/rhythms/` | Keep 3,538 snippets | Meter-indexed representatives; file counts do not determine stylistic frequency |
|
||||
| `composition/rhythms.json` | Keep unchanged | Existing nine melodic cells remain consumed; six comping figures and accompaniment seed content preserved |
|
||||
| `composition/melodic_vocabulary.json` | Migrate then remove | Eleven useful cells move to `solo.json`; prefix traversal/repair operators superseded |
|
||||
| `composition/grammar.json` | Remove | Old feature-preservation/development and scoring contracts no longer construct music |
|
||||
| `composition/solo.json` | Add, schema 1 | Parameterized phrase actions, nine atomic rhythm groups, arrivals, rests, contour alternatives, evaluation and deterministic search budgets |
|
||||
| `composition/harmony.json`, `lead_ins.json` | Keep | Typed colors, suspensions, anticipations, approaches, enclosures and blues validation |
|
||||
| `composition/roles.json` | Replace solo fields only | Action preferences replace compulsory source-feature preferences; accompaniment fields untouched |
|
||||
| `data/expected/` | Keep validation-only | Existing arrangement compatibility checks |
|
||||
| Four supplied XML files under `build/` | Keep untouched | Explicit optional comparison only |
|
||||
| `data/songs/` | Keep untouched | Authoritative source melody, harmony, meter and form |
|
||||
| `data/scales/` | Keep 21 patterns | Ordinary mapped movement; no added scale family |
|
||||
| `data/sequences/` | Keep 2,954 paths | `Operators` licenses exact repeated degree strides; other paths remain available but are not all active choices |
|
||||
| `data/rhythms/` | Keep 3,538 snippets | Whole coherent meter-specific representatives; file count does not determine frequency |
|
||||
| `composition/rhythms.json` | Keep | Authored melodic cells and canonical sparse comping |
|
||||
| `composition/solo.json` | Migrate | Keep cells/groups; remove hash-selection menus and dead scorer settings; add action durations and search budgets |
|
||||
| `composition/harmony.json` | Extend | Stable/resolving colors; mapped half-diminished natural 13 only before a functional dominant |
|
||||
| `composition/lead_ins.json` | Extend | Whole approaches, separated enclosures, complete subdivisions, delayed exact-target resolution |
|
||||
| `composition/roles.json` | Migrate solo fields | Soft registers and shared action weights replace unused menus; comping fields retained |
|
||||
| `data/expected/` | Keep validation-only | Arrangement compatibility, never selection |
|
||||
| Four XML files in `build/` | Keep untouched | Explicit development derivation/fitting and downstream comparison |
|
||||
| `build/.../repair/fitting/` | Development artifact | Not loaded by production; failed fits are not promoted |
|
||||
| `<composition-cache>/_resources/` | Add derivative | Checksummed JSON production catalogs keyed by content and decoder code; no song/role/reference result |
|
||||
|
||||
Removed composer modules: drafts, enclosures (old post-pass), features, gesture_rhythm, gestures (old repair proposals), leads, motifs, phrasing, planning, realization, references (feature scoring), similarity and themes. Their useful harmonic/event/placement semantics remain in the shared validators and event types, not in a hidden alternate composer.
|
||||
`routes.py` and `Rhythms.realize` are retired. `scoring.py` retains the evaluation record; `selection.py` is the sole scorer. `operators.py` owns enumerable actions. There is no legacy fallback. Deleted-API tests were replaced by active-constructor tests, not counted as passing removed tests.
|
||||
|
||||
Tests tied to the removed plan/draft/paraphrase APIs were retired. Independent timing, chord-role, suspended-note, expression, placement, isolation and export tests were retained or migrated. `test_phrase_composer.py` covers the replacement through its actual constructor. Removed tests are not counted as passing tests.
|
||||
The formulas are general relative intervals and durations, not extracted phrases. Supporting discussion: [Berklee tension and resolution](https://www.berklee.edu/berklee-today/summer-2014/tension-and-resolution), [available tensions](https://college.berklee.edu/berklee-today-55), [Locrian natural six](https://www.jazz-guitar-licks.com/pages/guitar-scales-modes/modes-of-the-harmonic-minor-scale/the-locrian-natural-6-mode.html). No example melody from these sources was imported. Numerical bounds are project choices, not universal jazz rules. Authored resources retain MIT licensing.
|
||||
|
||||
New material uses the repository MIT license and explicit provenance. No source songs, scale families, physical restrictions or runtime score schemas changed. Diagnostic reports deliberately move to `composer-report-3`; no draft/refinement stages are fabricated for the new path.
|
||||
Cold-build independence cannot establish provenance alone; review policy/code diffs too. No source score, target score, tuning, fret limit, runtime score schema, velocity or playback feel was changed.
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
# Reproducibility boundary
|
||||
# Independent inputs and reproducibility
|
||||
|
||||
Production consumes canonical original source content, role-specific settings, versioned production resources and the semantic algorithm version `phrase-composer-1`. Titles, paths, target presence, requested-role order and process completion order are not musical inputs.
|
||||
Production consumes canonical source music, relevant versioned production data, role settings and `joint-actions-1`. Optional targets, comparison outputs and completed parts are not runtime musical inputs. The separate development fitter is allowed to inspect explicit targets; that is development influence, not a runtime read. Failed fitted artifacts are not promoted.
|
||||
|
||||
The composition audit records actual reads and rejects reads outside production data, generator code, Python runtime and the current role's correctly keyed cache. Optional reference readers run only after generation. The four target files remain untouched and their hashes are recorded with the comparison baseline.
|
||||
`input_audit.composition` records actual reads and rejects paths outside production data, generator code, the Python runtime, the current role's concrete cache and a narrowly shared `_resources` catalog cache. The latter contains only compiled generic production catalogs, keyed by content and decoder semantics, with a payload checksum. Other roles' concrete caches remain forbidden. Tests also cover corrupted catalog regeneration.
|
||||
|
||||
No solo or accompaniment generator reads another selected part. Accompaniment remains the existing canonical source-derived part, including during the other guitar's solo. Assembly only applies source-defined participation windows. Source melody/vocal output is unchanged by this rewrite.
|
||||
The explicit `audition.dependency_check` passed for all six roles on an independently authored one-bar source using real production catalogs:
|
||||
|
||||
Verification artifacts under `build/solo_reference_comparison/` record:
|
||||
- Alone/cold versus reversed order/warm.
|
||||
- Serial versus two spawned processes/cold.
|
||||
- Deliberately invalid optional targets present versus removed/cold.
|
||||
- Rebuilding after an electric-only setting change.
|
||||
|
||||
- Synthetic source: standalone/reversed/parallel roles, target presence/removal, cold/warm caches, role-only settings and observed reads.
|
||||
- Full production songs: canonical document hashes, selected-note/XML/TAB correspondence, reference/source hashes and comparison to the previous eight non-solo roles.
|
||||
- Independent source excerpts: legitimately sourced evaluation material outside production data; no reference-derived source harmony.
|
||||
Evidence, actual read paths and all six hashes: `build/solo_reference_comparison/repair/dependency-final/dependency.json`. The changed electric register produced hash `bf16a8b06e38ab50384d98a1fdd6bf13d62eddc1eaf350dd91d2186ca39d92ee`, proving the exercised setting changed music rather than only invalidating a cache. Example unchanged solo document hashes:
|
||||
|
||||
Musical document equality excludes incidental timing diagnostics. Warm results must match clean regeneration. Provenance review of new data is separate from deletion tests: output equality alone would not prove that target-derived material had never been embedded.
|
||||
- Acoustic: `6ded04c27b1ee638644b0717be095ff1af5820da17b89876e5c89f1e18901997`.
|
||||
- Electric: `56b7bc09146facb91c0dc0ffe71dbdd0239d23d5fab5955c2b3fe7e03b94a882`.
|
||||
|
||||
This is a dependency test, not a whole-song musical or performance assessment. Separate full-source checks in `repair/asset-verification.json` establish that all four selected event streams and TAB match reparsed exports, source vocal/head documents are unchanged, and the source/reference/published-baseline hashes remain unchanged.
|
||||
|
||||
The complete All of Me acoustic solo also has identical musical document hash in the independent cold benchmark, warm own-role cache build and two-worker full production build: `adcc372315aa10cae8a956cc5002700859c81cdcf4eeaa59b4ae6f8b41161baf`. This strengthens the synthetic proof for that full source/role; it does not claim an isolated cold/warm benchmark for every role.
|
||||
|
||||
Canonical ordering is independent of requested-role order and worker completion. Ties resolve through stable musical content keys. Catalog/report/cache implementation changes invalidate affected cached representations without intentionally changing the semantic generation version. Timing and observed-read diagnostics remain outside the musical document.
|
||||
|
||||
Deletion tests cannot detect target-derived material already embedded in code/data. Review new rule provenance and the explicit learned-feature whitelist separately. The development vocabulary is target-guided, and these examples are not claimed as unseen generalization evidence.
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
# One musical evaluator
|
||||
# Authoritative action evaluation
|
||||
|
||||
`scoring.evaluate` returns `Evaluation(version='phrase-musical-1')`: validity, raw values, enabled weights, weighted contributions, total, and exact-event normalization counts. The selected phrase score is exactly its reported total. Selection additionally reports the bounded next-phrase total; it is not disguised as the local score.
|
||||
`selection.Selector.evaluate` returns `Evaluation(version='action-selection-1')`: raw normalized values, enabled weights, exact rational contributions, total and denominators. Ranking uses exactly the sum of reported contributions. Tied continuations merge before evaluation; repeated attacks remain distinct.
|
||||
|
||||
Lookahead averages the current normalized phrase score and the best feasible following phrase score. Candidate phrases can have different durations. This is a bounded, variable-horizon heuristic, not equal-duration whole-chorus optimization or a proven remaining-cost bound. Both local and lookahead totals appear in the decision trace so this limitation is inspectable.
|
||||
The scope is one complete musical action, not a chorus. Alternatives compete by cost per exact quarter; compound routes remain indivisible. Prefix and reporting-phrase totals sum local decisions. They are diagnostics, not global objectives or partition-invariant phrase quality scores. Computational partitioning that preserves the action objects does not change evaluation. Changing musical action boundaries can change the local search objective.
|
||||
|
||||
| Contribution | Weight | Interpretation |
|
||||
|---|---:|---|
|
||||
| Purpose | 2 | Unprepared large motion, repeated oscillation and preparation of the chosen destination |
|
||||
| Continuity | 2 | Incoming musical connection beyond a comfortable interval |
|
||||
| Repetition | 1 | Recent complete rhythmic/interval figure overuse; deliberate return receives bounded allowance |
|
||||
| Register | 1 | Duration-normalized excursions beyond the role's broad comfortable region |
|
||||
Fixed priors address excessive leaps, register distance outside a soft corridor, oscillation, repeated attacks, unprepared space, repeated rests, source interval relationships, actual theme relationships, sustained chord-tone arrivals and directed steps. Shared integer coefficients can distinguish action kind, duration, chord-relative pitch role, incoming interval, relative metric position, motion direction, register band, source rhythm, realized theme evidence and context of silence. Title, filename, absolute bar index, reference hash and event lookups are not allowed features.
|
||||
|
||||
These are contextual preferences, not a perceptual memorability model. Broad acceptability can produce equal musical totals. A stable role-specific choice key breaks those ties. Guitar travel is absent from this total: it chooses a fingering for the selected music rather than giving an easier but otherwise unrelated tune an automatic advantage.
|
||||
Silence has no pitch/register value. A rest after an arrival differs from an interruption or repeated rest. A sustain is one attack with its complete duration. Approved expression is validated by contract rather than automatically rewarded or penalized as chromatic.
|
||||
|
||||
Harmonic validity, complete typed resolutions, exact duration, promised held destinations, rhythmic-group integrity, physical range and arrangement allocation are checked independently. Approved expression has no generic chromatic penalty or reward. Active empty candidates cannot pass; explicit no-harmony space can.
|
||||
Hard validity remains separate: mapped collections, typed exceptions, exact target register/time/duration, pending deadlines, rhythm groups, physical domain, monophony and source-derived allocation. Pending obligations filter the frontier before pruning. Final placement cannot change selected pitches or rhythm.
|
||||
|
||||
Rests are constructed within groups, at entrances, or after destinations. There is no global attack-density/occupancy optimization. Holds count as one sounding event, regardless of tied notation fragments. A repeated attack does not fulfill a single committed sustain.
|
||||
The optional fitter uses an integer early-update structured perceptron, at most 200 passes. Coefficients are shared between songs within each role. Its checkpoint prefers fewer incorrect rollouts, then longer exact prefixes, then smaller coefficients. This differs from minimizing whole-output discrepancy; full export comparison remains the acceptance gate. Fitted artifacts stay under `build/` until a verified model is deliberately promoted. Constructibility alone is not grounds for promotion.
|
||||
|
||||
Ordinary phrase endpoints may be mapped non-chord tones when the phrase remains open. Local settling, completion and handoff have actual timed destinations. Expression disabling reconstructs valid strict phrases; it does not delete notes from a chromatic winner.
|
||||
Synthetic tests check contribution sums, weight counterfactuals, tied-event invariance, forbidden feature names and a learnable two-choice fitting problem. They establish mechanics, not that this finite feature set expresses the four desired decisions or predicts musical preference.
|
||||
|
||||
@@ -1,84 +1,76 @@
|
||||
# Complete-phrase solo rewrite
|
||||
# Exact-target composer repair — release status
|
||||
|
||||
Starting revision: `68df7292ee85918a0cdbd0965139ccb2ceecb130`. This revision replaces the active solo constructor. Source import, harmony, exact timing, typed expression validation, independent roles, guitar placement, notation, playback and atomic asset publication remain in use.
|
||||
**Not released. Exact production reproduction of the four targets has not been achieved.** Valid derivations exist for all four complete event streams, but a target-constrained derivation is not an unrestricted production winner. The existing published assets remain intact. The previous release's report is [archived here](baseline_0e68f53.md); its tests and performance numbers do not apply to this rewrite.
|
||||
|
||||
The selected direction is a new melody over the source changes, with optional head references. The source head no longer supplies a compulsory rhythmic prefix or protected initial hold. The four supplied solos remain outside production and unchanged; exact reproduction or listener preference is not claimed.
|
||||
Starting revision: `0e68f5364bb0196ab1b7087ecc186f5d7446c42b`. Preserved baseline: `build/solo_reference_comparison/repair/baseline/`. Its `inputs.json` records source/data/reference hashes and initial cache state. The sources remain `data/songs/all-of-me.xml` and `data/songs/autumn_leaves.xml`; no archive source has replaced either.
|
||||
|
||||
## Construction and simplification
|
||||
## Acceptance and target identity
|
||||
|
||||
The normal path is `production.generate_role → composer.compose → construction.Builder → rhythm.Rhythms/routes.Routes → scoring.evaluate → placement → validation → notation/export`.
|
||||
The exact target files are `build/all-of-me-solo-1.xml`, `build/all-of-me-solo-2.xml`, `build/autumn-leaves-solo-1.xml`, and `build/autumn-leaves-solo-2.xml`. Solo 1 is acoustic; Solo 2 is electric. They are untouched. The optional adapter applies the confirmed guitar-written octave convention and merges ties while preserving repeated attacks. Autumn aligns at the first Gm7 in measure 2, including the entire preceding measure at negative chorus-relative times. All of Me aligns at measure 1.
|
||||
|
||||
Each proposal chooses a phrase purpose, rhythm groups, direction, harmonic destination, arrival timing, sounding duration and optional treatment together. Complete pitch paths realize those choices. Open continuations need no chord-tone stop; settling, completion and handoff have actual destination contracts. Anticipations, approaches, enclosures, connections, colors, blues treatments and prepared suspensions enter before route selection and must validate as complete relationships.
|
||||
The latest approved development scope allows target-guided general-rule work and shared-coefficient fitting, but prohibits stored tunes, per-song recipes and runtime target reads. No fitted model is promoted automatically. Optional development tools and their artifacts remain outside production imports and cache identities.
|
||||
|
||||
An introduced realized phrase supplies the home idea. Development, answer, deliberate return and optional head reference are distinct reachable actions. A return protects a short heard rhythm and directional signature with harmonic adaptation; ordinary phrases have no compulsory three-note prefix. This signature is not a perceptual guarantee of recognition.
|
||||
The baseline exact comparison returned failure: All of Me acoustic/electric had 0/3 exact matching attacks; Autumn acoustic/electric had 0/0. Differing onset positions were 205, 201, 211 and 178 respectively, and rests differed in all four. These are exact-event diagnostics, not musical-quality scores.
|
||||
|
||||
One versioned evaluator reports purpose, incoming continuity, contextual repetition and broad register costs. Physical travel selects fingering, not the winning tune. One-phrase lookahead averages normalized completed-phrase scores across variable durations; it remains a bounded heuristic, not a whole-chorus optimum. Route construction has its own explicitly separate heuristic.
|
||||
## Changes in this working tree
|
||||
|
||||
The solo package decreases from **6,232 to 1,729 Python lines**, and from 30 to 19 modules. Thirteen obsolete constructors/helpers were removed and two focused modules added. Old plan/draft/paraphrase API tests were retired; retained musical and representation contracts were migrated. Removed tests are not counted as passing tests. No legacy production fallback remains.
|
||||
The hash-preselected phrase constructor and separate route solver are replaced by enumerable joint musical actions, one interpretable rational evaluator and bounded deterministic backtracking. Actions include ordinary notes/rests, complete degree routes, realized-theme returns/answers, colors, approaches, enclosures and anticipations. Pending treatments carry their actual future targets and deadlines across actions. Whole-line guitar placement follows selection; it cannot change the tune or inspect another generated part.
|
||||
|
||||
## Inputs and data
|
||||
General expressive rules now cover delayed exact-target approaches, separated enclosures, reattacked anticipations and anticipated colors. Color octave enumeration no longer loses alternating F-sharp registers through rounding. The base scale vocabulary remains the same 21 collections; exceptions have typed, bounded contracts. Full target endings and source-derived pickup allocations are supported. Electric may overlap the returning vocal pickup as authorized; source vocal events remain unchanged.
|
||||
|
||||
The 21 scales, 2,954 sequence paths and 3,538 rhythm snippets remain intact. `solo.json` contains independently authored parameterized actions, nine atomic rhythm groups and migrated authored cells. `grammar.json` and `melodic_vocabulary.json` were retired after their consumers migrated. Accompaniment fields and resources were not changed.
|
||||
Performance changes remove redundant parent catalog parsing, add content-addressed non-executable catalog JSON, memoize pure chord/color/spelling checks, and avoid building full rational contribution reports for thousands of rejected candidates. The lightweight ranking total and detailed evaluation are equal and tested. Existing independent process workers remain available.
|
||||
|
||||
References: `build/all-of-me-solo-1.xml`, `all-of-me-solo-2.xml`, `autumn-leaves-solo-1.xml`, `autumn-leaves-solo-2.xml`. Solo 1 maps to acoustic; Solo 2 to electric. The optional reader applies the confirmed guitar-written octave convention and preserves raw evidence. Autumn Leaves aligns at the first Gm7 in reference measure 2; its preceding measure remains in the comparison. Four Autumn electric reference events extend beyond the source-defined vocal allocation. Neither the target nor the vocal timing is changed to force equality.
|
||||
The development fitter uses only shared whitelisted musical features and integer updates. An initial trial exposed two defects: unordered source-interval evidence and per-attack rewards that favored subdivisions. Ordered source relationships and duration-normalized rewards repaired them. A later trace exposed indistinguishable pending targets; their relative delay, step and hold now enter selection explicitly. Interrupted diagnostic trials are not reported as completed fitting or benchmark results.
|
||||
|
||||
All six strings remain available on frets 1–12 without open strings. Ties retain one sounding event and one position. No performance-expression changes were introduced. The existing independently generated accompaniment, electric melody and vocals are intentionally preserved by this solo rewrite.
|
||||
## Evidence and limitations
|
||||
|
||||
## Verification and review
|
||||
The staged build completed with `--check`: six performance scores, complete solo XML, notation/TAB, playback assets, and independent accompaniment for both current sources. It is under `build/solo_reference_comparison/repair/staged`, not the published asset directory. The exact gate returned exit code 1:
|
||||
|
||||
The final Python command was the repository Python runner followed by `-m unittest discover -s tests -p 'test_*.py'`: **435 tests passed in 218.783 seconds**. During implementation, repeat-build tests caught profiling timings leaking into stable diagnostics. Timings now live only in the separate build profile; the final suite includes those repeat-build checks. Earlier interrupted/stale-import runs are not counted as passing verification.
|
||||
| Output | Generated / target attacks | Exactly matching attacks | Differing onset positions | Exact result |
|
||||
|---|---:|---:|---:|---|
|
||||
| All of Me acoustic | 118 / 137 | 2 | 189 | Fail |
|
||||
| All of Me electric | 128 / 141 | 0 | 232 | Fail |
|
||||
| Autumn Leaves acoustic | 106 / 121 | 1 | 177 | Fail |
|
||||
| Autumn Leaves electric | 143 / 115 | 5 | 195 | Fail |
|
||||
|
||||
The explicit dependency command passed all six roles on independently authored input: alone/cold, reverse/warm, parallel/cold, invalid optional targets present, targets removed, and after an electric-only setting change. Actual reads and canonical document hashes are retained. This synthetic test is not a whole-song quality assessment.
|
||||
Rests differ in every case. These results are not close reproduction, and attack counts are not a substitute for matching the requested music. The full comparison page is `build/solo_reference_comparison/repair/comparison/index.html`. It includes untouched references, the baseline and staged XML, complete solo-only and fixed-backing WAVs, opening excerpts, and exact note differences. Rendering used one neutral treatment and constant shared gains; no ear-based audition was performed.
|
||||
|
||||
The optional transfer command passed all 18 role builds on evaluation-only four-bar excerpts from After You've Gone, Hindustan and Indiana. Their provenance remains under `tests/evaluation_sources/`. This demonstrates limited transfer; it does not establish arbitrary meter, form or chord support.
|
||||
The actual selected events and TAB were checked against all four reparsed exports: all agree. Thus these discrepancies arise in composition/selection, not from an exporter shifting the selected melody. Source vocal and electric head documents are unchanged from the baseline. Accompaniment documents changed with the shared difficulty configuration and the source-derived participation allocation; they are still independently generated. Source and reference SHA-256 values, and the previously published solo exports, remain unchanged. Evidence: `repair/asset-verification.json`.
|
||||
|
||||
The comparison page includes full generated, previous and reference solo audio, common-backing mixes and opening excerpts. The same neutral renderer and constant comparison gains are used. Comparison reads happen only after generation. No ear-based audition or listener approval is claimed.
|
||||
The fitting trial was stopped after **40 completed passes of its configured 200-pass maximum**, without convergence. Every pass still had four incorrect rollouts; the best had only eight matching prefix attacks in total across the four cases. This is an interrupted diagnostic trial, not a completed 200-pass result and not a proof that every shared model is infeasible. Its best coefficients remain development-only under `repair/fitting`; none were copied to production data.
|
||||
|
||||
## Evidence locations
|
||||
A further normal-production check of that best checkpoint completed All of Me acoustic: **5 matching attacks, 223 differing onset positions, and different rests**; it also failed the exact gate. The other fitted-model jobs were interrupted without completed exports. This is not a four-case fitted-model release test. Its completed XML and comparison are in `repair/fitted` and `repair/fitted-comparison`; the interruption is recorded in `repair/fitted-build-status.json`. The complete four-case results above use the unfitted production defaults.
|
||||
|
||||
- [Active path and findings](active_path_audit.md)
|
||||
- [Resource/code migration and provenance](data_migration.md)
|
||||
- [Evaluation contract and search limitation](evaluation_contract.md)
|
||||
- [Capability matrix](musical_capability_matrix.md)
|
||||
- [Independent-input verification](dependency_proof.md)
|
||||
- [Reproduction commands](commands.md)
|
||||
The default selection trace explains an important failure. All of Me acoustic selected 29 arpeggio actions and six returns; electric selected 32 scalar actions, nine returns and one ordinary tone. No standalone rest won in either output. Many legal expressive candidates were proposed and rejected by score. The role's action-kind preferences overwhelm much of the vocabulary, and the local policy has no complete-phrase destination comparison. Adding legal actions has not repaired that selection limitation.
|
||||
|
||||
`build/solo_reference_comparison/` is the sole comparison location. `previous/` retains the immediate pre-rewrite output and input hashes; `generated/` contains the replacement. The complete operation/pruning trace and local/next-phrase evaluations are in the manifest. `analysis/selected_phrases.md` provides readable selected events, destinations, holds, expression evidence and alternative scores.
|
||||
### Commands actually validated
|
||||
|
||||
## Remaining boundaries
|
||||
All commands use `build/build_common/python/bin/python3 build/build_tools/python_runner.py build/build_common/modulus` as their prefix. Exact invocations are in [commands.md](commands.md).
|
||||
|
||||
The reference solos are outside-review examples, not a numerical loss. The replacement output remains new music, not those four scores. Counts and matching attacks do not establish greater musical quality.
|
||||
- `-m unittest discover -s tests -p 'test_*.py'`: **446 tests passed in 348.905 seconds** (`repair/verified-tests.log`).
|
||||
- `-m unittest tests.test_solo_comparison`: **9 tests passed in 0.083 seconds** after removing an invalid optional report link (`repair/comparison-tests.log`).
|
||||
- `-m audition.dependency_check --output .../dependency-final`: passed all six roles, including an operational electric register change that changed that role's document and left the other roles intact.
|
||||
- Full staged `-m generator ... --check --jobs 2 --composition-jobs 2 --export-solo-xml`: passed. The first attempt exposed an unfillable final quarter-beat; a tempo-gated sixteenth note/rest primitive and a regression test repaired that failure before the successful build.
|
||||
- `-m audition.solo_compare ... --require-exact`: failed all four outputs, as shown above. This is the release-blocking musical result, not a passing check.
|
||||
- Full-score selected-event/TAB verification and source/reference/published-baseline hash checks: passed (`repair/asset-verification.json`).
|
||||
|
||||
The search retains a small frontier and limited lookahead. It can choose another complete phrase, but does not perform arbitrary two-sided revision of committed music. Some exact thematic returns may still sound too weak; the selected rhythm and contour contract is less strict than exact intervals. Acceptable candidates can share a zero musical penalty, leaving the stable role key to break ties.
|
||||
The staged asset run took **430.402 seconds** with two composition workers and warm generic catalog/non-solo caches. Tests and development fitting were running concurrently, so that time is not an isolated speed benchmark. Solo role times were 156.117, 228.292, 182.301 and 174.160 seconds in that run. No cold-generation speedup is claimed from these numbers. The full recorded stage timings and actual read traces are in `repair/build-profile.json`.
|
||||
|
||||
Unsupported written harmony produces an explicit diagnostic. Optional expression rules do not repair ambiguous source chords or authorize arbitrary chromatic notes. Legal TAB and coarse tempo limits are not full biomechanical certification. The next useful listening check is the four complete comparisons: whether their openings establish memorable ideas and whether later returns remain recognizable through the freer continuations.
|
||||
After other generation/rendering jobs stopped, `-m audition.benchmark --source data/songs/all-of-me.xml --role acoustic_solo --cache .../performance/cache --output .../performance/cold` took **134.722 seconds** for the role, plus 0.046 seconds for source analysis, with one worker and an empty concrete/catalog cache. Repeating it with `--output .../performance/warm` took **2.068 seconds** with a role-cache hit. Hardware/runtime: 12 reported CPUs, Linux 7.2.3 x86-64, repository Python environment; cProfile disabled. Cold, warm and parallel-full-build documents have the same hash `adcc372315aa10cae8a956cc5002700859c81cdcf4eeaa59b4ae6f8b41161baf`. Evidence: `repair/performance/summary.json` and the two benchmark records.
|
||||
|
||||
## Published assets and measured cost
|
||||
The requested cold-generation reduction is **not achieved**. Warm memoization avoids recomposition, but the wider action enumeration is still expensive. The historical baseline's different music, algorithm and benchmark conditions do not support a controlled speedup ratio. Candidate construction/evaluation remains the dominant recorded stage; no further beam expansion is justified by these results.
|
||||
|
||||
Full assets are published at `build/build_x86_debug/generated_assets/`; the four exported solos are in `solo_xml/`. All twelve musical role documents match between cold staged generation and cached publication. The eight non-solo documents are exactly equal to the preserved baseline. Every exported solo sounding event and TAB position matches the selected stream. All committed holds and typed harmonic contracts passed validation. All six source/reference file hashes are unchanged.
|
||||
- [Active path, reproduced findings and search limits](active_path_audit.md)
|
||||
- [Data migration and provenance](data_migration.md)
|
||||
- [Authoritative evaluation and fitting contract](evaluation_contract.md)
|
||||
- [Capabilities and unimplemented behavior](musical_capability_matrix.md)
|
||||
- [Independent-input checks](dependency_proof.md)
|
||||
- [Reproduction and exact-release commands](commands.md)
|
||||
|
||||
| Solo | New attacks | Sounding MIDI range | Occupancy |
|
||||
|---|---:|---|---:|
|
||||
| all_of_me/acoustic | 117 | 47–67 | 0.781 |
|
||||
| all_of_me/electric | 139 | 52–72 | 0.824 |
|
||||
| autumn_leaves/acoustic | 153 | 47–67 | 0.859 |
|
||||
| autumn_leaves/electric | 109 | 52–70 | 0.732 |
|
||||
Detailed winning/runner-up contributions, selected note groups and proposal/valid/retained/selected counts are in `build/solo_reference_comparison/repair/decisions.md`. Superseded diagnostic logs are consolidated in `repair/diagnostic-history.zip`; current evidence, the baseline and targets remain intact.
|
||||
|
||||
These descriptive measurements are not targets or proof of musical improvement. None of the four generated solos exactly matches its reference. All four normalized references have legal pitch/position paths within the existing guitar constraints; the Autumn electric vocal-allocation conflict remains explicit.
|
||||
The search is a local action policy, not a global complete-phrase optimizer. Dedicated suspension/blues constructors and two-sided phrase revision are not present in this replacement; validators remain. Source influence and the short realized home theme are limited models of melodic identity. A catalog containing thousands of paths does not mean all those paths currently compete as complete musical ideas. No new held-out source-standard evaluation set was added. Existing synthetic transposition/meter/harmony tests do not establish generalization to an unseen standard or subjective musical quality.
|
||||
|
||||
The native integration test `ctest --test-dir build/build_x86_debug -R '^product_vamp$' --output-on-failure` passed in **30.45 seconds**, with required localhost access. The optional comparison adapter suite passed **8 tests in 0.018 seconds** after the audio-directory update. All **36 comparison WAVs** passed PCM clipping checks, and all **50 page links** resolved. The signal review is not an ear-based audition.
|
||||
The earliest demonstrated remaining failure is selection: legal target-compatible actions lose even at the opening, and later action choices collapse toward a few favored families. The next justified investigation is whether a shared context model can distinguish those conflicting choices over complete phrases, before adding more rules, expanding the beam, or running another long fit. The finite trial here does not settle that question. There is no automatic fallback to reference notes or previous output.
|
||||
|
||||
The isolated final staged asset build took **107.15 seconds** wall time, including **91.31 seconds** in the performance-part stage. All twelve role caches missed. Two composition workers ran in separate processes; no tests or audition rendering overlapped. Engraving was warm and reused all six scores. The **27.57-second** normal CMake publication reused all twelve role caches and six engraving results.
|
||||
|
||||
| Cold solo role | Role seconds | Construction seconds | Route seconds |
|
||||
|---|---:|---:|---:|
|
||||
| all-of-me/electric_solo | 40.82 | 26.27 | 21.47 |
|
||||
| all-of-me/acoustic_solo | 44.38 | 29.96 | 24.90 |
|
||||
| autumn_leaves/electric_solo | 35.56 | 33.09 | 28.31 |
|
||||
| autumn_leaves/acoustic_solo | 44.04 | 41.71 | 35.80 |
|
||||
|
||||
Worker times overlap. Role time includes resource setup, notation and validation beyond construction. Route time is included in construction, not an additional cost. Hardware/process evidence: 12 logical CPUs, Linux x86-64, repository Python 3.13; `build-profile.json` contains actual reads, worker settings and stage times. The first worker use still spends time loading the full catalogs. Route search remains the dominant construction cost.
|
||||
|
||||
An isolated one-process All of Me acoustic build took **39.80 seconds** without cProfile. The diagnostic run before the performance-only optimization took 163.09 seconds **with cProfile enabled**, so those times are not a valid speedup ratio. Profiling exposed repeated pitch hashing/stringification and harmony lookups. Memoization and hoisted identical calculations preserved the complete musical document hash `2b6b35011e353d166d287a8bfff6074723e62559b5e64ff978084623e1520e67`, also matching final production. The rewrite changes music and search structure; its timing is not presented as a semantics-preserving speedup over the old composer.
|
||||
|
||||
Cleanup removed **151.2 MiB** of superseded comparison audio, stages, probes and temporary rendering tones during this rewrite. The immediate baseline, four user references, build toolchains, caches, published assets and useful verification evidence remain. Current audio is consolidated under `audio/` and linked from one `index.html`; `cleanup.json` records removed paths.
|
||||
Technical completion, exact target equality and listener preference are separate. No ear-based audition is claimed. The release remains blocked unless all four unrestricted exported comparisons pass; a valid or stylistically plausible mismatch is not substituted for that requirement.
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
# Demonstrated capabilities and limits
|
||||
# Capabilities and release limits
|
||||
|
||||
| Behavior | Active implementation | Evidence |
|
||||
|---|---|---|
|
||||
| New tune without a compulsory head rhythm | Source-independent action construction | Empty-head and held-head synthetic tests |
|
||||
| Arpeggiated expansion / scalar route / neighbor motion | Complete contextual pitch route | Operation reachability and route tests |
|
||||
| Development, answer, return, optional head reference | Heard-theme/source feature used only by the selected action | Each action requested and realized in eligible authored contexts |
|
||||
| Open continuation / local settling / completion / handoff | Different timing and destination contracts | Candidate diversity and terminal-hold tests |
|
||||
| Rest, pickup, syncopation, cross-bar group, tuplet | Exact rhythm groups and complete authored cells | Group, notation and playback tests |
|
||||
| Early destination, approach, enclosure, connection | Joint proposals and existing typed validation | Positive/negative contract tests and final expression reports |
|
||||
| Stable/resolving colors, prepared suspension, blues | General chord/function policy; complete realized evidence | Retained validator tests and construction coverage |
|
||||
| All six strings; no open strings; frets 1–12 | Shared pitch domain and complete-melody placement | Physical tests and published TAB validation |
|
||||
| No target dependency / no other generated-role dependency | Input audit and independent role cache | Dependency checks and full-song document hashes |
|
||||
| Behavior | Current status |
|
||||
|---|---|
|
||||
| Ordinary scale motion, rests and held chord tones | Enumerable and validated |
|
||||
| Complete scalar/third-stride groups | Reachable; exact scale indices, whole rhythm groups |
|
||||
| Heard-theme return and inverted answer | Reachable from actual realization; no required count |
|
||||
| Approaches, enclosures, prepared chromatic connections | Reachable joint actions; target, time and register validated |
|
||||
| Anticipation with hold or deliberate reattack | Reachable and separately validated |
|
||||
| Stable/resolving colors and anticipated colors | Reachable; complete sounding exposure checked |
|
||||
| Delayed semitone resolution | Pending across actions; exact target and bounded deadline required |
|
||||
| Prepared suspension and named blues inflection | Validators retained; no dedicated new action constructor yet |
|
||||
| Optional full head quotation | No new constructor; source influence currently uses ordered interval relationships and rhythm evidence |
|
||||
| Two-sided revision / phrase-level destination lookahead | Not implemented by the local action policy |
|
||||
| All six strings, frets 1–12, no opens | Shared physical domain and whole-line fingering |
|
||||
| Source-only independent accompaniment | Existing canonical sparse comping retained |
|
||||
| Exact construction coverage for all four targets | Demonstrated by constrained derivation plus final harmonic validation; not a production winner |
|
||||
| Exact unrestricted reproduction of all four targets | Requires the separate full-export gate; a fit/derivation alone is insufficient |
|
||||
|
||||
Not every enabled treatment must be selected in every song. Rejections and selected uses are separate from capability. A failed bounded search is not a proof that a musical solution is impossible.
|
||||
A complete route is indivisible, but many independent single attacks remain available. This flexibility removes former coverage restrictions without proving melodic coherence. Source relationships are richer than an unordered interval set but remain a limited identity model. Register and fingering are separate; TAB legality is not biomechanical certification.
|
||||
|
||||
The source melody is optional material, not a compulsory blueprint. A return currently preserves the rhythm and direction of a short heard signature with harmonic adaptation; this is weaker than requiring its exact intervals. This can still be perceptually insufficient. Phrase-level context and the user's listening assessment remain important.
|
||||
|
||||
The route solver selects a complete path but internally uses a bounded pitch frontier. It does not perform a global whole-chorus optimum or arbitrary two-sided rewriting of committed phrases. One-phrase lookahead chooses a different complete alternative instead. Faster passages use a coarse tempo policy; legal TAB is not proof of human comfort.
|
||||
|
||||
Unsupported written chords still produce explicit mapper errors. No missing initial harmony is invented. Source-defined vocal protection remains authoritative even when a comparison reference continues beyond it. No new dynamics, swing, accents, velocities or timbral effects were introduced.
|
||||
Unsupported written chords still fail explicitly. No source harmony is manufactured from a target. Electric may continue over the returning vocal pickup under the latest authorized allocation; vocal timing is unchanged. No dynamics, velocity shaping or new playback feel was introduced.
|
||||
|
||||
@@ -84,19 +84,13 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
if source.parent != data_dir / 'songs':
|
||||
raise ValueError(f'{source}: MusicXML sources belong directly under data/songs')
|
||||
generator.parts.source_paths(data_dir / 'songs')
|
||||
report('loading rhythm snippets')
|
||||
rhythms = generator.rhythm.load_library(data_dir / 'rhythms')
|
||||
report('loading solo scale patterns')
|
||||
scales = generator.scales.library.load_library(data_dir / 'scales')
|
||||
report('loading solo interval sequences')
|
||||
sequences = generator.solo.sequences.load_library(data_dir / 'sequences')
|
||||
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix='.vamp_assets_', dir=output_dir.parent) as temporary:
|
||||
staged = pathlib.Path(temporary) / 'assets'
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
copy_runtime_fonts(static_assets_dir, staged)
|
||||
report('generating performance parts')
|
||||
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences,
|
||||
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs',
|
||||
composition_jobs=getattr(args,'composition_jobs',2), cache_dir=getattr(args,'composition_cache',None), progress=progress,timings=timings)
|
||||
if getattr(args,'trace_level','summary')=='summary':
|
||||
for path in (staged/'analysis').glob('*.roles'):
|
||||
|
||||
@@ -17,10 +17,13 @@ def _read(event,args):
|
||||
# r+ and w+ can read; they must obey the same boundary as a plain read.
|
||||
if flags & os.O_WRONLY:return
|
||||
path=Path(os.fsdecode(name)).resolve()
|
||||
data,code,cache,reads=scope
|
||||
data,code,cache,compiled,reads=scope
|
||||
python_runtime=any(path.is_relative_to(Path(root).resolve()) for root in (sys.base_prefix,sys.prefix))
|
||||
allowed=path.is_relative_to(data) or path.is_relative_to(code) or python_runtime
|
||||
if cache and path.is_relative_to(cache):allowed=True
|
||||
# Only the content-addressed generic catalog directory is shared by roles.
|
||||
# Concrete musical caches remain confined to the requested role.
|
||||
if compiled and path.is_relative_to(compiled):allowed=True
|
||||
if not allowed:raise ValueError('Forbidden composition input: '+str(path))
|
||||
reads.add(str(path))
|
||||
|
||||
@@ -32,6 +35,7 @@ sys.addaudithook(_read)
|
||||
def composition(data,role,cache=None):
|
||||
reads=set()
|
||||
code=Path(__file__).resolve().parent
|
||||
token=ACTIVE.set((Path(data).resolve(),code,Path(cache).resolve()/role if cache else None,reads))
|
||||
token=ACTIVE.set((Path(data).resolve(),code,Path(cache).resolve()/role if cache else None,
|
||||
Path(cache).resolve()/'_resources' if cache else None,reads))
|
||||
try:yield reads
|
||||
finally:ACTIVE.reset(token)
|
||||
|
||||
+26
-17
@@ -17,7 +17,7 @@ import tempfile
|
||||
import time
|
||||
from fractions import Fraction as F
|
||||
|
||||
from generator import accompaniment, comping, fretboard, model, musicxml, parts, rhythm, score, vocabulary
|
||||
from generator import accompaniment, comping, fretboard, model, musicxml, parts, rhythm, score, vocabulary, resources as catalogs
|
||||
from generator.arrange import form, generate, pickup
|
||||
from generator.solo import analysis, composer, notation, report, sequences
|
||||
from generator.scales import library as scales
|
||||
@@ -66,13 +66,13 @@ def windows(document, part):
|
||||
a, _, old = result[-1]; result[-1] = (a, start, old)
|
||||
result.append((start, cursor, part+'_'+role))
|
||||
end = cursor+duration
|
||||
result.append((cursor, end, part+'_'+role))
|
||||
if part == 'electric' and section.role == 'slash' and score.body_start_measure_index(source):
|
||||
attacks = [model.fraction(n['onset']) for n in score.measure_notes(source[0]) if n['pitches']]
|
||||
if attacks:
|
||||
cutoff = end-model.fraction(source[0]['duration'])+min(attacks)
|
||||
result[-1] = (cursor, cutoff, part+'_solo')
|
||||
result.append((cutoff, end, part+'_rest'))
|
||||
start=cursor
|
||||
if section.role == 'slash' and score.body_start_measure_index(source) and result:
|
||||
# Allocate the source's complete pickup bar before composition. This
|
||||
# does not inspect either generated solo; a role may leave it silent.
|
||||
start=cursor-model.fraction(source[0]['duration'])
|
||||
a,_,old=result[-1];result[-1]=(a,start,old)
|
||||
result.append((start, end, part+'_'+role))
|
||||
cursor = end
|
||||
return tuple(result)
|
||||
|
||||
@@ -89,7 +89,7 @@ def _code_key(role):
|
||||
paths += list((root/'arrange').glob('*.py'))
|
||||
if role.endswith('_solo'):
|
||||
paths += [p for p in (root/'solo').glob('*.py') if 'audition' not in p.name and p.name != 'references.py']
|
||||
paths += list((root/'scales').glob('*.py')) + [root/'rhythm.py']
|
||||
paths += list((root/'scales').glob('*.py')) + [root/'rhythm.py', root/'resources.py', root/'musicxml.py']
|
||||
elif role.endswith('_accompaniment'):
|
||||
paths += [root/n for n in ('comping.py', 'accompaniment.py', 'guitar.py')]
|
||||
elif role == 'electric_melody':
|
||||
@@ -102,8 +102,8 @@ def _resource_key(directory, role):
|
||||
return {name: vocabulary.fingerprint([(p.relative_to(directory/name).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted((directory/name).rglob('*.xml'))]) for name in names}
|
||||
|
||||
@functools.lru_cache(maxsize=4)
|
||||
def _solo_libraries(directory, fingerprints):
|
||||
return scales.load_library(directory/'scales'),sequences.load_library(directory/'sequences'),rhythm.load_library(directory/'rhythms')
|
||||
def _solo_libraries(directory, fingerprints, cache_dir=None):
|
||||
return catalogs.load(directory,dict(fingerprints),cache_dir)
|
||||
|
||||
|
||||
def _mask(document, allowed):
|
||||
@@ -153,7 +153,7 @@ def generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
with composition(data_dir,role,cache_dir) as reads:
|
||||
result=_generate_role(source,role,data_dir,settings,cache_dir)
|
||||
# Observed reads are diagnostics, never cache identity or musical seeds.
|
||||
result['trace']={**result['trace'],'input_boundary':'data, generator code, Python runtime, own role cache'}
|
||||
result['trace']={**result['trace'],'input_boundary':'data, generator code, Python runtime, own role cache, content-addressed immutable catalogs'}
|
||||
result['observed_reads']=sorted(reads)
|
||||
result['seconds']=time.monotonic()-started
|
||||
return result
|
||||
@@ -194,15 +194,24 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
effective=generate.section_source(authored,section,form.full_arrangement_sections(authored,part)[si+1])
|
||||
template=copy.deepcopy(effective[section.source_measure_index:section.source_measure_index+count])
|
||||
initial=accompaniment.carried_chords(effective)[section.source_measure_index]
|
||||
end=next(b for a,b,r in windows(document,part) if r==role)-offset
|
||||
scale_library,sequence_library,rhythm_library=_solo_libraries(data_dir,tuple(sorted(inputs['catalogs'].items())))
|
||||
start,end=next((a-offset,b-offset) for a,b,r in windows(document,part) if r==role)
|
||||
notation_first=first
|
||||
if start<0:
|
||||
# The preceding arrangement harmony comes from the source form. Do
|
||||
# not invent harmony from a comparison score's unlabelled lead-in.
|
||||
template.insert(0,copy.deepcopy(result['staves'][0]['measures'][first-1]))
|
||||
notation_first-=1
|
||||
initial=accompaniment.carried_chords(result['staves'][0]['measures'])[first-1]
|
||||
scale_library,sequence_library,rhythm_library=_solo_libraries(data_dir,tuple(sorted(inputs['catalogs'].items())),cache_dir)
|
||||
solo=composer.compose(analysis.analyze(document),template,part,end,
|
||||
initial=initial,scales=scale_library,sequences=sequence_library,rhythms=rhythm_library,
|
||||
expressions_enabled=vocabulary.role().get('expressions',True))
|
||||
expressions_enabled=vocabulary.role().get('expressions',True),start=start)
|
||||
composition_timings={k:v for k,v in solo.diagnostics.items() if k.endswith('_seconds')}
|
||||
output=notation.measures(result['staves'][0]['measures'][first:first+count],solo.notes(),'solo_'+part)
|
||||
notation.replace_region(result,first,output);annotate(result,solo,offset)
|
||||
relative=tuple(dataclasses.replace(n,start=n.start-start,end=n.end-start) for n in solo.notes())
|
||||
output=notation.measures(result['staves'][0]['measures'][notation_first:first+count],relative,'solo_'+part)
|
||||
notation.replace_region(result,notation_first,output);annotate(result,solo,offset)
|
||||
details=report.structure(solo)
|
||||
details['allocation']={'start':str(start),'end':str(end),'lead_in_measures':first-notation_first}
|
||||
solo_report=report.describe(solo,scale_library,first,offset,detail=details)
|
||||
elif role.endswith('_accompaniment'):
|
||||
canonical=comping.Canonical(document,part,seed)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Content-addressed, non-executable catalog compilation.
|
||||
|
||||
Only validated production XML is compiled here. The cache contains no song,
|
||||
role, candidate, reference, or fitted musical result. A checksum and decoder
|
||||
version protect stale/corrupt payloads; no pickle or executable serialization.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from generator import rhythm, vocabulary
|
||||
from generator.scales import library as scales
|
||||
from generator.solo import sequences
|
||||
|
||||
VERSION = 'catalog-json-1'
|
||||
|
||||
|
||||
def identity(directory):
|
||||
directory=Path(directory)
|
||||
return {name:vocabulary.fingerprint([(p.relative_to(directory/name).as_posix(),
|
||||
hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted((directory/name).rglob('*.xml'))])
|
||||
for name in ('scales','sequences','rhythms')}
|
||||
|
||||
|
||||
def encode(libraries):
|
||||
scale,sequence,rhythms=libraries
|
||||
return {'scales':[dataclasses.asdict(p) for p in scale.patterns],
|
||||
'sequences':[dataclasses.asdict(p) for values in sequence.paths.values() for p in values],
|
||||
'rhythms':[dataclasses.asdict(p) for values in rhythms.snippets.values() for p in values]}
|
||||
|
||||
|
||||
def decode(value):
|
||||
scale=scales.Library(tuple(scales.Pattern(p['family'],p['mode'],tuple(p['intervals'])) for p in value['scales']))
|
||||
sequence=sequences.Library(tuple(sequences.Sequence(p['id'],tuple(p['steps'])) for p in value['sequences']))
|
||||
snippets=[]
|
||||
for p in value['rhythms']:
|
||||
bars=[]
|
||||
for b in p['bars']:
|
||||
events=tuple(rhythm.Event(F(e['onset']),F(e['duration']),e['sounding'],e['written_type'],e['dots'],
|
||||
tuple(tuple(v) for v in e['beams']),e['continuation'],F(e['ratio'])) for e in b['events'])
|
||||
bars.append(rhythm.Bar(events,F(b['length'])))
|
||||
snippets.append(rhythm.Snippet(p['id'],p['meter'],tuple(bars)))
|
||||
return scale,sequence,rhythm.Library(tuple(snippets))
|
||||
|
||||
|
||||
def load(directory, fingerprints=None, cache_dir=None):
|
||||
directory=Path(directory)
|
||||
fingerprints=fingerprints or identity(directory)
|
||||
# Code edits invalidate compiled importer semantics, but do not reseed music.
|
||||
root=Path(__file__).parent
|
||||
code=[root/n for n in ('resources.py','rhythm.py','musicxml.py','model.py','pitch.py','harmony.py','solo/sequences.py','scales/library.py')]
|
||||
key=vocabulary.fingerprint({'version':VERSION,'inputs':fingerprints,
|
||||
'decoder':[(p.relative_to(root).as_posix(),hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted(code)]})
|
||||
return _load(str(directory.resolve()),key,str(Path(cache_dir).resolve()) if cache_dir else '')
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4)
|
||||
def _load(directory,key,cache_dir):
|
||||
path=Path(cache_dir)/'_resources'/(key+'.json') if cache_dir else None
|
||||
if path and path.is_file() and not path.is_symlink():
|
||||
try:
|
||||
entry=json.loads(path.read_text())
|
||||
if entry['key']==key and vocabulary.fingerprint(entry['value'])==entry['digest']:
|
||||
return decode(entry['value'])
|
||||
except (ValueError,KeyError,TypeError,OverflowError):
|
||||
pass
|
||||
root=Path(directory)
|
||||
libraries=(scales.load_library(root/'scales'),sequences.load_library(root/'sequences'),rhythm.load_library(root/'rhythms'))
|
||||
if path:
|
||||
value=json.loads(json.dumps(encode(libraries),default=str))
|
||||
path.parent.mkdir(parents=True,exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile('w',dir=path.parent,delete=False) as out:
|
||||
json.dump({'key':key,'digest':vocabulary.fingerprint(value),'value':value},out,separators=(',',':'))
|
||||
temporary=out.name
|
||||
os.replace(temporary,path)
|
||||
return libraries
|
||||
@@ -6,15 +6,16 @@ from generator import harmony as chords, vocabulary
|
||||
from generator.scales import timeline, selection
|
||||
from generator.solo import types, profiles, harmonic, construction, placement, policy
|
||||
|
||||
def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,expressions_enabled=True):
|
||||
def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,expressions_enabled=True,start=F()):
|
||||
began=time.monotonic()
|
||||
choices=selection.select(timeline.read({'measures':measures},initial=initial),timeline.Solo(F(),end,part.capitalize()+' Solo'),scales)
|
||||
choices=selection.select(timeline.read({'measures':measures},initial=initial),timeline.Solo(F(),end-start,part.capitalize()+' Solo'),scales)
|
||||
choices=tuple(dataclasses.replace(c,span=dataclasses.replace(c.span,start=c.span.start+start,end=c.span.end+start)) for c in choices)
|
||||
for c in choices:
|
||||
if c.span.chord and c.scale is None:raise ValueError(f'{part} solo, source measure {c.span.source_measure}, beat {c.span.beat}, {chords.symbol(c.span.chord)}: No exact match within the permitted catalog')
|
||||
profile=profiles.for_part(part,analysis);harmony=harmonic.Harmony(choices,profile)
|
||||
source=tuple((n.start,n.end,n.pitch) for n in analysis.melody)
|
||||
seed=vocabulary.role().get('choice_seed') or policy.stable_rank(part,(source,tuple((c.span.start,c.span.end,c.span.chord) for c in choices),vocabulary.role(part+'_solo'),policy.DEFAULT.composition_version))
|
||||
builder=construction.Builder(analysis,measures,harmony,sequences,rhythms,end,seed,expressions_enabled)
|
||||
builder=construction.Builder(analysis,measures,harmony,sequences,rhythms,end,seed,expressions_enabled,start=start)
|
||||
phrases=builder.compose()
|
||||
# Resolve hand-position transitions over the selected whole melody. This
|
||||
# cannot reselect pitches, rhythm or another independently generated part.
|
||||
@@ -23,22 +24,22 @@ def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,e
|
||||
if placed is None:raise construction.SearchExhausted('selected melody has no complete legal guitar path')
|
||||
by_id={n.event_id:n for n in placed[1]}
|
||||
phrases=tuple(dataclasses.replace(p,notes=tuple(by_id[n.event_id] for n in p.notes)) for p in phrases)
|
||||
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'complete_phrases','version':policy.DEFAULT.composition_version,
|
||||
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'joint_actions','version':policy.DEFAULT.composition_version,
|
||||
'construction_seconds':time.monotonic()-began,**builder.timings,'operation_coverage':{k:dict(v) for k,v in builder.coverage.items()},
|
||||
'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile)
|
||||
'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile,start)
|
||||
validate(result)
|
||||
return result
|
||||
|
||||
def validate(solo):
|
||||
previous=None
|
||||
for note in solo.notes():
|
||||
if note.start<0 or note.end>solo.end or note.end<=note.start or previous and previous.end>note.start:raise ValueError('overlapping/out-of-bounds sounding event')
|
||||
if note.start<solo.start or note.end>solo.end or note.end<=note.start or previous and previous.end>note.start:raise ValueError('overlapping/out-of-bounds sounding event')
|
||||
if note.position not in placement.positions(note.pitch.number(),solo.profile):raise ValueError('invalid guitar placement')
|
||||
previous=note
|
||||
expressions=tuple(c for p in solo.phrases for c in p.expressions)
|
||||
checked=harmonic.Harmony(solo.choices).evaluate(solo.notes(),until=solo.end,complete=True,expressions=expressions)
|
||||
if not checked.valid:raise ValueError(checked.error)
|
||||
cursor=F()
|
||||
cursor=solo.start
|
||||
for p in solo.phrases:
|
||||
if p.start!=cursor or p.end<=p.start:raise ValueError('phrase coverage gap or overlap')
|
||||
cursor=p.end
|
||||
|
||||
+130
-217
@@ -1,230 +1,143 @@
|
||||
"""Construct complete phrases directly from harmonic/rhythmic purposes.
|
||||
"""Bounded search of joint musical actions; one normal production path.
|
||||
|
||||
The head is optional material, never a compulsory prefix or rhythmic template.
|
||||
No reference score, completed part, or previous published output enters here.
|
||||
Selection compares complete actions by cost per quarter. Actions are indivisible;
|
||||
cross-bar paths and resolution contracts never restart at search boundaries.
|
||||
"""
|
||||
import dataclasses
|
||||
import time
|
||||
from __future__ import annotations
|
||||
from collections import Counter
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary, harmony as chords, model
|
||||
from generator.solo import types, expressions, groups, routes, rhythm, scoring, placement, policy
|
||||
import time
|
||||
|
||||
from generator import vocabulary
|
||||
from generator.solo import types, groups, operators, selection, sequences, scoring
|
||||
|
||||
class SearchExhausted(ValueError):pass
|
||||
|
||||
def choose(values,seed,domain):
|
||||
return min(values,key=lambda v:policy.stable_rank(seed,(domain,v)))
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class State:
|
||||
total: F = F()
|
||||
recent: tuple = ()
|
||||
home: tuple = ()
|
||||
last_kind: str = ''
|
||||
parent: object = None
|
||||
action: object = None
|
||||
evaluation: object = None
|
||||
tie: tuple = ()
|
||||
pending: tuple = ()
|
||||
alternatives: tuple = ()
|
||||
|
||||
class Builder:
|
||||
def __init__(self,analysis,measures,harmony,sequences,rhythms,end,seed,enabled=True):
|
||||
self.analysis=analysis;self.harmony=harmony;self.end=end;self.seed=seed;self.enabled=enabled
|
||||
self.rules=vocabulary.data('solo');self.budgets=self.rules['search'];self.routes=routes.Routes(harmony,sequences)
|
||||
self.rhythms=rhythm.Rhythms(rhythms,analysis.tempo);self.windows=[];cursor=F()
|
||||
for m in measures:
|
||||
length=model.fraction(m['duration']);self.windows.append((cursor,cursor+length,m['time_signature']));cursor+=length
|
||||
self.coverage={a:Counter() for a in self.rules['actions']};self.failures=Counter();self.cache={};self.decisions=[];self.timings=Counter()
|
||||
def __init__(self,analysis,measures,harmony,sequences,rhythms,end,seed,enabled=True,*,start=F()):
|
||||
self.analysis=analysis;self.harmony=harmony;self.start=start;self.end=end
|
||||
self.operators=operators.Operators(harmony,analysis,end,start,enabled,sequences,rhythms)
|
||||
self.selector=selection.Selector(harmony,analysis,harmony.profile.reference or 60)
|
||||
self.rules=vocabulary.data('solo')['operators'];self.decisions=[];self.failures=Counter();self.coverage={};self.timings=Counter()
|
||||
|
||||
def at(self,time):return next((m for m in self.windows if m[0]<=time<m[1]),self.windows[-1])
|
||||
def candidates(self,at,state):
|
||||
candidates=self.operators.at(at)
|
||||
if state.home and state.last_kind!='return':
|
||||
candidates+=self.operators.related(at,state.home,'return')+self.operators.related(at,state.home,'answer')
|
||||
evaluated=[]
|
||||
for a in candidates:
|
||||
if state.pending and self.advance(state,None,a) is None:continue
|
||||
cost=self.selector.cost(a,state.recent,state.home,state.last_kind)
|
||||
evaluated.append((cost,a))
|
||||
# Rank complete actions by cost per quarter before bounded repair.
|
||||
# Accumulated prefix totals are diagnostic, not a global search objective.
|
||||
retained=sequences.diverse(evaluated,
|
||||
lambda v:(v[0],v[1].key),
|
||||
lambda v:(v[1].kind,v[1].end-v[1].start),self.rules['per_state'])
|
||||
for _,a in retained:self.operators.coverage['retained:'+a.kind]+=1
|
||||
return tuple((self.selector.evaluate(a,state.recent,state.home,state.last_kind),a) for _,a in retained)
|
||||
|
||||
def actions(self,history):
|
||||
result=['arpeggio','scalar','neighbor']
|
||||
if history:
|
||||
result+=['develop','answer']
|
||||
if len(history)>1 and history[-1].purpose!='return':result.append('return')
|
||||
if self.analysis.melody and len(history)>2:result.append('head')
|
||||
preferred=vocabulary.role().get('preferred_actions',[])
|
||||
return sorted(result,key=lambda a:(a not in preferred,preferred.index(a) if a in preferred else 0,a))
|
||||
|
||||
def propose(self,start,history,variant,batch=0):
|
||||
incoming=next((p.notes[-1] for p in reversed(history) if p.notes),None)
|
||||
home=next((p for p in history if len(p.notes)>=4),None)
|
||||
seed=policy.stable_rank(self.seed,('phrase',start,tuple(p.id for p in history[-2:]),variant,batch))
|
||||
purpose=self.actions(history)[variant%len(self.actions(history))]
|
||||
self.coverage[purpose]['proposed']+=1
|
||||
_,_,meter=self.at(start);bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
||||
span=self.harmony.at(start)
|
||||
if not span or not span.scale:return None
|
||||
limit=min(self.end,start+self.rules['max_phrase_bars']*bar)
|
||||
# Source harmony boundaries and available time supply opportunities, not mandatory closes.
|
||||
opportunities=sorted({min(self.end,start+2*bar),*(s.start for s in self.harmony.spans if start+2<=s.start<limit),*(s.end for s in self.harmony.spans if start+2<=s.end<limit),self.end if self.end<=limit else limit})
|
||||
boundary=choose(opportunities,seed,'destination time')
|
||||
ending=choose(('open','settle','complete'),seed,'ending')
|
||||
if boundary==self.end:ending='handoff'
|
||||
elif history and history[-1].ending=='open':ending=choose(('settle','complete'),seed,'complete continuation')
|
||||
offset=choose(tuple(map(F,self.rules['arrival_offsets'])) if self.enabled else (F(),F(1,2)),seed,'arrival')
|
||||
arrival=boundary+offset
|
||||
if ending=='open':arrival=min(boundary,self.end)-F(1,2)
|
||||
if ending=='handoff':arrival=self.end-choose((F(2),F(5,2),F(3)),seed,'handoff hold')
|
||||
arrival=max(start+(F(1) if ending=='handoff' else F(3,2)),min(arrival,self.end-F(1,2)))
|
||||
if arrival<=start:return None
|
||||
anticipated=arrival<boundary and ending!='open' and any(s.start==boundary for s in self.harmony.spans) and boundary-arrival<=F(vocabulary.data('harmony')['anticipation_max'])
|
||||
harmonic_time=boundary if anticipated else arrival
|
||||
target_span=self.harmony.at(harmonic_time)
|
||||
if not target_span or not target_span.scale:return None
|
||||
hold=F(1,2) if ending=='open' else choose(tuple(map(F,self.rules['holds'])),seed,'hold')
|
||||
if ending in ('complete','handoff'):hold=max(F(2),hold)
|
||||
release=min(self.end,arrival+hold)
|
||||
if anticipated and release<=boundary:return None
|
||||
if ending=='handoff':release=self.end
|
||||
breath=F() if ending=='open' else choose(tuple(map(F,self.rules['breaths'])),seed,'breath')
|
||||
finish=min(self.end,release+breath)
|
||||
if 0<self.end-finish<3:return None
|
||||
if finish==self.end and ending!='open':
|
||||
if release-arrival<min(F(2),self.end-start-1):return None
|
||||
ending='handoff'
|
||||
center=self.harmony.profile.reference or 60
|
||||
# A coherent excursion and return; no per-note octave folding or string quota.
|
||||
shift=choose(tuple(vocabulary.role().get('register_alternatives',(-7,0,7))),seed,'register')
|
||||
if incoming and incoming.pitch.number()>center+7:shift=min(0,shift)
|
||||
if incoming and incoming.pitch.number()<center-7:shift=max(0,shift)
|
||||
goal=center+shift
|
||||
permitted=types.permitted(self.harmony.choices,harmonic_time,release)
|
||||
members=self.harmony.members(harmonic_time,release)
|
||||
targets=[types.scale_pitch(target_span.scale,i) for i in range(-21,22)]
|
||||
targets=[p for p in targets if p.number()%12 in permitted and (ending=='open' or p.number()%12 in members) and self.harmony.profile.permits(p.number()) and abs(p.number()-goal)<=7]
|
||||
if not targets:return None
|
||||
# Choose among actual chord degrees and registers, without always picking the nearest fifth/root.
|
||||
target_pitch=choose(targets,seed,'destination pitch')
|
||||
target=types.Note(arrival,release,target_pitch,role='arrival' if ending!='open' else 'motion',event_id=seed[:16]+':target')
|
||||
contracts=[]
|
||||
if anticipated:contracts.append(types.ExpressionContract(seed[:16]+':anticipation','anticipation','early_chord_tone',(target,),target,target_span.chord,boundary=boundary))
|
||||
lead=();movement_end=arrival
|
||||
# Shape-changing expression is planned before either rhythm or route is realized.
|
||||
treatment=choose(('direct','approach','color','blues','suspension'),seed,'expression') if self.enabled and ending!='open' else 'direct'
|
||||
if treatment=='approach':
|
||||
rule=choose(vocabulary.data('lead_ins')['patterns'],seed,'approach rule')
|
||||
movement_end=arrival-F(rule['gap'])-sum(map(F,rule['durations']),F());cursor=movement_end;events=[]
|
||||
for i,(step,duration) in enumerate(zip(rule['semitones'],rule['durations'])):
|
||||
events.append(types.Note(cursor,cursor+F(duration),expressions.chromatic(target_pitch,step),role=rule['kind'],event_id=seed[:16]+':approach:'+str(i)));cursor+=F(duration)
|
||||
lead=tuple(events)
|
||||
contracts.append(types.ExpressionContract(seed[:16]+':approach',rule['kind'],rule['id'],lead,target,target_span.chord,boundary=boundary if anticipated else None))
|
||||
elif treatment=='color' and not anticipated:
|
||||
region=self.harmony.at(arrival-1)
|
||||
eligible=[r for r in vocabulary.data('harmony')['colors'] if region and expressions.eligible_color(r,region,self.harmony)]
|
||||
if eligible:
|
||||
rule=choose(eligible,seed,'color rule');p=expressions.degree_pitch(chords.parse_symbol(region.chord),rule['degree'],rule['alter'],target_pitch.number())
|
||||
if not rule.get('resolve') and choose((True,False),seed,'sustained color'):
|
||||
target=dataclasses.replace(target,pitch=p,role='color');target_pitch=p
|
||||
contracts.append(types.ExpressionContract(seed[:16]+':color','color',rule['id'],(target,),target,target_span.chord))
|
||||
else:
|
||||
color=types.Note(arrival-1,arrival,p,role='color',event_id=seed[:16]+':color')
|
||||
if rule.get('resolve') and not 0<abs(target_pitch.number()-p.number())<=2:return None
|
||||
lead=(color,);movement_end=color.start
|
||||
contracts.append(types.ExpressionContract(seed[:16]+':color','color',rule['id'],lead,target if rule.get('resolve') else color,target_span.chord if rule.get('resolve') else region.chord))
|
||||
elif treatment=='blues' and not anticipated:
|
||||
eligible=[r for r in vocabulary.data('lead_ins')['blues'] if expressions.degree_pitch(chords.parse_symbol(target_span.chord),r['target_degree'],0,target_pitch.number())==target_pitch]
|
||||
if eligible:
|
||||
rule=choose(eligible,seed,'blues rule');p=expressions.degree_pitch(chords.parse_symbol(target_span.chord),rule['degree'],rule['alter'],target_pitch.number())
|
||||
lead=(types.Note(arrival-F(1,2),arrival,p,role='blues',event_id=seed[:16]+':blues'),);movement_end=lead[0].start
|
||||
contracts.append(types.ExpressionContract(seed[:16]+':blues','blues',rule['id'],lead,target,target_span.chord))
|
||||
elif treatment=='suspension' and offset>0 and arrival==boundary+offset:
|
||||
previous=self.harmony.at(boundary-F(1,2));rule=vocabulary.data('harmony')['suspensions'][0]
|
||||
if previous and previous.scale:
|
||||
candidates=[p for p in self.routes.pitches(boundary-F(1,2),arrival) if p.number()%12 in previous.roles.basic and target_pitch.number()-p.number() in rule['steps']]
|
||||
if candidates:
|
||||
p=choose(candidates,seed,'suspension pitch');lead=(types.Note(boundary-F(1,2),arrival,p,role='suspension',event_id=seed[:16]+':suspension'),);movement_end=lead[0].start
|
||||
contracts.append(types.ExpressionContract(seed[:16]+':suspension','suspension',rule['id'],lead,target,target_span.chord,boundary=boundary))
|
||||
entrance=F() if history and history[-1].ending=='open' else choose(tuple(map(F,self.rules['entrances'])),seed,'entrance')
|
||||
entry=start+entrance
|
||||
if movement_end-entry<F(1):return None
|
||||
hint=home.notes[:4] if home else ()
|
||||
if purpose=='head':
|
||||
candidates=[self.analysis.melody[i:i+4] for i in range(max(0,len(self.analysis.melody)-3))]
|
||||
candidates=[ns for ns in candidates if ns[-1].end-ns[0].start<=movement_end-entry]
|
||||
if not candidates:return None
|
||||
hint=choose(candidates,seed,'head reference')
|
||||
rhythm_purpose=purpose if purpose in ('arpeggio','scalar','neighbor','develop','answer') else 'develop'
|
||||
rhythm_began=time.monotonic()
|
||||
timing,rhythm_groups,sources=self.rhythms.realize(entry,movement_end,rhythm_purpose,seed,meter)
|
||||
self.timings['rhythm_seconds']+=time.monotonic()-rhythm_began
|
||||
if not timing:return None
|
||||
if purpose in ('return','head'):
|
||||
if len(hint)<3:return None
|
||||
duration=hint[-1].end-hint[0].start
|
||||
if entry+duration>movement_end:return None
|
||||
pattern=tuple((entry+n.start-hint[0].start,entry+n.end-hint[0].start) for n in hint)
|
||||
tail,tail_groups,tail_sources=self.rhythms.realize(entry+duration,movement_end,'develop',seed,meter)
|
||||
timing=(*pattern,*tail)
|
||||
if not groups.valid(tuple((a-entry,b-entry) for a,b in timing)):return None
|
||||
rhythm_groups=(*groups.describe(tuple((a-entry,b-entry) for a,b in pattern),origin=entry,source='heard_theme' if purpose=='return' else 'head'),*tail_groups)
|
||||
sources=(purpose,*tail_sources)
|
||||
self.coverage[purpose]['rhythm_valid']+=1
|
||||
first_span=self.harmony.at(entry)
|
||||
starting=[p for p in self.routes.pitches(*timing[0]) if p.number()%12 in first_span.roles.basic and abs(p.number()-(incoming.pitch.number() if incoming else goal))<=7]
|
||||
if not starting:return None
|
||||
first=choose(starting,seed,'entry pitch')
|
||||
notes=tuple(types.Note(a,b,first,role=purpose,event_id=seed[:16]+':'+str(i)) for i,(a,b) in enumerate(timing))
|
||||
all_notes=(*notes,*lead,target)
|
||||
fixed={0,*range(len(notes),len(all_notes))}
|
||||
direction=choose(self.rules['directions'],seed,'contour')
|
||||
route_began=time.monotonic()
|
||||
paths=self.routes.realize(all_notes,fixed,purpose,direction,seed,goal,incoming,hint)
|
||||
self.timings['route_seconds']+=time.monotonic()-route_began
|
||||
self.coverage[purpose]['complete_routes']+=len(paths)
|
||||
for actual in paths:
|
||||
bound=[]
|
||||
for c in contracts:
|
||||
prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None)
|
||||
bound.append(dataclasses.replace(c,preparation=prior if c.kind in ('connection','blues') else c.preparation))
|
||||
validation_began=time.monotonic()
|
||||
checked=self.harmony.evaluate(actual,until=finish,complete=True,expressions=tuple(bound))
|
||||
self.timings['harmonic_seconds']+=time.monotonic()-validation_began
|
||||
if not checked.valid:self.failures[checked.error.split(':')[-1]]+=1;continue
|
||||
anchor=None if ending=='open' else types.Anchor(seed[:16]+':destination',arrival,arrival,target_pitch,members|frozenset({target_pitch.number()%12}),self.harmony.profile.register,release-arrival,'terminal' if ending in ('complete','handoff') else 'intermediate',target_span.chord,False,release,ending)
|
||||
phrase=types.Phrase(seed[:16],start,finish,actual,purpose,direction,ending,anchor,tuple(bound),rhythm_groups,home.id if home else seed[:16],sources=sources)
|
||||
evaluation_began=time.monotonic()
|
||||
evaluation=scoring.evaluate(phrase,incoming,history,center)
|
||||
self.timings['evaluation_seconds']+=time.monotonic()-evaluation_began
|
||||
self.coverage[purpose]['valid']+=1
|
||||
return dataclasses.replace(phrase,score=evaluation.total,evaluation=evaluation)
|
||||
return None
|
||||
|
||||
def candidates(self,start,history):
|
||||
key=(start,len(history),next(((p.id,p.notes) for p in history if len(p.notes)>=4),None),tuple((p.id,p.purpose,p.ending,p.notes,p.expressions) for p in history[-4:]))
|
||||
if key in self.cache:return self.cache[key]
|
||||
span=self.harmony.at(start)
|
||||
if not span or not span.scale:
|
||||
finish=min(self.end,span.end if span else self.end)
|
||||
result=(types.Phrase('space:'+str(start),start,finish,(),'space','none','open'),)
|
||||
self.cache[key]=result;return result
|
||||
for action in self.actions(history):self.coverage[action]['eligible_contexts']+=1
|
||||
proposals=[]
|
||||
for batch in range(self.budgets['replans']+1):
|
||||
for variant in range(self.budgets['proposals']):
|
||||
p=self.propose(start,history,variant,batch)
|
||||
if p:proposals.append(p)
|
||||
if proposals:break
|
||||
if not proposals:raise SearchExhausted(f'at beat {start}: no complete phrase within deterministic budget; rejections {self.failures.most_common(3)}; not proof of harmonic incompatibility')
|
||||
from generator.solo.sequences import diverse
|
||||
finals=diverse(proposals,lambda p:(p.score,policy.stable_rank(self.seed,p.id)),lambda p:(p.purpose,p.ending,p.direction),self.budgets['finalists'])
|
||||
placed=[];incoming=next((p.notes[-1] for p in reversed(history) if p.notes),None)
|
||||
for p in finals:
|
||||
self.coverage[p.purpose]['finalists']+=1
|
||||
placement_began=time.monotonic()
|
||||
options=placement.alternatives(p.notes,self.analysis.tempo,incoming,self.budgets['fingerings'],self.harmony.profile)
|
||||
self.timings['placement_seconds']+=time.monotonic()-placement_began
|
||||
if options:
|
||||
# Physical economy selects a fingering of this same melody, not the tune itself.
|
||||
placed.append(dataclasses.replace(p,notes=options[0][1]));self.coverage[p.purpose]['placed']+=1
|
||||
self.cache[key]=tuple(placed)
|
||||
return self.cache[key]
|
||||
def advance(self,state,evaluation,action):
|
||||
for contract in state.pending:
|
||||
target=contract.target
|
||||
if action.start<=target.start<action.end:
|
||||
# Reject a missed committed attack/hold before revalidating all the
|
||||
# harmonic relationships. Partial target evidence remains possible.
|
||||
if not any(n.event_id==target.event_id and n.start==target.start
|
||||
and n.end==min(action.end,target.end) and n.pitch==target.pitch for n in action.notes):return None
|
||||
obligations=(*state.pending,*action.contracts);pending=()
|
||||
if not state.pending:
|
||||
pending=tuple(c for c in action.contracts if c.target.end>action.end)
|
||||
elif obligations:
|
||||
heard={n.event_id:n for c in state.pending for n in c.events if n.start<action.start}
|
||||
heard.update({n.event_id:n for n in action.notes})
|
||||
check=self.harmony.evaluate(tuple(sorted(heard.values(),key=lambda n:n.start)),
|
||||
expressions=obligations,until=action.end,complete=action.end==self.end)
|
||||
if not check.valid:return None
|
||||
pending=check.pending
|
||||
recent=(*state.recent,*action.notes)[-8:];home=state.home
|
||||
if not home and len(recent)>=4:
|
||||
proposed=recent[:4]
|
||||
if proposed[-1].end-proposed[0].start<=8:home=proposed
|
||||
return State(state.total+(evaluation.total if evaluation else 0),recent,home,action.kind,state,action,evaluation,(),pending)
|
||||
|
||||
def compose(self):
|
||||
history=[];start=F()
|
||||
while start<self.end:
|
||||
options=self.candidates(start,tuple(history));ranked=[]
|
||||
for p in options[:self.budgets['frontier']]:
|
||||
future=()
|
||||
if p.end<self.end:
|
||||
try:future=self.candidates(p.end,(*history,p))
|
||||
except SearchExhausted:continue
|
||||
if not future:continue
|
||||
next_score=min((f.score for f in future),default=p.score)
|
||||
total=(p.score+next_score)/2
|
||||
ranked.append((total,policy.stable_rank(self.seed,p.id),p))
|
||||
if p.purpose in self.coverage:self.coverage[p.purpose]['lookahead_valid']+=1
|
||||
if not ranked:raise SearchExhausted(f'at beat {start}: no legal following phrase within lookahead budget')
|
||||
_,_,chosen=min(ranked,key=lambda v:v[:2]);history.append(chosen);start=chosen.end
|
||||
if chosen.purpose in self.coverage:self.coverage[chosen.purpose]['selected']+=1
|
||||
self.decisions.append({'start':str(chosen.start),'selected':chosen.id,'candidates':[{'id':p.id,'purpose':p.purpose,'end':str(p.end),'evaluation':dataclasses.asdict(p.evaluation) if p.evaluation else {},'lookahead_total':next((v[0] for v in ranked if v[2].id==p.id),None)} for p in options]})
|
||||
return tuple(history)
|
||||
self.decisions=[];began=time.monotonic();backtracks=0
|
||||
# Compare full actions by cost per exact quarter, with a bounded repair
|
||||
# frontier. This is a local policy, not a claim of globally optimal melody.
|
||||
stack=[(self.start,State(),None)];rankings={}
|
||||
while stack:
|
||||
at,state,iterator=stack[-1]
|
||||
if at==self.end:final=state;break
|
||||
if iterator is None:
|
||||
options=sorted(self.candidates(at,state),key=lambda v:(v[0].total,v[1].key))
|
||||
rankings[id(state)]=tuple({'kind':a.kind,'start':str(a.start),'end':str(a.end),
|
||||
'notes':[(str(n.start),str(n.end),n.pitch.number()) for n in a.notes],
|
||||
'evaluation':dataclasses.asdict(e)} for e,a in options[:4])
|
||||
iterator=iter(options);stack[-1]=(at,state,iterator)
|
||||
for evaluation,action in iterator:
|
||||
next_state=self.advance(state,evaluation,action)
|
||||
if next_state is not None:
|
||||
next_state=dataclasses.replace(next_state,alternatives=rankings[id(state)])
|
||||
stack.append((action.end,next_state,None));break
|
||||
else:
|
||||
stack.pop();backtracks+=1
|
||||
if backtracks>self.rules['backtracks']:raise SearchExhausted(f'at {at}: action repair budget exhausted')
|
||||
else:raise SearchExhausted('no complete action path')
|
||||
self.timings['backtracks']=backtracks
|
||||
self.timings['action_search_seconds']=time.monotonic()-began
|
||||
path=[];state=final
|
||||
while state.action:
|
||||
path.append(state);state=state.parent
|
||||
path.reverse();phrases=[];pending=[]
|
||||
self.selected_actions=tuple(s.action for s in path)
|
||||
for state in path:
|
||||
action=state.action;pending.append(state)
|
||||
self.operators.coverage['selected:'+action.kind]+=1
|
||||
self.decisions.append({'start':str(action.start),'end':str(action.end),'selected':action.kind,
|
||||
'evaluation':dataclasses.asdict(state.evaluation),'prefix_total':state.total,'alternatives':state.alternatives})
|
||||
# Reporting groups the selected thought; it does not insert events.
|
||||
held=bool(action.notes and action.notes[-1].end-action.notes[-1].start>=2)
|
||||
breath=not action.notes and action.end-action.start>=1
|
||||
if held or breath or action.end==self.end:
|
||||
phrases.append(self.phrase(pending,len(phrases)));pending=[]
|
||||
for name,count in self.operators.coverage.items():
|
||||
stage,kind=name.split(':',1);self.coverage.setdefault(kind,{})[stage]=count
|
||||
self.timings['selected_cost']=final.total
|
||||
return tuple(phrases)
|
||||
|
||||
def phrase(self,states,index):
|
||||
actions=tuple(s.action for s in states);ns=tuple(n for a in actions for n in a.notes)
|
||||
start,end=actions[0].start,actions[-1].end
|
||||
last=ns[-1] if ns else None
|
||||
held=last and last.end-last.start>=2
|
||||
ending='handoff' if end==self.end else 'settle' if held else 'open'
|
||||
anchor=None
|
||||
if held:
|
||||
region=self.harmony.at(last.start)
|
||||
anchor=types.Anchor(f'gesture_{index}:arrival',last.start,last.start,last.pitch,
|
||||
frozenset({last.pitch.number()%12}),self.harmony.profile.register,last.end-last.start,
|
||||
'terminal' if ending=='handoff' else 'intermediate',region.chord,False,last.end,ending)
|
||||
raw=Counter();contributions=Counter();weights={}
|
||||
for s in states:
|
||||
raw.update(s.evaluation.raw);contributions.update(s.evaluation.contributions);weights.update(s.evaluation.weights)
|
||||
evaluation=scoring.Evaluation(selection.VERSION,True,dict(raw),weights,dict(contributions),sum(s.evaluation.total for s in states),
|
||||
{'span':str(end-start),'attacks':len(ns),'ticks_per_quarter':12})
|
||||
direction='up' if len(ns)>1 and ns[-1].pitch.number()>ns[0].pitch.number() else 'down'
|
||||
rhythm=tuple(g for a in actions for g in groups.describe(tuple((n.start-a.start,n.end-a.start) for n in a.notes),origin=a.start,source=a.kind))
|
||||
return types.Phrase(f'gesture_{index}',start,end,ns,actions[0].kind,direction,ending,anchor,
|
||||
tuple(c for a in actions for c in a.contracts),rhythm,'home',evaluation.total,evaluation,tuple(a.source or a.kind for a in actions))
|
||||
|
||||
@@ -53,6 +53,8 @@ def write_role(result, path):
|
||||
part = role.split('_')[0]
|
||||
document = result['document']
|
||||
first, count = region(document, part)
|
||||
leadin=int(result.get('details',{}).get('allocation',{}).get('lead_in_measures',0))
|
||||
first-=leadin;count+=leadin
|
||||
selected = excerpt(document, first, count)
|
||||
numbers = [m['number'] for m in selected['staves'][0]['measures']]
|
||||
for i, measure in enumerate(selected['staves'][0]['measures'], 1):
|
||||
@@ -60,7 +62,7 @@ def write_role(result, path):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
musicxml_writer.write_musicxml(path, selected, tablature=True)
|
||||
return {'role': role, 'file': path.name, 'form_start_measure': 1,
|
||||
return {'role': role, 'file': path.name, 'form_start_measure': 1+leadin,
|
||||
'measure_count': count, 'arrangement_measures': numbers,
|
||||
'arrangement_start_beat': str(sum((model.fraction(m['duration']) for m in document['staves'][0]['measures'][:first]), F())),
|
||||
'pitch_convention': 'guitar-written; explicit octave transposition -1',
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
"""Typed, target-driven harmonic expression over a strictly mapped theme."""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
from generator import harmony as chord_model, model, pitch as spelling, vocabulary
|
||||
from generator.solo import types
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def parsed_chord(symbol):
|
||||
"""Private read-only chord analysis shared by pure expression operations."""
|
||||
return chord_model.parse_symbol(symbol)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def degree_spelling(letter,accidental,degree,alter):
|
||||
root={'letter':letter,'alter':model.rational(accidental),'octave':4}
|
||||
p=spelling.shift_pitch(root,degree-1,chord_model.degree_semitones(degree,alter))
|
||||
return types.Pitch(p['letter'],model.alteration(p['alter']),p['octave'])
|
||||
|
||||
|
||||
def quality(chord):
|
||||
degrees=chord_model.degrees(chord)
|
||||
@@ -15,16 +28,15 @@ def quality(chord):
|
||||
|
||||
|
||||
def degree_pitch(chord,degree,alter,near):
|
||||
root={**chord['root'],'octave':4}
|
||||
p=spelling.shift_pitch(root,degree-1,chord_model.degree_semitones(degree,alter))
|
||||
value=types.Pitch(p['letter'],model.alteration(p['alter']),p['octave'])
|
||||
value=degree_spelling(chord['root']['letter'],model.alteration(chord['root']['alter']),degree,alter)
|
||||
return dataclasses.replace(value,octave=value.octave+round((near-value.number())/12))
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1024)
|
||||
def chromatic(target,delta):
|
||||
# Chromatic neighbors are spelled relative to their exact destination.
|
||||
if delta in (-2,-1,1,2):
|
||||
step=1 if delta>0 else -1
|
||||
if delta in (-3,-2,-1,1,2,3):
|
||||
step=(1 if delta>0 else -1)*(2 if abs(delta)==3 else 1)
|
||||
root={'letter':target.letter,'alter':model.rational(target.alter),'octave':target.octave}
|
||||
p=spelling.shift_pitch(root,step,delta)
|
||||
return types.Pitch(p['letter'],model.alteration(p['alter']),p['octave'])
|
||||
@@ -32,7 +44,14 @@ def chromatic(target,delta):
|
||||
|
||||
|
||||
def eligible_color(rule,span,harmony):
|
||||
chord=chord_model.parse_symbol(span.chord)
|
||||
key=(tuple(rule['qualities']),rule['degree'],rule['alter'],rule.get('function'),span)
|
||||
cache=harmony.color_eligibility
|
||||
if key not in cache:cache[key]=_eligible_color(rule,span,harmony)
|
||||
return cache[key]
|
||||
|
||||
|
||||
def _eligible_color(rule,span,harmony):
|
||||
chord=parsed_chord(span.chord)
|
||||
degrees=chord_model.degrees(chord)
|
||||
if quality(chord) not in rule['qualities']:return False
|
||||
degree=rule['degree']; simple=(degree-1)%7+1
|
||||
@@ -41,9 +60,10 @@ def eligible_color(rule,span,harmony):
|
||||
if degree in degrees and degrees[degree]!=rule['alter']:return False
|
||||
following=next((s for s in harmony.spans if s.start>=span.end and s.chord!=span.chord and s.scale),None)
|
||||
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))%12
|
||||
next_chord=chord_model.parse_symbol(following.chord) if following else None
|
||||
next_chord=parsed_chord(following.chord) if following else None
|
||||
next_root=int(model.NATURAL[next_chord['root']['letter']]+model.alteration(next_chord['root']['alter']))%12 if next_chord else None
|
||||
function=rule.get('function')
|
||||
if function=='predominant_to_dominant' and not (next_chord and quality(next_chord)=='dominant' and (next_root-root)%12==5):return False
|
||||
if function=='resolving_dominant' and (next_root is None or (root-next_root)%12!=7):return False
|
||||
if function=='tonic_or_predominant':
|
||||
tonic=model.parse_pitch_name(span.scale.root)
|
||||
@@ -70,27 +90,43 @@ def validate(notes,contracts,harmony):
|
||||
if not rule or len(c.events)!=len(rule['semitones']):return {},c.id+': unknown approach formula'
|
||||
arrival=c.boundary if c.boundary is not None else target.start
|
||||
if c.boundary is not None and not any(a.kind=='anticipation' and a.target==target and a.boundary==c.boundary for a in contracts):return {},c.id+': future target lacks anticipation contract'
|
||||
if target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,arrival,target.end):return {},c.id+': invalid destination hold'
|
||||
gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F())
|
||||
planned_color=any(a.kind=='color' and a.target==target and a.events==(target,) for a in contracts)
|
||||
if not planned_color and (target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,arrival,target.end)):return {},c.id+': invalid destination hold'
|
||||
gaps=tuple(map(F,rule.get('internal_gaps',['0']*(len(c.events)-1))))
|
||||
if len(gaps)!=len(c.events)-1 or any(g<0 for g in gaps):return {},c.id+': invalid internal approach gaps'
|
||||
gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F())-sum(gaps,F())
|
||||
if target.start-cursor>F(vocabulary.data('lead_ins')['max_approach']):return {},c.id+': approach too long'
|
||||
for n,step,duration in zip(c.events,rule['semitones'],rule['durations']):
|
||||
for i,(n,step,duration) in enumerate(zip(c.events,rule['semitones'],rule['durations'])):
|
||||
if n.start!=cursor or n.end!=cursor+F(duration) or n.pitch.number()!=target.pitch.number()+step:return {},c.id+': wrong register, interval, or timing'
|
||||
cursor=n.end
|
||||
cursor=n.end+(gaps[i] if i<len(gaps) else 0)
|
||||
for index in rule.get('mapped_indices',[]):
|
||||
n=c.events[index]
|
||||
if n.pitch.number()%12 not in types.permitted(harmony.choices,n.start,n.end):return {},c.id+': declared diatonic neighbor is outside the mapped collection'
|
||||
if any(n.start<c.target.start and n.end>c.events[0].start and n.event_id not in {e.event_id for e in c.events} for n in notes):return {},c.id+': interrupted approach'
|
||||
if c.kind=='connection' and (not c.preparation or c.preparation.end!=c.events[0].start or abs(c.preparation.pitch.number()-c.events[0].pitch.number())>2):return {},c.id+': chromatic connection lacks its incoming motion'
|
||||
elif c.kind=='delayed_approach':
|
||||
rule=vocabulary.data('lead_ins').get('delayed_approach',{})
|
||||
if c.rule!=rule.get('id') or len(c.events)!=1:return {},c.id+': unknown delayed approach'
|
||||
n,=c.events
|
||||
if n.end-n.start>F(rule['max_note']) or not n.end<=target.start<=n.start+F(rule['deadline']):return {},c.id+': expired delayed approach'
|
||||
if target.pitch.number()-n.pitch.number() not in rule['steps']:return {},c.id+': wrong delayed target register'
|
||||
if target.pitch.number()%12 not in harmony.members(target.start,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,target.start,target.end):return {},c.id+': invalid delayed destination'
|
||||
elif c.kind=='color':
|
||||
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
|
||||
if not rule:return {},c.id+': unknown color policy'
|
||||
colored=False
|
||||
for n in c.events:
|
||||
if n.end-n.start>F(rule['max_beats']):return {},c.id+': excessive color duration'
|
||||
for region,a,b in harmony.segments(n):
|
||||
if n.pitch.number()%12 in region.roles.basic:continue
|
||||
if not region.scale or not eligible_color(rule,region,harmony):return {},c.id+': incompatible color harmony/function'
|
||||
p=degree_pitch(chord_model.parse_symbol(region.chord),rule['degree'],rule['alter'],n.pitch.number())
|
||||
if p!=n.pitch:return {},c.id+': color degree/spelling mismatch'
|
||||
p=degree_pitch(parsed_chord(region.chord),rule['degree'],rule['alter'],n.pitch.number())
|
||||
# The rule retains the functional degree/spelling. Encoded
|
||||
# enharmonic notation must not change sounding harmonic validity.
|
||||
if p.number()!=n.pitch.number():return {},c.id+': color degree/register mismatch'
|
||||
colored=True
|
||||
if not rule['outside'] and n.pitch.number()%12 not in region.scale.pitches:return {},c.id+': color requires mapped collection'
|
||||
if not colored:return {},c.id+': no interval expresses the declared color'
|
||||
last=c.events[-1]
|
||||
if rule.get('resolve'):
|
||||
if target.event_id==last.event_id or not 0<=target.start-last.end<=F(vocabulary.data('harmony')['resolution_gap']) or not 0<abs(target.pitch.number()-last.pitch.number())<=2 or target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': unresolved color'
|
||||
@@ -105,17 +141,29 @@ def validate(notes,contracts,harmony):
|
||||
return {},c.id+': missed suspension resolution'
|
||||
if target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': invalid suspension target'
|
||||
if held.pitch.number()%12 not in types.permitted(harmony.choices,held.start,held.end):return {},c.id+': suspension outside mapped collections'
|
||||
elif c.kind=='anticipated_color':
|
||||
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
|
||||
if not rule or len(c.events)!=1 or c.boundary!=span.start:return {},c.id+': unsupported anticipated color'
|
||||
n,=c.events
|
||||
if not n.start<c.boundary<n.end or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipated color interval'
|
||||
arrived=dataclasses.replace(n,start=c.boundary)
|
||||
target=arrived if c.target==n else c.target
|
||||
inner=types.ExpressionContract(c.id+':arrived','color',rule['id'],(arrived,),target,span.chord)
|
||||
_,error=validate((arrived,*((target,) if target!=arrived else ())), (inner,),harmony)
|
||||
if error:return {},error
|
||||
elif c.kind=='anticipation':
|
||||
if c.rule!='early_chord_tone' or len(c.events)!=1 or c.target!=c.events[0] or span.start!=c.boundary:
|
||||
if c.rule not in ('early_chord_tone','early_reattack') or len(c.events)!=1 or span.start!=c.boundary:
|
||||
return {},c.id+': unknown anticipation or non-boundary target'
|
||||
n,=c.events
|
||||
if c.boundary is None or not n.start<c.boundary<n.end or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipation interval'
|
||||
if n.pitch.number()%12 not in harmony.members(c.boundary,n.end) or n.pitch.number()%12 not in types.permitted(harmony.choices,c.boundary,n.end):return {},c.id+': invalid future destination'
|
||||
if c.boundary is None or not n.start<c.boundary or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipation interval'
|
||||
if c.rule=='early_chord_tone' and (n!=target or not c.boundary<n.end):return {},c.id+': invalid held anticipation'
|
||||
if c.rule=='early_reattack' and (n.end!=c.boundary or target.start!=c.boundary or target.event_id==n.event_id or target.pitch.number()!=n.pitch.number()):return {},c.id+': invalid anticipated reattack'
|
||||
if target.pitch.number()%12 not in harmony.members(c.boundary,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,c.boundary,target.end):return {},c.id+': invalid future destination'
|
||||
elif c.kind=='blues':
|
||||
if len(c.events)!=1:return {},c.id+': blues treatment requires one inflected event'
|
||||
rule=next((r for r in vocabulary.data('lead_ins')['blues'] if r['id']==c.rule),None)
|
||||
n,=c.events
|
||||
chord=chord_model.parse_symbol(span.chord)
|
||||
chord=parsed_chord(span.chord)
|
||||
if not rule or n.end-n.start>F(1,2) or n.end!=target.start or target.pitch.number()%12 not in span.roles.basic:return {},c.id+': unresolved blues inflection'
|
||||
expected=degree_pitch(chord,rule['degree'],rule['alter'],n.pitch.number())
|
||||
resolved=degree_pitch(chord,rule['target_degree'],0,target.pitch.number())
|
||||
|
||||
@@ -58,6 +58,7 @@ class Harmony:
|
||||
self._segments = {}
|
||||
self._members = {}
|
||||
self.evaluations = {}
|
||||
self.color_eligibility = {}
|
||||
def at(self, time):
|
||||
if time not in self._at:
|
||||
self._at[time] = next((s for s in self.spans if s.start <= time < s.end), None)
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Shared, enumerable musical actions for composition and derivation checking.
|
||||
|
||||
An action owns its complete rhythm and path. A rest is an action; an ordinary
|
||||
attack is not an implied cadence. Compound actions retain approach targets and
|
||||
whole tuplet groups. This module has no training/reference-file interface.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
from collections import Counter
|
||||
|
||||
from generator import harmony as chords, vocabulary
|
||||
from generator.solo import types, expressions, groups, rhythm
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Action:
|
||||
start: F
|
||||
end: F
|
||||
notes: tuple
|
||||
kind: str
|
||||
contracts: tuple = ()
|
||||
source: str = ''
|
||||
|
||||
@functools.cached_property
|
||||
def key(self):
|
||||
return (self.start,self.end,self.kind,tuple((n.start,n.end,n.pitch.number()) for n in self.notes),
|
||||
tuple((c.rule,str(c.boundary),c.target.start,c.target.end,c.target.pitch.number()) for c in self.contracts))
|
||||
|
||||
|
||||
class Operators:
|
||||
def __init__(self,harmony,analysis,end,start=F(),enabled=True,sequences=None,rhythms=None):
|
||||
self.harmony=harmony;self.analysis=analysis;self.start=start;self.end=end;self.enabled=enabled
|
||||
self.rules=vocabulary.data('solo')['operators'];self.cache={};self.pitch_cache={};self.coverage=Counter()
|
||||
self.ordinary_cache={}
|
||||
self.sequences=sequences
|
||||
self.patterns=[]
|
||||
if rhythms is not None:
|
||||
library=rhythm.Rhythms(rhythms,analysis.tempo)
|
||||
# Purposeful curated groups are peers; exhaustive catalogs contribute
|
||||
# representatives, not a probability proportional to XML file counts.
|
||||
candidates=library.groups
|
||||
for meter,values in library.catalog.items():
|
||||
if any(b-a==F(meter.split('/')[0])*4/int(meter.split('/')[1]) for a,b in zip(analysis.boundaries,analysis.boundaries[1:])):
|
||||
candidates+=list(values)
|
||||
seen=set()
|
||||
for name,pattern,length,_ in candidates:
|
||||
if 2<=len(pattern)<=4 and length<=4 and (pattern,length) not in seen:
|
||||
seen.add((pattern,length));self.patterns.append((name,pattern,length))
|
||||
|
||||
def pitches(self,time):
|
||||
span=self.harmony.at(time)
|
||||
if not span or not span.scale:return ()
|
||||
if span.scale not in self.pitch_cache:
|
||||
self.pitch_cache[span.scale]=tuple(p for i in range(-28,29)
|
||||
if self.harmony.profile.permits((p:=types.scale_pitch(span.scale,i)).number()))
|
||||
return self.pitch_cache[span.scale]
|
||||
|
||||
def note(self,start,end,pitch,role):
|
||||
# Identity follows exact musical content, not an output filename or hash.
|
||||
return types.Note(start,end,pitch,role=role,event_id=f'{start}:{end}:{pitch.number()}')
|
||||
|
||||
def degree_pitches(self,chord,rule):
|
||||
spelling=expressions.degree_pitch(chord,rule['degree'],rule['alter'],60)
|
||||
# Enumerate octaves, not repeated rounding from C-octave centers. Halfway
|
||||
# pitches (e.g. F#) otherwise disappear because round uses even ties.
|
||||
return tuple(p for octave in range(1,6)
|
||||
if self.harmony.profile.permits((p:=dataclasses.replace(spelling,octave=octave)).number()))
|
||||
|
||||
def _accepted(self,action):
|
||||
self.coverage['proposed:'+action.kind]+=1
|
||||
if action.end>self.end or action.end<=action.start:
|
||||
self.coverage['rejected_by_allocation:'+action.kind]+=1;return False
|
||||
if any(not self.harmony.profile.permits(n.pitch.number()) for n in action.notes):
|
||||
self.coverage['rejected_by_physical_domain:'+action.kind]+=1;return False
|
||||
if not groups.valid(tuple((n.start-action.start,n.end-action.start) for n in action.notes)):
|
||||
self.coverage['rejected_by_rhythm:'+action.kind]+=1;return False
|
||||
if action.contracts:
|
||||
valid=self.harmony.evaluate(action.notes,expressions=action.contracts,complete=False,until=action.end).valid
|
||||
else:
|
||||
valid=True
|
||||
for n in action.notes:
|
||||
key=(n.start,n.end,n.pitch.number())
|
||||
if key not in self.ordinary_cache:self.ordinary_cache[key]=self.harmony.evaluate((n,),complete=True,until=n.end).valid
|
||||
if not self.ordinary_cache[key]:valid=False;break
|
||||
if not valid:
|
||||
self.coverage['rejected_by_rule:'+action.kind]+=1
|
||||
return False
|
||||
self.coverage['valid:'+action.kind]+=1
|
||||
return True
|
||||
|
||||
def _color(self,n,span):
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
if rule.get('resolve') or not expressions.eligible_color(rule,span,self.harmony):continue
|
||||
expected=expressions.degree_pitch(expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],n.pitch.number())
|
||||
if expected.number()!=n.pitch.number():continue
|
||||
c=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(n,),n,span.chord)
|
||||
yield Action(n.start,n.end,(n,),'color',(c,),rule['id'])
|
||||
|
||||
def at(self,time,eligible=None,first=None):
|
||||
"""Unpruned legal actions. Incoming-state ranking is a separate operation."""
|
||||
if eligible is None and first is None and time in self.cache:return self.cache[time]
|
||||
span=self.harmony.at(time);values=[]
|
||||
if not span or not span.scale:
|
||||
end=min(self.end,span.end if span else self.end)
|
||||
result=(Action(time,end,(),'space'),)
|
||||
if eligible is not None:return tuple(a for a in result if eligible(a))
|
||||
self.cache[time]=result;return result
|
||||
def add(action):
|
||||
if eligible is not None and not eligible(action):return
|
||||
if self._accepted(action):values.append(action)
|
||||
def entry(a,b,pitch):
|
||||
return first is None or first==(a,b,pitch.number())
|
||||
durations=tuple(map(F,self.rules['durations']))
|
||||
if self.analysis.tempo>vocabulary.data('roles')['difficulty']['sixteenth_max_bpm']:
|
||||
durations=tuple(d for d in durations if d>=F(1,2))
|
||||
for duration in durations:
|
||||
if time+duration>self.end:continue
|
||||
if (time*(4 if duration==F(1,4) else 2)).denominator==1:
|
||||
add(Action(time,time+duration,(),'rest'))
|
||||
for pitch in self.pitches(time):
|
||||
if not entry(time,time+duration,pitch):continue
|
||||
n=self.note(time,time+duration,pitch,'motion')
|
||||
if pitch.number()%12 in types.permitted(self.harmony.choices,time,n.end):
|
||||
add(Action(time,n.end,(n,),'tone'))
|
||||
if self.enabled:
|
||||
delayed=vocabulary.data('lead_ins').get('delayed_approach')
|
||||
if delayed:
|
||||
for delay in map(F,delayed['arrivals']):
|
||||
arrival=time+delay
|
||||
for hold in map(F,delayed['holds']):
|
||||
if arrival+hold>self.end:continue
|
||||
region=self.harmony.at(arrival)
|
||||
if not region:continue
|
||||
for p in self.pitches(arrival):
|
||||
if p.number()%12 not in self.harmony.members(arrival,arrival+hold):continue
|
||||
target=self.note(arrival,arrival+hold,p,'arrival')
|
||||
for step in delayed['steps']:
|
||||
n=self.note(time,time+F(delayed['max_note']),expressions.chromatic(p,-step),'delayed_approach')
|
||||
if not entry(n.start,n.end,n.pitch):continue
|
||||
if n.pitch.number()%12 in types.permitted(self.harmony.choices,n.start,n.end):continue
|
||||
c=types.ExpressionContract(n.event_id+':delayed','delayed_approach',delayed['id'],(n,),target,region.chord)
|
||||
add(Action(time,n.end,(n,),'delayed_approach',(c,),delayed['id']))
|
||||
# Optional colors retain functional degree identity independently of
|
||||
# the reference/exporter's enharmonic notation. No scale is widened.
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
if not expressions.eligible_color(rule,span,self.harmony):continue
|
||||
for p in self.degree_pitches(expressions.parsed_chord(span.chord),rule):
|
||||
if not self.harmony.profile.permits(p.number()):continue
|
||||
for duration in durations:
|
||||
if duration>F(rule['max_beats']) or time+duration>self.end:continue
|
||||
if not entry(time,time+duration,p):continue
|
||||
n=self.note(time,time+duration,p,'color')
|
||||
if not rule.get('resolve'):
|
||||
c=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(n,),n,span.chord)
|
||||
add(Action(time,n.end,(n,),'color',(c,),rule['id']))
|
||||
else:
|
||||
for gap in (F(),F(1,2),F(1)):
|
||||
for q in self.pitches(n.end+gap):
|
||||
if not 0<abs(q.number()-p.number())<=2:continue
|
||||
for hold in map(F,self.rules['approach_holds']):
|
||||
target=self.note(n.end+gap,n.end+gap+hold,q,'arrival')
|
||||
target_span=self.harmony.at(target.start)
|
||||
if not target_span or target.end>self.end:continue
|
||||
c=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(n,),target,target_span.chord)
|
||||
add(Action(time,n.end,(n,),'color_resolution',(c,),rule['id']))
|
||||
for boundary in (s.start for s in self.harmony.spans if time<s.start<=time+F(vocabulary.data('harmony')['anticipation_max'])):
|
||||
region=self.harmony.at(boundary)
|
||||
for hold in durations:
|
||||
for p in self.pitches(boundary):
|
||||
n=self.note(time,boundary,p,'anticipation')
|
||||
if not entry(n.start,n.end,p):continue
|
||||
target=self.note(boundary,boundary+hold,p,'arrival')
|
||||
c=types.ExpressionContract(n.event_id+':anticipation','anticipation','early_reattack',(n,),target,region.chord,boundary=boundary)
|
||||
add(Action(time,target.end,(n,target),'anticipation',(c,),c.rule))
|
||||
for duration in durations:
|
||||
if time+duration<=boundary:continue
|
||||
for p in self.pitches(boundary):
|
||||
if not entry(time,time+duration,p):continue
|
||||
n=self.note(time,time+duration,p,'anticipation')
|
||||
c=types.ExpressionContract(n.event_id+':anticipation','anticipation','early_chord_tone',(n,),n,region.chord,boundary=boundary)
|
||||
add(Action(time,n.end,(n,),'anticipation',(c,),c.rule))
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
if not expressions.eligible_color(rule,region,self.harmony):continue
|
||||
for p in self.degree_pitches(expressions.parsed_chord(region.chord),rule):
|
||||
if not entry(time,time+duration,p):continue
|
||||
n=self.note(time,time+duration,p,'anticipated_color')
|
||||
if not rule.get('resolve'):
|
||||
c=types.ExpressionContract(n.event_id+':anticipated_color','anticipated_color',rule['id'],(n,),n,region.chord,boundary=boundary)
|
||||
add(Action(time,n.end,(n,),'anticipated_color',(c,),rule['id']))
|
||||
else:
|
||||
for q in self.pitches(n.end):
|
||||
if not 0<abs(q.number()-p.number())<=2:continue
|
||||
for hold in map(F,self.rules['approach_holds']):
|
||||
target=self.note(n.end,n.end+hold,q,'arrival')
|
||||
c=types.ExpressionContract(n.event_id+':anticipated_color','anticipated_color',rule['id'],(n,),target,region.chord,boundary=boundary)
|
||||
add(Action(time,target.end,(n,target),'anticipated_color',(c,),rule['id']))
|
||||
for rule in vocabulary.data('lead_ins')['patterns']:
|
||||
if rule.get('difficulty') and self.analysis.tempo>vocabulary.data('roles')['difficulty'][rule['difficulty']+'_max_bpm']:continue
|
||||
# A connection's preceding event is explicit in the action. It cannot
|
||||
# obtain a fictitious preparation from a label or from a future note.
|
||||
if rule['kind']=='connection':
|
||||
for preparation in map(F,self.rules['connection_preparations']):
|
||||
if first is not None and (first[0]!=time or first[1]!=time+preparation):continue
|
||||
arrival=time+preparation+sum(map(F,rule['durations']),F())+F(rule['gap'])
|
||||
for hold in map(F,self.rules['approach_holds']):
|
||||
if arrival+hold>self.end:continue
|
||||
for p in self.pitches(arrival):
|
||||
region=self.harmony.at(arrival)
|
||||
target=self.note(arrival,arrival+hold,p,'arrival')
|
||||
cursor=time+preparation;notes=[]
|
||||
for step,length in zip(rule['semitones'],map(F,rule['durations'])):
|
||||
notes.append(self.note(cursor,cursor+length,expressions.chromatic(p,step),'connection'));cursor+=length
|
||||
for prep_pitch in self.pitches(time):
|
||||
if not entry(time,time+preparation,prep_pitch) or not 0<abs(prep_pitch.number()-notes[0].pitch.number())<=2:continue
|
||||
prep=self.note(time,time+preparation,prep_pitch,'preparation')
|
||||
c=types.ExpressionContract(prep.event_id+':'+rule['id'],'connection',rule['id'],tuple(notes),target,region.chord,preparation=prep)
|
||||
add(Action(time,target.end,(prep,*notes,target),'connection',(c,),rule['id']))
|
||||
continue
|
||||
gaps=tuple(map(F,rule.get('internal_gaps',[])))
|
||||
arrival=time+sum(map(F,rule['durations']),F())+sum(gaps,F())+F(rule['gap'])
|
||||
for hold in map(F,self.rules['approach_holds']):
|
||||
for p in self.pitches(arrival):
|
||||
if not entry(time,time+F(rule['durations'][0]),expressions.chromatic(p,rule['semitones'][0])):continue
|
||||
region=self.harmony.at(arrival)
|
||||
if not region:continue
|
||||
target=self.note(arrival,arrival+hold,p,'arrival')
|
||||
cursor=time;notes=[]
|
||||
for i,(step,length) in enumerate(zip(rule['semitones'],map(F,rule['durations']))):
|
||||
notes.append(self.note(cursor,cursor+length,expressions.chromatic(p,step),rule['kind']));cursor+=length+(gaps[i] if i<len(gaps) else 0)
|
||||
c=types.ExpressionContract(notes[0].event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(notes),target,region.chord)
|
||||
add(Action(time,target.end,(*notes,target),rule['kind'],(c,),rule['id']))
|
||||
for colored in self._color(target,region):
|
||||
add(Action(time,target.end,(*notes,target),rule['kind'],(c,*colored.contracts),rule['id']))
|
||||
for upcoming in self.harmony.spans:
|
||||
if not arrival<upcoming.start<target.end or upcoming.start-arrival>F(vocabulary.data('harmony')['anticipation_max']):continue
|
||||
anticipation=types.ExpressionContract(target.event_id+':early','anticipation','early_chord_tone',(target,),target,upcoming.chord,boundary=upcoming.start)
|
||||
approach=dataclasses.replace(c,chord=upcoming.chord,boundary=upcoming.start)
|
||||
add(Action(time,target.end,(*notes,target),rule['kind'],(approach,anticipation),rule['id']))
|
||||
# Complete directional groups use exact scale indexing at each harmonic
|
||||
# time. A changed collection retains actual pitches, never degree labels.
|
||||
lengths_list=[(F(1,2),)*2,(F(1,2),)*4,(F(1),F(1,2),F(1,2)),(F(1,2),F(1),F(1,2))]
|
||||
difficulty=vocabulary.data('roles')['difficulty']
|
||||
if self.analysis.tempo<=difficulty['triplet_max_bpm']:lengths_list.append((F(1,3),)*3)
|
||||
if self.analysis.tempo<=difficulty['sixteenth_max_bpm']:lengths_list.extend(((F(1,4),)*4,(F(1,4),)*2))
|
||||
patterns=list(self.patterns)
|
||||
for lengths in lengths_list:
|
||||
cursor=F();pattern=[]
|
||||
for d in lengths:pattern.append((cursor,cursor+d));cursor+=d
|
||||
patterns.append(('complete_subdivision',tuple(pattern),cursor))
|
||||
for name,pattern,length in patterns:
|
||||
if time+length>self.end:continue
|
||||
if first is not None and (time+pattern[0][0],time+pattern[0][1])!=first[:2]:continue
|
||||
for stride in (-2,-1,1,2):
|
||||
# Catalog licenses relative paths, but never sets family probability.
|
||||
if self.sequences and not any(s.steps==(stride,)*(len(pattern)-1) for s in self.sequences.paths.get(len(pattern),())):continue
|
||||
for initial in self.pitches(time):
|
||||
if not entry(time+pattern[0][0],time+pattern[0][1],initial):continue
|
||||
cursor=time;p=initial;notes=[]
|
||||
for index,(a,b) in enumerate(pattern):
|
||||
cursor=time+a
|
||||
region=self.harmony.at(cursor)
|
||||
if not region or not region.scale:break
|
||||
if index:
|
||||
try:p=types.scale_pitch(region.scale,types.scale_index(region.scale,p.number())+stride)
|
||||
except ValueError:break
|
||||
notes.append(self.note(cursor,time+b,p,'scalar' if abs(stride)==1 else 'arpeggio'))
|
||||
if len(notes)==len(pattern):add(Action(time,time+length,tuple(notes),'scalar' if abs(stride)==1 else 'arpeggio',source=f'{name}:degree_stride:{stride}'))
|
||||
# Equivalent musical actions need one stable derivation, not extra votes.
|
||||
unique={}
|
||||
for a in values:unique.setdefault(a.key,a)
|
||||
result=tuple(unique[k] for k in sorted(unique))
|
||||
if eligible is not None or first is not None:return result
|
||||
if len(self.cache)>=32:self.cache.pop(next(iter(self.cache)))
|
||||
if len(self.ordinary_cache)>8192:self.ordinary_cache.clear()
|
||||
self.cache[time]=result
|
||||
return result
|
||||
|
||||
def related(self,time,home,kind):
|
||||
"""Construct a heard-theme return or answer as a whole group, never a prefix."""
|
||||
if len(home)<3:return ()
|
||||
pattern=tuple((n.start-home[0].start,n.end-home[0].start) for n in home)
|
||||
if not groups.valid(pattern):return ()
|
||||
values=[]
|
||||
for p in self.pitches(time):
|
||||
notes=[]
|
||||
for n,(a,b) in zip(home,pattern):
|
||||
number=p.number()+(n.pitch.number()-home[0].pitch.number())*(1 if kind=='return' else -1)
|
||||
q=next((q for q in self.pitches(time+a) if q.number()==number),None)
|
||||
if q is None:break
|
||||
notes.append(self.note(time+a,time+b,q,kind))
|
||||
if len(notes)==len(home):
|
||||
action=Action(time,time+pattern[-1][1],tuple(notes),kind,source='realized_theme')
|
||||
if self._accepted(action):values.append(action)
|
||||
return tuple(values)
|
||||
@@ -4,7 +4,7 @@ import dataclasses, hashlib
|
||||
class Policy:
|
||||
long_note: float = 2.
|
||||
stopping_note: float = 1.
|
||||
composition_version: str = 'phrase-composer-1'
|
||||
composition_version: str = 'joint-actions-1'
|
||||
DEFAULT=Policy()
|
||||
def stable_rank(seed, value):
|
||||
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
|
||||
|
||||
@@ -25,16 +25,18 @@ def structure(solo):
|
||||
phrases.append(entry)
|
||||
if anchor:endings.append({'phrase':p.id,'effect':p.ending,'arrival':str(anchor.earliest),'pitch':anchor.pitch.label(),'required_beats':str(anchor.duration),'completed':completed})
|
||||
ns=solo.notes()
|
||||
duration=solo.end-solo.start
|
||||
return {'schema':'composer-report-3','selection_trace':plain({k:v for k,v in solo.diagnostics.items() if not k.endswith('_seconds')}),
|
||||
'instrument_profile':plain(solo.profile),'phrases':phrases,'expressions':plain(contracts),'fretboard':fretboard_report(solo),
|
||||
'phrasing':{'endings':endings,'attacks':len(ns),'attacks_per_four_beats':4*len(ns)/float(solo.end) if solo.end else 0,
|
||||
'occupancy':sum(float(n.end-n.start) for n in ns)/float(solo.end) if solo.end else 0},
|
||||
'phrasing':{'endings':endings,'attacks':len(ns),'allocation_beats':str(duration),
|
||||
'attacks_per_four_beats':4*len(ns)/float(duration) if duration else 0,
|
||||
'occupancy':sum(float(n.end-n.start) for n in ns)/float(duration) if duration else 0},
|
||||
'harmonic_roles':harmony.describe(ns,expressions=contracts),'harmonic_valid':checked.valid,'harmonic_error':checked.error,
|
||||
'harmonic_exposure':checked.exposure,'unresolved_treatments':plain(checked.pending)}
|
||||
|
||||
def describe(solo,scales,measure_offset=0,time_offset=0,*,detail=None):
|
||||
detail=detail or structure(solo)
|
||||
lines=[f'Independent complete-phrase solo: {solo.part}', 'Times are exact quarter beats from the solo start; pitches sound one octave below guitar notation.',
|
||||
lines=[f'Independent joint-action solo: {solo.part}', 'Times are exact quarter beats from the chorus start; negative times are allocated lead-ins. Pitches sound one octave below guitar notation.',
|
||||
'Approximate fingering costs are not a biomechanical guarantee.',json.dumps(detail['phrasing'])]
|
||||
for p in solo.phrases:
|
||||
lines.append(f'{p.id}: {p.start}–{p.end}; {p.purpose}, {p.direction}, {p.ending}; theme {p.theme}; score {p.score:.4f}')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Complete generated rhythm groups, indexed once, independent of source rhythms."""
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary
|
||||
from generator.solo import groups, policy
|
||||
from generator.solo import groups
|
||||
|
||||
class Rhythms:
|
||||
def __init__(self, library, tempo):
|
||||
@@ -31,32 +31,3 @@ class Rhythms:
|
||||
representatives.setdefault(descriptor,(item.id,pattern,bar.length,('develop','answer')))
|
||||
self.catalog[meter]=tuple(representatives.values())
|
||||
self.cache={}
|
||||
|
||||
def realize(self,start,end,purpose,seed,meter):
|
||||
key=(start,end,purpose,seed,meter)
|
||||
if key in self.cache:return self.cache[key]
|
||||
cursor=start;notes=[];sources=[];chosen=[];home=None;uses=0
|
||||
while cursor<end:
|
||||
available=[g for g in self.groups if purpose in g[3] and g[2]<=end-cursor]
|
||||
# Catalog is a separate family, not thousands of duplicate votes.
|
||||
if purpose in ('develop','answer'):
|
||||
available += sorted((g for g in self.catalog.get(meter,()) if g[2]<=end-cursor),key=lambda g:policy.stable_rank(seed,('catalog',cursor,g[0])))[:1]
|
||||
available=[g for g in available if ((end-cursor-g[2])*2).denominator==1]
|
||||
if not available:return (),(),()
|
||||
# Prefer complete multi-event groups; single values finish an exact remainder.
|
||||
large=[g for g in available if g[2]>=1]
|
||||
if home is not None and uses<2 and home in available:
|
||||
selected=home
|
||||
else:
|
||||
# Establish one rhythmic idea before reshaping it. A complete phrase
|
||||
# is not a fresh unrelated catalog draw at every short boundary.
|
||||
selected=min(large or available,key=lambda g:policy.stable_rank(seed,('rhythm',len(chosen)//4,g[0])))
|
||||
home=selected;uses=0
|
||||
uses+=1
|
||||
name,pattern,length,_=selected
|
||||
events=tuple((cursor+a,cursor+b) for a,b in pattern)
|
||||
notes.extend(events);sources.append(name)
|
||||
chosen.extend(groups.describe(pattern,origin=cursor,source=name))
|
||||
cursor+=length
|
||||
result=(tuple(notes),tuple(chosen),tuple(sources));self.cache[key]=result
|
||||
return result
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Complete pitch paths for joint phrase proposals, with explicit structural motion."""
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary
|
||||
from generator.solo import types, policy, sequences
|
||||
|
||||
class Routes:
|
||||
def __init__(self,harmony,library):
|
||||
self.harmony=harmony;self.library=library;self.pitch_cache={};self.cache={}
|
||||
# Every short relative path is reachable by attack count and exact displacement.
|
||||
self.index={}
|
||||
self.steps=frozenset(s.steps[0] for s in library.paths.get(2,()))
|
||||
for values in library.paths.values():
|
||||
for value in values:self.index.setdefault((len(value.steps)+1,value.offsets()[-1]),[]).append(value)
|
||||
|
||||
def pitches(self,start,end):
|
||||
key=(start,end)
|
||||
if key not in self.pitch_cache:
|
||||
span=self.harmony.at(start)
|
||||
allowed=types.permitted(self.harmony.choices,start,end)
|
||||
if end-start>=2:allowed &= self.harmony.members(start,end)| (span.roles.specified if span else frozenset())
|
||||
self.pitch_cache[key]=tuple(types.scale_pitch(span.scale,i) for i in range(-28,29)
|
||||
if types.scale_pitch(span.scale,i).number()%12 in allowed and self.harmony.profile.permits(types.scale_pitch(span.scale,i).number())) if span and span.scale else ()
|
||||
return self.pitch_cache[key]
|
||||
|
||||
def realize(self,notes,fixed,purpose,direction,seed,center,incoming=None,hint=()):
|
||||
"""Only completed paths leave this function; internal frontier cuts have no cadence."""
|
||||
if not notes:return ()
|
||||
# Identical musical work is cached without event/provenance IDs.
|
||||
key=(tuple((n.start,n.end,n.pitch if i in fixed else None) for i,n in enumerate(notes)),tuple(sorted(fixed)),purpose,direction,seed,center,incoming.pitch if incoming else None,tuple((n.start,n.end,n.pitch) for n in hint))
|
||||
if key in self.cache:
|
||||
return tuple(tuple(dataclasses.replace(n,pitch=p) for n,p in zip(notes,path)) for path in self.cache[key])
|
||||
width=vocabulary.data('solo')['search']['route_frontier'];states=[(0.,())];count=len(notes)
|
||||
for i,n in enumerate(notes):
|
||||
options=(n.pitch,) if i in fixed else self.pitches(n.start,n.end)
|
||||
region=self.harmony.at(n.start)
|
||||
pitch_order={p:policy.stable_rank(seed,('pitch',i,p)) for p in options}
|
||||
following=[]
|
||||
for cost,path in states:
|
||||
prior=path[-1] if path else incoming.pitch if incoming else None
|
||||
phase=i/max(1,count-1)
|
||||
sign=1 if direction=='up' or direction=='arch' and phase<.5 or direction=='valley' and phase>=.5 else -1
|
||||
target=next((notes[j].pitch for j in sorted(fixed) if j>i),None)
|
||||
choices=[]
|
||||
for pitch in options:
|
||||
number=pitch.number();motion=number-prior.number() if prior else 0
|
||||
if not self.harmony.profile.permits(number) or prior and abs(motion)>12:continue
|
||||
if prior and i not in fixed:
|
||||
try:
|
||||
displacement=types.scale_index(region.scale,number)-types.scale_index(region.scale,prior.number())
|
||||
if displacement and displacement not in self.steps:continue
|
||||
except ValueError:
|
||||
pass # Changed collection: retain the actual incoming pitch.
|
||||
local=max(0,abs(number-center)-10)/12
|
||||
if i not in fixed:
|
||||
local+=max(0,abs(motion)-7)/3
|
||||
if prior:
|
||||
span=region
|
||||
chordal=number%12 in span.roles.basic
|
||||
# Arpeggios expand during the gesture; the last portion connects to its goal.
|
||||
if purpose=='arpeggio' and phase<.65:local+=(0 if chordal else 1)+(.4 if abs(motion)<3 else 0)
|
||||
elif purpose=='neighbor':local+=max(0,abs(motion)-2)/2
|
||||
else:local+=max(0,abs(motion)-3)/3
|
||||
desired=sign
|
||||
if hint and 0<i<len(hint):
|
||||
step=hint[i].pitch.number()-hint[i-1].pitch.number()
|
||||
if purpose=='answer':step=-step
|
||||
desired=(step>0)-(step<0)
|
||||
if purpose in ('return','head') and ((motion>0)-(motion<0))!=desired:continue
|
||||
local+=abs(abs(motion)-abs(step))/12
|
||||
if purpose=='neighbor':desired=sign if i%2 else -sign
|
||||
local+=.35 if motion*desired<0 else .2 if motion==0 else 0
|
||||
if len(path)>=2 and path[-2]==prior==pitch:local+=1
|
||||
if target and phase>.6:
|
||||
remaining=max(1,next(j for j in sorted(fixed) if j>i)-i)
|
||||
local+=max(0,abs(target.number()-number)-remaining*2)/3
|
||||
choices.append((local,pitch))
|
||||
# Preserve distinct register/motion alternatives, not merely two nearest notes.
|
||||
for local,pitch in sorted(choices,key=lambda v:(v[0],pitch_order[v[1]]))[:width]:
|
||||
following.append((cost+local,(*path,pitch)))
|
||||
if not following:return ()
|
||||
states=sequences.diverse(following,lambda v:(round(v[0],8),policy.stable_rank(seed,('path',v[1]))),
|
||||
lambda v:((v[1][-1].number()-v[1][0].number()>0),v[1][-1].number()),width)
|
||||
# Where applicable, add complete catalog routes as peers of the contextual route.
|
||||
if 2<=count<=4 and fixed=={0,count-1}:
|
||||
span=self.harmony.at(notes[0].start)
|
||||
if span and span.scale and all(self.harmony.at(n.start).scale==span.scale for n in notes):
|
||||
try:
|
||||
origin=types.scale_index(span.scale,notes[0].pitch.number());displacement=types.scale_index(span.scale,notes[-1].pitch.number())-origin
|
||||
for route in self.index.get((count,displacement),())[:32]:
|
||||
path=tuple(types.scale_pitch(span.scale,origin+x) for x in route.offsets())
|
||||
if all(p in self.pitches(n.start,n.end) for p,n in zip(path,notes)):states=(*states,(0.,path))
|
||||
except ValueError:pass
|
||||
paths=[]
|
||||
for _,path in sorted(states,key=lambda v:(v[0],policy.stable_rank(seed,v[1]))):
|
||||
if path not in paths:paths.append(path)
|
||||
if len(paths)==2:break
|
||||
self.cache[key]=tuple(paths)
|
||||
return tuple(tuple(dataclasses.replace(n,pitch=p) for n,p in zip(notes,path)) for path in paths)
|
||||
@@ -1,8 +1,6 @@
|
||||
"""One authoritative evaluation of completed phrases, never guitar travel."""
|
||||
"""The authoritative action-evaluation record, independent of guitar travel."""
|
||||
from dataclasses import dataclass
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary
|
||||
from generator.solo import types
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Evaluation:
|
||||
@@ -11,38 +9,5 @@ class Evaluation:
|
||||
raw: dict
|
||||
weights: dict
|
||||
contributions: dict
|
||||
total: float
|
||||
total: F
|
||||
denominators: dict
|
||||
|
||||
def signature(notes):
|
||||
ns=types.sounding_notes(notes)
|
||||
if not ns:return ()
|
||||
origin=ns[0].start;pitch=ns[0].pitch.number()
|
||||
return tuple((n.start-origin,n.end-n.start,n.pitch.number()-pitch) for n in ns)
|
||||
|
||||
def evaluate(phrase,incoming,history,center,weights=None):
|
||||
ns=types.sounding_notes(phrase.notes); span=float(phrase.end-phrase.start)
|
||||
weights=dict(vocabulary.data('solo')['weights'] if weights is None else weights)
|
||||
if set(weights)!={'purpose','continuity','repetition','register'}:raise ValueError('Unknown musical contribution')
|
||||
if not ns:
|
||||
valid=phrase.purpose=='space';raw=dict.fromkeys(weights,0.)
|
||||
else:
|
||||
valid=True
|
||||
movement=[b.pitch.number()-a.pitch.number() for a,b in zip(ns,ns[1:])]
|
||||
# Declared expansion permits arpeggio leaps; avoid unprepared octave flailing.
|
||||
purpose=sum(max(0,abs(v)-7)/12 for v in movement)/max(1,len(movement))
|
||||
oscillation=sum(a==-b==c and a!=0 for a,b,c in zip(movement,movement[1:],movement[2:]))
|
||||
purpose+=max(0,oscillation-1)/max(1,len(movement))
|
||||
if phrase.destination:
|
||||
prior=next((n for n in reversed(ns) if n.end<=phrase.destination.earliest),None)
|
||||
if prior:purpose+=max(0,abs(prior.pitch.number()-phrase.destination.pitch.number())-3)/12
|
||||
continuity=max(0,abs(ns[0].pitch.number()-incoming.pitch.number())-5)/12 if incoming else 0.
|
||||
current=signature(ns[:6]); repetition=0.
|
||||
for age,old in enumerate(reversed(history[-4:])):
|
||||
if len(current)>=3 and current==signature(old.notes[:6]):repetition+=1/(age+1)
|
||||
if phrase.purpose=='return':repetition=max(0,repetition-.5)
|
||||
duration=sum(float(n.end-n.start) for n in ns)
|
||||
register=sum(float(n.end-n.start)*max(0,abs(n.pitch.number()-center)-10)/12 for n in ns)/max(duration,.001)
|
||||
raw={'purpose':min(1,purpose),'continuity':min(1,continuity),'repetition':min(1,repetition),'register':min(1,register)}
|
||||
contributions={k:raw[k]*weights[k] for k in weights}
|
||||
return Evaluation('phrase-musical-1',valid,raw,weights,contributions,sum(contributions.values()) if valid else float('inf'),{'span':span,'attacks':len(ns),'sounding':sum(float(n.end-n.start) for n in ns)})
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Interpretable shared action ranking; no song identity or absolute bar features.
|
||||
|
||||
Complete actions compete by exact cost per quarter, with bounded backtracking.
|
||||
The final report exposes the exact same contributions used by search.
|
||||
Role-specific learned integer coefficients can augment these general priors.
|
||||
"""
|
||||
from collections import Counter
|
||||
from fractions import Fraction as F
|
||||
import dataclasses
|
||||
from generator import vocabulary, harmony as chords, model
|
||||
from generator.solo import expressions, scoring, types
|
||||
|
||||
VERSION='action-selection-1'
|
||||
DEFAULT={'excessive_leap':12,'register_distance':1,'oscillation':6,'repeated_attack':3,
|
||||
'unprepared_space':12,'rest_after_rest':24,'source_relation':-2,'theme_relation':-4,
|
||||
'sustained_arrival':-1,'directed_motion':-1}
|
||||
|
||||
|
||||
def validate_weights(weights):
|
||||
durations={str(F(i,4)).replace('/','_') for i in range(1,17)}|{'1_3','2_3'}
|
||||
kinds={'tone','color','color_resolution','anticipation','anticipated_color','approach','enclosure','connection','delayed_approach','space','rest','scalar','arpeggio','return','answer'}
|
||||
allowed={f'kind/{k}' for k in kinds}|{f'duration/{d}' for d in durations}
|
||||
allowed|={f'harmony/{q}/{p}/{d}' for q in ('major','minor','dominant','half_diminished','other') for p in range(12) for d in durations}
|
||||
allowed|={f'motion/{p}/{d}' for p in range(-12,13) for d in durations}
|
||||
allowed|={f'metric/{m}/{d}' for m in ('barline','beat','eighth','subdivision') for d in durations}
|
||||
allowed|={f'register/{i}' for i in range(-4,5)}
|
||||
allowed|={f'obligation/step/{i}' for i in range(-12,13)}
|
||||
allowed|={f'obligation/delay/{str(F(i,4)).replace("/","_")}' for i in range(1,21)}
|
||||
allowed|={f'obligation/hold/{d}' for d in durations}
|
||||
allowed|={f'continuation/{a}/{b}' for a in ('up','down','repeat') for b in ('up','down','repeat')}
|
||||
allowed|={'source/rhythm_match','theme/realized_identity',*(f'space/{s}' for s in ('unharmonized','breath','after_arrival','delayed_entrance'))}
|
||||
for key,value in weights.items():
|
||||
if key not in allowed or not isinstance(value,int) or isinstance(value,bool):
|
||||
raise ValueError('Unsupported fitted feature (only shared musical categories): '+key)
|
||||
if any(c not in 'abcdefghijklmnopqrstuvwxyz_0123456789/-' for c in key):
|
||||
raise ValueError('Noncanonical fitted feature: '+key)
|
||||
return dict(weights)
|
||||
|
||||
|
||||
def signature(notes):
|
||||
if not notes:return ()
|
||||
first=notes[0]
|
||||
return tuple((n.start-first.start,n.end-n.start,n.pitch.number()-first.pitch.number()) for n in notes)
|
||||
|
||||
|
||||
class Selector:
|
||||
def __init__(self,harmony,analysis,center,weights=None):
|
||||
self.harmony=harmony;self.analysis=analysis;self.center=center
|
||||
self.weights=validate_weights(weights if weights is not None else vocabulary.role().get('selection_weights',{}))
|
||||
self.source_intervals=Counter(b.pitch.number()-a.pitch.number() for a,b in zip(analysis.melody,analysis.melody[1:]))
|
||||
self.source_pairs=Counter((b.pitch.number()-a.pitch.number(),c.pitch.number()-b.pitch.number())
|
||||
for a,b,c in zip(analysis.melody,analysis.melody[1:],analysis.melody[2:]))
|
||||
self.source_rhythms=Counter(n.end-n.start for n in analysis.melody)
|
||||
self.static={}
|
||||
|
||||
def base(self,action):
|
||||
if action.key in self.static:return self.static[action.key]
|
||||
values=Counter();prior=Counter();ticks=int((action.end-action.start)*12)
|
||||
values['kind/'+action.kind]=ticks
|
||||
for contract in action.contracts:
|
||||
if contract.target.start>=action.end and contract.events:
|
||||
# Pending destinations must be visible to selection. Identical heard
|
||||
# attacks with different future obligations are different actions.
|
||||
last=contract.events[-1];target=contract.target
|
||||
values[f'obligation/step/{max(-12,min(12,target.pitch.number()-last.pitch.number()))}']+=ticks
|
||||
values['obligation/delay/'+str(target.start-last.start).replace('/','_')]+=ticks
|
||||
values['obligation/hold/'+str(target.end-target.start).replace('/','_')]+=ticks
|
||||
if not action.notes:
|
||||
values['space/'+('unharmonized' if action.kind=='space' else 'breath')]=ticks
|
||||
for n in action.notes:
|
||||
duration=n.end-n.start;length=int(duration*12)
|
||||
label=str(duration).replace('/','_')
|
||||
values['duration/'+label]+=length
|
||||
beat=(n.start%1)
|
||||
metric='barline' if n.start+self.analysis.body_offset in self.analysis.boundaries else 'beat' if beat==0 else 'eighth' if beat==F(1,2) else 'subdivision'
|
||||
values['metric/'+metric+'/'+label]+=length
|
||||
band=max(-4,min(4,(n.pitch.number()-self.center+6)//12))
|
||||
values[f'register/{band}']+=length
|
||||
region=self.harmony.at(n.start)
|
||||
chord=expressions.parsed_chord(region.chord)
|
||||
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))
|
||||
degree=(n.pitch.number()-root)%12
|
||||
values[f'harmony/{expressions.quality(chord)}/{degree}/{label}']+=length
|
||||
prior['register_distance']+=length*max(0,abs(n.pitch.number()-self.center)-7)
|
||||
if duration>=2 and n.pitch.number()%12 in self.harmony.members(n.start,n.end):
|
||||
prior['sustained_arrival']+=length
|
||||
values['source/rhythm_match']+=length if duration in self.source_rhythms else 0
|
||||
self.static[action.key]=(values,prior)
|
||||
if len(self.static)>8192:self.static.pop(next(iter(self.static)))
|
||||
return values,prior
|
||||
|
||||
def features(self,action,recent=(),home=(),previous_kind=''):
|
||||
if any(n.continuation for n in action.notes):action=dataclasses.replace(action,notes=types.sounding_notes(action.notes))
|
||||
static,base=self.base(action);values=Counter(static);prior=Counter(base)
|
||||
if not action.notes:
|
||||
if action.kind=='space':return values,prior
|
||||
ticks=int((action.end-action.start)*12)
|
||||
if previous_kind in ('rest','space'):prior['rest_after_rest']+=ticks
|
||||
elif recent and recent[-1].end-recent[-1].start>=F(3,2):values['space/after_arrival']+=ticks
|
||||
elif recent:prior['unprepared_space']+=ticks
|
||||
else:values['space/delayed_entrance']+=ticks
|
||||
return values,prior
|
||||
previous=recent[-1] if recent else None
|
||||
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(recent,recent[1:])]
|
||||
for n in action.notes:
|
||||
length=int((n.end-n.start)*12)
|
||||
if previous:
|
||||
delta=n.pitch.number()-previous.pitch.number()
|
||||
token=max(-12,min(12,delta));label=str(n.end-n.start).replace('/','_')
|
||||
values[f'motion/{token}/{label}']+=length
|
||||
if intervals:
|
||||
# Direction changes are contextual, not a blanket leap prohibition.
|
||||
sign=lambda x: 'up' if x>0 else 'down' if x<0 else 'repeat'
|
||||
values[f'continuation/{sign(intervals[-1])}/{sign(delta)}']+=length
|
||||
if len(intervals)>=2 and intervals[-2]==-intervals[-1]==delta and delta:prior['oscillation']+=length
|
||||
prior['excessive_leap']+=length*max(0,abs(delta)-7)
|
||||
prior['repeated_attack']+=length if delta==0 and n.start==previous.end else 0
|
||||
# Ordered relationships, not the set of available intervals. Weight
|
||||
# by sounding duration so subdivisions cannot multiply a reward.
|
||||
if intervals and (intervals[-1],delta) in self.source_pairs:prior['source_relation']+=length
|
||||
if 0<abs(delta)<=2 and intervals and delta*intervals[-1]>0:prior['directed_motion']+=length
|
||||
intervals.append(delta)
|
||||
previous=n
|
||||
if action.kind=='return' and len(home)>=3:
|
||||
if signature(action.notes)==signature(home):
|
||||
recently_heard=any(signature(recent[i:i+len(home)])==signature(home)
|
||||
for i in range(max(0,len(recent)-len(home)+1)))
|
||||
if not recently_heard:prior['theme_relation']+=int((action.end-action.start)*12)
|
||||
if recent and home:
|
||||
joined=(*recent,*action.notes)
|
||||
count=min(len(home),len(joined))
|
||||
if count>=3 and signature(joined[-count:])==signature(home[:count]):
|
||||
values['theme/realized_identity']+=12
|
||||
return values,prior
|
||||
|
||||
def evaluate(self,action,recent=(),home=(),previous_kind=''):
|
||||
features,prior=self.features(action,recent,home,previous_kind)
|
||||
contributions={'prior/'+k:v*DEFAULT[k] for k,v in prior.items() if v}
|
||||
contributions.update({k:v*self.weights.get(k,0) for k,v in features.items() if v})
|
||||
weights={'prior/'+k:v for k,v in DEFAULT.items()}|self.weights
|
||||
raw={'prior/'+k:v for k,v in prior.items()}|dict(features)
|
||||
span=action.end-action.start
|
||||
contributions={k:F(v)/span for k,v in contributions.items()}
|
||||
raw={k:F(v)/span for k,v in raw.items()}
|
||||
total=sum(contributions.values(),F())
|
||||
return scoring.Evaluation(VERSION,True,raw,weights,contributions,total,
|
||||
{'span':str(action.end-action.start),'ticks_per_quarter':12,'attacks':len(types.sounding_notes(action.notes))})
|
||||
|
||||
def cost(self,action,recent=(),home=(),previous_kind=''):
|
||||
"""Identical selection total without serializing every rejected breakdown."""
|
||||
features,prior=self.features(action,recent,home,previous_kind)
|
||||
numerator=sum(v*DEFAULT[k] for k,v in prior.items())
|
||||
numerator+=sum(v*self.weights.get(k,0) for k,v in features.items())
|
||||
return F(numerator)/(action.end-action.start)
|
||||
@@ -172,5 +172,6 @@ class Solo:
|
||||
end: fractions.Fraction
|
||||
diagnostics: dict = dataclasses.field(default_factory=dict, compare=False)
|
||||
profile: generator.solo.profiles.Instrument | None = None
|
||||
start: fractions.Fraction = fractions.Fraction()
|
||||
def notes(self):
|
||||
return sounding_notes(n for p in self.phrases for n in p.notes)
|
||||
|
||||
@@ -32,12 +32,10 @@ def _load(contents):
|
||||
cells=result['rhythms']['cells']+result['solo']['cells']
|
||||
if len({c['id'] for c in cells})!=len(cells):raise ValueError('Duplicate rhythm identity')
|
||||
rules=result['solo']
|
||||
if set(rules['actions'])!={'arpeggio','scalar','neighbor','develop','answer','return','head'}:raise ValueError('Unknown/missing phrase action')
|
||||
if set(rules['weights'])!={'purpose','continuity','repetition','register'}:raise ValueError('Unknown/missing musical contribution')
|
||||
if any(v<=0 for v in rules['search'].values()):raise ValueError('Invalid deterministic search budget')
|
||||
if any(type(rules['operators'][k]) is not int or rules['operators'][k]<=0 for k in ('backtracks','per_state')):raise ValueError('Invalid deterministic search budget')
|
||||
if any(F(d)<=0 for d in rules['operators']['durations']):raise ValueError('Invalid action duration')
|
||||
for group in rules['groups']:
|
||||
if not group['values'] or any(F(v.removeprefix('r:'))<=0 for v in group['values']):raise ValueError('Invalid rhythm group')
|
||||
if set(group['purpose'])-set(rules['actions']):raise ValueError('Unreachable rhythm purpose')
|
||||
return result
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Independently authored operator, fitting-boundary and cache regressions."""
|
||||
import copy
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import contextlib
|
||||
import io
|
||||
from unittest.mock import patch
|
||||
|
||||
from generator import resources, vocabulary
|
||||
from generator.solo import expressions, harmonic, operators, selection, types
|
||||
from tests import solo_fixtures as fixture, rhythm_fixtures, scale_fixtures
|
||||
from tests.test_phrase_composer import builder
|
||||
|
||||
class Operators(unittest.TestCase):
|
||||
def test_supported_sixteenth_tail_can_finish_the_allocation(self):
|
||||
b=builder(('CM7',),count=1)
|
||||
actions=b.operators.at(F(15,4))
|
||||
self.assertTrue(any(a.kind=='tone' and a.end==4 for a in actions))
|
||||
# A source/role cutoff does not require a new long cadence.
|
||||
b.start=F(15,4)
|
||||
phrases=b.compose()
|
||||
self.assertEqual(phrases[-1].end,F(4))
|
||||
self.assertLessEqual(sum(len(p.notes) for p in phrases),1)
|
||||
|
||||
def test_source_relation_uses_order_and_does_not_reward_subdivision(self):
|
||||
b=builder(('CM7',),count=1)
|
||||
slow=operators.Action(F(),F(4),tuple(fixture.note(i,i+1,p,event_id=str(i)) for i,p in enumerate(('c','d','e','f'))),'scalar')
|
||||
fast=dataclasses.replace(slow,end=F(2),notes=tuple(dataclasses.replace(n,start=n.start/2,end=n.end/2) for n in slow.notes))
|
||||
self.assertEqual(b.selector.evaluate(slow).raw['prior/source_relation'],b.selector.evaluate(fast).raw['prior/source_relation'])
|
||||
changed=dataclasses.replace(b.analysis,melody=tuple(fixture.note(i,i+1,p,event_id=str(i)) for i,p in enumerate(('e','f','g','a'))))
|
||||
other=selection.Selector(b.harmony,changed,b.selector.center,{})
|
||||
self.assertNotEqual(b.selector.evaluate(slow).raw.get('prior/source_relation',0),other.evaluate(slow).raw.get('prior/source_relation',0))
|
||||
|
||||
def test_shared_fitter_learns_an_independent_synthetic_decision(self):
|
||||
from audition.fit_selection import fit
|
||||
b=builder(('CM7',),count=1)
|
||||
low=operators.Action(F(),F(4),(fixture.note(0,4,'c',event_id='low'),),'tone')
|
||||
high=operators.Action(F(),F(4),(fixture.note(0,4,'e',event_id='high'),),'tone')
|
||||
b.selector.weights={}
|
||||
with patch.object(b.operators,'at',return_value=(low,high)),contextlib.redirect_stdout(io.StringIO()):
|
||||
result=fit([('synthetic','acoustic_solo',b,(high,))],10)
|
||||
self.assertTrue(result['complete'])
|
||||
b.selector.weights=result['weights']['acoustic_solo']
|
||||
self.assertLess(b.selector.evaluate(high).total,b.selector.evaluate(low).total)
|
||||
selection.validate_weights(b.selector.weights)
|
||||
|
||||
def test_fitting_normalization_covers_pickup_plus_whole_note(self):
|
||||
from audition.fit_selection import fit
|
||||
b=builder(('CM7',),count=2);b.end=F(17,4)
|
||||
low=operators.Action(F(),b.end,(fixture.note(0,F(1,4),'c',event_id='lead'),fixture.note(F(1,4),b.end,'e',event_id='low')),'anticipation')
|
||||
high=dataclasses.replace(low,notes=(low.notes[0],fixture.note(F(1,4),b.end,'g',event_id='high')))
|
||||
b.selector.weights={}
|
||||
with patch.object(b.operators,'at',return_value=(low,high)),contextlib.redirect_stdout(io.StringIO()):
|
||||
result=fit([('synthetic','acoustic_solo',b,(high,))],10)
|
||||
self.assertTrue(result['complete'])
|
||||
selection.validate_weights(result['weights']['acoustic_solo'])
|
||||
|
||||
def test_anticipated_reattack_is_not_an_invented_tie(self):
|
||||
b=builder(('C7','FM7'),count=2)
|
||||
actions=b.operators.at(F(7,2))
|
||||
a=next(a for a in actions if a.kind=='anticipation' and a.source=='early_reattack')
|
||||
self.assertEqual(len(a.notes),2)
|
||||
self.assertEqual(a.notes[0].end,a.notes[1].start)
|
||||
self.assertEqual(a.notes[0].pitch,a.notes[1].pitch)
|
||||
self.assertFalse(a.notes[1].continuation)
|
||||
self.assertTrue(b.harmony.evaluate(a.notes,expressions=a.contracts).valid)
|
||||
self.assertFalse(b.harmony.evaluate(a.notes[:1],expressions=a.contracts).valid)
|
||||
|
||||
def test_open_motion_and_all_color_octaves_are_constructed(self):
|
||||
b=builder(('C7','FM7'),count=2)
|
||||
actions=b.operators.at(F())
|
||||
self.assertTrue(any(len(a.notes)==1 and a.notes[0].pitch.number()%12==2 and a.end==F(1,2) for a in actions))
|
||||
h=builder(('E7','Am7'),count=2)
|
||||
values=h.operators.at(F())
|
||||
self.assertTrue(any(a.kind=='color' and len(a.notes)==1 and a.notes[0].pitch.number()==66 for a in values))
|
||||
self.assertTrue(any(a.kind=='rest' for a in values))
|
||||
|
||||
def test_anticipation_color_has_actual_future_resolution(self):
|
||||
h=harmonic.Harmony(fixture.choices(('Dm7','G7','CM7')))
|
||||
held=fixture.note(F(7,2),F(11,2),'c',alter=1,event_id='held')
|
||||
target=fixture.note(F(11,2),7,'d',event_id='target')
|
||||
c=types.ExpressionContract('anticipated','anticipated_color','lydian_color',(held,),target,'G7',boundary=F(4))
|
||||
self.assertTrue(h.evaluate((held,target),expressions=(c,)).valid)
|
||||
self.assertFalse(h.evaluate((held,),expressions=(c,)).valid)
|
||||
changed=dataclasses.replace(target,pitch=dataclasses.replace(target.pitch,octave=5))
|
||||
self.assertFalse(h.evaluate((held,changed),expressions=(c,)).valid)
|
||||
self.assertFalse(h.evaluate((held,target)).valid)
|
||||
|
||||
def test_delayed_approach_is_pending_and_expires(self):
|
||||
h=harmonic.Harmony(fixture.choices(('Dm7',)))
|
||||
approach=fixture.note(0,F(1,2),'c',alter=1,event_id='approach')
|
||||
target=fixture.note(3,F(7,2),'d',event_id='target')
|
||||
c=types.ExpressionContract('delayed','delayed_approach','bounded_semitone_resolution',(approach,),target,'Dm7')
|
||||
r=h.evaluate((approach,),expressions=(c,),until=F(1,2),complete=False)
|
||||
self.assertTrue(r.valid,r.error);self.assertEqual(r.pending,(c,))
|
||||
self.assertFalse(h.evaluate((approach,),expressions=(c,),until=4,complete=True).valid)
|
||||
self.assertTrue(h.evaluate((approach,target),expressions=(c,)).valid)
|
||||
|
||||
def test_distinct_pending_targets_are_visible_to_the_selector(self):
|
||||
b=builder(('Dm7',),count=2)
|
||||
actions=[a for a in b.operators.at(F()) if a.kind=='delayed_approach']
|
||||
first=actions[0]
|
||||
second=next(a for a in actions if a.notes==first.notes and a.contracts[0].target.start!=first.contracts[0].target.start)
|
||||
left,_=b.selector.features(first);right,_=b.selector.features(second)
|
||||
self.assertNotEqual(left,right)
|
||||
selection.validate_weights(dict.fromkeys(left,1))
|
||||
|
||||
def test_missed_pending_target_is_rejected_before_expensive_validation(self):
|
||||
from generator.solo.construction import State
|
||||
b=builder(('Dm7',),count=2)
|
||||
approach=fixture.note(0,F(1,2),'c',alter=1,event_id='approach')
|
||||
target=fixture.note(3,4,'d',event_id='target')
|
||||
c=types.ExpressionContract('delayed','delayed_approach','bounded_semitone_resolution',(approach,),target,'Dm7')
|
||||
state=State(pending=(c,))
|
||||
wrong=operators.Action(F(3),F(4),(fixture.note(3,4,'e',event_id='wrong'),),'tone')
|
||||
with patch.object(b.harmony,'evaluate',side_effect=AssertionError('unnecessary full check')):
|
||||
self.assertIsNone(b.advance(state,None,wrong))
|
||||
good=dataclasses.replace(wrong,notes=(target,))
|
||||
self.assertIsNotNone(b.advance(state,None,good))
|
||||
|
||||
def test_functional_degree_does_not_depend_on_enharmonic_label(self):
|
||||
h=harmonic.Harmony(fixture.choices(('E7','Am7')))
|
||||
n=fixture.note(0,2,'g',event_id='sharp9')
|
||||
c=types.ExpressionContract('color','color','dominant_sharp_ninth',(n,),n,'E7')
|
||||
self.assertTrue(h.evaluate((n,),expressions=(c,)).valid)
|
||||
wrong=dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,alter=F(1)))
|
||||
self.assertFalse(h.evaluate((wrong,),expressions=(dataclasses.replace(c,events=(wrong,),target=wrong),)).valid)
|
||||
|
||||
def test_weight_contributions_and_target_free_features(self):
|
||||
b=builder(count=1)
|
||||
a=next(a for a in b.operators.at(F()) if a.kind=='tone')
|
||||
v=b.selector.evaluate(a)
|
||||
self.assertEqual(v.total,sum(v.contributions.values()))
|
||||
self.assertEqual(v.total,b.selector.cost(a))
|
||||
key='kind/tone';b.selector.weights={key:3}
|
||||
changed=b.selector.evaluate(a)
|
||||
self.assertEqual(changed.total,b.selector.cost(a))
|
||||
self.assertEqual(changed.total-v.total,3*v.raw[key])
|
||||
with self.assertRaises(ValueError):selection.validate_weights({'song/all_of_me':1})
|
||||
|
||||
def test_derivation_cannot_inject_an_illegal_reference_note(self):
|
||||
from audition.fit_selection import derive
|
||||
b=builder(('CM7',),count=1)
|
||||
target={'alignment':{'encoded_offset':'0'},'sounding_events':[{'start':'0','end':'4','sounding_midi':'99','pitch':{},'measure':1}]}
|
||||
path,result=derive(b,target)
|
||||
self.assertFalse(result['complete']);self.assertFalse(path)
|
||||
|
||||
class CompiledResources(unittest.TestCase):
|
||||
def test_compiled_and_xml_libraries_have_identical_contents(self):
|
||||
original=(scale_fixtures.library(),fixture.library(),rhythm_fixtures.library())
|
||||
import json
|
||||
encoded=json.loads(json.dumps(resources.encode(original),default=str))
|
||||
restored=resources.decode(encoded)
|
||||
self.assertEqual(original[0],restored[0])
|
||||
self.assertEqual(original[1].paths,restored[1].paths)
|
||||
self.assertEqual(original[2].snippets,restored[2].snippets)
|
||||
|
||||
def test_warm_cache_avoids_xml_import_and_corruption_recompiles(self):
|
||||
original=(scale_fixtures.library(),fixture.library(),rhythm_fixtures.library())
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
with patch('generator.resources.scales.load_library',return_value=original[0]) as sc,patch('generator.resources.sequences.load_library',return_value=original[1]),patch('generator.resources.rhythm.load_library',return_value=original[2]):
|
||||
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
|
||||
self.assertEqual(sc.call_count,1)
|
||||
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
|
||||
self.assertEqual(sc.call_count,1)
|
||||
next((Path(root)/'_resources').glob('*.json')).write_text('{}')
|
||||
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
|
||||
self.assertEqual(sc.call_count,2)
|
||||
+3
-1
@@ -193,7 +193,9 @@ class PartGenerationTests(unittest.TestCase):
|
||||
unittest.mock.patch('generator.playback.compile_tree'),
|
||||
):
|
||||
generator.__main__.generate_assets(args)
|
||||
load_library.assert_called_once_with(args.data_dir.resolve() / 'rhythms')
|
||||
# Role catalog loading is replaced by this infrastructure fixture. The
|
||||
# parent asset pipeline must not perform a redundant catalog parse.
|
||||
load_library.assert_not_called()
|
||||
self.assertEqual(load.call_args_list, [unittest.mock.call(args.data_dir.resolve() / 'songs/other.xml'), unittest.mock.call(args.data_dir.resolve() / 'songs/song.xml')])
|
||||
self.assertEqual(normalize.call_count, 2)
|
||||
original = {p.relative_to(args.output_dir): p.read_bytes() for p in args.output_dir.rglob('*') if p.is_file()}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""General, independently authored capabilities; no optional reference inputs."""
|
||||
import copy,dataclasses,unittest
|
||||
"""General, independently authored capabilities of the active action search."""
|
||||
import dataclasses, unittest
|
||||
from fractions import Fraction as F
|
||||
from unittest.mock import patch
|
||||
from generator import vocabulary,model
|
||||
from generator.scales import selection,timeline
|
||||
from generator.solo import analysis,construction,harmonic,profiles,types,scoring,composer,routes,placement
|
||||
from tests import solo_fixtures as fixtures,rhythm_fixtures,scale_fixtures
|
||||
from generator import model, vocabulary
|
||||
from generator.scales import selection, timeline
|
||||
from generator.solo import analysis, construction, harmonic, profiles, types, placement, operators
|
||||
from tests import solo_fixtures as fixtures, rhythm_fixtures, scale_fixtures
|
||||
|
||||
|
||||
def builder(symbols=('C7','FM7','Dm7','G7'),seed='independent',count=8):
|
||||
@@ -15,111 +14,78 @@ def builder(symbols=('C7','FM7','Dm7','G7'),seed='independent',count=8):
|
||||
return construction.Builder(a,ms,harmonic.Harmony(cs,profiles.Instrument(reference=60)),fixtures.library(),rhythm_fixtures.library(),end,seed)
|
||||
|
||||
class PhraseConstruction(unittest.TestCase):
|
||||
def test_complete_candidates_have_varied_functions_and_joint_expression(self):
|
||||
b=builder();values=[b.propose(F(),(),i) for i in range(64)];values=[p for p in values if p]
|
||||
self.assertTrue(values)
|
||||
self.assertIn('open',{p.ending for p in values})
|
||||
self.assertTrue(any(p.destination and p.destination.duration>=2 for p in values))
|
||||
self.assertTrue(any(p.expressions for p in values))
|
||||
for p in values:
|
||||
self.assertTrue(b.harmony.evaluate(p.notes,until=p.end,complete=True,expressions=p.expressions).valid)
|
||||
self.assertEqual(p.score,p.evaluation.total)
|
||||
self.assertTrue(all(p.start<=n.start<n.end<=p.end for n in p.notes))
|
||||
def test_candidates_include_open_motion_arrivals_and_joint_expression(self):
|
||||
b=builder(count=2);values=b.operators.at(F())
|
||||
self.assertTrue(any(a.kind=='tone' and a.end==F(1,2) for a in values))
|
||||
self.assertTrue(any(len(a.notes)==1 and a.end>=2 for a in values))
|
||||
self.assertTrue(any(a.contracts for a in values))
|
||||
for a in values:
|
||||
checked=b.harmony.evaluate(a.notes,until=a.end,complete=False,expressions=a.contracts)
|
||||
self.assertTrue(checked.valid,checked.error)
|
||||
evaluation=b.selector.evaluate(a)
|
||||
self.assertEqual(evaluation.total,sum(evaluation.contributions.values()))
|
||||
self.assertTrue(all(a.start<=n.start<n.end<=a.end for n in a.notes))
|
||||
|
||||
def test_all_operations_are_eligible_and_construct_complete_music(self):
|
||||
b=builder(count=12);first=b.candidates(F(),())[0]
|
||||
history=(first,dataclasses.replace(first,id='second'))
|
||||
self.assertEqual(set(b.actions(history)),set(vocabulary.data('solo')['actions'])-{'head'})
|
||||
history=(*history,dataclasses.replace(first,id='third'))
|
||||
self.assertEqual(set(b.actions(history)),set(vocabulary.data('solo')['actions']))
|
||||
# Request each operation through the same active constructor in eligible contexts.
|
||||
for action in b.actions(history):
|
||||
with self.subTest(action=action),patch.object(b,'actions',return_value=[action]):
|
||||
candidates=[b.propose(F(16),history,i) for i in range(32)]
|
||||
self.assertTrue(any(p and p.purpose==action for p in candidates))
|
||||
def test_audible_theme_returns_and_answers_are_reachable(self):
|
||||
b=builder(('CM7',),count=2)
|
||||
home=(fixtures.note(0,F(1,2),'c'),fixtures.note(F(1,2),1,'d'),fixtures.note(1,2,'e'))
|
||||
for kind in ('return','answer'):
|
||||
values=b.operators.related(F(4),home,kind)
|
||||
self.assertTrue(values,kind)
|
||||
self.assertTrue(all(len(a.notes)==len(home) and a.kind==kind for a in values))
|
||||
|
||||
def test_head_is_optional_not_required_rhythm(self):
|
||||
b=builder();b.analysis=dataclasses.replace(b.analysis,melody=())
|
||||
values=b.candidates(F(),())
|
||||
self.assertTrue(values)
|
||||
self.assertTrue(all(p.purpose!='head' for p in values))
|
||||
|
||||
def test_recomposed_opening_is_not_source_hold(self):
|
||||
source=fixtures.song(count=4,chords=('CM7',))
|
||||
n=source['staves'][0]['measures'][0]['voices'][0]['notes'][0]
|
||||
source['staves'][0]['measures'][0]['voices'][0]['notes']=[{**n,'duration':model.rational(4),'pitches':[{**n['pitches'][0],'duration':model.rational(4)}]}]
|
||||
result=fixtures.compose(source)
|
||||
self.assertTrue(result.notes());self.assertLess(result.notes()[0].end-result.notes()[0].start,4)
|
||||
|
||||
def test_disabled_expressions_are_strict_and_reproducible(self):
|
||||
b=builder();b.enabled=False
|
||||
def test_disabled_expressions_stay_strict(self):
|
||||
b=builder(count=1);b.operators.enabled=False
|
||||
ps=b.compose();ns=tuple(n for p in ps for n in p.notes)
|
||||
self.assertFalse(any(p.expressions for p in ps))
|
||||
self.assertTrue(all(n.pitch.number()%12 in types.permitted(b.harmony.choices,n.start,n.end) for n in ns))
|
||||
self.assertEqual(ps,b.compose())
|
||||
again=b.compose()
|
||||
self.assertEqual(ps,again)
|
||||
|
||||
def test_role_domains_and_complete_tied_position(self):
|
||||
for role in ('acoustic','electric'):
|
||||
p=profiles.for_part(role)
|
||||
self.assertEqual(p.strings,(1,2,3,4,5,6))
|
||||
p=profiles.for_part(role);self.assertEqual(p.strings,(1,2,3,4,5,6))
|
||||
for midi in range(41,77):self.assertTrue(placement.positions(midi,p))
|
||||
a=fixtures.note(0,2,'e',event_id='held');ns=(a,dataclasses.replace(a,start=F(2),end=F(4),continuation=True))
|
||||
positioned=placement.place(ns,F(120))[1]
|
||||
self.assertEqual(positioned[0].position,positioned[1].position)
|
||||
self.assertEqual(len(types.sounding_notes(positioned)),1)
|
||||
|
||||
def test_routes_keep_actual_pitches_across_collection_change(self):
|
||||
def test_complete_routes_cross_boundaries_without_a_forced_chord_tone(self):
|
||||
b=builder(('C7','DmM7'),count=2)
|
||||
ns=(fixtures.note(3,F(7,2),'g'),fixtures.note(F(7,2),4,'a'),fixtures.note(4,F(9,2),'c'),fixtures.note(F(9,2),5,'d'))
|
||||
values=b.routes.realize(ns,{0,3},'scalar','up','crossing',64)
|
||||
self.assertTrue(values)
|
||||
for route in values:
|
||||
self.assertEqual(route[0].pitch,ns[0].pitch);self.assertEqual(route[-1].pitch,ns[-1].pitch)
|
||||
self.assertTrue(all(n.pitch.number()%12 in types.permitted(b.harmony.choices,n.start,n.end) for n in route))
|
||||
values=b.operators.at(F(3))
|
||||
crossed=[a for a in values if a.start<4<a.end and a.kind=='scalar']
|
||||
self.assertTrue(crossed)
|
||||
for a in crossed:self.assertTrue(b.harmony.evaluate(a.notes).valid)
|
||||
one=builder(('C7',),count=1)
|
||||
self.assertTrue(any(a.kind=='tone' and a.notes[0].pitch.number()%12==5 and a.end==F(1,2) for a in one.operators.at(F())))
|
||||
|
||||
def test_no_internal_chordal_endpoint_requirement(self):
|
||||
b=builder(('C7',),count=2)
|
||||
ns=(fixtures.note(0,F(1,2),'e'),fixtures.note(F(1,2),1,'f'))
|
||||
paths=b.routes.realize(ns,{0,1},'scalar','up','open',64)
|
||||
self.assertTrue(paths)
|
||||
self.assertTrue(b.harmony.evaluate(paths[0]).valid)
|
||||
self.assertNotIn(paths[0][-1].pitch.number()%12,b.harmony.at(F(1,2)).roles.basic)
|
||||
def test_actual_incoming_state_changes_ranking(self):
|
||||
b=builder(count=1)
|
||||
a=b.candidates(F(),construction.State(recent=(fixtures.note(-1,0,'c',octave=3),)))
|
||||
c=b.candidates(F(),construction.State(recent=(fixtures.note(-1,0,'c',octave=5),)))
|
||||
self.assertNotEqual([v[1].key for v in a],[v[1].key for v in c])
|
||||
|
||||
def test_meaningful_incoming_state_changes_continuations(self):
|
||||
b=builder();history=(b.candidates(F(),())[0],)
|
||||
a=b.candidates(history[-1].end,history)
|
||||
old=history[0];last=old.notes[-1]
|
||||
changed=dataclasses.replace(old,id='changed',notes=(*old.notes[:-1],dataclasses.replace(last,pitch=dataclasses.replace(last.pitch,octave=last.pitch.octave+1))))
|
||||
c=b.candidates(changed.end,(changed,))
|
||||
self.assertNotEqual(tuple(p.notes for p in a),tuple(p.notes for p in c))
|
||||
def test_handoff_is_not_forced_to_have_a_long_note(self):
|
||||
b=builder(count=1)
|
||||
values=b.operators.at(F(7,2))
|
||||
self.assertTrue(any(a.kind=='tone' and a.end==4 for a in values))
|
||||
self.assertTrue(any(a.kind=='rest' and a.end==4 for a in values))
|
||||
|
||||
def test_no_short_accidental_handoff_tail(self):
|
||||
b=builder(count=4);ps=b.compose()
|
||||
self.assertEqual(ps[-1].end,b.end)
|
||||
if ps[-1].destination:self.assertGreaterEqual(ps[-1].destination.duration,2)
|
||||
|
||||
def test_cache_restores_exact_music_without_source_ancestry_growth(self):
|
||||
b=builder(count=4);cold=b.candidates(F(),());warm=b.candidates(F(),())
|
||||
self.assertEqual(cold,warm)
|
||||
self.assertTrue(all(not n.sources for p in cold for n in p.notes))
|
||||
def test_cached_actions_do_not_copy_source_ancestry(self):
|
||||
b=builder(count=1);a=b.operators.at(F());self.assertIs(a,b.operators.at(F()))
|
||||
self.assertTrue(all(not n.sources for action in a for n in action.notes))
|
||||
|
||||
class EvaluationContract(unittest.TestCase):
|
||||
def phrase(self,ns,**kwargs):return types.Phrase('test',F(),F(8),tuple(ns),'scalar','up','open',**kwargs)
|
||||
def test_configured_terms_are_authoritative_and_nonzero(self):
|
||||
ns=(fixtures.note(0,1,'c',octave=3),fixtures.note(1,2,'e',octave=4),fixtures.note(2,4,'c',octave=4))
|
||||
p=self.phrase(ns);incoming=fixtures.note(-1,0,'c',octave=5)
|
||||
base=scoring.evaluate(p,incoming,(p,),40)
|
||||
self.assertEqual(base.total,sum(base.contributions.values()))
|
||||
for name,value in base.raw.items():
|
||||
self.assertGreater(value,0,name)
|
||||
altered=scoring.evaluate(p,incoming,(p,),40,weights={**base.weights,name:base.weights[name]+1})
|
||||
self.assertAlmostEqual(altered.total-base.total,value)
|
||||
def test_silence_is_explicit_and_tie_partition_is_invariant(self):
|
||||
a=fixtures.note(0,4,'e',event_id='a')
|
||||
split=(dataclasses.replace(a,end=F(2)),dataclasses.replace(a,start=F(2),continuation=True))
|
||||
self.assertEqual(scoring.evaluate(self.phrase((a,)),None,(),60),scoring.evaluate(self.phrase(split),None,(),60))
|
||||
self.assertFalse(scoring.evaluate(self.phrase(()),None,(),60).valid)
|
||||
self.assertTrue(scoring.evaluate(dataclasses.replace(self.phrase(()),purpose='space'),None,(),60).valid)
|
||||
def test_tie_partition_is_invariant_and_reattack_is_distinct(self):
|
||||
b=builder(('C7',),count=1);n=fixtures.note(0,4,'e',event_id='hold')
|
||||
a=operators.Action(F(),F(4),(n,),'tone')
|
||||
split=(dataclasses.replace(n,end=F(2)),dataclasses.replace(n,start=F(2),continuation=True))
|
||||
x=b.selector.evaluate(a);y=b.selector.evaluate(dataclasses.replace(a,notes=split))
|
||||
self.assertEqual(x,y)
|
||||
reattack=tuple(dataclasses.replace(n,continuation=False) for n in split)
|
||||
self.assertNotEqual(x,b.selector.evaluate(dataclasses.replace(a,notes=reattack)))
|
||||
|
||||
def test_target_touch_and_repeated_attacks_do_not_fulfill_hold(self):
|
||||
a=fixtures.note(2,4,'e',event_id='arrival')
|
||||
anchor=types.Anchor('held',F(2),F(2),a.pitch,frozenset({4}),(41,76),F(2),'terminal','C7',hold_until=F(4))
|
||||
|
||||
@@ -39,6 +39,10 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tests.production_fixtures.install(self)
|
||||
def test_real_asset_regeneration_removes_pages_and_rolls_back_font_failure(self):
|
||||
from generator import resources
|
||||
# Validation now belongs to the catalog-consuming worker, not a redundant
|
||||
# parent parse. Exercise its real content keys/importer in this rollback test.
|
||||
self.enterContext(unittest.mock.patch('generator.production._solo_libraries',side_effect=lambda directory,keys,cache=None:resources.load(directory,cache_dir=cache)))
|
||||
self.enterContext(unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()))
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = pathlib.Path(temporary)
|
||||
|
||||
@@ -7,10 +7,13 @@ from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
from audition import reference_review, solo_compare
|
||||
from generator import vocabulary, production
|
||||
from generator.solo import export, harmonic, notation, placement, routes, types
|
||||
from generator.solo import export, harmonic, notation, placement, types
|
||||
from tests.solo_fixtures import song
|
||||
from tests.solo_fixtures import note
|
||||
from tests.solo_fixtures import choices
|
||||
@@ -26,6 +29,17 @@ def pitched(duration, tie='', octave=5):
|
||||
|
||||
|
||||
class ComparisonTests(unittest.TestCase):
|
||||
def test_exact_cli_is_a_real_exit_gate(self):
|
||||
args=['solo_compare','--generated-dir','unused','--output','unused','--reference','fixture/acoustic=unused','--require-exact']
|
||||
review={'exact_musical_match':False,'exact_matching_attacks':0,'differences':[{}],'rest_difference':True}
|
||||
with patch('sys.argv',args),patch.object(solo_compare,'run',return_value={'solos':{'fixture/acoustic':review}}),contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(solo_compare.main(),1)
|
||||
review.update(exact_musical_match=True,differences=[],rest_difference=False)
|
||||
self.assertEqual(solo_compare.main(),0)
|
||||
with patch('sys.argv',args[:-3]+['--require-exact']),contextlib.redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit) as error:solo_compare.main()
|
||||
self.assertEqual(error.exception.code,2)
|
||||
|
||||
def test_note_container_does_not_turn_color_label_into_fingering(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
note_xml=pitched(24).replace('</note>','<notations><technical><fingering>6</fingering></technical></notations></note>')
|
||||
|
||||
Reference in New Issue
Block a user